Unityを触り始めた頃にやりがちなのが、Update() に「移動処理」「入力処理」「アニメーション」「当たり判定」「UI更新」…と、全部を詰め込んでしまう実装です。
動き始めているうちはよくても、あとから「マス目にスナップしたい」「停止したときだけ座標を補正したい」といった仕様変更が入ると、一気に地獄化します。
そんなときに便利なのが、「動きそのもの」から独立した、グリッド補正専用コンポーネントです。
移動ロジックは移動ロジック、グリッドへの吸着はグリッド吸着コンポーネントに分離しておくと、プレイヤーでも敵でも動く床でも、同じルールで気持ちよく「マス目の中心」に揃えられます。
この記事では、親オブジェクトの移動が止まったタイミングで、座標を最も近いグリッド(32pxなど)に吸着させる 「GridSnapper」コンポーネント を作っていきます。
【Unity】止まった瞬間ピタッとマス目に!「GridSnapper」コンポーネント
ここでは、以下のような要件を満たすコンポーネントを実装します。
- 親オブジェクト(または任意の対象)の 移動速度が一定以下になったときだけ グリッドに吸着
- グリッドサイズ(例:32px 相当)を インスペクターから調整可能
- X/Y/Z それぞれについて、スナップする軸を選択可能
- 「ワールド座標でスナップ」と「親からのローカル座標でスナップ」を切り替え可能
- Rigidbody を使う場合・使わない場合の両方に対応
フルコード:GridSnapper.cs
using UnityEngine;
/// <summary>
/// 親(または指定Transform)の移動が止まったとき、
/// 最も近いグリッドの中心に座標をスナップ(吸着)させるコンポーネント。
///
/// - Rigidbody があれば速度から停止を判定
/// - なければ前フレームとの差分から停止を判定
/// </summary>
[DisallowMultipleComponent]
public class GridSnapper : MonoBehaviour
{
// ==============================
// インスペクタ設定
// ==============================
[Header("スナップ対象")]
[Tooltip("スナップさせたいTransform。未指定ならこのコンポーネントが付いているオブジェクト自身。")]
[SerializeField] private Transform targetTransform;
[Header("グリッド設定")]
[Tooltip("グリッド1マスのサイズ(単位:Unity単位)。例:32px相当なら 32 / PixelsPerUnit。")]
[SerializeField] private float gridSize = 1f;
[Tooltip("グリッドの原点(0,0,0 からずらしたい場合に使用)。")]
[SerializeField] private Vector3 gridOrigin = Vector3.zero;
[Header("スナップする軸")]
[Tooltip("X軸方向にスナップするかどうか。")]
[SerializeField] private bool snapX = true;
[Tooltip("Y軸方向にスナップするかどうか。2Dゲームなら true 推奨。")]
[SerializeField] private bool snapY = true;
[Tooltip("Z軸方向にスナップするかどうか。3Dゲームなら true 推奨。")]
[SerializeField] private bool snapZ = true;
[Header("停止判定")]
[Tooltip("この速度未満になったら「停止」とみなすしきい値。")]
[SerializeField] private float stopThreshold = 0.01f;
[Tooltip("停止状態がこの秒数以上続いたらスナップを行う。ノイズ対策。")]
[SerializeField] private float stopDurationToSnap = 0.05f;
[Header("座標系の指定")]
[Tooltip("ワールド座標でスナップするかどうか。false の場合はローカル座標でスナップ。")]
[SerializeField] private bool useWorldSpace = true;
[Header("挙動オプション")]
[Tooltip("スナップ後に Rigidbody の速度をゼロにするか。物理挙動を完全に止めたい場合に使用。")]
[SerializeField] private bool resetRigidbodyVelocityOnSnap = true;
[Tooltip("1度スナップしたら、このコンポーネントを無効化する(何度もスナップさせたくない場合)。")]
[SerializeField] private bool disableAfterSnap = false;
// ==============================
// 内部状態
// ==============================
private Rigidbody _rigidbody;
private Vector3 _prevPosition; // 前フレームの位置
private float _stoppedTime; // 停止していると判断されている時間
private bool _hasSnappedOnce; // すでにスナップ済みかどうか
// プロパティ:実際のターゲットTransform(未指定なら自分)
private Transform TargetTransform
{
get
{
if (targetTransform == null)
{
targetTransform = transform;
}
return targetTransform;
}
}
private void Awake()
{
// Rigidbody があれば取得(任意)
_rigidbody = TargetTransform.GetComponent<Rigidbody>();
// 初期位置を記録
_prevPosition = GetCurrentPosition();
}
private void Update()
{
if (_hasSnappedOnce && disableAfterSnap)
{
// すでにスナップ済みで、1度きりの設定なら何もしない
return;
}
// 現在速度を計算
float currentSpeed = GetCurrentSpeed();
// 停止判定
if (currentSpeed < stopThreshold)
{
// 停止状態が続いている時間を加算
_stoppedTime += Time.deltaTime;
// 一定時間以上止まっていたらスナップ
if (_stoppedTime >= stopDurationToSnap)
{
SnapToGrid();
_hasSnappedOnce = true;
if (disableAfterSnap)
{
// コンポーネントを無効化して、これ以上処理しない
enabled = false;
}
else
{
// スナップ後も再度スナップできるようにタイマーをリセット
_stoppedTime = 0f;
}
}
}
else
{
// 動いているので停止タイマーをリセット
_stoppedTime = 0f;
}
// 次フレームのために位置を記録
_prevPosition = GetCurrentPosition();
}
/// <summary>
/// 現在の速度(スカラー)を取得する。
/// Rigidbody があればその速度、なければ前フレームとの位置差分から計算。
/// </summary>
private float GetCurrentSpeed()
{
if (_rigidbody != null)
{
// Rigidbody の速度ベクトルの大きさ
return _rigidbody.velocity.magnitude;
}
// Transform ベースでの簡易速度計算
Vector3 currentPos = GetCurrentPosition();
float distance = Vector3.Distance(currentPos, _prevPosition);
float speed = distance / Mathf.Max(Time.deltaTime, 0.0001f);
return speed;
}
/// <summary>
/// 現在の座標を、設定に応じてワールド or ローカルで取得。
/// </summary>
private Vector3 GetCurrentPosition()
{
if (useWorldSpace)
{
return TargetTransform.position;
}
else
{
return TargetTransform.localPosition;
}
}
/// <summary>
/// グリッドにスナップさせるメイン処理。
/// </summary>
private void SnapToGrid()
{
if (gridSize <= 0f)
{
Debug.LogWarning($"[GridSnapper] gridSize が 0 以下です。スナップをスキップします。", this);
return;
}
// 現在位置を取得(ワールド or ローカル)
Vector3 pos = GetCurrentPosition();
// グリッド原点からの相対位置に変換
Vector3 relative = pos - gridOrigin;
// 各軸ごとに、最も近いグリッドに丸める
if (snapX)
{
relative.x = Mathf.Round(relative.x / gridSize) * gridSize;
}
if (snapY)
{
relative.y = Mathf.Round(relative.y / gridSize) * gridSize;
}
if (snapZ)
{
relative.z = Mathf.Round(relative.z / gridSize) * gridSize;
}
// グリッド原点を足して、元の座標系に戻す
Vector3 snappedPos = relative + gridOrigin;
// 位置を適用(ワールド or ローカル)
if (useWorldSpace)
{
TargetTransform.position = snappedPos;
}
else
{
TargetTransform.localPosition = snappedPos;
}
// Rigidbody の速度を止めたい場合はゼロにする
if (_rigidbody != null && resetRigidbodyVelocityOnSnap)
{
_rigidbody.velocity = Vector3.zero;
_rigidbody.angularVelocity = Vector3.zero;
}
}
// ==============================
// デバッグ用の可視化
// ==============================
private void OnDrawGizmosSelected()
{
if (gridSize <= 0f) return;
// 選択中に、現在位置からスナップされる位置を可視化
Transform t = (targetTransform != null) ? targetTransform : transform;
Vector3 currentPos = useWorldSpace ? t.position : t.localPosition;
Vector3 relative = currentPos - gridOrigin;
if (snapX)
{
relative.x = Mathf.Round(relative.x / gridSize) * gridSize;
}
if (snapY)
{
relative.y = Mathf.Round(relative.y / gridSize) * gridSize;
}
if (snapZ)
{
relative.z = Mathf.Round(relative.z / gridSize) * gridSize;
}
Vector3 snappedPos = relative + gridOrigin;
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(useWorldSpace ? snappedPos : t.TransformPoint(snappedPos), gridSize * 0.1f);
}
}
使い方の手順
ここからは、具体的なシーン例を交えながら使い方を見ていきましょう。
手順①:スクリプトを用意する
- 上記の
GridSnapper.csをプロジェクトのScriptsフォルダなどに保存します。 - Unity に戻ると自動的にコンパイルされ、コンポーネントとして追加できるようになります。
手順②:プレイヤー(または移動オブジェクト)にアタッチ
例として、2Dのプレイヤーキャラクターを 32px グリッド にスナップさせるケースを想定します。
- プレイヤーの GameObject を選択します。
(例:Playerプレハブ) Add Componentから GridSnapper を追加します。- インスペクターで以下のように設定します:
- Target Transform:空欄のまま(自分自身を対象にする)
- Grid Size:
もし 1ユニット=32px なら1、
1ユニット=100px で 32px グリッドにしたいなら0.32など、プロジェクトの Pixels Per Unit に合わせて設定します。 - Grid Origin:通常は
(0,0,0)のままでOK。
マップの左下を原点にしたいなど、グリッドの基準をずらしたいときに調整します。 - Snap X:2D横スクロールなら
true - Snap Y:上方向にもマス目を揃えたいなら
true - Snap Z:2Dなら通常
false(奥行き方向にはスナップ不要) - Stop Threshold:
0.01〜0.1程度。
プレイヤーの最高速度に対してかなり小さめにしておくと良いです。 - Stop Duration To Snap:
0.05〜0.1秒くらい。
「一瞬だけ速度が小さくなった」ようなケースを無視するためのバッファ時間です。 - Use World Space:ワールド座標でスナップしたいなら
true。
プレイヤーが親オブジェクトの子になっていて、親基準でマス目を揃えたいならfalse。 - Reset Rigidbody Velocity On Snap:
Rigidbody で移動していて、スナップ後に完全に慣性を止めたいならtrue。 - Disable After Snap:
「一度だけスナップして終わり」にしたい場合はtrue。
何度も移動と停止を繰り返すプレイヤーならfalseのまま。
手順③:敵キャラや動く床にも使い回す
このコンポーネントは「移動ロジック」に一切依存していないので、以下のような使い回しが簡単です。
- 敵キャラ:パトロール移動が止まったときにマス目の中心に揃える。
- 動く床(Moving Platform):端まで動いたらピタッとグリッドに揃えて、プレイヤーが乗りやすいようにする。
- パズルブロック:ドラッグ&ドロップで動かしたあと、離した位置を最寄りのマス目に補正する。
例えば動く床のケースでは、床の GameObject に GridSnapper をアタッチし、Disable After Snap = true にしておけば、「移動アニメーションが終わって止まった瞬間にだけ1回スナップ」させることができます。
手順④:2Dグリッドマップでの具体例(32pxタイル)
2Dタイルマップで 1タイル=32px、Pixels Per Unit = 32 の場合:
- Grid Size:
1(1ユニット=1タイル=32px) - Grid Origin:タイルマップの原点に合わせて
(0,0,0)またはタイルマップの左下の座標 - Snap X / Snap Y:どちらも
true - Use World Space:タイルマップと同じ基準で揃えたいので
true
これで、プレイヤーやブロックを自由に動かしても、停止した瞬間に必ずタイルの中心にピタッと揃うようになります。
メリットと応用
この GridSnapper コンポーネントを導入することで、プレハブ管理やレベルデザインがかなり楽になります。
メリット①:移動ロジックとグリッド処理の分離
「移動処理」と「グリッド補正」を同じスクリプトに書いてしまうと、
- プレイヤーの移動コードがどんどん肥大化する
- 敵やギミックでも同じようなグリッド処理をコピペする羽目になる
- あとからグリッドサイズを変えたいときに、複数のスクリプトを修正する必要が出る
といった問題が出てきます。
GridSnapper のように「グリッド補正だけ」を担当するコンポーネントに分けておけば、
- プレイヤー、敵、ギミック、動く床など、どのオブジェクトにも同じコンポーネントをポン付けできる
- グリッドサイズや停止判定のチューニングを 1箇所のスクリプト で完結できる
- 移動ロジックを差し替えても(物理移動、補間移動、DOTween など)、グリッド補正側のコードはそのままでOK
というメリットが得られます。
メリット②:レベルデザインの安定性アップ
グリッドベースのゲーム(ローグライク、タクティカルRPG、パズルなど)では、キャラクターやギミックがマス目から少しでもズレるとバグの温床になります。
- 「当たり判定が半マスずれている」
- 「タイルの境界線にひっかかって動けない」
- 「見た目とロジック上の座標が一致しない」
といった問題は、ほとんどが「マス目に揃っていない」ことが原因です。
GridSnapper を仕込んでおけば、「止まったときは必ずマス目の中心にいる」ことが保証されるので、レベルデザインやデバッグがかなり楽になります。
メリット③:プレハブの再利用性が高まる
プレイヤー、敵、ギミックそれぞれのプレハブに GridSnapper を組み込んでおけば、
- 別のシーンに持っていっても、同じグリッドルールで動作する
- 「このシーンは 16px グリッド、このシーンは 32px グリッド」といった切り替えも、インスペクターの値を変えるだけで対応可能
- レベルデザイナーが「とりあえず置いて動かしてみる」際に、細かい座標調整を意識しなくてよい
といった恩恵があります。
改造案:スナップ完了時にイベントを飛ばす
もう少し発展させたい場合、「スナップが完了した瞬間に何か処理をしたい」ということがあります。
例えば、
- ブロックがマス目にハマったら、パズル判定を行う
- 動く床が所定位置に着いたら、次のギミックを動かし始める
といったケースですね。
その場合は、SnapToGrid() の最後に、UnityEvent やコールバックを追加すると便利です。
using UnityEngine;
using UnityEngine.Events;
public class GridSnapperWithEvent : GridSnapper
{
[Header("スナップ完了イベント")]
[SerializeField] private UnityEvent onSnapped;
// GridSnapper の SnapToGrid をラップしてイベントを発火する例
// (元の GridSnapper 側で protected にしておくと継承しやすい)
private void SnapToGridWithEvent()
{
// ここで base の SnapToGrid() を呼び出す想定
// base.SnapToGrid();
// スナップ完了時にイベントを発火
onSnapped?.Invoke();
}
}
このように、小さな責任のコンポーネントを積み重ねていくと、
「移動」「グリッド補正」「スナップ時イベント」「エフェクト再生」などを、必要なオブジェクトにだけ組み合わせて使えるようになります。
巨大な God クラスを避けつつ、柔軟なレベルデザインができる構成を目指していきましょう。




