【Cocos Creator】アタッチするだけ!Draggable (ドラッグ移動)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】Draggableコンポーネントの実装:アタッチするだけでノードをマウスドラッグで移動できる汎用スクリプト

このコンポーネントは、任意のノードにアタッチするだけで「マウス(またはタッチ)で掴んでドラッグして動かせる」挙動を付与する汎用スクリプトです。UIのウィンドウやパネル、ゲーム内オブジェクトを簡単にドラッグ移動させたいときに使えます。

また、インスペクタから「どのノードを動かすか(親のControlノードなど)」「ドラッグ可能な領域(画面内・任意の矩形)」「ドラッグ開始条件(ボタン種別)」などを細かく調整できるように設計します。


コンポーネントの設計方針

1. 機能要件の整理

  • このコンポーネントがアタッチされたノードを「ドラッグ開始のトリガー」とする。
  • 実際に移動させるノード(Controlノード)はインスペクタから指定可能にする。
    • 未指定の場合は、自身の親ノードを自動でControl対象とする。
    • 親が存在しない場合は、自身をControl対象とする。
  • マウス/タッチ入力でドラッグ開始・移動・終了を検出し、Controlノードの位置を更新する。
  • ドラッグ中は、ドラッグ開始時の「ポインタ座標とControlノード位置の差分」を維持して移動させる(掴んだ位置を保つ)。
  • ドラッグ可能領域を制限できるようにする。(例:画面内だけ、任意の矩形範囲)
  • 外部のGameManagerやシングルトンに依存せず、このスクリプト単体で完結する。

2. 使用する標準コンポーネント/API

  • イベント検出
    • Node.EventType.TOUCH_START
    • Node.EventType.TOUCH_MOVE
    • Node.EventType.TOUCH_END
    • Node.EventType.TOUCH_CANCEL
    • PC環境ではマウスもこれらのタッチイベントとして扱われます(Cocosの仕様)。
  • 座標変換
    • UITransformCamera を使ったスクリーン座標 <-> ワールド座標変換。
    • 2D UI前提で、Canvas のカメラを利用する。

3. インスペクタで設定可能なプロパティ

以下のようなプロパティを用意します。

  • controlTarget: Node | null
    • 説明: 実際に移動させたいノード(Controlノード)。
    • 未設定(null)の場合:
      • 親ノードがあれば親をControlノードとする。
      • 親ノードがなければ自分自身をControlノードとする。
  • useCustomBounds: boolean
    • 説明: ドラッグできる範囲を任意の矩形で制限するかどうか。
    • false: 制限なし(どこまでもドラッグ可能)。
    • true: boundsMin, boundsMax の矩形内に制限。
  • boundsMin: Vec2
    • 説明: ドラッグ可能範囲の左下座標(Controlノードの親座標系)。
    • 例: (-480, -320) など。
  • boundsMax: Vec2
    • 説明: ドラッグ可能範囲の右上座標(Controlノードの親座標系)。
    • 例: (480, 320) など。
  • enableClickThrough: boolean
    • 説明: ドラッグ開始判定に失敗したとき、イベントをそのまま他のUIに伝播させるかどうか。
    • true: event.propagationStopped を行わない。
    • false: ドラッグ操作を優先し、イベントを止める。
  • debugLog: boolean
    • 説明: ドラッグ開始/終了などのログをコンソールに出すかどうか。

※標準コンポーネントに対する必須依存はありませんが、UIとして使う場合は Canvas 配下のノードにアタッチすることを推奨します。


TypeScriptコードの実装


import { _decorator, Component, Node, EventTouch, Vec2, Vec3, UITransform, view, Camera, Canvas, find, sys } from 'cc';
const { ccclass, property } = _decorator;

/**
 * Draggable
 * 任意のノード(controlTarget)をマウス/タッチドラッグで移動させる汎用コンポーネント。
 * - このコンポーネントをアタッチしたノードが「ドラッグ開始のトリガー」になります。
 * - 実際に動かしたいノード(Controlノード)は controlTarget で指定してください。
 *   - 未指定の場合: 親ノードがあれば親を、なければ自分自身を Control とします。
 */
@ccclass('Draggable')
export class Draggable extends Component {

    @property({
        type: Node,
        tooltip: 'ドラッグで実際に移動させるノード(Controlノード)。\n未指定の場合は、親ノードがあれば親を、なければ自分自身を使用します。'
    })
    public controlTarget: Node | null = null;

    @property({
        tooltip: 'true の場合、controlTarget の移動範囲を boundsMin ~ boundsMax の矩形内に制限します。'
    })
    public useCustomBounds: boolean = false;

