【Cocos Creator 3.8】Draggableコンポーネントの実装:アタッチするだけでノードをマウスドラッグで移動できる汎用スクリプト
このコンポーネントは、任意のノードにアタッチするだけで「マウス(またはタッチ)で掴んでドラッグして動かせる」挙動を付与する汎用スクリプトです。UIのウィンドウやパネル、ゲーム内オブジェクトを簡単にドラッグ移動させたいときに使えます。
また、インスペクタから「どのノードを動かすか(親のControlノードなど)」「ドラッグ可能な領域(画面内・任意の矩形)」「ドラッグ開始条件(ボタン種別)」などを細かく調整できるように設計します。
コンポーネントの設計方針
1. 機能要件の整理
- このコンポーネントがアタッチされたノードを「ドラッグ開始のトリガー」とする。
- 実際に移動させるノード(Controlノード)はインスペクタから指定可能にする。
- 未指定の場合は、自身の親ノードを自動でControl対象とする。
- 親が存在しない場合は、自身をControl対象とする。
- マウス/タッチ入力でドラッグ開始・移動・終了を検出し、Controlノードの位置を更新する。
- ドラッグ中は、ドラッグ開始時の「ポインタ座標とControlノード位置の差分」を維持して移動させる(掴んだ位置を保つ)。
- ドラッグ可能領域を制限できるようにする。(例:画面内だけ、任意の矩形範囲)
- 外部のGameManagerやシングルトンに依存せず、このスクリプト単体で完結する。
2. 使用する標準コンポーネント/API
- イベント検出
Node.EventType.TOUCH_STARTNode.EventType.TOUCH_MOVENode.EventType.TOUCH_ENDNode.EventType.TOUCH_CANCEL- PC環境ではマウスもこれらのタッチイベントとして扱われます(Cocosの仕様)。
- 座標変換
UITransformとCameraを使ったスクリーン座標 <-> ワールド座標変換。- 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が反応しないようにする。
- タッチ位置をワールド座標に変換し、Controlノード位置との差分
- _onTouchMove
- 現在のタッチ位置から新しいワールド座標を計算し、親座標系に変換して
setPosition。 useCustomBoundsが true のとき、boundsMin~boundsMaxの矩形内にクランプ。
- 現在のタッチ位置から新しいワールド座標を計算し、親座標系に変換して
- _onTouchEnd / _onTouchCancel
- ドラッグ状態を解除し、必要に応じてデバッグログを出力。
- _getWorldPosFromEvent
- UIカメラが取得できた場合は
camera.screenToWorldで正確に変換。 - 取得できなかった場合は、簡易的に画面中央を(0,0)とみなした座標を返す(2D前提のフォールバック)。
- UIカメラが取得できた場合は
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
Draggable.tsとします。 - 自動生成されたコードをすべて削除し、本記事の
Draggableクラスのコードを丸ごと貼り付けて保存します。
2. テスト用シーンの準備
ここでは「親の Control ノード(パネル)を、ヘッダー部分をドラッグして移動する」例で説明します。
- Hierarchy パネルで右クリック → Create → UI → Canvas を作成します(既にある場合は再利用)。
- Canvas の子として、右クリック → Create → UI → Sprite を作成し、名前を
Panelに変更します。- Inspector の UITransform で Width: 400, Height: 300 など、適当なサイズに調整します。
- Sprite に任意の画像(単色でも可)を設定します。
Panelの子として、右クリック → Create → UI → Sprite を作成し、名前をHeaderに変更します。- UITransform の Width: 400, Height: 60 など、パネルの上部に細長いヘッダーを作ります。
- Position: (0, 120) など、パネルの上辺あたりに配置します。
3. Draggable コンポーネントのアタッチ
- Hierarchy で
Headerノードを選択します。 - Inspector の下部にある Add Component ボタンをクリックします。
- 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 = -360Bounds Max: X = 640, Y = 360- これで、パネルの中心が画面外に出ないようにできます。
- Enable Click Through:
- ドラッグ中やクリック時に、下のUIにイベントを渡したくない場合は
チェックを外す(デフォルト)。 - 他のUIと併用したい特殊なケースでは
チェックを入れます。
- ドラッグ中やクリック時に、下のUIにイベントを渡したくない場合は
- Debug Log:
- 動作確認中にログを見たい場合は
チェックを入れます。
- 動作確認中にログを見たい場合は
5. 実行して動作確認
- トップバーの Play ボタンを押してゲームを実行します。
- ゲームビュー上で、
Header(パネル上部の帯)をマウスでクリック&ドラッグしてみます。 - 設定した範囲内で、
Panel全体がマウスに追従して移動すれば成功です。 - スマホ実機やエミュレータでビルドした場合も、タッチ操作で同様にドラッグできます。
6. 他の使い方の例
- ノード自身をドラッグしたい場合
- ドラッグしたいノード(例:
Icon)にDraggableを直接アタッチします。 Control Targetを空のままにすると、自動的に親ノードが Control になりますが、親ごと動いてしまうと困る場合は、親をCanvasなどにし、Icon自身を動かしたいならControl TargetにIconを明示的に指定してください。
- ドラッグしたいノード(例:
- 複数のパネルをそれぞれドラッグ可能にしたい場合
- 各パネルにヘッダーノードを用意し、それぞれに
Draggableをアタッチ&Control Targetにそのパネルを指定します。 - これだけで複数ウィンドウ風UIが簡単に構築できます。
- 各パネルにヘッダーノードを用意し、それぞれに
まとめ
この Draggable コンポーネントは、
- アタッチするだけで「任意のノードをドラッグ移動可能」にできる。
- 動かす対象(Controlノード)をインスペクタから柔軟に指定できる。
- ドラッグ可能範囲を矩形で制限できるため、画面外へのはみ出しを簡単に防げる。
- 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結している。
といった特徴を持つ、再利用性の高い汎用コンポーネントです。
UIパネル、インベントリウィンドウ、ミニマップ、ツールチップの位置調整など、「ユーザーが自由に動かせるUI」を作る場面で、そのまま流用できます。シーン内のさまざまなノードにポン付けして、ドラッグ挙動を統一的に管理していくことで、ゲームのプロトタイピングやUI調整が大幅に効率化されるはずです。
必要に応じて、ドラッグ開始時/終了時にコールバックを追加するなど、プロジェクトに合わせた拡張も行いやすい構造になっているので、ベースコンポーネントとして活用してみてください。




