【Cocos Creator 3.8】GridSnapper の実装:アタッチするだけで「移動停止時に最寄りグリッド中心へ吸着」させる汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「動いて止まった瞬間、そのノードの座標を指定サイズのグリッド中心に自動補正」してくれる GridSnapper コンポーネントを実装します。
ドラッグ移動・キーボード移動・物理移動など、とにかく座標が変化して止まったタイミングを検出してスナップするため、パズルゲームのマス目配置やタイルマップ風のオブジェクト配置などに便利です。
コンポーネントの設計方針
要件の整理
- このコンポーネントをアタッチしたノード(以下「対象ノード」)が動き、移動が止まったと判断されたときに、最も近いグリッド中心に座標を補正する。
- グリッドサイズ(例: 32px, 64px)を自由に変更できる。
- 座標系は基本的に「親ノードのローカル座標」で扱い、親ごとに違うグリッド感覚を持たせられる。
- 停止判定のために、速度のしきい値や「止まったとみなすまでの時間」を調整可能にする。
- 外部スクリプトへの依存は禁止。ドラッグコンポーネントや GameManager などを前提にしない。
- 物理コンポーネント(RigidBody2D 等)があってもなくても動くようにし、あればそれを優先して速度を参照する。
設計アプローチ
「動きが止まったタイミング」を検出する方法として、以下の2パターンを両方サポートします。
- 物理ボディがある場合:
RigidBody2D / RigidBody の線形速度を取得し、一定以下の速度が一定時間続いたら「停止」とみなす。 - 物理ボディがない場合:
前フレームの位置との差分(移動量)を計算し、その大きさが一定以下の状態が一定時間続いたら「停止」とみなす。
また、「ゲーム開始直後にいきなりスナップしてしまう」のを避けるために、開始直後の一定時間はスナップしないオプションも用意します。
インスペクタで設定可能なプロパティ
以下のプロパティを @property で公開し、インスペクタから調整できるようにします。
- gridSizeX: number
グリッドの幅(X方向)。単位は親ノードローカル座標(通常はピクセル)。
例: 32 にすると、… -64, -32, 0, 32, 64 … のようなグリッド線の中心にスナップします。 - gridSizeY: number
グリッドの高さ(Y方向)。
例: 32 にすると、… -64, -32, 0, 32, 64 … のようなグリッド線の中心にスナップします。
gridSizeY を 0 にすると、Y 方向のスナップを無効化します(X のみグリッド補正したい場合など)。 - useWorldSpace: boolean
false(デフォルト): 親ノード基準のローカル座標でグリッドスナップ。true: ワールド座標でグリッドスナップ(親のスケールや回転に影響されないマス目にスナップしたい場合)。
- snapImmediatelyOnStart: boolean
true: start 時点の位置を即座にグリッドに補正。false: 最初に「停止した」と判断されるまで補正しない。
- minSpeedThreshold: number
停止判定に使う速度しきい値。- 物理ボディがある場合: 速度ベクトルの長さ(m/s 相当)がこの値未満なら「ほぼ停止」とみなす。
- 物理ボディがない場合: 1フレームあたりの移動量の長さがこの値未満なら「ほぼ停止」とみなす。
- minStillTime: number
「ほぼ停止」状態がこの秒数以上続いたときに「完全停止」とみなしてスナップを実行する。
例: 0.1 なら、おおよそ6フレーム程度(60fps想定)止まってからスナップ。 - cooldownAfterSnap: number
スナップ実行後、再度スナップ可能になるまでのクールダウン時間(秒)。
ドラッグ中に小刻みにスナップされるのを防ぐのに有効。 - maxSnapDistance: number
現在位置と「最寄りグリッド中心」との距離がこの値を超える場合はスナップしない。
「遠すぎる位置からの瞬間移動」を避けるための安全装置。0 以下なら無制限。 - debugLog: boolean
trueにすると、停止検出やスナップ処理に関するログをコンソールに出力し、挙動を確認しやすくする。
TypeScriptコードの実装
import { _decorator, Component, Node, Vec3, RigidBody2D, RigidBody, v3, math } from 'cc';
const { ccclass, property } = _decorator;
/**
* GridSnapper
* 対象ノードの移動が一定時間止まったと判断されたとき、
* その座標を最寄りグリッド中心にスナップする汎用コンポーネント。
*/
@ccclass('GridSnapper')
export class GridSnapper extends Component {
@property({
tooltip: 'グリッドの幅(X方向)。0以下は無効です。例: 32 で 32px 間隔のグリッドにスナップします。',
})
public gridSizeX: number = 32;
@property({
tooltip: 'グリッドの高さ(Y方向)。0 を指定すると Y方向のスナップは行いません。例: 32 で 32px 間隔。',
})
public gridSizeY: number = 32;
@property({
tooltip: 'true の場合はワールド座標でスナップします。false の場合は親ノード基準のローカル座標でスナップします。',
})
public useWorldSpace: boolean = false;
@property({
tooltip: 'true の場合、開始時点の位置を即座にグリッドにスナップします。',
})
public snapImmediatelyOnStart: boolean = false;
@property({
tooltip: '停止判定に使う速度のしきい値。これ未満の速度が続いたら「ほぼ停止」とみなします。',
min: 0,
})
public minSpeedThreshold: number = 1.0;
@property({
tooltip: '「ほぼ停止」状態がこの秒数以上続いたときにスナップを実行します。',
min: 0,
})
public minStillTime: number = 0.1;
@property({
tooltip: 'スナップ実行後、次にスナップできるようになるまでのクールダウン時間(秒)。',
min: 0,
})
public cooldownAfterSnap: number = 0.1;
@property({
tooltip: '現在位置から最寄りグリッド中心までの距離がこの値を超える場合はスナップしません。0 以下で無制限。',
min: 0,
})
public maxSnapDistance: number = 0;
@property({
tooltip: 'true にすると、停止検出やスナップ処理に関するデバッグログを出力します。',
})
public debugLog: boolean = false;
// 内部状態
private _prevPosition: Vec3 = new Vec3();
private _stillTime: number = 0;
private _cooldownTimer: number = 0;
private _started: boolean = false;
// 物理コンポーネント(あれば使用)
private _rigidBody2D: RigidBody2D | null = null;
private _rigidBody3D: RigidBody | null = null;
onLoad() {
// 物理コンポーネントを取得(存在しなくてもOK)
this._rigidBody2D = this.getComponent(RigidBody2D);
this._rigidBody3D = this.getComponent(RigidBody);
if (!this._rigidBody2D && !this._rigidBody3D && this.debugLog) {
console.log('[GridSnapper] RigidBody が見つかりません。位置の変化から停止を判定します。');
}
// 初期位置を記録
if (this.useWorldSpace) {
this._prevPosition.set(this.node.worldPosition);
} else {
this._prevPosition.set(this.node.position);
}
}
start() {
this._started = true;
if (this.snapImmediatelyOnStart) {
this.snapToGrid();
}
}
update(deltaTime: number) {
if (!this._started) {
return;
}
// クールダウンタイマー更新
if (this._cooldownTimer > 0) {
this._cooldownTimer -= deltaTime;
}
// 現在の位置取得
const currentPos = this.useWorldSpace ? this.node.worldPosition : this.node.position;
// 現在の速度(長さ)を取得
const speed = this.getCurrentSpeed(currentPos, deltaTime);
// 停止判定
if (speed < this.minSpeedThreshold) {
this._stillTime += deltaTime;
} else {
this._stillTime = 0;
}
if (this.debugLog) {
// たまにだけログを出したい場合は条件を絞る
// ここではシンプルに毎フレーム出す(必要に応じてコメントアウト)
// console.log(`[GridSnapper] speed=${speed.toFixed(3)}, stillTime=${this._stillTime.toFixed(3)}`);
}
// 停止時間がしきい値を超え、クールダウンも終わっていればスナップ
if (this._stillTime >= this.minStillTime && this._cooldownTimer <= 0) {
this.snapToGrid();
this._cooldownTimer = this.cooldownAfterSnap;
this._stillTime = 0;
}
// 前フレーム位置を更新
this._prevPosition.set(currentPos);
}
/**
* 現在の速度(長さ)を取得する。
* 物理ボディがあればそれを優先し、なければ位置の差分から推定する。
*/
private getCurrentSpeed(currentPos: Readonly<Vec3>, deltaTime: number): number {
// 2D物理
if (this._rigidBody2D) {
const v = this._rigidBody2D.linearVelocity;
// linearVelocity は Vec2 なので長さを計算
return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 3D物理
if (this._rigidBody3D) {
const v = this._rigidBody3D.linearVelocity;
return v.length();
}
// 物理ボディがない場合は、前フレームとの位置差分から速度を推定
if (deltaTime <= 0) {
return 0;
}
const dx = currentPos.x - this._prevPosition.x;
const dy = currentPos.y - this._prevPosition.y;
const dz = currentPos.z - this._prevPosition.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
return dist / deltaTime;
}
/**
* 最寄りのグリッド中心にスナップする。
*/
private snapToGrid() {
// グリッドサイズが無効なら何もしない
if (this.gridSizeX <= 0 && this.gridSizeY <= 0) {
if (this.debugLog) {
console.warn('[GridSnapper] gridSizeX と gridSizeY がともに 0 以下のため、スナップを行いません。');
}
return;
}
const originalPos = this.useWorldSpace ? this.node.worldPosition.clone() : this.node.position.clone();
const targetPos = originalPos.clone();
// X方向スナップ
if (this.gridSizeX > 0) {
const gx = this.gridSizeX;
// 最寄りグリッド中心 = round(現在位置 / グリッドサイズ) * グリッドサイズ
targetPos.x = Math.round(originalPos.x / gx) * gx;
}
// Y方向スナップ(gridSizeY が 0 の場合は無視)
if (this.gridSizeY > 0) {
const gy = this.gridSizeY;
targetPos.y = Math.round(originalPos.y / gy) * gy;
}
// Z は変更しない(2D想定のため)
// 必要なら Z もグリッド化する処理を追加可能
// 距離チェック(maxSnapDistance が有効な場合)
if (this.maxSnapDistance > 0) {
const dx = targetPos.x - originalPos.x;
const dy = targetPos.y - originalPos.y;
const dz = targetPos.z - originalPos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > this.maxSnapDistance) {
if (this.debugLog) {
console.log(`[GridSnapper] 距離 ${dist.toFixed(2)} が maxSnapDistance(${this.maxSnapDistance}) を超えたため、スナップをキャンセルします。`);
}
return;
}
}
// 実際に位置を適用
if (this.useWorldSpace) {
this.node.setWorldPosition(targetPos);
} else {
this.node.setPosition(targetPos);
}
if (this.debugLog) {
console.log(`[GridSnapper] スナップ実行: (${originalPos.x.toFixed(2)}, ${originalPos.y.toFixed(2)}) -> (${targetPos.x.toFixed(2)}, ${targetPos.y.toFixed(2)})`);
}
// 前フレーム位置も更新しておく
this._prevPosition.set(targetPos);
}
}
コードのポイント解説
- onLoad
- 同じノードに
RigidBody2DまたはRigidBodyがアタッチされていれば取得します(なくても動作)。 - 最初の
_prevPositionを現在位置で初期化し、非物理モードの速度推定に備えます。
- 同じノードに
- start
snapImmediatelyOnStartがtrueの場合、開始時に即スナップを行います。- 開始フラグ
_startedを立て、updateが本格的に動くようにします。
- update(deltaTime)
- クールダウンタイマーを更新し、0 以下になるまで再スナップを抑制します。
- 現在位置をローカル or ワールド座標で取得。
getCurrentSpeedで速度の大きさを取得(物理ボディがあればそれを使い、なければ位置差分から推定)。- 速度が
minSpeedThreshold未満なら「ほぼ停止」とみなし、その時間を_stillTimeに蓄積。
超えたら_stillTimeをリセット。 _stillTime >= minStillTimeかつ_cooldownTimer <= 0のとき、snapToGrid()を呼び出します。- 最後に
_prevPositionを更新し、次フレームの速度推定に使います。
- getCurrentSpeed
- 2D 物理(
RigidBody2D)があればlinearVelocityの長さを返します。 - 3D 物理(
RigidBody)があればlinearVelocity.length()を返します。 - どちらもなければ、
currentPos - _prevPositionの距離をdeltaTimeで割った値を速度とみなします。
- 2D 物理(
- snapToGrid
- グリッドサイズが両方 0 以下なら何もしません(警告ログ)。
- 現在位置を
originalPosとしてコピーし、targetPosに最寄りグリッド中心を計算します。
計算式はMath.round(position / gridSize) * gridSizeです。 maxSnapDistanceが設定されている場合、originalPosからtargetPosまでの距離がそれを超えたらスナップをキャンセルします。useWorldSpaceに応じてsetWorldPositionまたはsetPositionを使って位置を反映します。- 最後に
_prevPositionも更新しておき、次フレームの速度推定と整合を取ります。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - そのフォルダ上で右クリック → Create → TypeScript を選択します。
- 新しく作成されたファイル名を
GridSnapper.tsに変更します。 GridSnapper.tsをダブルクリックして開き、内容をすべて削除して、前述の TypeScript コードを丸ごと貼り付けて保存します。
2. テスト用ノードの作成
ここでは 2D シーンでの例を示しますが、3D でも同様に使えます。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノードを作成します。
- 作成されたノード名を分かりやすく
TestPieceなどに変更しておきます。 - Sprite コンポーネントに画像を設定しておくと、シーンビューで位置が確認しやすくなります。
3. GridSnapper コンポーネントのアタッチ
- Hierarchy で先ほど作成した
TestPieceノードを選択します。 - 右側の Inspector パネルの一番下にある Add Component ボタンをクリックします。
- Custom カテゴリを開き、その中から GridSnapper を選択します。
(もし表示されない場合は、スクリプト保存後にエディタがコンパイルを終えるまで少し待ってください。)
4. プロパティの設定例
まずはシンプルな 32px グリッドでのテスト設定を行います。
- gridSizeX:
32 - gridSizeY:
32 - useWorldSpace:
false(親ノードローカル座標でグリッド) - snapImmediatelyOnStart:
false - minSpeedThreshold:
1 - minStillTime:
0.1 - cooldownAfterSnap:
0.1 - maxSnapDistance:
0(制限なし) - debugLog: 任意(挙動確認したい場合は
true)
5. 簡単な動作確認(マウスで位置を動かす場合)
GridSnapper は「座標が変化して止まる」ことだけを前提にしているため、座標の動かし方は問いません。
ここでは、エディタ上でのテストと、簡易ドラッグスクリプトを使った実行時テストの2パターンを紹介します。
5-1. エディタ上のプレビューでざっくり確認
エディタのシーンビュー上でノードを動かすだけでは update が走らないため、実行時でないと GridSnapper は動きません。
そのため、必ず 再生ボタン(Play) を押してゲームを実行した状態で確認します。
5-2. 簡易ドラッグスクリプトで実行時に確認
もしまだ移動ロジックがない場合、以下のような超簡易ドラッグスクリプトを一時的に同じノードに付けてテストできます。
// テスト用: 非常に簡単なドラッグ移動コンポーネント
import { _decorator, Component, Node, EventTouch, Input, input, Vec3, Camera, find } from 'cc';
const { ccclass } = _decorator;
@ccclass('SimpleDrag')
export class SimpleDrag extends Component {
private _dragging = false;
private _offset = new Vec3();
private _camera: Camera | null = null;
onEnable() {
this._camera = find('Canvas/Camera')?.getComponent(Camera) || null;
input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
}
onDisable() {
input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
}
onTouchStart(event: EventTouch) {
if (!this._camera) return;
const loc = event.getUILocation();
const world = this._camera.screenToWorld(new Vec3(loc.x, loc.y, 0));
this._offset = this.node.worldPosition.clone().subtract(world);
this._dragging = true;
}
onTouchMove(event: EventTouch) {
if (!this._dragging || !this._camera) return;
const loc = event.getUILocation();
const world = this._camera.screenToWorld(new Vec3(loc.x, loc.y, 0));
world.add(this._offset);
this.node.setWorldPosition(world);
}
onTouchEnd() {
this._dragging = false;
}
}
この SimpleDrag を TestPiece と同じノードにアタッチし、タッチまたはマウスドラッグでノードを動かしてから指を離すと、止まった瞬間に最寄りの 32px グリッド中心へスッと吸着されるのが確認できます。
6. 物理挙動との組み合わせテスト
物理挙動で移動しているノードにもそのまま使えます。
TestPieceノードに RigidBody2D と Collider2D(BoxCollider2D など)を追加します。- RigidBody2D の Type を
Dynamicに設定し、重力や外力で動くようにします。 - 何らかの方法(別コンポーネントやシーンの仕掛け)でノードを動かし、最終的に止まる状況を作ります。
GridSnapper は RigidBody2D.linearVelocity を使って速度を判定するため、物理シミュレーションが止まったタイミングでグリッドに吸着します。
まとめ
この GridSnapper コンポーネントは、以下のような場面で特に威力を発揮します。
- パズルゲームのピースをマス目にきれいに配置したいとき。
- ステージエディタやレベルエディタで、オブジェクトをタイルグリッドに沿って配置したいとき。
- 物理挙動で動いたオブジェクトを、最終的にきれいな座標に揃えたいとき。
特徴として:
- 完全に独立したコンポーネントであり、他のカスタムスクリプトやシングルトンに一切依存しません。
- 物理あり/なしの両方に対応し、「座標さえ変化していれば」どんな移動方法とも組み合わせ可能です。
- グリッドサイズ、停止判定のしきい値、クールダウン、ワールド/ローカル座標の切り替えなど、すべてインスペクタから調整できます。
プロトタイプ段階では、GridSnapper をアタッチしておけば「とりあえずいい感じにマス目に揃う」状態を簡単に作れますし、製品段階でもそのまま利用できます。
今後、Z方向のグリッドスナップや、特定の軸のみスナップするオプションなどを追加すれば、さらに汎用性の高いコンポーネントとして発展させることも可能です。
このコンポーネントをベースに、さまざまなグリッドベースゲームやレベルエディタの実装を効率化してみてください。




