【Cocos Creator 3.8】PathMover の実装:アタッチするだけで「Path2D に沿った移動(動く床など)」を実現する汎用スクリプト
このガイドでは、任意のノードを Path2D コンポーネントで定義したパスに沿って自動移動させる汎用コンポーネント PathMover を実装します。
動く床・巡回する敵・往復するエレベーターなど、ノードにアタッチして Path2D を指定するだけで使えるように設計します。
コンポーネントの設計方針
1. 機能要件の整理
- 任意のノードを Path2D に沿って移動させる。
- Path2D コンポーネントを持つノード(パスノード)をインスペクタで指定できる。
- パス上の移動は以下のように制御できる:
- 移動速度(一定速度)
- 移動方向(順方向/逆方向)
- ループ方式(ループ/往復/一回のみ)
- 開始位置(パス上の 0〜1 の正規化位置)
- 開始時に待機時間を入れる
- 往復時の折り返し時に待機時間を入れる
- Unity などでよくある「パスに沿った巡回」を、Cocos Creator 3.8 の Path2D で簡単に再現する。
- 他のカスタムスクリプトには一切依存しない(完全独立)。
2. 外部依存をなくす設計アプローチ
- 移動対象ノードは このコンポーネントがアタッチされているノード自身とする。
- パス情報は インスペクタで指定された Path2D を持つノードから取得する。
- Path2D の標準 API を使い、
getNormalizedLengthPositionなどでパス上の座標を取得する。 - パスノードが設定されていない、または Path2D コンポーネントが見つからない場合は エラーログを出して自動移動を停止する。
- すべての挙動は インスペクタの @property から調整できるようにする。
3. インスペクタで設定可能なプロパティ設計
以下のようなプロパティを用意します(名前は英語、説明はツールチップで日本語)。
@property(Node) pathNode- 役割: Path2D コンポーネントを持つノードを指定する。
- 説明: ここで指定したノードから Path2D を取得し、そのパスに沿って移動する。
@propertyspeed: number- 役割: パスに沿った移動速度(単位: 単位長 / 秒)。
- 説明: 正の値のみを想定。0 以下にすると停止扱いになる。
- 例: 100 〜 400 くらいの値を想定。
@propertystartPosition: number- 役割: パス上の開始位置(0〜1 の正規化値)。
- 説明: 0 でパスの始点、1 で終点。0.5 なら中間からスタート。
@propertymoveDirection: MoveDirection(enum)- 役割: パスを辿る向き。
- 値:
Forward: Path2D の正方向(0 → 1)Backward: 逆方向(1 → 0)
@propertyloopType: LoopType(enum)- 役割: パス端に到達したときの挙動。
- 値:
Loop: 端に着いたら反対側にワープしてループ。PingPong: 端で折り返して往復。Once: 端に着いたら停止。
@propertystartDelay: number- 役割: 移動開始前の待機時間(秒)。
- 説明: ゲーム開始直後に少し待ってから動かしたい場合に使用。
@propertyturnBackDelay: number- 役割:
PingPong時に、端で折り返す前の待機時間(秒)。 - 説明: エレベーターが端で少し停止してから戻るような演出に使う。
- 役割:
@propertyplayOnStart: boolean- 役割:
start()時に自動で移動を開始するか。 - 説明: false にした場合、
this.play()を別スクリプトから呼ぶ想定だが、本記事では「自動開始」を主用途とする。
- 役割:
@propertyuseLocalPosition: boolean- 役割: パス座標をローカル座標として適用するか、ワールド座標として適用するか。
- 説明:
- true: pathNode のローカル空間上のパスを、pathNode の子として動かすようなイメージ。
- false: ワールド座標で動かす。pathNode と離れた場所でも同じ軌跡を描く。
TypeScriptコードの実装
以下が完成した PathMover.ts の実装です。
import { _decorator, Component, Node, Vec3, warn, error } from 'cc';
import { Path2D } from 'cc';
const { ccclass, property } = _decorator;
/**
* パス上の移動方向
*/
enum MoveDirection {
Forward = 0,
Backward = 1,
}
/**
* 端に到達したときのループ方式
*/
enum LoopType {
Loop = 0,
PingPong = 1,
Once = 2,
}
@ccclass('PathMover')
export class PathMover extends Component {
@property({
type: Node,
tooltip: '移動に使用する Path2D コンポーネントを持つノードを指定します。',
})
public pathNode: Node | null = null;
@property({
tooltip: 'パスに沿った移動速度(単位: パスの長さ/秒)。0 以下で移動しません。',
})
public speed: number = 200;
@property({
tooltip: 'パス上の開始位置(0〜1)。0: 始点, 1: 終点。',
min: 0,
max: 1,
step: 0.01,
slide: true,
})
public startPosition: number = 0;
@property({
type: MoveDirection,
tooltip: 'パスを辿る向き。Forward: 0→1, Backward: 1→0。',
})
public moveDirection: MoveDirection = MoveDirection.Forward;
@property({
type: LoopType,
tooltip: 'パス端に到達したときの挙動。Loop: ループ, PingPong: 往復, Once: 一度だけで停止。',
})
public loopType: LoopType = LoopType.Loop;
@property({
tooltip: '移動開始前の待機時間(秒)。',
min: 0,
})
public startDelay: number = 0;
@property({
tooltip: 'PingPong 時に端で折り返す前の待機時間(秒)。Loop, Once では無視されます。',
min: 0,
})
public turnBackDelay: number = 0;
@property({
tooltip: 'true の場合、start() 時に自動で移動を開始します。',
})
public playOnStart: boolean = true;
@property({
tooltip: 'true: パス座標をローカル座標として適用 / false: ワールド座標として適用。',
})
public useLocalPosition: boolean = false;
// 内部状態
private _path2D: Path2D | null = null;
private _pathLength: number = 0;
private _normalizedPos: number = 0; // 0〜1
private _isPlaying: boolean = false;
private _currentDirection: number = 1; // 1 or -1
private _delayTimer: number = 0; // 開始/折り返し待機用
onLoad() {
// Path2D の取得を試みる
this._resolvePath2D();
// 初期方向を設定
this._currentDirection = (this.moveDirection === MoveDirection.Forward) ? 1 : -1;
// 開始位置をクランプ
this._normalizedPos = this._clamp01(this.startPosition);
// 初期位置に配置
this._updateNodePositionByPath();
}
start() {
if (this.playOnStart) {
this.play();
}
}
update(deltaTime: number) {
if (!this._isPlaying) {
return;
}
if (!this._path2D || this._pathLength <= 0) {
return;
}
// ディレイ中であればタイマーを減らす
if (this._delayTimer > 0) {
this._delayTimer -= deltaTime;
return;
}
if (this.speed <= 0) {
return;
}
// 正規化位置の更新
const deltaNormalized = (this.speed * deltaTime) / this._pathLength;
this._normalizedPos += deltaNormalized * this._currentDirection;
// 端の処理
this._handleBoundary();
// 実際の位置を更新
this._updateNodePositionByPath();
}
/**
* 移動を開始 / 再開します。
*/
public play() {
if (!this._path2D) {
this._resolvePath2D();
}
if (!this._path2D) {
error('[PathMover] Path2D が見つからないため、再生できません。pathNode を確認してください。');
return;
}
this._isPlaying = true;
this._delayTimer = this.startDelay;
}
/**
* 移動を一時停止します。
*/
public pause() {
this._isPlaying = false;
}
/**
* パスの先頭に戻して停止します。
*/
public stopAndReset() {
this._isPlaying = false;
this._normalizedPos = this._clamp01(this.startPosition);
this._currentDirection = (this.moveDirection === MoveDirection.Forward) ? 1 : -1;
this._delayTimer = 0;
this._updateNodePositionByPath();
}
/**
* Path2D コンポーネントを解決して内部に保持します。
*/
private _resolvePath2D() {
if (!this.pathNode) {
warn('[PathMover] pathNode が設定されていません。インスペクタで Path2D を持つノードを指定してください。');
this._path2D = null;
this._pathLength = 0;
return;
}
const path2D = this.pathNode.getComponent(Path2D);
if (!path2D) {
error('[PathMover] pathNode に Path2D コンポーネントが見つかりません。パスノードに Path2D を追加してください。');
this._path2D = null;
this._pathLength = 0;
return;
}
this._path2D = path2D;
this._pathLength = path2D.getLength();
if (this._pathLength <= 0) {
warn('[PathMover] Path2D の長さが 0 です。パスに十分なポイントが設定されているか確認してください。');
}
}
/**
* 0〜1 にクランプします。
*/
private _clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/**
* パスの端に達したときの処理。
*/
private _handleBoundary() {
if (!this._path2D) {
return;
}
if (this.loopType === LoopType.Loop) {
// 0〜1 をループ
if (this._normalizedPos > 1) {
this._normalizedPos -= 1;
} else if (this._normalizedPos < 0) {
this._normalizedPos += 1;
}
} else if (this.loopType === LoopType.PingPong) {
// 端で折り返し
if (this._normalizedPos > 1) {
this._normalizedPos = 1;
this._currentDirection *= -1;
if (this.turnBackDelay > 0) {
this._delayTimer = this.turnBackDelay;
}
} else if (this._normalizedPos < 0) {
this._normalizedPos = 0;
this._currentDirection *= -1;
if (this.turnBackDelay > 0) {
this._delayTimer = this.turnBackDelay;
}
}
} else if (this.loopType === LoopType.Once) {
// 一度だけで停止
if (this._normalizedPos >= 1) {
this._normalizedPos = 1;
this._isPlaying = false;
} else if (this._normalizedPos <= 0) {
this._normalizedPos = 0;
this._isPlaying = false;
}
}
}
/**
* 現在の _normalizedPos に基づいてノード位置を更新します。
*/
private _updateNodePositionByPath() {
if (!this._path2D || this._pathLength <= 0) {
return;
}
const pos = this._path2D.getPositionAt(this._normalizedPos);
if (!pos) {
return;
}
if (this.useLocalPosition) {
// ローカル座標で適用
this.node.setPosition(pos);
} else {
// ワールド座標で適用
// Path2D が返す座標は pathNode のローカル座標系なので、
// pathNode のワールド位置に変換してから適用
const worldPos = new Vec3();
this.pathNode!.getWorldMatrix().transformPoint(worldPos, pos);
this.node.worldPosition = worldPos;
}
}
}
主要な処理の解説
- onLoad()
_resolvePath2D()でpathNodeからPath2Dを取得。moveDirectionから_currentDirection(1 or -1)を決定。startPositionを 0〜1 にクランプして_normalizedPosに設定。_updateNodePositionByPath()で初期位置に配置。
- start()
playOnStartが true の場合、自動的にplay()を呼び出して移動開始。
- update(deltaTime)
_isPlayingが false、または Path2D が無効な場合は何もしない。_delayTimerが 0 より大きい場合はディレイ中としてタイマーを減らし、0 になるまで移動しない。speedとdeltaTime、パス長からdeltaNormalizedを計算し、_currentDirection(1 or -1)を掛けて_normalizedPosを更新。_handleBoundary()で 0〜1 の範囲外に出たときの処理(ループ/往復/停止)を行う。- 最後に
_updateNodePositionByPath()で実際の座標を更新。
- _updateNodePositionByPath()
Path2D.getPositionAt(normalizedPos)でパス上の位置を取得。useLocalPositionが true の場合は、そのままthis.node.setPosition()に適用。- false の場合は、pathNode のワールド行列を使ってワールド座標に変換し、
this.node.worldPositionに適用。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - フォルダ上で右クリック → Create → TypeScript を選択します。
- 新しく作成されたスクリプトに
PathMover.tsという名前を付けます。 - ダブルクリックしてエディタ(VS Code など)で開き、先ほどの TypeScript コード全文 を貼り付けて保存します。
2. パス用ノード(Path2D)の作成
まずは Path2D コンポーネントを持つノードを作成します。
- Hierarchy パネルで右クリック → Create → Empty Node を選択し、
PathNodeなど分かりやすい名前に変更します。 PathNodeを選択した状態で、右側の Inspector パネル下部にある Add Component ボタンをクリックします。- 検索欄に
Path2Dと入力し、表示された Path2D コンポーネントを追加します。 - Path2D の編集モードに入り、以下のようにパスを作成します(基本的な例):
- Scene ビュー上部の Edit Path などのボタン(Path2D の編集)を押す。
- Scene ビュー内でクリックしてポイントを追加し、直線やカーブを作る。
- 例えば、X 方向に 0 → 300 → 600 の 3 点を置いて、水平に移動するパスを作る。
- パスの編集が終わったら、編集モードを終了します。
3. 移動させたいノードの作成
次に、実際にパスに沿って動かしたいノード(例: 動く床)を作ります。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を
MovingPlatformとします。 MovingPlatformを選択し、Inspector の Sprite コンポーネントで適当な画像(床画像など)を設定します。- 初期位置はどこでも構いませんが、テストしやすいように
PathNodeの近くに配置しておくと良いです。
4. PathMover コンポーネントをアタッチ
- Hierarchy で
MovingPlatformを選択します。 - Inspector 下部の Add Component ボタンをクリックし、Custom → PathMover を選択します。
- Custom カテゴリに表示されない場合は、スクリプト名とクラス名(
@ccclass('PathMover'))が一致しているか、コンパイルエラーが出ていないか確認してください。
- Custom カテゴリに表示されない場合は、スクリプト名とクラス名(
5. PathMover のプロパティ設定
MovingPlatform にアタッチされた PathMover コンポーネントの各プロパティを設定します。
- Path Node:
- Hierarchy から
PathNodeをドラッグ&ドロップして、このフィールドにセットします。
- Hierarchy から
- Speed:
- 例として
200〜300あたりを入力してみてください。 - 値を大きくすると速く移動します。
- 例として
- Start Position:
- とりあえず
0のままで OK(パスの始点からスタート)。 - パスの中間から開始したい場合は
0.5などに変更します。
- とりあえず
- Move Direction:
Forward: Path2D の定義通り(0 → 1)に移動。Backward: 逆方向(1 → 0)に移動。
- Loop Type:
- 常にぐるぐる回したい場合は
Loop。 - 行ったり来たりさせたい場合は
PingPong。 - 一度だけ動かして止めたい場合は
Once。
- 常にぐるぐる回したい場合は
- Start Delay:
- ゲーム開始からすぐ動かしたい場合は
0。 - 例えば
1.0にすると、1 秒待ってから動き始めます。
- ゲーム開始からすぐ動かしたい場合は
- Turn Back Delay:
LoopTypeがPingPongのときのみ有効。- 例えば
0.5にすると、端に到達してから 0.5 秒止まって反対方向へ動き出します。
- Play On Start:
- 基本的には
trueのままで OK。 - false にすると、他のスクリプトから
getComponent(PathMover).play()を呼ぶまで動きません。
- 基本的には
- Use Local Position:
false(デフォルト):- パス座標をワールド座標に変換して適用するため、
PathNodeと離れた位置に置いても、同じ形の軌跡を描きます。
- パス座標をワールド座標に変換して適用するため、
true:- パス座標をそのままローカル座標として適用するため、
MovingPlatformをPathNodeの子にして使うケースなどに向いています。
- パス座標をそのままローカル座標として適用するため、
6. ゲーム再生で動作確認
- 上部ツールバーの Play ボタン(▶)を押してゲームを再生します。
- Scene または Game ビューを見ながら、
MovingPlatformがPathNodeの Path2D で定義したパスに沿って移動することを確認します。 - 速度や LoopType、StartPosition などを変えて再生し直し、挙動が変わることを試してみてください。
7. 動かないときのチェックポイント
- Path Node が未設定:
- Console に
[PathMover] pathNode が設定されていませんの警告が出ていないか確認。 - Inspector で Path Node に正しく
PathNodeを指定してください。
- Console に
- Path2D が付いていない:
- Console に
pathNode に Path2D コンポーネントが見つかりませんのエラーが出ていないか確認。 PathNodeに Path2D コンポーネントが追加されているか確認し、必要なら再追加します。
- Console に
- パス長が 0:
- Console に
Path2D の長さが 0 ですの警告が出ていないか確認。 - Path2D に十分な数のポイントが配置されているか、パスが重なりすぎていないかを確認してください。
- Console に
- Speed が 0 以下:
speedが 0 になっていないか確認。
- LoopType.Once で端に到達済み:
- 一度端まで移動した後は
_isPlayingが false になり停止します。 - 再度動かしたい場合は
stopAndReset()→play()を呼ぶか、Play を押し直してシーンをリセットしてください。
- 一度端まで移動した後は
まとめ
この PathMover コンポーネントを使うことで、
- 動く床やエレベーター
- パトロールする敵キャラ
- 背景をぐるぐる回るオブジェクト
- 演出用のカメラ移動(カメラノードにアタッチ)
といった「パスに沿った移動」を、スクリプト 1 つをノードにアタッチして Path2D を指定するだけで簡単に実現できます。
本コンポーネントは他のカスタムスクリプトに一切依存せず、インスペクタのプロパティだけで完結しているため、プロジェクト内のどのシーン・どのノードにもそのまま再利用できます。
パスの形状は Path2D の編集で柔軟に変えられるので、レベルデザインの段階で移動ルートを調整するのにも向いています。
まずはシンプルな水平移動から試し、慣れてきたらカーブや複雑なパスを作って、敵やギミックの動きにバリエーションを持たせてみてください。




