Unityを触り始めた頃にやりがちなのが、プレイヤーの移動処理を全部 Update() にベタ書きしてしまうパターンですね。

  • 入力の取得
  • 移動量の計算
  • アニメーションの切り替え
  • 当たり判定のチェック

これらをひとつの巨大スクリプトに詰め込んでしまうと、ちょっと仕様を変えたいだけでもコード全体に影響が出てしまいます。とくに「RPG的な1マス移動(グリッド移動)」を実装するとき、フレームごとの移動とごちゃ混ぜにするとバグの温床になりがちです。

そこでこの記事では、「1入力につき正確に1マス分だけ移動する」挙動だけに責務を絞ったコンポーネント GridWalker を作ってみます。マスの大きさ(例: 32px, 1unit など)を指定して、その分だけカチッと移動する、RPGライクな移動コンポーネントです。

【Unity】カチッと1マス移動!「GridWalker」コンポーネント

今回の GridWalker は以下のような特徴を持たせます。

  • 1回の入力で「1マス」だけ移動(押しっぱなしで連続移動も可)
  • グリッドの大きさ(1マスのサイズ)をインスペクターから設定可能
  • 移動は補間(スムーズに移動) or 瞬間移動のどちらも選択可能
  • 移動中は次の入力を受け付けない(1歩ずつ確実に進む)
  • 新Input Systemを使った実装(InputActionリファレンスをSerializeFieldで受け取る)

責務は「グリッドに沿って移動すること」だけに絞り、アニメーションや当たり判定などは別コンポーネントに任せられるようにします。


フルコード:GridWalker.cs


using UnityEngine;
using UnityEngine.InputSystem;

namespace GridMovementSample
{
    /// <summary>
    /// 1入力につき「ちょうど1マス」だけ移動するグリッド移動コンポーネント。
    /// - 新Input System の Vector2 アクションを利用
    /// - グリッドサイズ、移動速度、補間の有無をインスペクターから調整可能
    /// - 責務は「グリッドに沿った移動」のみ
    /// </summary>
    [RequireComponent(typeof(Transform))]
    public class GridWalker : MonoBehaviour
    {
        // ==== インスペクター設定項目 ====

        [Header("グリッド設定")]
        [Tooltip("1マスのワールド座標上の大きさ(例: 1なら1unit, 0.32なら32px相当など)")]
        [SerializeField] private float gridSize = 1f;

        [Tooltip("グリッドにスナップさせて開始位置を補正するか")]
        [SerializeField] private bool snapOnStart = true;

        [Header("移動設定")]
        [Tooltip("1マス移動にかかる時間(秒)。0以下なら瞬間移動")]
        [SerializeField] private float moveDuration = 0.15f;

        [Tooltip("対角移動(上下左右以外の移動)を許可するか")]
        [SerializeField] private bool allowDiagonal = false;

        [Tooltip("移動中も入力を受け付けてキューにためるか(falseなら移動中は無視)")]
        [SerializeField] private bool queueInputWhileMoving = true;

        [Header("入力設定 (Input System)")]
        [Tooltip("Vector2 型の移動アクション(例: Player/Move)")]
        [SerializeField] private InputActionReference moveActionRef;

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

        // 現在移動中かどうか
        private bool isMoving = false;

        // 現在の移動コルーチン
        private Coroutine moveCoroutine = null;

        // キューに保持しておく次の移動入力(単一ステップ)
        private Vector2Int? queuedDirection = null;

        // Transform のキャッシュ
        private Transform cachedTransform;

        // 移動閾値:どれくらい入力されたら「1マス分動く」とみなすか
        private const float INPUT_THRESHOLD = 0.5f;

        private void Awake()
        {
            cachedTransform = transform;
        }

        private void OnEnable()
        {
            if (moveActionRef != null && moveActionRef.action != null)
            {
                moveActionRef.action.Enable();
            }
        }

        private void OnDisable()
        {
            if (moveActionRef != null && moveActionRef.action != null)
            {
                moveActionRef.action.Disable();
            }
        }

        private void Start()
        {
            // スタート時にグリッドにスナップしておくと、ズレがなくて綺麗
            if (snapOnStart)
            {
                SnapToGrid();
            }
        }

        private void Update()
        {
            // 入力アクションが設定されていない場合は何もしない
            if (moveActionRef == null || moveActionRef.action == null)
            {
                return;
            }

            // 現在のフレームの入力を取得
            Vector2 rawInput = moveActionRef.action.ReadValue<Vector2>();

            // 入力をグリッド方向に丸める
            Vector2Int stepDirection = GetStepDirection(rawInput);

            if (stepDirection == Vector2Int.zero)
            {
                // 入力が弱い、またはニュートラルなら何もしない
                return;
            }

            if (isMoving)
            {
                // すでに移動中の場合
                if (queueInputWhileMoving)
                {
                    // 次の1歩をキューしておく(上書きされる)
                    queuedDirection = stepDirection;
                }
                return;
            }

            // 移動開始
            StartStep(stepDirection);
        }

