【Cocos Creator 3.8】GridWalker の実装:アタッチするだけで「1入力ごとにピタッと1マス移動」するRPG風グリッド移動を実現する汎用スクリプト

このコンポーネントは、RPGのように「上下左右キーを1回入れると、キャラクターがちょうど1マス分だけスッと移動する」挙動を、ノードにアタッチするだけで実現するためのものです。
マスの大きさ(例: 32px, 64px)や移動方向の制限、移動速度、入力方法(キーボード / 仮想スティックなど)をインスペクタから調整でき、他のスクリプトに依存せず単体で完結します。


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

実現したい挙動

  • 1回の入力で、指定した「グリッドサイズ」分だけノードを移動する。
  • 移動中は次の入力を受け付けず、1マス移動し終わってから次の入力を受け付ける。
  • 移動方向は「上下左右」のみ(斜め移動はしない)。
  • 移動は瞬間移動ではなく、一定速度でスムーズに補間される。
  • 入力は以下の2系統をサポート(どちらか or 両方を有効化可能):
    • キーボード入力(WASD / カーソルキー)
    • 外部からの「方向ベクトル入力」(例: 仮想スティックやAIからの指示)
  • 外部依存は一切なし。GridWalker 単体で動作する。

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

  • ゲーム全体を管理する GameManager や InputManager などには依存しない。
  • 「方向ベクトル入力」を受け取りたい場合も、イベントリスナーで受ける形にして、任意の他コンポーネントから node.emit() するだけで連携できるようにする。
  • 必要な設定値はすべて @property でインスペクタから設定可能にする。
  • 標準コンポーネントへの依存はない(Transform / Node のみで動作)。RigidBody や Collider は不要。

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

GridWalker が持つプロパティと、その役割は以下の通りです。

  • gridSize: number
    • 1マスあたりのピクセルサイズ(ローカル座標系の単位)。
    • 例: 32, 48, 64 など。
    • 正の値のみ有効。0 以下の場合は自動的に 32 に補正します。
  • moveSpeed: number
    • 1秒あたりの移動ピクセル数。
    • 例: 128 にすると、1秒で128px移動する速度。
    • gridSize / moveSpeed で「1マス移動にかかる秒数」が決まる。
  • useKeyboardInput: boolean
    • true の場合、キーボード入力(WASD / カーソルキー)で移動方向を受け付ける。
    • false の場合、キーボードは無効になり、外部イベント入力のみで動く。
  • keyboardPriority: ‘arrow’ | ‘wasd’ | ‘both’
    • どのキーを有効にするかの選択。
    • 'arrow': カーソルキーのみ(↑↓←→)。
    • 'wasd': WASD のみ。
    • 'both': 両方を有効。
  • acceptExternalInput: boolean
    • true の場合、ノードに対して発行されるカスタムイベント "grid-walk-direction" を受け付ける。
    • このイベントには { x: number, y: number } 形式の方向ベクトルを渡す想定。
    • 例: node.emit('grid-walk-direction', { x: 0, y: 1 }); で「上移動」の入力扱い。
  • snapToGridOnStart: boolean
    • true の場合、start() 時に現在位置を最も近いグリッド位置にスナップ(丸め)する。
    • マップ上のタイルと位置を揃えたいときに便利。
  • allowBackToBackInput: boolean
    • true の場合、移動完了直後にすぐ次の入力を受け付ける。
    • false の場合、キーが一度離されるまで同じ方向の連続入力を無効にする(「カチッカチッ」と1マスずつ動かしたいとき用)。
  • debugLog: boolean
    • true の場合、移動開始/完了などの情報を console.log で出力する。
    • デバッグ用途。リリース時は false 推奨。

※ 方向入力は常に「上下左右の単一方向」に正規化します。斜め(x, y 両方 ≠ 0)は、より大きい絶対値の軸のみ採用します。


TypeScriptコードの実装

以下が完成した GridWalker コンポーネントの全コードです。


import { _decorator, Component, Node, Vec3, input, Input, EventKeyboard, KeyCode, Enum } from 'cc';
const { ccclass, property } = _decorator;

