【Cocos Creator】アタッチするだけ!VirtualJoystick (仮想スティック)の実装方法【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】VirtualJoystick(仮想スティック)の実装:アタッチするだけで「指を置いた場所に出現するスティック入力」を実現する汎用スクリプト

本記事では、スマホ・タブレット向けのアクションゲームなどでよく使われる「仮想スティック」を、1つのスクリプトだけで完結させる汎用コンポーネントとして実装します。

  • 画面の任意の位置をタッチすると、その場所にスティックが出現
  • 指をスライドすると、スティックのハンドルが移動し、方向と強さ(-1〜1のベクトル)を出力
  • 指を離すとスティックは非表示になり、入力はゼロにリセット

このコンポーネントは、Canvas 直下のノードにアタッチするだけで動作し、他の GameManager などの外部スクリプトには一切依存しません。
プレイヤー制御コンポーネントなどからは、プロパティ経由で現在の入力ベクトルを参照するだけで仮想スティック入力を利用できます。


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

1. 要件整理

  • タッチ開始位置にスティックを表示する(固定位置でなく、どこでも OK)。
  • ドラッグ中は「中心位置」と「ハンドル位置」から入力ベクトルを計算する。
  • 一定半径以上ドラッグした場合は、ハンドルは外周でクランプし、入力ベクトルは正規化(長さ1)される。
  • タッチ終了時にスティックを非表示にし、入力ベクトルをゼロにする。
  • 外部依存なし:UI ノード(背景円・ハンドル円)も、コードで自動生成する。
  • 複数タッチ環境でも、最初に押された指1本だけをスティックとして扱う(ID管理)。

2. 外部依存をなくすためのアプローチ

  • このコンポーネントがアタッチされるノードは、Canvas 直下または Canvas 配下を想定。
  • 必要な UI 要素(背景円・ハンドル円)は、onLoad 時に自動で子ノードとして生成する。
  • 背景・ハンドルの見た目は、SpriteFrame を Inspector から差し替え可能にし、未設定時は単色のデフォルトマテリアルを使用。
  • 入力結果は「public readonly input: Vec2」として公開し、他コンポーネントから参照できるようにする(直接書き換えはさせない)。

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

以下のプロパティを用意し、ゲームごとに柔軟に調整できるようにします。

  • radius (number)
    • スティックの有効半径(ピクセル)。
    • 指をどれだけスライドすると最大入力になるかを決める。
    • 例: 100 に設定すると、中心から 100px 以上は常にフル入力。
  • backgroundSpriteFrame (SpriteFrame)
    • スティックの背景円に使用する画像。
    • 未設定の場合は、単色マテリアルで円形ノードを表示(サイズのみ有効)。
  • handleSpriteFrame (SpriteFrame)
    • スティックのハンドル(内側の小さい円)に使用する画像。
    • 未設定の場合は、単色マテリアルで円形ノードを表示。
  • backgroundSize (Size)
    • 背景円ノードのサイズ(幅・高さ)。
    • 通常は radius * 2 と同じか、少し大きめにする。
  • handleSize (Size)
    • ハンドル円ノードのサイズ。
    • 背景より小さく設定する(例: 背景 200, ハンドル 100)。
  • alwaysVisible (boolean)
    • true: 常に画面に表示され、タッチ開始位置は背景の中心に固定。
    • false: 指を置いた位置にスティックが出現し、指を離すと非表示。
  • useWholeScreen (boolean)
    • true: このコンポーネントがアタッチされたノードの UITransform 領域全体をタッチ判定に使う。
    • false: touchArea プロパティで指定したノードのみをタッチ判定に使う。
  • touchArea (Node)
    • useWholeScreen が false の場合に有効。
    • 左側だけ・右側だけなど、任意の UI 領域を指定したいときに使用。
  • deadZone (number)
    • 中心からこの距離(ピクセル)以内の入力は 0 とみなす。
    • 小さな指ブレを無視したい場合に有効。
  • clampToCircle (boolean)
    • true: 入力を円形に制限(斜め方向も均等)。
    • false: 入力を四角形(x, y それぞれ -1〜1)として扱う。
    • 今回は「円形の仮想スティック」を想定して true を推奨。
  • debugLog (boolean)
    • タッチ開始・移動・終了時にログを出力するかどうか。
    • 開発中のデバッグ用。不要なら false。