        /// <summary>
        /// 現在位置を最も近いグリッド座標にスナップする
        /// </summary>
        public void SnapToGrid()
        {
            Vector3 pos = cachedTransform.position;

            // gridSize の倍数に丸める
            float x = Mathf.Round(pos.x / gridSize) * gridSize;
            float y = Mathf.Round(pos.y / gridSize) * gridSize;
            float z = pos.z; // 2Dの場合はzはそのまま

            cachedTransform.position = new Vector3(x, y, z);
        }

        /// <summary>
        /// 入力ベクトルから 1マス分の方向(上下左右 or 対角)を決定する
        /// </summary>
        private Vector2Int GetStepDirection(Vector2 input)
        {
            // 入力が弱すぎる場合は無視
            if (input.magnitude < INPUT_THRESHOLD)
            {
                return Vector2Int.zero;
            }

            // 対角移動を許可しない場合は、より大きい軸を優先(典型的なRPGの操作感)
            if (!allowDiagonal)
            {
                if (Mathf.Abs(input.x) > Mathf.Abs(input.y))
                {
                    // 水平方向優先
                    return new Vector2Int(Mathf.RoundToInt(Mathf.Sign(input.x)), 0);
                }
                else
                {
                    // 垂直方向優先
                    return new Vector2Int(0, Mathf.RoundToInt(Mathf.Sign(input.y)));
                }
            }

            // 対角移動を許可する場合は、各軸を-1,0,1に丸める
            int stepX = Mathf.Abs(input.x) >= INPUT_THRESHOLD ? Mathf.RoundToInt(Mathf.Sign(input.x)) : 0;
            int stepY = Mathf.Abs(input.y) >= INPUT_THRESHOLD ? Mathf.RoundToInt(Mathf.Sign(input.y)) : 0;

            return new Vector2Int(stepX, stepY);
        }

        /// <summary>
        /// 指定した方向に「1マス分」の移動を開始する
        /// </summary>
        private void StartStep(Vector2Int direction)
        {
            if (direction == Vector2Int.zero)
            {
                return;
            }

            // すでに移動コルーチンが動いていれば止めておく(基本的には起こらない想定)
            if (moveCoroutine != null)
            {
                StopCoroutine(moveCoroutine);
            }

            // 移動先のワールド座標を計算
            Vector3 startPos = cachedTransform.position;
            Vector3 offset = new Vector3(direction.x * gridSize, direction.y * gridSize, 0f);
            Vector3 targetPos = startPos + offset;

            // 移動開始
            if (moveDuration > 0f)
            {
                moveCoroutine = StartCoroutine(MoveRoutine(startPos, targetPos, moveDuration));
            }
            else
            {
                // moveDuration が 0 以下なら瞬間移動
                cachedTransform.position = targetPos;
                OnStepComplete();
            }
        }

        /// <summary>
        /// 1マス分の移動を補間するコルーチン
        /// </summary>
        private System.Collections.IEnumerator MoveRoutine(Vector3 start, Vector3 target, float duration)
        {
            isMoving = true;

            float elapsed = 0f;

            while (elapsed < duration)
            {
                elapsed += Time.deltaTime;
                float t = Mathf.Clamp01(elapsed / duration);

                // イージングをかけたい場合はここで t を加工してもよい
                // 例: t = t * t * (3f - 2f * t); // smoothstep

                cachedTransform.position = Vector3.Lerp(start, target, t);
                yield return null;
            }

            // 最終位置をきっちり合わせる
            cachedTransform.position = target;

            isMoving = false;
            moveCoroutine = null;

            OnStepComplete();
        }

        /// <summary>
        /// 1歩の移動が完了したときに呼ばれる
        /// 次のキューがあれば即座に次の移動を開始する
        /// </summary>
        private void OnStepComplete()
        {
            isMoving = false;

            if (queuedDirection.HasValue)
            {
                Vector2Int dir = queuedDirection.Value;
                queuedDirection = null;
                StartStep(dir);
            }
        }

        // ==== 外部からも使えるユーティリティ ====

        /// <summary>
        /// 今のグリッドインデックス(整数座標)を取得する。
        /// 例: gridSize=1 のとき (2,3) マス目なら (2,3) が返る。
        /// </summary>
        public Vector2Int GetGridIndex()
        {
            Vector3 pos = cachedTransform.position;
            int x = Mathf.RoundToInt(pos.x / gridSize);
            int y = Mathf.RoundToInt(pos.y / gridSize);
            return new Vector2Int(x, y);
        }

        /// <summary>
        /// 強制的にグリッド座標へワープさせる(アニメーションなし)。
        /// </summary>
        public void WarpToGridIndex(Vector2Int gridIndex)
        {
            Vector3 newPos = new Vector3(gridIndex.x * gridSize, gridIndex.y * gridSize, cachedTransform.position.z);
            cachedTransform.position = newPos;
        }
    }
}

使い方の手順