// キーボード入力の種類を Enum 化してインスペクタで選択可能にする
enum KeyboardScheme {
    ARROW = 0,
    WASD = 1,
    BOTH = 2,
}
Enum(KeyboardScheme);

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

    @property({
        tooltip: '1マスあたりの移動量(ローカル座標系の単位)。例: 32, 48, 64 など。\n0 以下の場合は自動的に 32 に補正されます。',
        min: 1,
    })
    public gridSize: number = 32;

    @property({
        tooltip: '1秒あたりの移動速度(ピクセル/秒)。\n例: 128 にすると、1秒で128px移動します。',
        min: 1,
    })
    public moveSpeed: number = 128;

    @property({
        tooltip: 'キーボード入力(WASD / カーソルキー)を有効にするかどうか。',
    })
    public useKeyboardInput: boolean = true;

    @property({
        type: KeyboardScheme,
        tooltip: 'どのキーボードレイアウトを受け付けるか。\nARROW: カーソルキーのみ\nWASD: WASD のみ\nBOTH: 両方を受け付ける',
    })
    public keyboardPriority: KeyboardScheme = KeyboardScheme.BOTH;

    @property({
        tooltip: '外部からの方向入力イベント "grid-walk-direction" を受け付けるかどうか。\ntrue の場合、node.emit("grid-walk-direction", { x: 0, y: 1 }) などで移動指示が可能になります。',
    })
    public acceptExternalInput: boolean = false;

    @property({
        tooltip: 'start() 時に現在位置を最も近いグリッド位置にスナップ(丸め)するかどうか。',
    })
    public snapToGridOnStart: boolean = true;

    @property({
        tooltip: '移動完了直後にすぐ次の入力を受け付けるかどうか。\nfalse の場合、同じ方向に連続で動かすには一度キーを離す必要があります。',
    })
    public allowBackToBackInput: boolean = true;

    @property({
        tooltip: 'デバッグログを有効にするかどうか。\ntrue の場合、移動開始/完了などの情報をコンソールに出力します。',
    })
    public debugLog: boolean = false;

    // ==== 内部状態 ====

    // 現在移動中かどうか
    private _isMoving: boolean = false;

    // 現在の移動開始位置
    private _startPos: Vec3 = new Vec3();

    // 現在の移動目標位置
    private _targetPos: Vec3 = new Vec3();

    // 1マス移動にかかる時間(秒)
    private _moveDuration: number = 0;

    // 現在の移動経過時間(秒)
    private _elapsedTime: number = 0;

    // 最後に移動した方向(連続入力制御用)
    private _lastMoveDirX: number = 0;
    private _lastMoveDirY: number = 0;

    // 現在キーが押されているかどうか(連続入力制御用)
    private _keyHeldX: number = 0;
    private _keyHeldY: number = 0;

    onLoad() {
        // パラメータの防御的補正
        if (this.gridSize <= 0) {
            console.warn('[GridWalker] gridSize が 0 以下のため 32 に補正します。');
            this.gridSize = 32;
        }
        if (this.moveSpeed <= 0) {
            console.warn('[GridWalker] moveSpeed が 0 以下のため 128 に補正します。');
            this.moveSpeed = 128;
        }

        this._moveDuration = this.gridSize / this.moveSpeed;

        if (this.useKeyboardInput) {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
        }

        if (this.acceptExternalInput) {
            // 外部からの方向入力イベントを受け付ける
            this.node.on('grid-walk-direction', this._onExternalDirection, this);
        }
    }

    start() {
        if (this.snapToGridOnStart) {
            this._snapToGrid();
        }
    }

    onDestroy() {
        if (this.useKeyboardInput) {
            input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
            input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
        }
        if (this.acceptExternalInput) {
            this.node.off('grid-walk-direction', this._onExternalDirection, this);
        }
    }

    update(dt: number) {
        if (!this._isMoving) {
            return;
        }

        this._elapsedTime += dt;
        let t = this._elapsedTime / this._moveDuration;
        if (t > 1) t = 1;

        // 線形補間で位置を更新
        const newPos = new Vec3(
            this._startPos.x + (this._targetPos.x - this._startPos.x) * t,
            this._startPos.y + (this._targetPos.y - this._startPos.y) * t,
            this._startPos.z + (this._targetPos.z - this._startPos.z) * t,
        );
        this.node.setPosition(newPos);

        if (t >= 1) {
            // 移動完了
            this._isMoving = false;
            this.node.setPosition(this._targetPos);

            if (this.debugLog) {
                console.log('[GridWalker] Move finished. New position:', this._targetPos);
            }
        }
    }

    // ==== キーボード入力処理 ====

    private _onKeyDown(event: EventKeyboard) {
        if (!this.useKeyboardInput) {
            return;
        }

        const keyCode = event.keyCode;

        let dirX = 0;
        let dirY = 0;

        const useArrow = this.keyboardPriority === KeyboardScheme.ARROW || this.keyboardPriority === KeyboardScheme.BOTH;
        const useWASD = this.keyboardPriority === KeyboardScheme.WASD || this.keyboardPriority === KeyboardScheme.BOTH;

        if (useArrow) {
            switch (keyCode) {
                case KeyCode.ARROW_UP:
                    dirY = 1;
                    break;
                case KeyCode.ARROW_DOWN:
                    dirY = -1;
                    break;
                case KeyCode.ARROW_LEFT:
                    dirX = -1;
                    break;
                case KeyCode.ARROW_RIGHT:
                    dirX = 1;
                    break;
            }
        }

        if (useWASD) {
            switch (keyCode) {
                case KeyCode.KEY_W:
                    dirY = 1;
                    break;
                case KeyCode.KEY_S:
                    dirY = -1;
                    break;
                case KeyCode.KEY_A:
                    dirX = -1;
                    break;
                case KeyCode.KEY_D:
                    dirX = 1;
                    break;
            }
        }

        if (dirX === 0 && dirY === 0) {
            return;
        }

        // キー押し込み状態を更新
        this._keyHeldX = dirX;
        this._keyHeldY = dirY;

        this._tryMove(dirX, dirY);
    }

    private _onKeyUp(event: EventKeyboard) {
        if (!this.useKeyboardInput) {
            return;
        }

        const keyCode = event.keyCode;

        const useArrow = this.keyboardPriority === KeyboardScheme.ARROW || this.keyboardPriority === KeyboardScheme.BOTH;
        const useWASD = this.keyboardPriority === KeyboardScheme.WASD || this.keyboardPriority === KeyboardScheme.BOTH;

        if (useArrow) {
            switch (keyCode) {
                case KeyCode.ARROW_UP:
                case KeyCode.ARROW_DOWN:
                    if (this._keyHeldY !== 0) this._keyHeldY = 0;
                    break;
                case KeyCode.ARROW_LEFT:
                case KeyCode.ARROW_RIGHT:
                    if (this._keyHeldX !== 0) this._keyHeldX = 0;
                    break;
            }
        }

        if (useWASD) {
            switch (keyCode) {
                case KeyCode.KEY_W:
                case KeyCode.KEY_S:
                    if (this._keyHeldY !== 0) this._keyHeldY = 0;
                    break;
                case KeyCode.KEY_A:
                case KeyCode.KEY_D:
                    if (this._keyHeldX !== 0) this._keyHeldX = 0;
                    break;
            }
        }

        // キーを離したら、次の同方向入力を再び受け付けられるようにする
        if (!this.allowBackToBackInput) {
            if (this._keyHeldX === 0 && this._keyHeldY === 0) {
                this._lastMoveDirX = 0;
                this._lastMoveDirY = 0;
            }
        }
    }

    // ==== 外部入力処理 ====

    /**
     * 外部からの方向入力イベントハンドラ。
     * 期待する payload: { x: number, y: number }
     */
    private _onExternalDirection(payload: { x: number; y: number } | null | undefined) {
        if (!this.acceptExternalInput) {
            return;
        }
        if (!payload) {
            console.warn('[GridWalker] "grid-walk-direction" イベントに有効な payload が渡されていません。');
            return;
        }

        let dirX = payload.x || 0;
        let dirY = payload.y || 0;

        this._tryMove(dirX, dirY);
    }

    // ==== 共通移動処理 ====

    /**
     * 与えられた方向で移動を試みる。
     * @param dirX -1, 0, 1 のいずれかを想定
     * @param dirY -1, 0, 1 のいずれかを想定
     */
    private _tryMove(dirX: number, dirY: number) {
        if (this._isMoving) {
            // すでに移動中なら新しい入力は無視
            return;
        }

        // 方向ベクトルを正規化(上下左右いずれか1方向のみ)
        const absX = Math.abs(dirX);
        const absY = Math.abs(dirY);

        if (absX === 0 && absY === 0) {
            return;
        }

        if (absX > absY) {
            dirX = dirX > 0 ? 1 : -1;
            dirY = 0;
        } else if (absY > absX) {
            dirX = 0;
            dirY = dirY > 0 ? 1 : -1;
        } else {
            // absX === absY の場合、優先方向は Y とする(好みで変更可)
            dirX = 0;
            dirY = dirY > 0 ? 1 : -1;
        }

        // 連続入力制御
        if (!this.allowBackToBackInput) {
            if (dirX === this._lastMoveDirX && dirY === this._lastMoveDirY) {
                // 同じ方向を押しっぱなしの場合は無視
                return;
            }
        }

        this._startPos = this.node.position.clone();
        this._targetPos = new Vec3(
            this._startPos.x + dirX * this.gridSize,
            this._startPos.y + dirY * this.gridSize,
            this._startPos.z,
        );

        this._elapsedTime = 0;
        this._moveDuration = this.gridSize / this.moveSpeed;
        this._isMoving = true;

        this._lastMoveDirX = dirX;
        this._lastMoveDirY = dirY;

        if (this.debugLog) {
            console.log('[GridWalker] Move start. From', this._startPos, 'to', this._targetPos, 'dir:', dirX, dirY);
        }
    }

    /**
     * 現在位置を最も近いグリッド位置にスナップする。
     */
    private _snapToGrid() {
        const pos = this.node.position;
        const snappedX = Math.round(pos.x / this.gridSize) * this.gridSize;
        const snappedY = Math.round(pos.y / this.gridSize) * this.gridSize;
        const snappedPos = new Vec3(snappedX, snappedY, pos.z);
        this.node.setPosition(snappedPos);

        if (this.debugLog) {
            console.log('[GridWalker] Position snapped to grid:', snappedPos);
        }
    }
}

