【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 が呼ばれ、インスペクタの変更に反応できます。
    • enableEditorPrevieweditorPreviewT によって、エディタ上でカーブの形を確認可能です。
  • onLoad()
    • duration が 0 以下の場合に警告ログを出します。
    • initialT をもとに初期位置をカーブ上に設定します。
  • start()
    • playOnStart が ON かつ duration > 0 のとき、自動で play() を呼びます。
  • update(deltaTime)
    • ゲーム停止中(エディタプレビュー時)は editorPreviewT の変化を監視し、その位置にノードを移動。
    • ゲーム実行中は _playingduration を確認し、t を進めてノードを移動。
    • Once / Loop / PingPong の各モードごとに t の範囲外になったときの処理を変えています。
  • _evaluateBezier(t)
    • 2次ベジェと3次ベジェの数式をそのまま実装しています。
    • Vec3.clone()multiplyScalar() を組み合わせて中間項を計算し、最後に add() で合成しています。
  • useLocalSpace
    • ON: node.setPosition() でローカル座標に反映。
    • OFF: node.worldPosition = pos; でワールド座標に直接反映。
  • 公開メソッド
    • play(reset?: boolean) / pause() / resume() / stop() / setT() / getT() を用意し、他スクリプトからも制御しやすいようにしています。

      (本コンポーネントは単体で完結しますが、必要であれば別スクリプトからこれらを呼ぶだけで制御可能です)

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ左下の Assets パネルで、任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create > TypeScript を選択し、ファイル名を SplineFollower.ts にします。
  3. 自動生成されたコードをすべて削除し、前述の SplineFollower のコードを貼り付けて保存します。

2. テスト用ノードの作成

  1. Hierarchy パネルで右クリック → Create2D Object > Sprite(または 3D Object > Cube など)を作成します。
  2. 作成したノードにわかりやすい名前(例: BezierMover)を付けます。

3. コンポーネントのアタッチ

  1. Hierarchy で先ほど作成したノード(BezierMover)を選択します。
  2. 右側の Inspector パネル下部で Add Component ボタンを押します。
  3. CustomSplineFollower を選択してアタッチします。

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. エディタ上でカーブを確認する

  1. ゲームを再生していない状態で、enableEditorPreview が ON になっていることを確認します。
  2. editorPreviewT のスライダーを 0〜1 の間で動かします。
  3. ノードがカーブに沿って移動するので、カーブの形を目視で確認しながら controlPoint1 を調整します。

6. 実行して動作確認

  1. エディタ上部の Play ボタンを押してゲームを実行します。
  2. BezierMover ノードが、開始点から終了点まで 2秒かけて滑らかに移動することを確認します。
  3. カーブの膨らみを変えたい場合は、実行を止めて 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: Loop
    • duration: 3.0
    • 敵キャラやアイテムを一定のパターンで往復させるときに便利です。
  • PingPong モードで往復移動
    • moveMode: PingPong
    • duration: 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アニメーションなど、さまざまな用途にそのまま再利用できる汎用コンポーネントです。

特に、エディタプレビュー機能enableEditorPrevieweditorPreviewT)によって、ゲームを再生せずにカーブ形状を確認・調整できるため、パスづくりの試行錯誤を大きく短縮できます。

このスクリプト単体で完結しているため、プロジェクト間のコピー&ペーストもしやすく、「とりあえずアタッチして動かしてみる」ことが簡単にできます。必要に応じて、

  • 複数の SplineFollower を組み合わせて複雑なパスを作る
  • 特定の t に達したときにイベントを発火する処理を追加する
  • エディタ上で制御点をギズモ表示する拡張

など、さらに発展させていくことも可能です。まずは本記事のコードをそのまま貼り付けて、ベジェ曲線移動のベースコンポーネントとして活用してみてください。