    @property({
        tooltip: 'ドラッグ可能範囲の左下座標(controlTarget の親座標系)。\nuseCustomBounds が true のときのみ有効です。'
    })
    public boundsMin: Vec2 = new Vec2(-480, -320);

    @property({
        tooltip: 'ドラッグ可能範囲の右上座標(controlTarget の親座標系)。\nuseCustomBounds が true のときのみ有効です。'
    })
    public boundsMax: Vec2 = new Vec2(480, 320);

    @property({
        tooltip: 'true の場合、このコンポーネントがドラッグを処理しなかったときにイベントを他のUIへ伝播させます。'
    })
    public enableClickThrough: boolean = false;

    @property({
        tooltip: 'true にすると、ドラッグ開始・終了などのデバッグログをコンソールに出力します。'
    })
    public debugLog: boolean = false;

    // 内部状態
    private _isDragging: boolean = false;
    private _dragOffset: Vec3 = new Vec3();   // ポインタ座標と controlTarget の差分
    private _camera: Camera | null = null;   // UI用カメラ(スクリーン座標変換に使用)

    onLoad() {
        // controlTarget が未設定なら自動で決定
        if (!this.controlTarget) {
            if (this.node.parent) {
                this.controlTarget = this.node.parent;
                if (this.debugLog) {
                    console.log('[Draggable] controlTarget が未設定のため、親ノードを使用します:', this.controlTarget.name);
                }
            } else {
                this.controlTarget = this.node;
                if (this.debugLog) {
                    console.log('[Draggable] controlTarget が未設定かつ親なしのため、自身を使用します:', this.controlTarget.name);
                }
            }
        }

        if (!this.controlTarget) {
            console.error('[Draggable] controlTarget を決定できませんでした。ノード階層を確認してください。');
        }

        // UI用カメラの取得(Canvas > Camera)
        this._camera = this._findUICamera();
        if (!this._camera) {
            console.warn('[Draggable] UI用カメラが見つかりませんでした。Canvas 配下で使用することを推奨します。');
        }

        // タッチ(マウス)イベント登録
        this.node.on(Node.EventType.TOUCH_START, this._onTouchStart, this);
        this.node.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        this.node.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        this.node.on(Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this);
    }

    onDestroy() {
        // イベント解除(念のため)
        this.node.off(Node.EventType.TOUCH_START, this._onTouchStart, this);
        this.node.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        this.node.off(Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this);
    }

    /**
     * UI用カメラを探す。
     * - 優先: このノードが属する Canvas の cameraComponent
     * - 次点: シーン内の最初の Canvas の cameraComponent
     */
    private _findUICamera(): Camera | null {
        // 自分から親方向に Canvas を探す
        let current: Node | null = this.node;
        while (current) {
            const canvas = current.getComponent(Canvas);
            if (canvas && canvas.cameraComponent) {
                return canvas.cameraComponent;
            }
            current = current.parent;
        }

        // シーン直下から Canvas を探す(fallback)
        const rootCanvas = find('Canvas');
        if (rootCanvas) {
            const canvas = rootCanvas.getComponent(Canvas);
            if (canvas && canvas.cameraComponent) {
                return canvas.cameraComponent;
            }
        }

        return null;
    }

    /**
     * TOUCH_START: ドラッグ開始判定
     */
    private _onTouchStart(event: EventTouch) {
        if (!this.controlTarget) {
            if (this.debugLog) {
                console.warn('[Draggable] controlTarget が未設定のため、ドラッグできません。');
            }
            if (!this.enableClickThrough) {
                event.propagationStopped = true;
            }
            return;
        }

        const worldPos = this._getWorldPosFromEvent(event);
        if (!worldPos) {
            if (this.debugLog) {
                console.warn('[Draggable] スクリーン座標からワールド座標への変換に失敗しました。');
            }
            if (!this.enableClickThrough) {
                event.propagationStopped = true;
            }
            return;
        }

        // controlTarget の現在位置
        const targetWorldPos = this.controlTarget.worldPosition.clone();

        // ポインタと controlTarget の差分を保存
        this._dragOffset.set(
            targetWorldPos.x - worldPos.x,
            targetWorldPos.y - worldPos.y,
            targetWorldPos.z - worldPos.z
        );

        this._isDragging = true;

        if (this.debugLog) {
            console.log('[Draggable] Drag Start at', worldPos.toString(), 'offset:', this._dragOffset.toString());
        }

        // 自分がドラッグを扱うので、必要に応じてイベントを止める
        if (!this.enableClickThrough) {
            event.propagationStopped = true;
        }
    }