コードのポイント解説

  • onLoad()
    • gridSizemoveSpeed を防御的に補正し、_moveDuration を計算。
    • useKeyboardInput が true の場合、グローバル input に KEY_DOWN / KEY_UP ハンドラを登録。
    • acceptExternalInput が true の場合、ノードに対して "grid-walk-direction" イベントリスナーを登録。
  • start()
    • snapToGridOnStart が true なら、現在位置をグリッドにスナップ。
  • update(dt)
    • _isMoving が true のときのみ、経過時間 _elapsedTime を進める。
    • t = elapsed / duration を計算し、線形補間で _startPos から _targetPos まで移動。
    • t ≥ 1 になったら移動完了として _isMoving = false、最終位置を _targetPos にセット。
  • _onKeyDown / _onKeyUp()
    • WASD / カーソルキーから方向を判定し、_keyHeldX/Y に保持。
    • 押されたときに _tryMove() を呼び出して 1マス移動を開始。
    • allowBackToBackInput が false のときは、キーを離すまで同方向連続入力を抑制。
  • _onExternalDirection()
    • node.emit('grid-walk-direction', { x, y }) から渡された方向ベクトルを受け取り、_tryMove() に渡す。
    • payload が無効な場合は警告ログ。
  • _tryMove()
    • 移動中なら新規入力を無視。
    • 方向ベクトルを「上下左右いずれか1方向」に正規化(斜め入力は一方の軸に丸める)。
    • allowBackToBackInput が false のとき、直前と同方向なら無視。
    • 現在位置から gridSize 分だけ進めた _targetPos を計算し、移動フラグをオン。
  • _snapToGrid()
    • 現在位置を gridSize 単位で丸めて、マップ上のタイルと位置を揃える。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を GridWalker.ts に変更します。
  3. 作成された GridWalker.ts をダブルクリックしてエディタで開き、既存のコードをすべて削除して、前述のコードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択して、テスト用キャラクターノードを作成します。
    • ノード名は例として Player とします。
  2. Player ノードを選択し、Inspector の Transform で Position (X, Y) を 0, 0 付近にしておくと分かりやすいです。

