【Cocos Creator 3.8】BoomerangProjectileの実装:アタッチするだけで「投げた方向に飛んでから発射者の元へ戻る」ブーメラン挙動を実現する汎用スクリプト

このコンポーネントは、ノードにアタッチして初期位置を「発射者の手元」とみなすことで、

  • 前方に一定距離だけ直進
  • 到達後、自動的に進行方向を反転
  • 元の位置(発射者の位置)に戻る

というブーメランのような挙動を実現します。Rigidbody などの物理コンポーネントに依存せず、Transform だけで制御するため、どのノードにもアタッチしてすぐに使える汎用スクリプトとして設計します。

コンポーネントの設計方針

本コンポーネント BoomerangProjectile は、次のような要件を満たすように設計します。

  • 外部スクリプトやシングルトンに依存しない(完全に独立したコンポーネント)。
  • アタッチされたノードの「現在位置」を発射者の位置(戻り先)として自動的に記録する。
  • ノードのローカル座標系の「前方向」(デフォルトでは +X)に向かって飛ばす。
  • 指定した「最大移動距離」に達したら、自動で進行方向を反転させて戻り始める。
  • 戻り中に発射者の位置に十分近づいたら、自動で動作を停止し、任意でノードを自動削除できる。
  • 時間ベースではなく距離ベースで制御することで、フレームレートに依存しない挙動にする。

また、ブーメランの挙動をインスペクタから簡単に調整できるよう、以下のようなプロパティを用意します。

インスペクタで設定可能なプロパティ

  • moveSpeed: number
    ブーメランの移動速度(単位:ユニット/秒)。
    役割: 1秒あたりに進む距離を決めます。
    例: 5〜20 くらいが扱いやすいレンジ。
  • maxDistance: number
    発射位置からどれだけ離れたら折り返すか(単位:ユニット)。
    役割: ブーメランの「飛んでいく距離」を決定します。
    例: 3〜10 くらいにしておくと、画面内で完結しやすいです。
  • returnSpeedMultiplier: number
    戻りフェーズの速度倍率。
    役割: 行きの速度に対して、戻りの速度を何倍にするかを指定します。
    例: 1.0 で同速、2.0 で戻りの方が2倍速い。
  • autoDestroyOnReturn: boolean
    戻り完了時に自動でノードを破棄するかどうか。
    役割: 投擲後にブーメランオブジェクトを自動的に消したい場合に有効にします。
  • returnCompleteThreshold: number
    発射位置にどれだけ近づいたら「戻り完了」とみなすかの距離しきい値。
    役割: 完全に同じ座標まで戻らなくても、ある程度近づいたら完了扱いにして停止/削除するためのしきい値。
    例: 0.1〜0.3 程度。
  • autoStart: boolean
    onEnable 時に自動でブーメラン挙動を開始するかどうか。
    役割: 有効にしておくと、ノードが有効化された瞬間に投げられたように動き始めます。
    無効にしておくと、外部から begin() を呼ぶまで待機します(他スクリプトからの制御用)。
  • debugDrawPath: boolean
    デバッグ用に、Scene ビュー上で移動の軌跡となるラインを描画するかどうか。
    役割: ゲーム中の挙動確認やチューニングに便利。
    実際のゲームリリース時には OFF にすることを推奨します。

内部的には、以下のような状態を持ちます。

  • _originPosition: 発射位置(戻り先)
  • _direction: 行きフェーズの正規化された移動方向(ローカル座標の +X をワールド座標に変換したもの)
  • _traveledDistance: 発射位置から現在位置までの移動距離
  • _isReturning: 現在が戻りフェーズかどうかのフラグ
  • _isActive: ブーメラン挙動が有効かどうか

TypeScriptコードの実装


