【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 });で「上移動」の入力扱い。
- true の場合、ノードに対して発行されるカスタムイベント
- snapToGridOnStart: boolean
- true の場合、
start()時に現在位置を最も近いグリッド位置にスナップ(丸め)する。 - マップ上のタイルと位置を揃えたいときに便利。
- true の場合、
- allowBackToBackInput: boolean
- true の場合、移動完了直後にすぐ次の入力を受け付ける。
- false の場合、キーが一度離されるまで同じ方向の連続入力を無効にする(「カチッカチッ」と1マスずつ動かしたいとき用)。
- debugLog: boolean
- true の場合、移動開始/完了などの情報を
console.logで出力する。 - デバッグ用途。リリース時は false 推奨。
- true の場合、移動開始/完了などの情報を
※ 方向入力は常に「上下左右の単一方向」に正規化します。斜め(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()
gridSizeとmoveSpeedを防御的に補正し、_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 のときは、キーを離すまで同方向連続入力を抑制。
- WASD / カーソルキーから方向を判定し、
- _onExternalDirection()
node.emit('grid-walk-direction', { x, y })から渡された方向ベクトルを受け取り、_tryMove()に渡す。- payload が無効な場合は警告ログ。
- _tryMove()
- 移動中なら新規入力を無視。
- 方向ベクトルを「上下左右いずれか1方向」に正規化(斜め入力は一方の軸に丸める)。
allowBackToBackInputが false のとき、直前と同方向なら無視。- 現在位置から
gridSize分だけ進めた_targetPosを計算し、移動フラグをオン。
- _snapToGrid()
- 現在位置を
gridSize単位で丸めて、マップ上のタイルと位置を揃える。
- 現在位置を
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を GridWalker.ts に変更します。
- 作成された
GridWalker.tsをダブルクリックしてエディタで開き、既存のコードをすべて削除して、前述のコードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択して、テスト用キャラクターノードを作成します。
- ノード名は例として Player とします。
- Player ノードを選択し、Inspector の Transform で Position (X, Y) を 0, 0 付近にしておくと分かりやすいです。
※ GridWalker は Transform / Node のみを使用しているため、Sprite や Collider は必須ではありませんが、見た目の確認のために Sprite を付けておくとよいです。
3. GridWalker コンポーネントのアタッチ
- Hierarchy で先ほど作成した Player ノードを選択します。
- Inspector 下部の Add Component ボタンをクリックします。
- 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. 再生して動作確認
- エディタ上部の ▶(Play) ボタンを押してゲームを実行します。
- ゲームウィンドウが開いたら、以下を試してみてください。
- ↑ or W: Player が上に 1マス(32px)移動する。
- ↓ or S: 下に 1マス移動。
- ← or A: 左に 1マス移動。
- → or D: 右に 1マス移動。
- キーを押しっぱなしにすると、
Allow Back To Back Input = trueの場合は、一定間隔で連続してマス移動します。
6. 1マスごとの「カチッカチッ」移動にしたい場合
「押しっぱなしでスーッと連続移動」ではなく、「1回押すたびに1マスだけ動いて止まる」挙動にしたい場合は以下のように設定します。
- Allow Back To Back Input:
OFF
この状態では、同じ方向に連続して動かすには、キーを一度離してから再度押す必要があります。
7. 外部からの方向入力(仮想スティックなど)で動かす例
仮想スティックやボタン UI から GridWalker に指示を出したい場合は、イベントを emit するだけで連携できます。
- Player ノードの GridWalker で、以下を設定:
- Use Keyboard Input: OFF(キーボード操作を無効にする)
- Accept External Input: ON
- 仮想ボタン用の別スクリプトから、以下のように呼び出します(例: ボタンが押されたとき):
// 例: ある 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 / アニメーション切り替え)
といった機能を追加していけば、より本格的なグリッドベースのキャラクター制御コンポーネントへと発展させることができます。