※ GridWalker は Transform / Node のみを使用しているため、Sprite や Collider は必須ではありませんが、見た目の確認のために Sprite を付けておくとよいです。

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

  1. Hierarchy で先ほど作成した Player ノードを選択します。
  2. Inspector 下部の Add Component ボタンをクリックします。
  3. Custom → GridWalker を選択してアタッチします。

4. プロパティの設定例

Inspector 上で、GridWalker のプロパティを以下のように設定してみてください。

  • Grid Size: 32
    • 1マス 32px のマップを想定。
  • Move Speed: 128
    • 1マス移動に 32 / 128 = 0.25 秒(約0.25秒)かかる。
  • Use Keyboard Input: ON
  • Keyboard Priority: BOTH
    • WASD でもカーソルキーでも動くようにする。
  • Accept External Input: OFF(まずはキーボードだけでテスト)
  • Snap To Grid On Start: ON
    • 開始時に自動でグリッドに揃えてくれる。
  • Allow Back To Back Input: ON
    • キー押しっぱなしで連続してマス移動したい場合。
  • Debug Log: OFF(必要に応じて ON にして挙動確認)

5. 再生して動作確認

  1. エディタ上部の ▶(Play) ボタンを押してゲームを実行します。
  2. ゲームウィンドウが開いたら、以下を試してみてください。
    • ↑ or W: Player が上に 1マス(32px)移動する。
    • ↓ or S: 下に 1マス移動。
    • ← or A: 左に 1マス移動。
    • → or D: 右に 1マス移動。
  3. キーを押しっぱなしにすると、Allow Back To Back Input = true の場合は、一定間隔で連続してマス移動します。

