【Cocos Creator 3.8】GhostTrail の実装:アタッチするだけで「動くスプライトに残像エフェクト」を付与する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで「移動中にスプライトの残像を自動生成し、時間経過でフェードアウトして消える」エフェクトを実現する GhostTrail コンポーネントを実装します。

移動するキャラクターや弾、エフェクトに「スピード感」「残像表現」を簡単に追加したいときに便利です。外部の GameManager やシングルトンには一切依存せず、このスクリプト単体で完結するように設計します。


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

実現したい機能

  • 親ノードが移動している間だけ、一定間隔で残像を生成する。
  • 残像は親ノードの
    • 位置(world position)
    • 回転
    • スケール
    • スプライト画像(SpriteFrame
    • 色(Color

    をコピーする。

  • 生成された残像は、指定時間でアルファ値を徐々に下げてフェードアウトし、最後に自動で破棄される。
  • 生成間隔、残像の寿命、初期透明度、色変化などをインスペクタから調整できる。
  • 親ノードがほとんど動いていないときは、無駄な残像を出さない(移動距離しきい値を設ける)。

外部依存をなくすためのアプローチ

  • 残像の元になるのは、このコンポーネントをアタッチしたノードに付いている Sprite コンポーネントとする。
  • 必要なコンポーネントはコード内で getComponent(Sprite) で取得し、見つからない場合は
    • console.error でエラーログを出す
    • 処理を無効化してゲームが落ちないようにする
  • 残像用ノードは new Node() でその場で生成し、親の親(もしくはルート)にぶら下げることで、座標のずれを防ぐ。
  • 残像の挙動(フェードアウトなど)は、このコンポーネント内で完結させ、追加のカスタムスクリプトは一切不要にする。

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

以下のプロパティを @property で公開し、インスペクタから調整できるようにします。

  • enabledTrail: boolean
    • 残像機能のオン/オフフラグ。
    • チェックを外すと、一切残像を生成しない。
  • spawnInterval: number
    • 残像を生成する間隔(秒)。
    • 例: 0.05 なら、毎秒約 20 個。
    • 小さくするほど残像が密になり、負荷も上がる。
  • minMoveDistance: number
    • 最後に残像を出してからの移動距離がこの値未満なら、残像を出さない。
    • 微小な揺れや停止中に残像を出さないためのしきい値。
  • trailLifetime: number
    • 1 つの残像が存在する時間(秒)。
    • この時間の間に透明度が 0 まで下がり、最後に自動で破棄される。
  • startOpacity: number
    • 残像生成時の初期アルファ値(0–255)。
    • 元のスプライトの不透明度とは別に、残像側の透明度として適用。
  • colorTint: Color
    • 残像に適用する色の乗算(ティント)。
    • 白(255,255,255)なら元の色そのまま、例えば青っぽくしたいなら (150,150,255) など。
  • inheritParentOpacity: boolean
    • 有効にすると、親スプライトの不透明度を基準に残像の透明度を決める。
    • 無効なら startOpacity をそのまま使う。
  • maxTrails: number
    • 同時に存在できる残像の最大数。
    • 0 以下なら無制限。
    • 制限を超えた場合は、最も古い残像から順に即時削除して数を保つ。
  • useWorldSpace: boolean
    • 残像ノードをワールド座標基準で配置するかどうか。
    • 通常は true 推奨(親の親にぶら下げて、見た目がずれないようにする)。

TypeScriptコードの実装


import { _decorator, Component, Node, Sprite, Color, Vec3, UITransform, math } from 'cc';
const { ccclass, property } = _decorator;

/**
 * GhostTrail
 * 親ノードが移動している間、スプライトのコピーを背後に生成して
 * フェードアウトさせる汎用コンポーネント。
 */
@ccclass('GhostTrail')
export class GhostTrail extends Component {

    @property({
        tooltip: '残像機能のオン/オフ。オフの場合は一切生成しません。'
    })
    public enabledTrail: boolean = true;

    @property({
        tooltip: '残像を生成する間隔(秒)。小さいほど残像が密になります。'
    })
    public spawnInterval: number = 0.05;

    @property({
        tooltip: '最後に残像を出してからの移動距離がこの値未満なら、残像を生成しません。'
    })
    public minMoveDistance: number = 5;

    @property({
        tooltip: '1つの残像が存在する時間(秒)。この時間で完全にフェードアウトします。'
    })
    public trailLifetime: number = 0.3;

    @property({
        tooltip: '残像の初期不透明度(0〜255)。inheritParentOpacity が true の場合は親の不透明度と掛け合わされます。'
    })
    public startOpacity: number = 200;

    @property({
        tooltip: '残像に適用する色のティント。白(255,255,255)で元の色そのまま。'
    })
    public colorTint: Color = new Color(255, 255, 255, 255);

    @property({
        tooltip: 'true の場合、親スプライトの不透明度を引き継いで残像の透明度を決定します。'
    })
    public inheritParentOpacity: boolean = true;

    @property({
        tooltip: '同時に存在できる残像の最大数。0 以下なら無制限。'
    })
    public maxTrails: number = 50;

    @property({
        tooltip: 'true の場合、残像をワールド座標基準で配置します(親の親にぶら下げます)。'
    })
    public useWorldSpace: boolean = true;

    // 内部用フィールド
    private _sprite: Sprite | null = null;
    private _lastSpawnPosition: Vec3 = new Vec3();
    private _timeSinceLastSpawn: number = 0;
    private _hasInitialPosition: boolean = false;

    // 残像の管理用
    private _activeTrails: {
        node: Node;
        sprite: Sprite;
        elapsed: number;
        lifetime: number;
        startOpacity: number;
    }[] = [];

    onLoad() {
        // 親ノードの Sprite コンポーネントを取得
        this._sprite = this.getComponent(Sprite);
        if (!this._sprite) {
            console.error('[GhostTrail] このノードには Sprite コンポーネントが必要です。残像は生成されません。ノード名:', this.node.name);
            return;
        }

        // 初期位置を記録
        this._lastSpawnPosition.set(this.node.worldPosition);
        this._hasInitialPosition = true;

        // 値のクランプ(防御的プログラミング)
        if (this.spawnInterval <= 0) {
            console.warn('[GhostTrail] spawnInterval が 0 以下だったため、0.01 に補正しました。');
            this.spawnInterval = 0.01;
        }
        if (this.trailLifetime <= 0) {
            console.warn('[GhostTrail] trailLifetime が 0 以下だったため、0.1 に補正しました。');
            this.trailLifetime = 0.1;
        }
        if (this.startOpacity < 0) this.startOpacity = 0;
        if (this.startOpacity > 255) this.startOpacity = 255;
        if (this.minMoveDistance < 0) this.minMoveDistance = 0;
    }

    start() {
        // start では特に処理は不要だが、将来の拡張に備えてメソッドを定義
    }

    update(deltaTime: number) {
        if (!this.enabledTrail) {
            // 残像機能がオフなら、既存の残像だけ更新して終了
            this._updateTrails(deltaTime);
            return;
        }

        if (!this._sprite) {
            // Sprite がない場合は何もしない
            return;
        }

        // 既存の残像のフェードアウト更新
        this._updateTrails(deltaTime);

        // 初期位置がまだ記録されていない場合(onLoad が呼ばれていないなど)はスキップ
        if (!this._hasInitialPosition) {
            this._lastSpawnPosition.set(this.node.worldPosition);
            this._hasInitialPosition = true;
            return;
        }

        // 一定間隔ごとに残像生成を試みる
        this._timeSinceLastSpawn += deltaTime;
        if (this._timeSinceLastSpawn < this.spawnInterval) {
            return;
        }

        // 移動距離チェック
        const currentPos = this.node.worldPosition;
        const dist = Vec3.distance(currentPos, this._lastSpawnPosition);
        if (dist < this.minMoveDistance) {
            // あまり動いていないので残像を出さない
            return;
        }

        // 残像を生成
        this._spawnTrail();

        // タイマーと位置をリセット
        this._timeSinceLastSpawn = 0;
        this._lastSpawnPosition.set(currentPos);
    }

    /**
     * 残像ノードを生成し、管理リストに追加する。
     */
    private _spawnTrail() {
        if (!this._sprite) return;

        // maxTrails を超えている場合は古いものから削除
        if (this.maxTrails > 0 && this._activeTrails.length >= this.maxTrails) {
            const oldest = this._activeTrails.shift();
            if (oldest) {
                oldest.node.destroy();
            }
        }

        const trailNode = new Node(`GhostTrail_${this.node.name}`);
        const trailSprite = trailNode.addComponent(Sprite);

        // SpriteFrame, color, size などをコピー
        trailSprite.spriteFrame = this._sprite.spriteFrame;

        // 色と不透明度の決定
        const baseColor = this._sprite.color.clone();
        let opacity = this.startOpacity;
        if (this.inheritParentOpacity) {
            opacity = Math.round(this._sprite.color.a * (this.startOpacity / 255));
        }
        if (opacity < 0) opacity = 0;
        if (opacity > 255) opacity = 255;

        // ティントと掛け合わせる
        const tinted = new Color(
            Math.round(baseColor.r * (this.colorTint.r / 255)),
            Math.round(baseColor.g * (this.colorTint.g / 255)),
            Math.round(baseColor.b * (this.colorTint.b / 255)),
            opacity
        );
        trailSprite.color = tinted;

        // 親子関係とトランスフォームのコピー
        if (this.useWorldSpace) {
            // 親の親(またはルート)にぶら下げることで、見た目の位置を保つ
            const parent = this.node.parent;
            if (parent && parent.parent) {
                parent.parent.addChild(trailNode);
            } else if (parent) {
                parent.addChild(trailNode);
            } else {
                // 親がいない場合はこのノードにぶら下げる
                this.node.addChild(trailNode);
            }

            // ワールド座標系で位置・回転・スケールをコピー
            trailNode.setWorldPosition(this.node.worldPosition);
            trailNode.setWorldRotation(this.node.worldRotation);
            trailNode.setWorldScale(this.node.worldScale);
        } else {
            // ローカル座標系でコピー
            const parent = this.node.parent;
            if (parent) {
                parent.addChild(trailNode);
            } else {
                this.node.addChild(trailNode);
            }
            trailNode.setPosition(this.node.position);
            trailNode.setRotation(this.node.rotation);
            trailNode.setScale(this.node.scale);
        }

        // UITransform のサイズをコピー(UI 系で重要)
        const srcTransform = this.node.getComponent(UITransform);
        const dstTransform = trailNode.getComponent(UITransform);
        if (srcTransform && dstTransform) {
            dstTransform.setContentSize(srcTransform.contentSize);
            dstTransform.anchorPoint = srcTransform.anchorPoint;
        }

        // 管理リストに追加
        this._activeTrails.push({
            node: trailNode,
            sprite: trailSprite,
            elapsed: 0,
            lifetime: this.trailLifetime,
            startOpacity: opacity,
        });
    }

    /**
     * 既存の残像を更新し、フェードアウトと破棄を行う。
     */
    private _updateTrails(deltaTime: number) {
        if (this._activeTrails.length === 0) return;

        // 後ろから前に向かってループし、削除を安全に行う
        for (let i = this._activeTrails.length - 1; i >= 0; i--) {
            const trail = this._activeTrails[i];
            trail.elapsed += deltaTime;

            const t = math.clamp01(trail.elapsed / trail.lifetime);
            const remaining = 1 - t;
            const newOpacity = Math.round(trail.startOpacity * remaining);

            const c = trail.sprite.color;
            c.a = newOpacity < 0 ? 0 : newOpacity;
            trail.sprite.color = c;

            if (trail.elapsed >= trail.lifetime || newOpacity <= 0) {
                // 寿命が尽きたら破棄
                trail.node.destroy();
                this._activeTrails.splice(i, 1);
            }
        }
    }

    onDisable() {
        // コンポーネントが無効化されたら、既存の残像はそのままにしつつ、
        // 新規生成を止める(enabledTrail フラグで制御しているので特に何もしない)。
    }

    onDestroy() {
        // 親ノードが破棄されたとき、残っている残像もまとめて破棄する
        for (const trail of this._activeTrails) {
            trail.node.destroy();
        }
        this._activeTrails.length = 0;
    }
}

コードのポイント解説

  • onLoad
    • アタッチ先ノードの Sprite を取得し、存在しない場合は console.error を出して機能を停止。
    • 初期位置を保存し、spawnIntervaltrailLifetime などの値を安全な範囲に補正。
  • update(deltaTime)
    • 毎フレーム、既存の残像を更新(フェードアウト&寿命チェック)。
    • enabledTrail がオフなら新規生成は行わず、既存の残像だけを更新。
    • _timeSinceLastSpawn で時間を貯め、spawnInterval を超えたら残像生成を試みる。
    • Vec3.distance で前回残像生成位置からの移動距離を測り、minMoveDistance 未満なら生成しない。
  • _spawnTrail()
    • 新しいノードを生成し、Sprite を追加。
    • 元のスプライトの spriteFrame、色、不透明度、位置、回転、スケールをコピー。
    • inheritParentOpacity が true の場合、親スプライトのアルファ値と startOpacity を掛け合わせて初期透明度を決定。
    • colorTint で色を乗算して、残像の色味を変える。
    • useWorldSpace に応じて、ワールド座標 or ローカル座標で配置。
    • UITransform があればサイズとアンカーもコピー。
    • maxTrails を超える場合は、最も古い残像から削除することで数を制限。
  • _updateTrails(deltaTime)
    • 各残像の経過時間を進め、寿命に応じてアルファ値を線形に減少させる。
    • アルファ値が 0 になるか寿命を超えたら、ノードを destroy() して管理リストから削除。
  • onDestroy
    • 親ノードが破棄されたときに残像がシーン内に残り続けないよう、すべて破棄。

使用手順と動作確認

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

  1. エディタの Assets パネルで任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を GhostTrail.ts にします。
  3. 自動生成された中身をすべて削除し、このガイドの「TypeScriptコードの実装」にあるコードをそのまま貼り付けて保存します。

2. テスト用ノード(スプライト)の作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノードを作成します(名前は Player など)。
  2. 選択したスプライトノードの Inspector で、Sprite コンポーネントの SpriteFrame に任意の画像を設定します。
  3. 必要に応じて UITransform のサイズや Color を調整します。

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

  1. 先ほど作成したスプライトノード(例: Player)を選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom カテゴリの中から GhostTrail を選択し、アタッチします。
    • もし一覧に見つからない場合は、GhostTrail.ts を保存したか、エディタがスクリプトをコンパイルし終えているかを確認してください。

4. プロパティの設定例

Inspector で GhostTrail コンポーネントの各プロパティを次のように設定してみてください。

  • Enabled Trail: チェック(オン)
  • Spawn Interval: 0.05
  • Min Move Distance: 5
  • Trail Lifetime: 0.3
  • Start Opacity: 200
  • Color Tint: (200, 200, 255, 255)(少し青みがかった残像)
  • Inherit Parent Opacity: チェック(オン)
  • Max Trails: 40
  • Use World Space: チェック(オン)

5. 移動スクリプトを簡単に追加して動作確認

残像は「移動しているとき」にだけ生成されるようにしているため、テスト用に単純な移動スクリプトを追加すると確認しやすくなります。

  1. Assets パネルで右クリック → Create → TypeScript を選択し、TestMover.ts などの名前で新規スクリプトを作成します。
  2. 以下のような簡単な左右移動コードを貼り付けます(任意)。

import { _decorator, Component, Vec3, math } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('TestMover')
export class TestMover extends Component {
    @property
    speed: number = 200;

    private _time = 0;
    private _startPos: Vec3 = new Vec3();

    start() {
        this._startPos.set(this.node.position);
    }

    update(deltaTime: number) {
        this._time += deltaTime;
        const offset = Math.sin(this._time) * 200;
        const pos = this._startPos.clone();
        pos.x += offset;
        this.node.setPosition(pos);
    }
}
  1. この TestMover を同じスプライトノード(Player)に Add Component → Custom → TestMover でアタッチします。
  2. Play(再生)ボタンを押してゲームを実行します。
  3. スプライトが左右に動き、その背後に半透明の残像が連続して生成され、数百ミリ秒でフェードアウトして消えていくことを確認してください。

6. 調整のヒント

  • スピード感を強調したい:
    • spawnInterval0.020.03 に下げる。
    • trailLifetime0.20.4 程度に調整。
  • 残像を控えめにしたい(パフォーマンス優先):
    • spawnInterval0.080.12 に上げる。
    • maxTrails20 前後に設定。
  • 色付きの残像を作りたい:
    • colorTint を赤系(例: 255, 150, 150, 255)や青系(例: 150, 150, 255, 255)に変更。

まとめ

この GhostTrail コンポーネントは、

  • 任意のスプライトノードにアタッチするだけで残像エフェクトを付与できる
  • 生成間隔・寿命・色・透明度・最大数などをインスペクタから柔軟に調整できる
  • 外部スクリプトやシングルトンに依存せず、この 1 ファイルだけで完結する

という点が特徴です。

キャラクターのダッシュ、スキル発動中の残像、素早い弾丸の軌跡など、多くのシーンで「スピード感」「迫力」を演出できます。ゲーム内のさまざまなノードに簡単に再利用できるようにしておくことで、エフェクト作成の手間を大きく削減できます。

このコンポーネントをベースに、

  • フェードアウトではなくスケールダウンを組み合わせる
  • 色を時間経過で変化させる
  • パーティクルと組み合わせてより派手な軌跡を作る

といった拡張も簡単に行えます。まずはこの基本形をプロジェクトに組み込んで、動くオブジェクトにどんどん残像演出を追加してみてください。