ここでは、典型的な「2DトップダウンRPG風プレイヤー」を例にして、GridWalker の使い方を手順で見ていきます。

  1. プレイヤー用のGameObjectを用意する
    • Hierarchy で Right Click > Create Empty などから Player オブジェクトを作成します。
    • 2Dなら SpriteRenderer を追加して見た目を設定しておきましょう。
  2. Input Systemの設定を行う
    Unity6 では新Input Systemが標準なので、以下のようなアクションを用意します。
    • プロジェクトビューで Right Click > Create > Input Actions を作成(例: PlayerInputActions)。
    • ダブルクリックしてアクションエディタを開き、以下のようなアクションを追加します。
      • Action Map: Player
      • Action: Move(Type: Value / Control Type: Vector2)
      • Bindings:
        • Keyboard WASD / Arrow keys を 2D Vector Composite として設定
        • Gamepad の左スティックも追加しておくと便利です
    • 保存したら、この InputActions アセットをシーン上のどこか(例: GameInput オブジェクト)で有効化しておきます。
  3. GridWalker コンポーネントをアタッチする
    • Player オブジェクトを選択し、Inspector で Add ComponentGridWalker を追加します。
    • インスペクターで以下を設定します。
      • Grid Size: 1マスの大きさ。例:
        • タイルマップ1マス = 1unit なら 1
        • ピクセルパーフェクトで 32px を 0.32unit としているなら 0.32
      • Snap On Start: 開始時にグリッドにスナップするか(ズレ防止にオン推奨)
      • Move Duration: 1マス移動にかかる時間(例: 0.150.2
      • Allow Diagonal: 斜め移動を許可するか(クラシックRPG風ならオフ)
      • Queue Input While Moving: 移動中の入力をキューするか(押しっぱなしで連続歩行させたいならオン)
      • Move Action Ref: 先ほど作った Input Actions の中の Player/Move をドラッグ&ドロップで設定
  4. シーンでテストする
    • シーンにタイルマップや床スプライトを並べて、グリッドの大きさと Grid Size が一致しているか確認します。
    • 再生して、キーボードやゲームパッドで入力してみると、1入力ごとにカチッと1マスずつ移動するはずです。
    • 敵キャラや動く床にも同じ GridWalker を付けて、別のスクリプトから WarpToGridIndex を使ったり、疑似的な入力を流し込むことで、同じグリッドルール上で動かすことができます。

メリットと応用

GridWalker のように「グリッド移動だけ」に責務を絞ったコンポーネントにしておくと、プレハブ管理やレベルデザインがかなり楽になります。

  • プレハブ化がしやすい
    プレイヤー、敵、動く床、ギミックなど、「グリッドに沿って動くもの」は全部このコンポーネントを共有できます。プレハブの中身は「見た目 + GridWalker」で完結し、動き方のルールを一元管理できます。
  • レベルデザインのブレが減る
    1マスの大きさを gridSize に集約しているので、「このマップだけ微妙にズレてる」といった事故を防げます。タイルマップのセルサイズを変えた場合でも、gridSize を合わせるだけで済みます。
  • 拡張しやすい
    当たり判定やイベントトリガー、アニメーションなどは別コンポーネントに分離できます。例えば:
    • GridWalker:グリッドに沿った移動だけを担当
    • GridCollider:次のマスが通行可能かどうかをチェック
    • CharacterAnimator:移動方向に応じてアニメーションを切り替え

    こんな感じで責務を分割しておくと、個別に差し替えやすくなります。

たとえば「次のマスが壁なら進めない」ような簡単な改造は、StartStep の前にチェックを挟むだけで実現できます。以下は、タイルマップやコライダーは使わず、「特定の範囲外には出られない」ようにする簡易ガードの例です。


/// <summary>
/// グリッド範囲外への移動を禁止する例。
/// 例えば (0,0) ~ (9,9) の範囲から出ないようにする。
/// </summary>
private bool CanStepTo(Vector2Int direction)
{
    // 現在のグリッド座標を取得
    Vector2Int currentIndex = GetGridIndex();
    Vector2Int nextIndex = currentIndex + direction;

    // 許可する範囲(ここはインスペクターから設定してもよい)
    Vector2Int minIndex = new Vector2Int(0, 0);
    Vector2Int maxIndex = new Vector2Int(9, 9);

    if (nextIndex.x < minIndex.x || nextIndex.y < minIndex.y) return false;
    if (nextIndex.x > maxIndex.x || nextIndex.y > maxIndex.y) return false;

    return true;
}

この CanStepToStartStep を呼ぶ前に挟んで、false のときは移動をキャンセルするようにすれば、簡易的なマップ制限ができます。さらに発展させて、タイルマップの情報やコライダーを参照するようにすれば、本格的なRPGのフィールド移動にも対応できますね。

巨大な PlayerController に全部詰め込むのではなく、「グリッド移動だけ」「当たり判定だけ」といった小さなコンポーネントに分けていくと、後からの仕様変更にも強いプロジェクトになっていきます。GridWalker をベースに、自分のゲームに合わせた小さな拡張コンポーネントを積み重ねていきましょう。