【Cocos Creator 3.8】SplineFollower(曲線移動)の実装:アタッチするだけでノードをベジェ曲線に沿って滑らかに移動させる汎用スクリプト
このガイドでは、任意のノードにアタッチするだけで「開始位置 → 制御点 → 終了位置」を結ぶベジェ曲線(2次 or 3次)に沿って自動で移動させる汎用コンポーネント SplineFollower を実装します。
移動経路はインスペクタ上で制御点を数値指定するだけで完結し、他のカスタムスクリプトや外部ノードへの参照は一切不要です。UIの演出、敵の移動パターン、カメラのスムーズな移動など、さまざまな場面でそのまま再利用できます。
コンポーネントの設計方針
要件整理
- ノードを「ベジェ曲線」に沿って移動させる。
- 外部ノードや GameManager などに依存せず、このスクリプト単体で完結する。
- インスペクタから以下を調整可能にする:
- 曲線の種類(2次 / 3次ベジェ)
- 制御点(開始・終了・制御点)の座標
- 移動にかかる時間 or 速度
- ループ・往復・一回きりなどの再生モード
- 自動開始の有無、一時停止・再開の制御
- エディタ上で「現在の t(0〜1)」を動かして経路確認できるようにする。
- 防御的実装:必須コンポーネントは特にないが、エラーになりそうなパラメータ(duration ≤ 0 など)はログを出して無効化する。
インスペクタで設定可能なプロパティ設計
SplineFollower で用意する主な @property は以下の通りです。
- curveType: 2次 or 3次ベジェの選択
- 型:
Enum - 説明: 2点+制御点1つ(2次)か、制御点2つ(3次)かを選ぶ。
- 型:
- useLocalSpace: ローカル座標で制御点を解釈するかどうか
- 型:
boolean - 説明: ONならノードの親を基準にしたローカル座標、OFFならワールド座標として扱う。
- 型:
- startPoint: 開始点
- 型:
Vec3 - 説明: 曲線の始点。通常はノードの初期位置と合わせる。
- 型:
- endPoint: 終了点
- 型:
Vec3 - 説明: 曲線の終点。移動の到達地点。
- 型:
- controlPoint1: 制御点1
- 型:
Vec3 - 説明: 2次・3次ベジェ両方で使用。カーブの向き・膨らみを調整。
- 型:
- controlPoint2: 制御点2
- 型:
Vec3 - 説明: 3次ベジェ用の第2制御点。2次選択時は無視される。
- 型:
- moveMode: 移動モード
- 型:
Enum(Once / Loop / PingPong) - 説明:
- Once: 0→1 まで進んだら停止。
- Loop: 0→1 まで進んだら 0 に戻って繰り返し。
- PingPong: 0→1→0→1… と往復。
- 型:
- duration: 1往復にかかる時間(秒)
- 型:
number - 説明: 0→1 の移動にかかる時間。0以下の場合はログを出して移動を無効化。
- 型:
- playOnStart: 自動再生
- 型:
boolean - 説明: ONなら
start()時に自動で移動開始。
- 型:
- initialT: 開始時の曲線位置
- 型:
number(0〜1) - 説明: 再生開始時にどの位置からスタートするか。
- 型:
- editorPreviewT: エディタ用プレビュー位置
- 型:
number(0〜1) - 説明: エディタ上で値を変えると、その位置にノードを移動させてカーブ確認(ゲーム実行中は無視)。
- 型:
- enableEditorPreview: エディタプレビュー有効化
- 型:
boolean - 説明: ONのときのみ
editorPreviewTを反映。
- 型:
このほか、スクリプト内部には現在の進行度 t や方向(正方向/逆方向)などの状態を保持するプライベート変数を持たせます。
TypeScriptコードの実装
以下が完成した SplineFollower.ts の全コードです。
import { _decorator, Component, Node, Vec3, math, Enum, CCFloat, CCBoolean, CCInteger, game } from 'cc';
const { ccclass, property, executeInEditMode, menu } = _decorator;
enum CurveType {
Quadratic = 0, // 2次ベジェ
Cubic = 1, // 3次ベジェ
}
enum MoveMode {
Once = 0,
Loop = 1,
PingPong = 2,
}
@ccclass('SplineFollower')
@executeInEditMode(true)
@menu('Custom/SplineFollower')
export class SplineFollower extends Component {
@property({
type: Enum(CurveType),
tooltip: '曲線の種類を選択します。\nQuadratic: 2次ベジェ (P0, C1, P1)\nCubic: 3次ベジェ (P0, C1, C2, P1)',
})
public curveType: CurveType = CurveType.Quadratic;
@property({
tooltip: '制御点をローカル座標として解釈するかどうか。\nON: ノードの親を基準にしたローカル座標\nOFF: ワールド座標',
})
public useLocalSpace: boolean = true;
@property({
tooltip: 'ベジェ曲線の開始点 (P0)。\n通常はノードの初期位置付近に設定します。',
})
public startPoint: Vec3 = new Vec3(0, 0, 0);
@property({
tooltip: 'ベジェ曲線の終了点 (P1)。\nノードが到達する終点です。',
})
public endPoint: Vec3 = new Vec3(300, 0, 0);
@property({
tooltip: 'ベジェ曲線の制御点1 (C1)。\nカーブの膨らみや向きを調整します。',
})
public controlPoint1: Vec3 = new Vec3(150, 150, 0);
@property({
tooltip: 'ベジェ曲線の制御点2 (C2)。\nCubic (3次ベジェ) のときのみ使用されます。',
})
public controlPoint2: Vec3 = new Vec3(150, -150, 0);
@property({
type: Enum(MoveMode),
tooltip: '移動モードを選択します。\nOnce: 0→1 まで進んだら停止\nLoop: 0→1 まで進んだら 0 に戻って繰り返し\nPingPong: 0→1→0→1… と往復',
})
public moveMode: MoveMode = MoveMode.Once;
@property({
type: CCFloat,
tooltip: '0→1 の移動にかかる時間(秒)。\n0以下の場合は移動せず警告を出します。',
})
public duration: number = 2.0;
@property({
type: CCBoolean,
tooltip: 'シーン開始時 (start) に自動で移動を開始するかどうか。',
})
public playOnStart: boolean = true;
@property({
type: CCFloat,
range: [0, 1, 0.001],
slide: true,
tooltip: '再生開始時の曲線上の位置 (0〜1)。\n0: 開始点, 1: 終了点。',
})
public initialT: number = 0.0;
@property({
type: CCBoolean,
tooltip: 'エディタ上でプレビューを有効にするかどうか。\nONのとき、editorPreviewT を変更するとノードをその位置に移動します。',
})
public enableEditorPreview: boolean = true;
@property({
type: CCFloat,
range: [0, 1, 0.001],
slide: true,
tooltip: 'エディタ用のプレビュー位置 (0〜1)。\nゲーム実行中は無視されます。',
})
public editorPreviewT: number = 0.0;
// --- 内部状態 ---
private _playing: boolean = false;
private _t: number = 0; // 現在の 0〜1 の位置
private _direction: number = 1; // 1: 正方向, -1: 逆方向 (PingPong 用)
private _lastEditorPreviewT: number = -1;
onLoad() {
// duration の防御チェック
if (this.duration <= 0) {
console.warn('[SplineFollower] duration が 0 以下のため、移動は行われません。ノード:', this.node.name);
}
// 実行中でもエディタでも、初期位置を設定
this._t = math.clamp01(this.initialT);
this._applyPositionByT(this._t);
}
start() {
if (!game.isPaused() && this.playOnStart && this.duration > 0) {
this.play();
}
}
update(deltaTime: number) {
// エディタプレビュー処理(ゲーム実行中ではないとき)
if (!game.isPlaying && this.enableEditorPreview) {
if (this.editorPreviewT !== this._lastEditorPreviewT) {
this._lastEditorPreviewT = this.editorPreviewT;
const tClamped = math.clamp01(this.editorPreviewT);
this._applyPositionByT(tClamped);
}
return;
}
// ゲーム実行中の移動処理
if (!this._playing) {
return;
}
if (this.duration <= 0) {
return;
}
// t を時間に応じて進める
const deltaT = (deltaTime / this.duration) * this._direction;
this._t += deltaT;
// モードに応じて t を処理
if (this.moveMode === MoveMode.Once) {
if (this._t >= 1) {
this._t = 1;
this._playing = false;
} else if (this._t <= 0) {
this._t = 0;
this._playing = false;
}
} else if (this.moveMode === MoveMode.Loop) {
if (this._t > 1) {
this._t -= 1;
} else if (this._t < 0) {
this._t += 1;
}
} else if (this.moveMode === MoveMode.PingPong) {
if (this._t >= 1) {
this._t = 1;
this._direction = -1;
} else if (this._t <= 0) {
this._t = 0;
this._direction = 1;
}
}
const tClamped = math.clamp01(this._t);
this._applyPositionByT(tClamped);
}
/**
* 外部から再生を開始するためのメソッド。
*/
public play(reset: boolean = false) {
if (reset) {
this._t = math.clamp01(this.initialT);
this._direction = 1;
this._applyPositionByT(this._t);
}
if (this.duration <= 0) {
console.warn('[SplineFollower] duration が 0 以下のため、play() しても移動しません。ノード:', this.node.name);
return;
}
this._playing = true;
}
/**
* 一時停止します。
*/
public pause() {
this._playing = false;
}
/**
* 現在位置から再開します。
*/
public resume() {
if (this.duration <= 0) {
console.warn('[SplineFollower] duration が 0 以下のため、resume() しても移動しません。ノード:', this.node.name);
return;
}
this._playing = true;
}
/**
* 再生を停止し、t を初期値に戻します。
*/
public stop() {
this._playing = false;
this._t = math.clamp01(this.initialT);
this._direction = 1;
this._applyPositionByT(this._t);
}
/**
* 現在の t を取得します。
*/
public getT(): number {
return this._t;
}
/**
* t を直接設定します (0〜1)。
*/
public setT(t: number) {
this._t = math.clamp01(t);
this._applyPositionByT(this._t);
}
// === 内部ユーティリティ ===
/**
* t (0〜1) に応じてノードの位置を更新します。
*/
private _applyPositionByT(t: number) {
const pos = this._evaluateBezier(t);
if (this.useLocalSpace) {
// ローカル座標として適用
this.node.setPosition(pos);
} else {
// ワールド座標として適用
this.node.worldPosition = pos;
}
}
/**
* 現在の設定に基づいて、t におけるベジェ曲線上の座標を返します。
*/
private _evaluateBezier(t: number): Vec3 {
const p0 = this.startPoint;
const p1 = this.endPoint;
const c1 = this.controlPoint1;
const c2 = this.controlPoint2;
const oneMinusT = 1 - t;
if (this.curveType === CurveType.Quadratic) {
// 2次ベジェ: B(t) = (1-t)^2 P0 + 2(1-t)t C1 + t^2 P1
const term0 = p0.clone().multiplyScalar(oneMinusT * oneMinusT);
const term1 = c1.clone().multiplyScalar(2 * oneMinusT * t);
const term2 = p1.clone().multiplyScalar(t * t);
return term0.add(term1).add(term2);
} else {
// 3次ベジェ: B(t) = (1-t)^3 P0 + 3(1-t)^2 t C1 + 3(1-t)t^2 C2 + t^3 P1
const term0 = p0.clone().multiplyScalar(oneMinusT * oneMinusT * oneMinusT);
const term1 = c1.clone().multiplyScalar(3 * oneMinusT * oneMinusT * t);
const term2 = c2.clone().multiplyScalar(3 * oneMinusT * t * t);
const term3 = p1.clone().multiplyScalar(t * t * t);
return term0.add(term1).add(term2).add(term3);
}
}
}
コードのポイント解説
@executeInEditMode(true):- ゲームを再生していない状態でも
updateが呼ばれ、インスペクタの変更に反応できます。 enableEditorPreviewとeditorPreviewTによって、エディタ上でカーブの形を確認可能です。
- ゲームを再生していない状態でも
- onLoad():
durationが 0 以下の場合に警告ログを出します。initialTをもとに初期位置をカーブ上に設定します。
- start():
playOnStartが ON かつduration > 0のとき、自動でplay()を呼びます。
- update(deltaTime):
- ゲーム停止中(エディタプレビュー時)は
editorPreviewTの変化を監視し、その位置にノードを移動。 - ゲーム実行中は
_playingとdurationを確認し、tを進めてノードを移動。 - Once / Loop / PingPong の各モードごとに
tの範囲外になったときの処理を変えています。
- ゲーム停止中(エディタプレビュー時)は
_evaluateBezier(t):- 2次ベジェと3次ベジェの数式をそのまま実装しています。
Vec3.clone()とmultiplyScalar()を組み合わせて中間項を計算し、最後にadd()で合成しています。
useLocalSpace:- ON:
node.setPosition()でローカル座標に反映。 - OFF:
node.worldPosition = pos;でワールド座標に直接反映。
- ON:
- 公開メソッド:
play(reset?: boolean)/pause()/resume()/stop()/setT()/getT()を用意し、他スクリプトからも制御しやすいようにしています。
(本コンポーネントは単体で完結しますが、必要であれば別スクリプトからこれらを呼ぶだけで制御可能です)
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ左下の Assets パネルで、任意のフォルダ(例:
assets/scripts)を右クリックします。 - Create > TypeScript を選択し、ファイル名を
SplineFollower.tsにします。 - 自動生成されたコードをすべて削除し、前述の
SplineFollowerのコードを貼り付けて保存します。
2. テスト用ノードの作成
- Hierarchy パネルで右クリック → Create → 2D Object > Sprite(または 3D Object > Cube など)を作成します。
- 作成したノードにわかりやすい名前(例:
BezierMover)を付けます。
3. コンポーネントのアタッチ
- Hierarchy で先ほど作成したノード(
BezierMover)を選択します。 - 右側の Inspector パネル下部で Add Component ボタンを押します。
- Custom → SplineFollower を選択してアタッチします。
4. 基本設定(2次ベジェでの例)
Inspector 上で SplineFollower の各プロパティを以下のように設定してみます。
- curveType:
Quadratic - useLocalSpace:
true - startPoint:
(0, 0, 0) - endPoint:
(300, 0, 0) - controlPoint1:
(150, 150, 0)(上に膨らむカーブ) - controlPoint2: (Quadratic では無視されるのでそのままでOK)
- moveMode:
Once - duration:
2.0 - playOnStart:
true - initialT:
0.0 - enableEditorPreview:
true - editorPreviewT: 任意(0〜1のスライダーで調整)
5. エディタ上でカーブを確認する
- ゲームを再生していない状態で、
enableEditorPreviewが ON になっていることを確認します。 editorPreviewTのスライダーを 0〜1 の間で動かします。- ノードがカーブに沿って移動するので、カーブの形を目視で確認しながら
controlPoint1を調整します。
6. 実行して動作確認
- エディタ上部の Play ボタンを押してゲームを実行します。
BezierMoverノードが、開始点から終了点まで 2秒かけて滑らかに移動することを確認します。- カーブの膨らみを変えたい場合は、実行を止めて
controlPoint1の Y 値を増減させてみましょう。- 例:
(150, 300, 0)にすると、より大きく上に膨らみます。
- 例:
7. 3次ベジェ(Cubic)でのテスト
より複雑なカーブを試したい場合は、以下のように設定します。
- curveType:
Cubic - startPoint:
(0, 0, 0) - endPoint:
(300, 0, 0) - controlPoint1:
(100, 150, 0) - controlPoint2:
(200, -150, 0) - その他は先ほどと同様。
この設定では、最初は上に膨らみ、途中から下に落ちてくるような S 字カーブになります。
エディタプレビューで editorPreviewT を動かしながら、2つの制御点を調整してみてください。
8. ループ・往復モードの利用例
- Loop モードでぐるぐる回す
moveMode:Loopduration:3.0- 敵キャラやアイテムを一定のパターンで往復させるときに便利です。
- PingPong モードで往復移動
moveMode:PingPongduration:2.0- 敵の巡回や、UIの上下揺れなどにそのまま使えます。
9. (任意)コードから制御したい場合
本コンポーネントは単体で完結していますが、別スクリプトから制御したい場合は以下のように呼び出せます。
// 他のスクリプトからの例(あくまで使用例であり、依存は不要)
import { _decorator, Component } from 'cc';
import { SplineFollower } from './SplineFollower';
const { ccclass } = _decorator;
@ccclass('ExampleController')
export class ExampleController extends Component {
start() {
const follower = this.node.getComponent(SplineFollower);
if (follower) {
follower.play(true); // 初期位置にリセットして再生
}
}
}
まとめ
SplineFollower コンポーネントは、
- 任意のノードにアタッチするだけで、
- 2次 / 3次ベジェ曲線に沿った滑らかな移動を実現し、
- 外部のカスタムスクリプトやノード参照に一切依存せず、
- インスペクタからパス形状・再生モード・速度を直感的に調整できる
という点で、ゲーム内の演出や敵の移動、カメラワーク、UIアニメーションなど、さまざまな用途にそのまま再利用できる汎用コンポーネントです。
特に、エディタプレビュー機能(enableEditorPreview と editorPreviewT)によって、ゲームを再生せずにカーブ形状を確認・調整できるため、パスづくりの試行錯誤を大きく短縮できます。
このスクリプト単体で完結しているため、プロジェクト間のコピー&ペーストもしやすく、「とりあえずアタッチして動かしてみる」ことが簡単にできます。必要に応じて、
- 複数の SplineFollower を組み合わせて複雑なパスを作る
- 特定の t に達したときにイベントを発火する処理を追加する
- エディタ上で制御点をギズモ表示する拡張
など、さらに発展させていくことも可能です。まずは本記事のコードをそのまま貼り付けて、ベジェ曲線移動のベースコンポーネントとして活用してみてください。