import { _decorator, Component, Node, Vec3, v3, math, Graphics, Color, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * BoomerangProjectile
 * アタッチされたノードを、投げた方向に一定距離進ませた後、
 * 自動的に発射位置へ戻すブーメラン挙動コンポーネント。
 */
@ccclass('BoomerangProjectile')
export class BoomerangProjectile extends Component {

    @property({
        tooltip: '行きの移動速度(ユニット/秒)。'
    })
    public moveSpeed: number = 10;

    @property({
        tooltip: '発射位置からどれだけ離れたら折り返すか(ユニット)。'
    })
    public maxDistance: number = 5;

    @property({
        tooltip: '戻りフェーズの速度倍率。1.0で行きと同じ速度。'
    })
    public returnSpeedMultiplier: number = 1.5;

    @property({
        tooltip: '戻り完了時に自動でノードを破棄するかどうか。'
    })
    public autoDestroyOnReturn: boolean = false;

    @property({
        tooltip: '発射位置にどれだけ近づいたら戻り完了とみなすか(ユニット)。'
    })
    public returnCompleteThreshold: number = 0.2;

    @property({
        tooltip: 'コンポーネントが有効化されたときに自動で動作を開始するか。'
    })
    public autoStart: boolean = true;

    @property({
        tooltip: 'Sceneビューに移動軌跡のデバッグラインを描画するかどうか。'
    })
    public debugDrawPath: boolean = false;

    // 内部状態
    private _originPosition: Vec3 = v3();
    private _direction: Vec3 = v3(1, 0, 0); // ローカル+X方向を基本とする
    private _traveledDistance: number = 0;
    private _isReturning: boolean = false;
    private _isActive: boolean = false;

    // デバッグ描画用
    private _graphics: Graphics | null = null;
    private _lastPosition: Vec3 = v3();

    onLoad() {
        // 初期状態の保存
        this._originPosition.set(this.node.worldPosition);
        this._lastPosition.set(this._originPosition);

        // ローカル+X方向をワールド方向に変換して移動方向とする
        const localForward = v3(1, 0, 0);
        this._direction = this.node.getWorldMatrix().transformVector(localForward).normalize();

        // 防御的: maxDistance, moveSpeed などの値が不正な場合は警告
        if (this.maxDistance <= 0) {
            console.warn('[BoomerangProjectile] maxDistance が 0 以下です。デフォルト値 5 を使用します。');
            this.maxDistance = 5;
        }
        if (this.moveSpeed <= 0) {
            console.warn('[BoomerangProjectile] moveSpeed が 0 以下です。デフォルト値 10 を使用します。');
            this.moveSpeed = 10;
        }
        if (this.returnCompleteThreshold <= 0) {
            console.warn('[BoomerangProjectile] returnCompleteThreshold が 0 以下です。デフォルト値 0.2 を使用します。');
            this.returnCompleteThreshold = 0.2;
        }

        // デバッグ描画用 Graphics 準備(必要な場合のみ)
        if (this.debugDrawPath) {
            this._setupDebugGraphics();
        }
    }

    onEnable() {
        // 有効化時に自動開始するかどうか
        if (this.autoStart) {
            this.begin();
        }
    }

    /**
     * ブーメラン挙動を開始する。
     * 自動開始をOFFにしている場合、外部から明示的に呼び出して使う。
     */
    public begin() {
        this._originPosition.set(this.node.worldPosition);
        this._lastPosition.set(this._originPosition);

        // 向きの再計算(ノードの回転が変わっている可能性があるため)
        const localForward = v3(1, 0, 0);
        this._direction = this.node.getWorldMatrix().transformVector(localForward).normalize();

        this._traveledDistance = 0;
        this._isReturning = false;
        this._isActive = true;

        if (this.debugDrawPath && !this._graphics) {
            this._setupDebugGraphics();
        }

        // デバッグ描画の初期ライン
        if (this._graphics) {
            this._graphics.clear();
            this._graphics.moveTo(this._originPosition.x, this._originPosition.y);
        }
    }

    /**
     * ブーメラン挙動を停止する。
     * 自動削除したくない場合に、外部から停止制御する用途も想定。
     */
    public stop() {
        this._isActive = false;
    }

    update(deltaTime: number) {
        if (!this._isActive) {
            return;
        }

        const currentPos = this.node.worldPosition.clone();

        if (!this._isReturning) {
            // ===== 行きフェーズ =====
            const moveDelta = this._direction.clone().multiplyScalar(this.moveSpeed * deltaTime);
            const newPos = currentPos.add(moveDelta);
            this.node.setWorldPosition(newPos);

            // 移動した距離を加算
            this._traveledDistance += moveDelta.length();

            // 折り返し判定
            if (this._traveledDistance >= this.maxDistance) {
                this._isReturning = true;
            }
        } else {
            // ===== 戻りフェーズ =====
            const toOrigin = this._originPosition.clone().subtract(currentPos);
            const distanceToOrigin = toOrigin.length();

            if (distanceToOrigin <= this.returnCompleteThreshold) {
                // 戻り完了
                this.node.setWorldPosition(this._originPosition);
                this._isActive = false;

                if (this.autoDestroyOnReturn) {
                    // 次のフレームで安全に破棄
                    this.node.destroy();
                }
                return;
            }

            // 発射位置へ向かう正規化ベクトル
            const returnDir = toOrigin.normalize();
            const speed = this.moveSpeed * this.returnSpeedMultiplier;
            const moveDelta = returnDir.multiplyScalar(speed * deltaTime);
            const newPos = currentPos.add(moveDelta);
            this.node.setWorldPosition(newPos);
        }

        // デバッグ描画(前フレームからの軌跡をラインで描画)
        if (this._graphics) {
            const newPos = this.node.worldPosition;
            this._graphics.moveTo(this._lastPosition.x, this._lastPosition.y);
            this._graphics.lineTo(newPos.x, newPos.y);
            this._graphics.stroke();
            this._lastPosition.set(newPos);
        }
    }

    /**
     * デバッグ用 Graphics コンポーネントをセットアップする。
     * 2Dゲームでの利用を想定(カメラやCanvasの設定に応じて見え方が変わる点に注意)。
     */
    private _setupDebugGraphics() {
        // すでに存在する場合は再利用
        if (this._graphics) {
            return;
        }

        // デバッグ描画用ノードを作成し、Graphicsコンポーネントを追加
        const parent = director.getScene();
        if (!parent) {
            console.warn('[BoomerangProjectile] シーンがロードされていないため、デバッグ描画を初期化できません。');
            return;
        }

        const debugNode = new Node('BoomerangDebugLine');
        parent.addChild(debugNode);

        this._graphics = debugNode.addComponent(Graphics);
        this._graphics.lineWidth = 2;
        this._graphics.strokeColor = new Color(0, 255, 0, 255); // 緑色
    }
}

コードの主要部分の解説

  • onLoad()
    • 現在の worldPosition を発射位置(_originPosition)として保存。
    • ローカル +X 方向ベクトルをノードのワールド行列で変換し、実際の飛行方向(_direction)を算出。
    • プロパティの値が不正(0以下)の場合に警告を出し、デフォルト値に補正。
    • debugDrawPath が有効なら、デバッグ描画用の Graphics を準備。
  • onEnable()
    • autoStarttrue の場合、自動的に begin() を呼んで挙動を開始。
  • begin()
    • 現在位置を再度発射位置として記録(Prefab を使い回すケースなどで、生成位置が変わるのに対応)。
    • ノードの現在の回転に基づいて、飛行方向ベクトルを再計算。
    • 移動距離や状態フラグ(_isReturning / _isActive)を初期化。
    • デバッグ描画用ラインの初期化。
  • update(deltaTime)
    • _isActivefalse のときは何もしない。
    • 行きフェーズ:
      • _directionmoveSpeed * deltaTime を掛けた分だけワールド座標を移動。
      • 移動距離を _traveledDistance に加算し、maxDistance に達したら _isReturning = true に。
    • 戻りフェーズ:
      • 現在位置から発射位置へのベクトル toOrigin を計算し、その長さで距離を取得。
      • 距離が returnCompleteThreshold 以下になったら戻り完了と判定し、位置をピタッと発射位置に戻して停止。
      • autoDestroyOnReturn が true の場合は node.destroy() でノードを破棄。
      • まだ距離がある場合は、正規化した toOriginmoveSpeed * returnSpeedMultiplier * deltaTime を掛けて移動。
    • デバッグ描画が有効な場合、前フレーム位置から現在位置までのラインを Graphics で描画。

使用手順と動作確認

ここからは、Cocos Creator 3.8.7 エディタ上で実際にこのコンポーネントを使う手順を、具体的に説明します。

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

  1. Assets パネルで任意のフォルダ(例:assets/scripts)を右クリックします。
  2. Create → TypeScript を選択します。
  3. 新しく作成されたファイルの名前を BoomerangProjectile.ts に変更します。
  4. ダブルクリックしてエディタ(VS Code など)で開き、先ほどの TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用ノード(ブーメラン本体)の作成

ここでは 2D のスプライトを例に説明しますが、3D オブジェクトでも同様に使えます。

  1. Hierarchy パネルで右クリックし、Create → 2D Object → Sprite を選択します。
  2. 作成された Sprite ノードの名前を Boomerang などに変更します。
  3. Inspector の Sprite コンポーネントで、任意の画像(ブーメランっぽい画像だと分かりやすい)を設定します。
  4. Scene ビューで、ブーメランを投げたい初期位置にノードを配置します(この位置が「発射者の手元」となります)。
  5. ブーメランの向きを変えたい場合は、Rotation の Z 角度を調整して、ローカル +X 方向(右向き)が飛んでいく方向になるようにします。
    • 例:右に飛ばしたい → Z = 0
    • 例:左に飛ばしたい → Z = 180
    • 例:上に飛ばしたい → Z = 90
    • 例:下に飛ばしたい → Z = -90

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

  1. Hierarchy で先ほど作成した Boomerang ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom Component → BoomerangProjectile を選択します。
    • もしリストに表示されない場合は、スクリプトのコンパイルが終わっていない可能性があります。エディタ右上のスピナーが止まるまで待つか、エディタを再起動してください。

4. プロパティの設定例

Inspector 上で、BoomerangProjectile の各プロパティを設定します。まずは分かりやすい値から試してみましょう。

  • Move Speed: 10
    • 1秒間に 10 ユニット進みます。
  • Max Distance: 5
    • 発射位置から 5 ユニット進んだら折り返します。
  • Return Speed Multiplier: 1.5
    • 戻りは行きの 1.5 倍の速さになります。
  • Auto Destroy On Return: OFF(チェックを外す)
    • 最初は OFF にして、戻った後の挙動を目で確認できるようにしておきます。
  • Return Complete Threshold: 0.2
    • 発射位置から 0.2 ユニット以内に近づいたら戻り完了と判定します。
  • Auto Start: ON(チェックを入れる)
    • ゲーム開始と同時にブーメランが飛び出すようにします。
  • Debug Draw Path: ON(チェックを入れる)
    • Scene ビュー上に移動の軌跡が緑色のラインで表示され、挙動が確認しやすくなります。

5. ゲームを再生して動作確認

  1. エディタ上部の Play ボタンを押してゲームを実行します。
  2. Scene または Game ビューで、ブーメランの動きを確認します。
    • 開始位置からローカル +X 方向に向かってスーッと進む。
    • 約 5 ユニット進んだところで折り返し、元の位置に戻ってくる。
    • Debug Draw Path が ON の場合は、移動軌跡がラインで表示されます。
  3. 値を変えて再度再生し、挙動の違いを確認してみましょう。
    • Max Distance = 10 にすると、画面の端まで飛んでから戻ってくるような動きになります。
    • Return Speed Multiplier = 3 にすると、「ゆっくり飛んで、シュッと早く戻る」メリハリのあるブーメランになります。
    • Auto Destroy On Return = ON にすると、戻り完了と同時にブーメランノードが自動的に削除されます(弾丸的な使い方)。

6. 応用:Prefab 化して「投げる」

このコンポーネントは完全に独立しているため、Prefab 化しておくと「どこからでも投げられるブーメラン弾」として再利用できます。

  1. Hierarchy の Boomerang ノードを Assets パネルにドラッグ&ドロップして Prefab を作成します。
  2. 今後は、任意の発射位置・発射者からこの Prefab を Instantiate してシーンに追加するだけで、autoStart が ON であれば自動的に飛び始めます。
  3. もしスクリプトから明示的に制御したい場合は、autoStart を OFF にしておき、インスタンス化後に TypeScript から
    
    // 例:生成後に明示的に begin() を呼ぶ
    const boomerang = instantiate(boomerangPrefab);
    this.node.scene.addChild(boomerang);
    const comp = boomerang.getComponent(BoomerangProjectile);
    comp?.begin();
        

    のように呼び出せます(他のカスタムスクリプトに依存せず、ここだけで完結します)。

まとめ

今回実装した BoomerangProjectile コンポーネントは、

  • アタッチしたノードを「ブーメランのように飛ばして戻す」挙動に変える。
  • 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結する。
  • 速度・最大距離・戻り速度倍率・戻り完了しきい値・自動削除の有無などをインスペクタから柔軟に調整できる。
  • デバッグ用の軌跡描画で、ゲーム中の動きを視覚的に確認しやすい。

これにより、

  • アクションゲームの「ブーメラン武器」
  • 戻ってくる投擲アイテム(チャクラム、ヨーヨー、魔法の杖など)
  • プレイヤーの周囲を一度離れてから戻ってくるオービット系エフェクト

といったギミックを、Prefab を配置して向きとパラメータを調整するだけで素早く実装できます。

プロジェクトごとにロジックを組み直すのではなく、このような汎用コンポーネントを積み重ねていくことで、Cocos Creator でのゲーム開発効率を大きく高めることができます。ぜひ、今回の BoomerangProjectile をベースに、回転アニメーションや当たり判定、エフェクト再生などを組み合わせて、自分のゲームに合ったブーメラン表現に発展させてみてください。