ターン制RPGやローグライクでよくある「1回キーを押すと、キャラがキッチリ1マスだけ動く」あの挙動、Godotで素直に組もうとするとちょっと面倒ですよね。
- 通常の
Input.get_axis()やInput.is_action_pressed()だと、キーを押しっぱなしにした瞬間にグイグイ連続で動いてしまう - タイルマップの1マス(例: 32px)にピッタリ揃えたいけど、フレームごとの速度計算だと微妙にズレが出がち
- プレイヤー、敵、動く床など「グリッド移動したいもの」が増えるたびに同じロジックをコピペしがち
Godot標準のやり方だと、CharacterBody2D を継承した「プレイヤー用スクリプト」「敵用スクリプト」をそれぞれ作って、そこにグリッド移動ロジックを書き込みがちです。でもそれだと、
- クラスが肥大化してテストしづらい
- 「やっぱりマスサイズを変えたい」「移動速度を変えたい」ときに複数スクリプトを修正するハメになる
そこで今回は、「グリッド移動だけ」を担当する独立コンポーネント GridWalker を作って、どんなノードにもアタッチするだけで「1入力につき1マス移動」を実現してみましょう。
【Godot 4】カチッと1マスだけ進む!「GridWalker」コンポーネント
この GridWalker コンポーネントは、
- 1回の入力でちょうど1マス分だけ移動
- 移動中は次の入力を受け付けない(「カチッ」とした操作感)
- マスサイズ(例: 32px, 48px)や移動速度をインスペクタから変更可能
- 「入力を自前で受けるモード」と「外部から方向だけ渡すモード」の両対応
という、RPGやローグライクのベースにちょうどいい動きを提供します。
前提
- Godot 4.x
- 2D前提(
Node2D/CharacterBody2D/Area2Dなど)
入力マップの設定例
以下のアクションを プロジェクト設定 > Input Map で定義している前提で書いています。
ui_up/ui_down/ui_left/ui_right
GridWalker.gd – フルコード
extends Node2D
class_name GridWalker
## グリッド単位で「1入力につき1マス」だけ移動させるコンポーネント。
## - どんな2Dノードにもアタッチ可能(親ノードの position を動かします)
## - 入力を自前で読むモード / 外部から命令だけ受けるモードの両方に対応
@export_group("Grid Settings")
## 1マスのピクセルサイズ(例: 32, 48, 64)
@export var cell_size: Vector2 = Vector2(32, 32)
## マスの位置をどこにスナップするか(通常は (0, 0) のままでOK)
@export var grid_origin: Vector2 = Vector2.ZERO
@export_group("Movement")
## 1マス移動にかける時間(秒)。0 にすると瞬間移動。
@export_range(0.0, 1.0, 0.01)
@export var move_duration: float = 0.12
## 対角移動を許可するか(true なら斜め移動可)
@export var allow_diagonal: bool = false
## 入力をこのコンポーネントが直接読むかどうか。
## false の場合、外部から move_in_direction() を呼び出して制御します。
@export var use_builtin_input: bool = true
@export_group("Input Actions (when use_builtin_input = true)")
@export var action_up: StringName = &"ui_up"
@export var action_down: StringName = &"ui_down"
@export var action_left: StringName = &"ui_left"
@export var action_right: StringName = &"ui_right"
@export_group("Collision / Block Check")
## 次のマスに進んで良いかどうかを判定するコールバック。
## 何もセットされていない場合は常に移動OK。
## 例: 親ノード側で
## func _ready():
## $GridWalker.can_move_checker = can_move_to
var can_move_checker: Callable
## 内部状態
var _is_moving: bool = false
var _move_from: Vector2
var _move_to: Vector2
var _move_elapsed: float = 0.0
func _ready() -> void:
# 初期位置をグリッドにスナップしておくと気持ちいい
var parent := get_parent()
if parent and parent is Node2D:
parent.position = _snap_to_grid(parent.position)
func _process(delta: float) -> void:
if _is_moving:
_update_movement(delta)
return
if use_builtin_input:
var dir := _get_input_direction()
if dir != Vector2.ZERO:
move_in_direction(dir)
## 外部から呼び出して「この方向に1マス動いて」と命令するための関数。
## 例: grid_walker.move_in_direction(Vector2.RIGHT)
func move_in_direction(direction: Vector2) -> void:
if _is_moving:
return # 移動中は新しい命令を受け付けない
direction = direction.sign() # (1,0), (-1,0) など正規化(ただし長さ1とは限らない)
if direction == Vector2.ZERO:
return
# 斜め移動を禁止している場合、入力を軸優先に丸める
if not allow_diagonal:
direction = _normalize_axis(direction)
if direction == Vector2.ZERO:
return
var parent := get_parent()
if not (parent and parent is Node2D):
push_warning("GridWalker must be a child of a Node2D or its subclass.")
return
var from_pos: Vector2 = parent.position
var target_pos: Vector2 = _get_next_cell_position(from_pos, direction)
# 進んでよいかどうかの判定(壁・障害物など)
if can_move_checker.is_valid():
var allowed := can_move_checker.call(target_pos)
if not allowed:
return
# 実際の移動を開始
_start_move(from_pos, target_pos)
## 現在移動中かどうかを返す。
func is_moving() -> bool:
return _is_moving
## 現在のグリッド座標(マス単位の Vector2i)を返す。
func get_grid_position() -> Vector2i:
var parent := get_parent()
if parent and parent is Node2D:
var snapped := _snap_to_grid(parent.position)
var local := snapped - grid_origin
return Vector2i(roundi(local.x / cell_size.x), roundi(local.y / cell_size.y))
return Vector2i.ZERO
## 指定したグリッド座標に瞬間移動させる。
## 例: set_grid_position(Vector2i(5, 3))
func set_grid_position(grid_pos: Vector2i) -> void:
var parent := get_parent()
if parent and parent is Node2D:
var world_pos := grid_origin + Vector2(grid_pos) * cell_size
parent.position = world_pos
_is_moving = false
# --- 内部実装 ---
func _get_input_direction() -> Vector2:
var dir := Vector2.ZERO
if Input.is_action_just_pressed(action_up):
dir.y -= 1
elif Input.is_action_just_pressed(action_down):
dir.y += 1
if Input.is_action_just_pressed(action_left):
dir.x -= 1
elif Input.is_action_just_pressed(action_right):
dir.x += 1
# 斜めを許可しない場合、縦優先 or 横優先などのルールを決められるが、
# ここでは「同時押しをそのまま許可(allow_diagonal = true の場合)」とする。
return dir
func _normalize_axis(dir: Vector2) -> Vector2:
# XとYのどちらの絶対値が大きいかで優先方向を決める
if absf(dir.x) > absf(dir.y):
return Vector2(signf(dir.x), 0)
elif absf(dir.y) > absf(dir.x):
return Vector2(0, signf(dir.y))
else:
# 同じならどちらでもいいが、ここではY優先にしてみる
if dir.y != 0:
return Vector2(0, signf(dir.y))
else:
return Vector2(signf(dir.x), 0)
func _get_next_cell_position(from_pos: Vector2, direction: Vector2) -> Vector2:
# 現在位置を一度グリッドにスナップしてから、1マス分オフセットする
var snapped := _snap_to_grid(from_pos)
var delta := Vector2(direction.x * cell_size.x, direction.y * cell_size.y)
return snapped + delta
func _snap_to_grid(pos: Vector2) -> Vector2:
# grid_origin を基準としたスナップ
var local := pos - grid_origin
var gx := round(local.x / cell_size.x) * cell_size.x
var gy := round(local.y / cell_size.y) * cell_size.y
return grid_origin + Vector2(gx, gy)
func _start_move(from_pos: Vector2, to_pos: Vector2) -> void:
_is_moving = true
_move_from = from_pos
_move_to = to_pos
_move_elapsed = 0.0
# 即時移動モード
if move_duration <= 0.0:
var parent := get_parent()
if parent and parent is Node2D:
parent.position = _move_to
_is_moving = false
func _update_movement(delta: float) -> void:
var parent := get_parent()
if not (parent and parent is Node2D):
_is_moving = false
return
_move_elapsed += delta
var t := 1.0
if move_duration > 0.0:
t = clamp(_move_elapsed / move_duration, 0.0, 1.0)
# 線形補間(必要ならイージングにしてもOK)
parent.position = _move_from.lerp(_move_to, t)
if t >= 1.0:
_is_moving = false
# 最後にもう一度スナップして誤差を潰す
parent.position = _snap_to_grid(parent.position)
使い方の手順
例1: プレイヤーをグリッド移動させる
プレイヤーのノード構成例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── GridWalker (Node2D)
- GridWalker スクリプトを用意
上のコードをres://components/GridWalker.gdなどに保存します。 - シーンにコンポーネントを追加
プレイヤーシーンを開き、Player (CharacterBody2D)の子としてNode2Dを追加し、名前をGridWalkerに変更。
そのノードにGridWalker.gdをアタッチします。 - インスペクタで設定
GridWalker ノードを選択して、インスペクタから以下を調整します:cell_size: 例)(32, 32)move_duration: 例)0.12(短くするとキビキビ動きます)use_builtin_input:ON(プレイヤーは自前入力で動かす)allow_diagonal: 4方向だけにしたいならOFF
- プレイヤースクリプトをシンプルに保つ
もともと_physics_process()で入力を読んでいたなら、その部分は削除してOKです。
プレイヤーのスクリプトは「アニメーション」「HP管理」などに集中させましょう。
例2: 敵キャラを外部から制御する
敵のノード構成例:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── GridWalker (Node2D)
この場合、敵のAIスクリプトから GridWalker に対して move_in_direction() を呼び出してやります。
# Enemy.gd (敵本体のスクリプト)
extends CharacterBody2D
@onready var grid_walker: GridWalker = $GridWalker
func _ready() -> void:
# 敵はプレイヤーと違い、入力は自前では読まない
grid_walker.use_builtin_input = false
func _process(delta: float) -> void:
# 非常に単純な例: 1秒ごとに右へ1マス移動しようとする
if not grid_walker.is_moving():
if randi() % 60 == 0:
grid_walker.move_in_direction(Vector2.RIGHT)
例3: 動く床(Moving Platform)をグリッドで動かす
ノード構成例:
MovingPlatform (Node2D) ├── Sprite2D └── GridWalker (Node2D)
動く床用のスクリプト:
# MovingPlatform.gd
extends Node2D
@onready var grid_walker: GridWalker = $GridWalker
var path: Array[Vector2] = [
Vector2.RIGHT,
Vector2.RIGHT,
Vector2.DOWN,
Vector2.LEFT,
Vector2.LEFT,
Vector2.UP,
]
var path_index: int = 0
func _ready() -> void:
grid_walker.use_builtin_input = false
func _process(delta: float) -> void:
if grid_walker.is_moving():
return
var dir := path[path_index]
grid_walker.move_in_direction(dir)
path_index = (path_index + 1) % path.size()
こんな感じで、「グリッドで動くもの」を全部 GridWalker に任せると、ノード階層はシンプルなまま、挙動の再利用性がグッと上がります。
メリットと応用
GridWalker コンポーネントを導入するメリットはかなり多いです。
- シーン構造がスッキリ
プレイヤー / 敵 / ギミックなどのスクリプトから「グリッド移動ロジック」が消えるので、本来の責務(AI、アニメーション、HP管理など)だけに集中できます。 - マスサイズの変更が一括で楽
プロジェクト途中で「やっぱり 32px → 48px にしよう」となっても、GridWalker のcell_sizeを変えるだけで、全員の移動単位が揃います。 - テストとデバッグがしやすい
GridWalker 単体で「1マスずつ動くか」「斜め移動の挙動はどうか」などを検証しやすくなります。バグが出ても、「GridWalker 側か、それともAI側か」が切り分けやすいですね。 - 継承ツリーに縛られない
プレイヤーはCharacterBody2D、敵はNode2D、ギミックはArea2D…と、ベースクラスがバラバラでも、GridWalker を子としてアタッチするだけで同じグリッド移動ロジックを共有できます。
「移動は全部 GridWalker に任せる」というルールをチーム内で共有しておくと、後から参加したメンバーも挙動の場所を見つけやすくなります。まさに「継承より合成」ですね。
改造案: 移動完了時にシグナルを飛ばす
ターン制の処理などで「プレイヤーの移動が終わったら敵ターンに移る」といった制御をしたい場合、GridWalker に「移動完了シグナル」を足すと便利です。
# GridWalker.gd の先頭付近に追加
signal move_finished(new_grid_position: Vector2i)
# _update_movement() の最後を少し改造
func _update_movement(delta: float) -> void:
var parent := get_parent()
if not (parent and parent is Node2D):
_is_moving = false
return
_move_elapsed += delta
var t := 1.0
if move_duration > 0.0:
t = clamp(_move_elapsed / move_duration, 0.0, 1.0)
parent.position = _move_from.lerp(_move_to, t)
if t >= 1.0:
_is_moving = false
parent.position = _snap_to_grid(parent.position)
emit_signal("move_finished", get_grid_position())
これで、プレイヤー側では
func _ready() -> void:
$GridWalker.move_finished.connect(_on_move_finished)
func _on_move_finished(grid_pos: Vector2i) -> void:
print("Player moved to: ", grid_pos)
# ここで敵ターン開始など
といった形で、「移動完了」をトリガーにゲームロジックを組み立てられるようになります。
このように、GridWalker をベースに少しずつ自分のゲームに合った機能を足していくと、「グリッド制ゲーム用の標準コンポーネント集」ができてきて、次のプロジェクトもかなり楽になりますよ。
