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 の使い方を手順で見ていきます。
-
プレイヤー用のGameObjectを用意する
- Hierarchy で
Right Click > Create EmptyなどからPlayerオブジェクトを作成します。 - 2Dなら
SpriteRendererを追加して見た目を設定しておきましょう。
- Hierarchy で
-
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 の左スティックも追加しておくと便利です
- Action Map:
- 保存したら、この
InputActionsアセットをシーン上のどこか(例:GameInputオブジェクト)で有効化しておきます。
- プロジェクトビューで
-
GridWalker コンポーネントをアタッチする
Playerオブジェクトを選択し、Inspector でAdd Component→GridWalkerを追加します。- インスペクターで以下を設定します。
Grid Size: 1マスの大きさ。例:- タイルマップ1マス = 1unit なら
1 - ピクセルパーフェクトで 32px を 0.32unit としているなら
0.32
- タイルマップ1マス = 1unit なら
Snap On Start: 開始時にグリッドにスナップするか(ズレ防止にオン推奨)Move Duration: 1マス移動にかかる時間(例:0.15〜0.2)Allow Diagonal: 斜め移動を許可するか(クラシックRPG風ならオフ)Queue Input While Moving: 移動中の入力をキューするか(押しっぱなしで連続歩行させたいならオン)Move Action Ref: 先ほど作った Input Actions の中のPlayer/Moveをドラッグ&ドロップで設定
-
シーンでテストする
- シーンにタイルマップや床スプライトを並べて、グリッドの大きさと
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;
}
この CanStepTo を StartStep を呼ぶ前に挟んで、false のときは移動をキャンセルするようにすれば、簡易的なマップ制限ができます。さらに発展させて、タイルマップの情報やコライダーを参照するようにすれば、本格的なRPGのフィールド移動にも対応できますね。
巨大な PlayerController に全部詰め込むのではなく、「グリッド移動だけ」「当たり判定だけ」といった小さなコンポーネントに分けていくと、後からの仕様変更にも強いプロジェクトになっていきます。GridWalker をベースに、自分のゲームに合わせた小さな拡張コンポーネントを積み重ねていきましょう。
