ターン制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)
  1. GridWalker スクリプトを用意
    上のコードを res://components/GridWalker.gd などに保存します。
  2. シーンにコンポーネントを追加
    プレイヤーシーンを開き、Player (CharacterBody2D) の子として Node2D を追加し、名前を GridWalker に変更。
    そのノードに GridWalker.gd をアタッチします。
  3. インスペクタで設定
    GridWalker ノードを選択して、インスペクタから以下を調整します:
    • cell_size: 例) (32, 32)
    • move_duration: 例) 0.12(短くするとキビキビ動きます)
    • use_builtin_input: ON(プレイヤーは自前入力で動かす)
    • allow_diagonal: 4方向だけにしたいなら OFF
  4. プレイヤースクリプトをシンプルに保つ
    もともと _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 をベースに少しずつ自分のゲームに合った機能を足していくと、「グリッド制ゲーム用の標準コンポーネント集」ができてきて、次のプロジェクトもかなり楽になりますよ。