出力値:

  • input (Vec2, 読み取り専用)
    • 現在の入力ベクトル。
    • x: 左(-1)〜右(1)、y: 下(-1)〜上(1)。
    • deadZone 内では (0, 0)。
    • 指が離れているときも (0, 0)。

TypeScriptコードの実装


import {
    _decorator,
    Component,
    Node,
    UITransform,
    EventTouch,
    Vec2,
    Vec3,
    Size,
    Sprite,
    SpriteFrame,
    Color,
    UIOpacity,
    math,
    view,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * VirtualJoystick
 * - Canvas 配下のノードにアタッチして使用する仮想スティック
 * - 他スクリプトへの依存は一切なし
 * - 背景・ハンドルはコードで自動生成(SpriteFrame を差し替え可能)
 */
@ccclass('VirtualJoystick')
export class VirtualJoystick extends Component {

    @property({
        tooltip: 'スティックの有効半径(ピクセル)。中心からこの距離で最大入力になります。',
    })
    public radius: number = 100;

    @property({
        tooltip: 'デッドゾーン(ピクセル)。この距離以内の入力は 0 とみなします。',
    })
    public deadZone: number = 5;

    @property({
        tooltip: '入力を円形に制限するかどうか。通常の仮想スティックでは true を推奨。',
    })
    public clampToCircle: boolean = true;

    @property({
        tooltip: 'スティックを常に表示するかどうか。false の場合、タッチ時のみ表示されます。',
    })
    public alwaysVisible: boolean = false;

    @property({
        tooltip: 'このコンポーネントがアタッチされたノード全体をタッチエリアとして使うかどうか。',
    })
    public useWholeScreen: boolean = true;

    @property({
        tooltip: 'useWholeScreen が false の場合にタッチエリアとして使うノード。',
    })
    public touchArea: Node | null = null;

    @property({
        type: Size,
        tooltip: '背景円のサイズ(幅・高さ)。通常は radius * 2 と同程度にします。',
    })
    public backgroundSize: Size = new Size(200, 200);

    @property({
        type: Size,
        tooltip: 'ハンドル円のサイズ(幅・高さ)。背景より小さく設定してください。',
    })
    public handleSize: Size = new Size(100, 100);

    @property({
        type: SpriteFrame,
        tooltip: '背景円に使用する SpriteFrame。未設定の場合は単色表示になります。',
    })
    public backgroundSpriteFrame: SpriteFrame | null = null;

    @property({
        type: SpriteFrame,
        tooltip: 'ハンドル円に使用する SpriteFrame。未設定の場合は単色表示になります。',
    })
    public handleSpriteFrame: SpriteFrame | null = null;

    @property({
        tooltip: 'タッチイベントや入力値をログ出力するかどうか(デバッグ用)。',
    })
    public debugLog: boolean = false;

    // 現在の入力ベクトル(読み取り専用として扱いたいので public readonly)
    public readonly input: Vec2 = new Vec2(0, 0);

    // 内部状態
    private _backgroundNode: Node | null = null;
    private _handleNode: Node | null = null;
    private _backgroundTransform: UITransform | null = null;
    private _handleTransform: UITransform | null = null;
    private _opacity: UIOpacity | null = null;

    private _activeTouchId: number | null = null;
    private _centerWorldPos: Vec3 = new Vec3();  // スティック中心のワールド座標
    private _centerLocalPos: Vec3 = new Vec3();  // スティック中心のローカル座標(this.node 基準)

    onLoad() {
        // タッチエリアの確認
        if (!this.useWholeScreen && !this.touchArea) {
            console.warn(
                '[VirtualJoystick] useWholeScreen が false ですが touchArea が設定されていません。' +
                'このノード全体をタッチエリアとして使用します。'
            );
        }

        // UITransform の存在確認
        const uiTransform = this.node.getComponent(UITransform);
        if (!uiTransform) {
            console.error(
                '[VirtualJoystick] このコンポーネントを使用するには、アタッチ先ノードに UITransform コンポーネントが必要です。' +
                'Canvas または UI ノードにアタッチしてください。'
            );
        }

        // 背景・ハンドルノードを生成
        this._createJoystickNodes();

        // タッチイベント登録
        this._registerTouchEvents();
    }

    start() {
        // alwaysVisible 設定に応じて初期表示を切り替え
        if (!this.alwaysVisible) {
            this._setVisible(false);
        } else {
            // 常時表示の場合、ノードの中央を初期中心とする
            this._updateCenterToNodeCenter();
            this._setVisible(true);
            this._updateHandlePosition(Vec2.ZERO);
        }
    }

    onDestroy() {
        this._unregisterTouchEvents();
    }

    /**
     * 背景・ハンドルノードを生成し、Sprite / UITransform / UIOpacity をセットアップ
     */
    private _createJoystickNodes() {
        // 背景ノード
        this._backgroundNode = new Node('JoystickBackground');
        this._backgroundTransform = this._backgroundNode.addComponent(UITransform);
        this._backgroundTransform.setContentSize(this.backgroundSize);
        const bgSprite = this._backgroundNode.addComponent(Sprite);
        if (this.backgroundSpriteFrame) {
            bgSprite.spriteFrame = this.backgroundSpriteFrame;
        } else {
            // デフォルト色(半透明の白)
            bgSprite.color = new Color(255, 255, 255, 128);
        }

        // ハンドルノード
        this._handleNode = new Node('JoystickHandle');
        this._handleTransform = this._handleNode.addComponent(UITransform);
        this._handleTransform.setContentSize(this.handleSize);
        const handleSprite = this._handleNode.addComponent(Sprite);
        if (this.handleSpriteFrame) {
            handleSprite.spriteFrame = this.handleSpriteFrame;
        } else {
            // デフォルト色(不透明の白)
            handleSprite.color = new Color(255, 255, 255, 255);
        }

        // 親子関係を設定
        this.node.addChild(this._backgroundNode);
        this._backgroundNode.addChild(this._handleNode);

        // 透明度制御用
        this._opacity = this._backgroundNode.addComponent(UIOpacity);
        // ハンドルは背景の子なので、背景の UIOpacity でまとめて制御される

        // 初期位置(0,0)に配置
        this._backgroundNode.setPosition(0, 0, 0);
        this._handleNode.setPosition(0, 0, 0);
    }

    /**
     * タッチイベントの登録
     */
    private _registerTouchEvents() {
        const targetNode = this.useWholeScreen || !this.touchArea ? this.node : this.touchArea;

        // UITransform がないとタッチイベントが正常に届かない可能性があるためチェック
        if (!targetNode.getComponent(UITransform)) {
            console.warn(
                '[VirtualJoystick] タッチイベントを受け取るノードに UITransform がありません。' +
                'UI ノードとして使用する場合は UITransform を追加してください。'
            );
        }

        targetNode.on(Node.EventType.TOUCH_START, this._onTouchStart, this);
        targetNode.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        targetNode.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        targetNode.on(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
    }

    /**
     * タッチイベントの解除
     */
    private _unregisterTouchEvents() {
        const targetNode = this.useWholeScreen || !this.touchArea ? this.node : this.touchArea;
        targetNode.off(Node.EventType.TOUCH_START, this._onTouchStart, this);
        targetNode.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        targetNode.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        targetNode.off(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
    }

    /**
     * タッチ開始
     */
    private _onTouchStart(event: EventTouch) {
        // すでに別の指を追跡している場合は無視
        if (this._activeTouchId !== null) {
            return;
        }

        this._activeTouchId = event.getID();

        // タッチ位置(ワールド座標)
        const worldPos = event.getUILocation(); // Vec2 (スクリーン座標系: 左下原点)
        const worldPos3 = new Vec3(worldPos.x, worldPos.y, 0);

        // this.node のローカル座標に変換
        const parent = this.node.parent;
        if (!parent) {
            console.error('[VirtualJoystick] this.node に parent がありません。Canvas 配下に配置してください。');
            return;
        }
        parent.getComponent(UITransform)?.convertToNodeSpaceAR(worldPos3, this._centerLocalPos);

        // ワールド座標も保持(入力計算時に使用しやすいため)
        this._centerWorldPos.set(worldPos3);

        // スティックの位置を更新
        if (this._backgroundNode) {
            this._backgroundNode.setWorldPosition(worldPos3);
        }

        // ハンドルは中心に戻す
        this._updateHandlePosition(Vec2.ZERO);

        // 表示制御
        if (!this.alwaysVisible) {
            this._setVisible(true);
        }

        if (this.debugLog) {
            console.log('[VirtualJoystick] TOUCH_START id=', this._activeTouchId, ' pos=', worldPos);
        }

        // 入力初期化
        this.input.set(0, 0);
    }

    /**
     * タッチ移動
     */
    private _onTouchMove(event: EventTouch) {
        if (this._activeTouchId === null || this._activeTouchId !== event.getID()) {
            return;
        }

        const worldPos = event.getUILocation();
        const worldPos3 = new Vec3(worldPos.x, worldPos.y, 0);

        // 中心からの差分ベクトル(ワールド座標)
        const delta = new Vec3();
        Vec3.subtract(delta, worldPos3, this._centerWorldPos);

        // 2D ベクトルとして扱う
        const delta2 = new Vec2(delta.x, delta.y);
        const distance = delta2.length();

        // deadZone 処理
        if (distance <= this.deadZone) {
            this.input.set(0, 0);
            this._updateHandlePosition(Vec2.ZERO);
            if (this.debugLog) {
                console.log('[VirtualJoystick] within deadZone, input=0');
            }
            return;
        }

        // 半径に応じて正規化
        let normalized = new Vec2(delta2.x, delta2.y);
        if (distance > 0) {
            normalized.multiplyScalar(1 / distance); // 単位ベクトル
        }

        let magnitude = math.clamp(distance / this.radius, 0, 1);

        if (this.clampToCircle) {
            // そのまま円形入力(長さ 0〜1)
            this.input.set(normalized.x * magnitude, normalized.y * magnitude);
        } else {
            // 四角形にクランプ(x, y それぞれ -1〜1)
            const x = math.clamp(delta2.x / this.radius, -1, 1);
            const y = math.clamp(delta2.y / this.radius, -1, 1);
            this.input.set(x, y);
        }

        // ハンドル位置を更新(クランプ)
        const handleOffset = new Vec2(delta2.x, delta2.y);
        if (distance > this.radius) {
            // 半径の外には出さない
            handleOffset.multiplyScalar(this.radius / distance);
        }
        this._updateHandlePosition(handleOffset);

        if (this.debugLog) {
            console.log('[VirtualJoystick] TOUCH_MOVE input=', this.input);
        }
    }

    /**
     * タッチ終了 / キャンセル
     */
    private _onTouchEnd(event: EventTouch) {
        if (this._activeTouchId === null || this._activeTouchId !== event.getID()) {
            return;
        }

        if (this.debugLog) {
            console.log('[VirtualJoystick] TOUCH_END id=', this._activeTouchId);
        }

        this._activeTouchId = null;

        // 入力リセット
        this.input.set(0, 0);

        // ハンドルを中心に戻す
        this._updateHandlePosition(Vec2.ZERO);

        // 表示制御
        if (!this.alwaysVisible) {
            this._setVisible(false);
        }
    }

    /**
     * スティックの可視状態を切り替える
     */
    private _setVisible(visible: boolean) {
        if (this._opacity) {
            this._opacity.opacity = visible ? 255 : 0;
        }
        if (this._backgroundNode) {
            this._backgroundNode.active = visible;
        }
    }

    /**
     * ハンドルノードのローカル位置を更新する
     * @param offset 中心からのオフセット(ローカル座標系, ピクセル)
     */
    private _updateHandlePosition(offset: Vec2) {
        if (!this._handleNode) {
            return;
        }
        this._handleNode.setPosition(offset.x, offset.y, 0);
    }

    /**
     * alwaysVisible = true のとき、ノードの中心をスティック中心として使用する
     */
    private _updateCenterToNodeCenter() {
        const uiTransform = this.node.getComponent(UITransform);
        if (!uiTransform) {
            return;
        }

        // this.node のローカル中心は (0,0,0)
        this._centerLocalPos.set(0, 0, 0);

        // ワールド座標に変換
        const worldPos = this.node.worldPosition;
        this._centerWorldPos.set(worldPos);

        // 背景ノードを中心に配置
        if (this._backgroundNode) {
            this._backgroundNode.setWorldPosition(worldPos);
        }
    }
}

ライフサイクルメソッドのポイント解説

  • onLoad
    • UITransform の存在チェック(なければエラーを出す)。
    • 背景・ハンドルノードを自動生成し、Sprite / UITransform / UIOpacity を設定。
    • タッチイベント(TOUCH_START / MOVE / END / CANCEL)を登録。
  • start
    • alwaysVisible が false の場合、最初は非表示。
    • alwaysVisible が true の場合、this.node の中心をスティック中心にし、常時表示。
  • onDestroy
    • タッチイベントの解除。

タッチ処理のコアは _onTouchStart / _onTouchMove / _onTouchEnd にまとまっており、タッチIDの管理と、中心位置からの差分計算→入力ベクトルの変換を行っています。


使用手順と動作確認

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

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

2. Canvas 配下にジョイスティックノードを作成

  1. Hierarchy パネルで Canvas ノードを確認します(なければ 3D → UI → Canvas で作成)。
  2. Canvas を右クリック → Create → Empty Node で空ノードを作成します。
  3. 名前を分かりやすく VirtualJoystickRoot などに変更します。
  4. 選択したノードの Inspector を確認し、UITransform コンポーネントが付いていることを確認します(Canvas 配下であれば自動で付いているはずです)。

3. VirtualJoystick コンポーネントをアタッチ

  1. Hierarchy で VirtualJoystickRoot ノードを選択します。
  2. Inspector の Add Component ボタンをクリックします。
  3. Custom → VirtualJoystick を選択してアタッチします。

4. プロパティの設定

Inspector 上で、VirtualJoystick コンポーネントの各プロパティを設定します。

  • radius: 100〜150 あたりから試してみましょう(例: 120)。
  • deadZone: 5〜10 程度(例: 8)。
  • clampToCircle: ON (チェックを入れる)。
  • alwaysVisible:
    • 「指を置いた場所に出現させたい」→ OFF(チェックを外す)。
    • 「左下に固定で常に表示したい」→ ON(チェックを入れ、後述の位置調整を行う)。
  • useWholeScreen: まずは ON(チェックを入れる)にして、画面全体をタッチエリアにします。
  • touchArea: useWholeScreen が ON の場合は無視されるので、空のままで構いません。
  • backgroundSize: (240, 240) など、radius * 2 と同程度に設定。
  • handleSize: (120, 120) など、背景より小さめに設定。
  • backgroundSpriteFrame / handleSpriteFrame:
    • 任意の円形画像を用意してドラッグ & ドロップで設定します。
    • 未設定でも動作しますが、見た目は単色の四角形になります。
  • debugLog: 動作確認中は ON にして、Console でログを見ながら調整すると分かりやすいです。

5. 位置調整(alwaysVisible = true の場合)

alwaysVisible を ON にして、「画面左下に固定表示したい」場合の手順です。

  1. Hierarchy で VirtualJoystickRoot ノードを選択します。
  2. Inspector の UITransform で Anchor を (0, 0)(左下)に設定します。
  3. Position を (100, 100, 0) などに設定し、左下から少し内側に配置します。
  4. この状態で再生すると、スティック背景が常に左下に表示され、タッチ開始位置はその中心に固定されます。

6. 実際の入力値を確認する(テスト用スクリプト)

VirtualJoystick 単体でも入力ベクトルは内部に保持されていますが、他コンポーネントからの参照方法を確認しておきます。

  1. Assets パネルで右クリック → Create → TypeScript
  2. ファイル名を JoystickDebugViewer.ts にします。
  3. 以下のような簡易スクリプトを貼り付けます。

import { _decorator, Component, Node, Vec2 } from 'cc';
import { VirtualJoystick } from './VirtualJoystick';
const { ccclass, property } = _decorator;

@ccclass('JoystickDebugViewer')
export class JoystickDebugViewer extends Component {

    @property({
        type: VirtualJoystick,
        tooltip: '入力を参照する VirtualJoystick コンポーネント',
    })
    public joystick: VirtualJoystick | null = null;

    update(deltaTime: number) {
        if (!this.joystick) {
            return;
        }
        const input: Vec2 = this.joystick.input;
        // ここでは単純にログ出力だけ
        // 実際のゲームでは、この input を使ってプレイヤー移動などを行います。
        // console.log('Joystick input:', input.x.toFixed(2), input.y.toFixed(2));
    }
}

使用手順:

  1. Hierarchy で任意のノード(例: Canvas 直下の DebugNode)を作成します。
  2. そのノードに JoystickDebugViewer を Add Component で追加します。
  3. Inspector の Joystick プロパティに、VirtualJoystickRoot ノードをドラッグ & ドロップします。
  4. ゲームを再生し、タッチ / ドラッグしながら Console のログを確認すると、-1〜1 のベクトルが変化しているのが分かります(コメントアウトを外してください)。

7. よくあるトラブルとチェックポイント

  • タッチしても反応しない
    • VirtualJoystickRoot ノード、または touchArea に UITransform が付いているか確認。
    • ノードが active = true になっているか確認。
    • Canvas の Priority や他 UI のブロック(Button など)に隠れていないか確認。
  • スティックの位置がおかしい
    • Canvas の Design Resolution と実機の解像度差による影響がないか。
    • VirtualJoystickRoot の Anchor / Position が意図した場所になっているか。
  • 入力が弱すぎる / 強すぎる
    • radius を調整(大きくすると同じ指の移動量でも入力が小さくなる)。
    • deadZone を調整(大きくしすぎると細かい入力が効かなくなる)。

まとめ

本記事では、Cocos Creator 3.8.7 + TypeScript で、

  • VirtualJoystick という完全独立の汎用コンポーネントを実装し、
  • Canvas 配下のノードにアタッチするだけで、
  • 「指を置いた位置にスティックが出現し、-1〜1 の入力ベクトルを取得できる」

という仕組みを作りました。

このコンポーネントは、

  • プレイヤー移動(2D/3D)
  • カメラ回転(右スティックとしてもう1つ配置)
  • メニューカーソル操作

など、スマホ向けゲームのあらゆる場面で再利用できます。
外部の GameManager やシングルトンに依存せず、必要なノードにアタッチして input プロパティを読むだけというシンプルな設計なので、プロジェクト間のコピペ移植も容易です。

あとは、この VirtualJoystick をプレイヤー制御スクリプトに接続し、joystick.input を元に速度ベクトルを計算するだけで、すぐに「スマホ対応の操作感」をゲームに組み込めます。ぜひ、自分のプロジェクトに合わせて radius や deadZone、見た目の SpriteFrame を調整し、使い回せる「自前標準コンポーネント」として育ててみてください。

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をコピーしました!