    /**
     * TOUCH_MOVE: ドラッグ中の移動
     */
    private _onTouchMove(event: EventTouch) {
        if (!this._isDragging || !this.controlTarget) {
            if (!this.enableClickThrough) {
                event.propagationStopped = true;
            }
            return;
        }

        const worldPos = this._getWorldPosFromEvent(event);
        if (!worldPos) {
            if (!this.enableClickThrough) {
                event.propagationStopped = true;
            }
            return;
        }

        // 新しい target のワールド座標 = ポインタ位置 + オフセット
        const newWorldPos = new Vec3(
            worldPos.x + this._dragOffset.x,
            worldPos.y + this._dragOffset.y,
            worldPos.z + this._dragOffset.z
        );

        // 親座標系に変換してから設定
        const parent = this.controlTarget.parent;
        if (parent) {
            const parentTransform = parent.getComponent(UITransform);
            if (parentTransform) {
                // world to local
                const localPos = parentTransform.convertToNodeSpaceAR(newWorldPos);

                // 範囲制限
                if (this.useCustomBounds) {
                    localPos.x = Math.max(this.boundsMin.x, Math.min(this.boundsMax.x, localPos.x));
                    localPos.y = Math.max(this.boundsMin.y, Math.min(this.boundsMax.y, localPos.y));
                }

                this.controlTarget.setPosition(localPos);
            } else {
                // UITransform がない場合は直接ローカルに設定(注意)
                const localPos = parent.inverseTransformPoint(new Vec3(), newWorldPos);
                if (this.useCustomBounds) {
                    localPos.x = Math.max(this.boundsMin.x, Math.min(this.boundsMax.x, localPos.x));
                    localPos.y = Math.max(this.boundsMin.y, Math.min(this.boundsMax.y, localPos.y));
                }
                this.controlTarget.setPosition(localPos);
            }
        } else {
            // 親がない場合はワールド座標をそのまま設定
            if (this.useCustomBounds) {
                // 親がない状態での範囲制限は意味が薄いので、そのまま設定
                if (this.debugLog) {
                    console.warn('[Draggable] controlTarget に親がないため、bounds 制限は適用されません。');
                }
            }
            this.controlTarget.setWorldPosition(newWorldPos);
        }

        if (!this.enableClickThrough) {
            event.propagationStopped = true;
        }
    }

    /**
     * TOUCH_END: ドラッグ終了
     */
    private _onTouchEnd(event: EventTouch) {
        if (this._isDragging && this.debugLog) {
            console.log('[Draggable] Drag End');
        }
        this._isDragging = false;

        if (!this.enableClickThrough) {
            event.propagationStopped = true;
        }
    }

    /**
     * TOUCH_CANCEL: ドラッグキャンセル
     */
    private _onTouchCancel(event: EventTouch) {
        if (this._isDragging && this.debugLog) {
            console.log('[Draggable] Drag Cancel');
        }
        this._isDragging = false;

        if (!this.enableClickThrough) {
            event.propagationStopped = true;
        }
    }

    /**
     * EventTouch からワールド座標を取得するヘルパー。
     * UI用カメラが見つからない場合は、screenToWorld による変換が行えないので null を返します。
     */
    private _getWorldPosFromEvent(event: EventTouch): Vec3 | null {
        const location = event.getLocation(); // スクリーン座標
        const screenPos = new Vec3(location.x, location.y, 0);

        if (!this._camera) {
            // カメラがない場合は 2D ゲーム前提で view のサイズをもとに原点を推定する簡易実装
            // (正確な UITransform ベースの変換ではないため、Canvas+Camera の設定を推奨)
            const size = view.getVisibleSize();
            const worldPos = new Vec3(
                screenPos.x - size.width / 2,
                screenPos.y - size.height / 2,
                0
            );
            return worldPos;
        }

        // カメラを使ったスクリーン座標 → ワールド座標変換
        const out = new Vec3();
        this._camera.screenToWorld(screenPos, out);
        return out;
    }
}