6. 1マスごとの「カチッカチッ」移動にしたい場合

「押しっぱなしでスーッと連続移動」ではなく、「1回押すたびに1マスだけ動いて止まる」挙動にしたい場合は以下のように設定します。

  • Allow Back To Back Input: OFF

この状態では、同じ方向に連続して動かすには、キーを一度離してから再度押す必要があります。

7. 外部からの方向入力(仮想スティックなど)で動かす例

仮想スティックやボタン UI から GridWalker に指示を出したい場合は、イベントを emit するだけで連携できます。

  1. Player ノードの GridWalker で、以下を設定:
    • Use Keyboard Input: OFF(キーボード操作を無効にする)
    • Accept External Input: ON
  2. 仮想ボタン用の別スクリプトから、以下のように呼び出します(例: ボタンが押されたとき):

// 例: ある UI ボタンのスクリプトから Player を参照して emit する
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;

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

    @property({
        type: Node,
        tooltip: 'GridWalker がアタッチされている対象ノード(例: Player)',
    })
    public target: Node | null = null;

    // ボタンのクリックイベントにこのメソッドを紐付ける
    public onClickMoveUp() {
        if (!this.target) {
            console.warn('[MoveUpButton] target が設定されていません。');
            return;
        }
        // 上方向に1マス移動させる指示
        this.target.emit('grid-walk-direction', { x: 0, y: 1 });
    }
}

同様に、左なら { x: -1, y: 0 }、右なら { x: 1, y: 0 }、下なら { x: 0, y: -1 } を emit すれば、GridWalker が 1マス移動してくれます。


まとめ

この GridWalker コンポーネントを使うことで、

  • 1入力ごとにピタッと1マス動く、RPG 風のグリッド移動を簡単に実装できる。
  • マップのタイルサイズ(gridSize)や移動速度(moveSpeed)をインスペクタから調整するだけで、ゲームのテンポを素早くチューニング可能。
  • キーボード操作と外部イベント入力の両方に対応しているため、PC ゲーム / モバイルゲーム / AI 制御など、さまざまな入力方式に柔軟に対応できる。
  • 他のカスタムスクリプトやシングルトンに依存せず、このスクリプト単体で完結しているため、プロジェクト間の再利用が非常にしやすい。

RPG、ローグライク、戦略シミュレーション、パズルなど、「マス目」を意識したゲームでは頻出の挙動なので、1つ汎用コンポーネントとして持っておくと、今後のプロトタイピングや量産がぐっと楽になります。
本記事の GridWalker をベースに、

  • 移動先のタイルが通行可能かどうかのチェック
  • 移動ごとにイベントを発火してエンカウントやギミックを発動
  • アニメーション再生や向き変更(Sprite の flipX / アニメーション切り替え)

といった機能を追加していけば、より本格的なグリッドベースのキャラクター制御コンポーネントへと発展させることができます。