コードのポイント解説

  • onLoad
    • controlTarget が未設定なら、親ノード > 自身の順で自動決定。
    • Canvas から UI 用の Camera を探索し、スクリーン座標変換に使用。
    • タッチイベント(マウス含む)を登録。
  • _onTouchStart
    • タッチ位置をワールド座標に変換し、Controlノード位置との差分 _dragOffset を保存。
    • これにより、掴んだ位置を保ったままドラッグできる。
    • enableClickThrough が false の場合、イベント伝播を止めて他のUIが反応しないようにする。
  • _onTouchMove
    • 現在のタッチ位置から新しいワールド座標を計算し、親座標系に変換して setPosition
    • useCustomBounds が true のとき、boundsMinboundsMax の矩形内にクランプ。
  • _onTouchEnd / _onTouchCancel
    • ドラッグ状態を解除し、必要に応じてデバッグログを出力。
  • _getWorldPosFromEvent
    • UIカメラが取得できた場合は camera.screenToWorld で正確に変換。
    • 取得できなかった場合は、簡易的に画面中央を(0,0)とみなした座標を返す(2D前提のフォールバック)。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を Draggable.ts とします。
  3. 自動生成されたコードをすべて削除し、本記事の Draggable クラスのコードを丸ごと貼り付けて保存します。

2. テスト用シーンの準備

ここでは「親の Control ノード(パネル)を、ヘッダー部分をドラッグして移動する」例で説明します。

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を作成します(既にある場合は再利用)。
  2. Canvas の子として、右クリック → Create → UI → Sprite を作成し、名前を Panel に変更します。
    • Inspector の UITransform で Width: 400, Height: 300 など、適当なサイズに調整します。
    • Sprite に任意の画像(単色でも可)を設定します。
  3. Panel の子として、右クリック → Create → UI → Sprite を作成し、名前を Header に変更します。
    • UITransform の Width: 400, Height: 60 など、パネルの上部に細長いヘッダーを作ります。
    • Position: (0, 120) など、パネルの上辺あたりに配置します。

3. Draggable コンポーネントのアタッチ

  1. Hierarchy で Header ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom カテゴリから Draggable を選択して追加します。

4. プロパティの設定例

Header に追加された Draggable コンポーネントを、Inspector で次のように設定します。

  • Control Target:
    • Panel ノードをドラッグ&ドロップして設定します。
      • これにより、「Header を掴んで Panel 全体を移動する」挙動になります。
  • Use Custom Bounds:
    • 画面内に制限したい場合は チェック を入れます。
  • Bounds Min / Bounds Max(例・フルHD想定でCanvas中央原点の場合):
    • Bounds Min: X = -640, Y = -360
    • Bounds Max: X = 640, Y = 360
    • これで、パネルの中心が画面外に出ないようにできます。
  • Enable Click Through:
    • ドラッグ中やクリック時に、下のUIにイベントを渡したくない場合は チェックを外す(デフォルト)。
    • 他のUIと併用したい特殊なケースでは チェック を入れます。
  • Debug Log:
    • 動作確認中にログを見たい場合は チェック を入れます。

5. 実行して動作確認

  1. トップバーの Play ボタンを押してゲームを実行します。
  2. ゲームビュー上で、Header(パネル上部の帯)をマウスでクリック&ドラッグしてみます。
  3. 設定した範囲内で、Panel 全体がマウスに追従して移動すれば成功です。
  4. スマホ実機やエミュレータでビルドした場合も、タッチ操作で同様にドラッグできます。

6. 他の使い方の例

  • ノード自身をドラッグしたい場合
    • ドラッグしたいノード(例: Icon)に Draggable を直接アタッチします。
    • Control Target を空のままにすると、自動的に親ノードが Control になりますが、親ごと動いてしまうと困る場合は、親を Canvas などにし、Icon 自身を動かしたいなら Control TargetIcon を明示的に指定してください。
  • 複数のパネルをそれぞれドラッグ可能にしたい場合
    • 各パネルにヘッダーノードを用意し、それぞれに Draggable をアタッチ&Control Target にそのパネルを指定します。
    • これだけで複数ウィンドウ風UIが簡単に構築できます。

まとめ

この Draggable コンポーネントは、

  • アタッチするだけで「任意のノードをドラッグ移動可能」にできる。
  • 動かす対象(Controlノード)をインスペクタから柔軟に指定できる。
  • ドラッグ可能範囲を矩形で制限できるため、画面外へのはみ出しを簡単に防げる。
  • 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結している。

といった特徴を持つ、再利用性の高い汎用コンポーネントです。

UIパネル、インベントリウィンドウ、ミニマップ、ツールチップの位置調整など、「ユーザーが自由に動かせるUI」を作る場面で、そのまま流用できます。シーン内のさまざまなノードにポン付けして、ドラッグ挙動を統一的に管理していくことで、ゲームのプロトタイピングやUI調整が大幅に効率化されるはずです。

必要に応じて、ドラッグ開始時/終了時にコールバックを追加するなど、プロジェクトに合わせた拡張も行いやすい構造になっているので、ベースコンポーネントとして活用してみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!