【Cocos Creator 3.8】AfterImage(残像)の実装:アタッチするだけで高速移動中のスプライトに残像エフェクトを付与する汎用スクリプト

このガイドでは、任意のスプライトノードにアタッチするだけで「残像エフェクト」を実現できる汎用コンポーネント AfterImage を実装します。
プレイヤーキャラやダッシュ中のエネミーなど、高速移動しているノードの見た目をコピーし、時間とともにフェードアウトさせることで、スピード感のある演出を簡単に追加できます。

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

このコンポーネントは単体で完結し、他のカスタムスクリプトには一切依存しません。
アタッチ先ノードの Sprite(または UITransform を持つ 2D UI ノード)を元に、一定間隔で「残像ノード」を複製し、アルファ値を徐々に下げて自動削除します。

要件整理

  • アタッチ先ノードの位置・回転・スケール・スプライト画像をコピーした残像を生成する。
  • 一定時間ごとに残像を生成する(生成間隔を Inspector で調整可能)。
  • 残像は指定時間でフェードアウトし、自動的に破棄される。
  • 必要に応じて「速度が一定以上のときのみ」残像を生成するオプションを持つ。
  • 生成上限数を設け、メモリ・描画負荷を抑える。
  • 外部の GameManager などには依存せず、すべての設定は @property で行う

防御的な設計

  • アタッチ先に Sprite が無い場合は、ログで警告を出し、自動生成を止める。
  • Canvas/UI レイヤーでの使用も考慮し、UITransform のサイズ・アンカーなどもコピー。
  • フェードアウト用に Spritecolor を操作するため、残像ノード側にも Sprite を必ず持たせる。

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

AfterImage コンポーネントでは、以下のプロパティを Inspector から調整できます。

  • enabledEffect: boolean
    残像エフェクトの有効/無効。
    一時的にオフにしたい場合に使います(スクリプトを削除せずに制御可能)。
  • spawnInterval: number
    残像を生成する間隔(秒)。
    例: 0.05 なら 1 秒間に約 20 個生成(速度に応じて調整)。
  • afterImageLifetime: number
    1 つの残像が存在する時間(秒)。
    この時間の間にアルファが 1 → 0 に線形に減少し、0 になったら自動で削除。
  • initialOpacity: number (0〜255)
    生成直後の残像の不透明度。
    例: 200 ならやや透明、255 なら元のスプライトと同等の濃さ。
  • tintColor: Color | null
    残像に乗せる色。
    • 未指定(デフォルト): 元スプライトの色をそのまま使用。
    • 指定した場合: その色をベースにアルファだけ変化させる(例: 青い残像など)。
  • maxAfterImages: number
    同時に存在できる残像の最大数。
    上限を超えた場合は、最も古い残像から順に即座に削除して数を保つ。
  • useVelocityThreshold: boolean
    「一定以上の移動速度のときだけ残像を出す」かどうか。
    これを true にすると、静止中や低速移動時は残像が出なくなります。
  • velocityThreshold: number
    残像を生成するために必要な最小速度(単位: 単位/秒)。
    useVelocityThreshold = true のときのみ有効。
    ノードの前フレームからの位置変化量から速度を計算します。
  • inheritLayerAndGroup: boolean
    残像ノードにアタッチ先ノードの layer / group をコピーするかどうか。
    2D UI や特定のカメラにだけ映したい場合などに有効。

TypeScriptコードの実装


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

@ccclass('AfterImage')
export class AfterImage extends Component {

    @property({
        tooltip: '残像エフェクトを有効にするかどうか。false にすると処理を全て停止します。'
    })
    public enabledEffect: boolean = true;

    @property({
        tooltip: '残像を生成する間隔(秒)。値が小さいほど多くの残像が生成されます。'
    })
    public spawnInterval: number = 0.05;

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

    @property({
        tooltip: '生成直後の残像の不透明度(0〜255)。255 で完全不透明。',
        min: 0,
        max: 255
    })
    public initialOpacity: number = 200;

    @property({
        tooltip: '残像に乗せる色。未設定の場合は元スプライトの色を使用します。'
    })
    public tintColor: Color | null = null;

    @property({
        tooltip: '同時に存在できる残像の最大数。超えた場合は古いものから削除されます。',
        min: 1,
        step: 1
    })
    public maxAfterImages: number = 20;

    @property({
        tooltip: '速度が一定以上のときのみ残像を生成するかどうか。'
    })
    public useVelocityThreshold: boolean = false;

    @property({
        tooltip: '残像を生成するために必要な最小速度(単位/秒)。useVelocityThreshold が true のときのみ有効。',
        min: 0
    })
    public velocityThreshold: number = 300;

    @property({
        tooltip: '残像ノードに元ノードの layer / group を引き継ぐかどうか。'
    })
    public inheritLayerAndGroup: boolean = true;

    // 内部用フィールド
    private _sourceSprite: Sprite | null = null;
    private _sourceTransform: UITransform | null = null;
    private _timeSinceLastSpawn: number = 0;
    private _lastPosition: Vec3 = new Vec3();
    private _hasLastPosition: boolean = false;

    // 残像情報管理用
    private _afterImages: {
        node: Node;
        sprite: Sprite;
        elapsed: number;
    }[] = [];

    onLoad() {
        // 必要なコンポーネントを取得
        this._sourceSprite = this.getComponent(Sprite);
        this._sourceTransform = this.getComponent(UITransform);

        if (!this._sourceSprite) {
            console.error('[AfterImage] このコンポーネントを使用するには、同じノードに Sprite コンポーネントが必要です。');
        }
        if (!this._sourceTransform) {
            console.warn('[AfterImage] UITransform が見つかりません。2D オブジェクトでの使用を推奨します。');
        }

        // 初期位置を記録
        this.node.getPosition(this._lastPosition);
        this._hasLastPosition = true;

        // 入力値の簡易バリデーション
        if (this.spawnInterval <= 0) {
            console.warn('[AfterImage] spawnInterval が 0 以下です。0.05 に補正します。');
            this.spawnInterval = 0.05;
        }
        if (this.afterImageLifetime <= 0) {
            console.warn('[AfterImage] afterImageLifetime が 0 以下です。0.3 に補正します。');
            this.afterImageLifetime = 0.3;
        }
        if (this.maxAfterImages <= 0) {
            console.warn('[AfterImage] maxAfterImages が 0 以下です。10 に補正します。');
            this.maxAfterImages = 10;
        }
    }

    start() {
        // 特に初期化は onLoad で完了しているが、将来の拡張用に分けておく
    }

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

        if (!this._sourceSprite) {
            // onLoad でエラーを出しているので、ここでは処理を止めるだけ
            return;
        }

        this._updateAfterImages(deltaTime);
        this._maybeSpawnAfterImage(deltaTime);
    }

    /**
     * 既存の残像をフェードアウト&削除する。
     */
    private _updateAfterImages(deltaTime: number) {
        // 後ろからループして削除してもインデックスが崩れないようにする
        for (let i = this._afterImages.length - 1; i >= 0; i--) {
            const info = this._afterImages[i];
            info.elapsed += deltaTime;

            const t = math.clamp01(info.elapsed / this.afterImageLifetime);
            const remaining = 1.0 - t;

            // アルファ値を更新
            const color = info.sprite.color.clone();
            const baseAlpha = this.initialOpacity;
            const newAlpha = Math.floor(baseAlpha * remaining);
            color.a = math.clamp(newAlpha, 0, 255);
            info.sprite.color = color;

            if (info.elapsed >= this.afterImageLifetime || color.a <= 0) {
                // ノードを破棄して配列から削除
                info.node.destroy();
                this._afterImages.splice(i, 1);
            }
        }
    }

    /**
     * 必要に応じて新しい残像を生成する。
     */
    private _maybeSpawnAfterImage(deltaTime: number) {
        this._timeSinceLastSpawn += deltaTime;

        if (this._timeSinceLastSpawn < this.spawnInterval) {
            return;
        }

        // 速度判定
        if (this.useVelocityThreshold) {
            if (!this._hasLastPosition) {
                this.node.getPosition(this._lastPosition);
                this._hasLastPosition = true;
                return;
            }
            const currentPos = this.node.getPosition();
            const distance = Vec3.distance(currentPos, this._lastPosition);
            const speed = distance / deltaTime; // 単位/秒

            if (speed < this.velocityThreshold) {
                // 速度が足りない場合は残像生成をスキップ
                this._lastPosition.set(currentPos);
                this._timeSinceLastSpawn = 0;
                return;
            }

            this._lastPosition.set(currentPos);
        }

        // 残像生成
        this._spawnAfterImage();
        this._timeSinceLastSpawn = 0;
    }

    /**
     * 実際に残像ノードを生成して設定する。
     */
    private _spawnAfterImage() {
        if (!this._sourceSprite) {
            return;
        }

        // 上限数を超えている場合は古いものから削除
        while (this._afterImages.length >= this.maxAfterImages) {
            const oldest = this._afterImages.shift();
            if (oldest) {
                oldest.node.destroy();
            }
        }

        const sourceNode = this.node;
        const parent = sourceNode.parent;
        if (!parent) {
            // 親がない場合は生成しても見えないので中止
            return;
        }

        // 新しいノードを生成し、親の下に追加
        const afterImageNode = new Node('AfterImageNode');
        parent.addChild(afterImageNode);

        // 位置・回転・スケールをコピー
        afterImageNode.setPosition(sourceNode.position);
        afterImageNode.setRotation(sourceNode.rotation);
        afterImageNode.setScale(sourceNode.scale);

        // layer / group をコピー(オプション)
        if (this.inheritLayerAndGroup) {
            afterImageNode.layer = sourceNode.layer;
            // Cocos Creator 3.x では group はビットマスクに統合されているが、
            // プロジェクトによっては拡張を使う場合があるため、ここでは layer のみコピー
        }

        // Sprite を追加して元の設定をコピー
        const sourceSprite = this._sourceSprite;
        const newSprite = afterImageNode.addComponent(Sprite);

        newSprite.spriteFrame = sourceSprite.spriteFrame;
        newSprite.type = sourceSprite.type;
        newSprite.sizeMode = sourceSprite.sizeMode;
        newSprite.fillType = sourceSprite.fillType;
        newSprite.fillCenter = sourceSprite.fillCenter.clone();
        newSprite.fillStart = sourceSprite.fillStart;
        newSprite.fillRange = sourceSprite.fillRange;

        // 色設定
        let baseColor: Color;
        if (this.tintColor) {
            baseColor = this.tintColor.clone();
        } else {
            baseColor = sourceSprite.color.clone();
        }
        baseColor.a = math.clamp(this.initialOpacity, 0, 255);
        newSprite.color = baseColor;

        // UITransform もコピー(サイズなど)
        const sourceTransform = this._sourceTransform;
        if (sourceTransform) {
            const newTransform = afterImageNode.addComponent(UITransform);
            newTransform.setContentSize(sourceTransform.contentSize);
            newTransform.anchorX = sourceTransform.anchorX;
            newTransform.anchorY = sourceTransform.anchorY;
        }

        // 管理リストに追加
        this._afterImages.push({
            node: afterImageNode,
            sprite: newSprite,
            elapsed: 0
        });
    }

    /**
     * エディタ上で値が変更されたときに呼ばれる(Editor のみ)。
     * 簡易的なバリデーションをここでも行う。
     */
    onValidate() {
        if (this.spawnInterval <= 0) {
            this.spawnInterval = 0.01;
        }
        if (this.afterImageLifetime <= 0) {
            this.afterImageLifetime = 0.1;
        }
        if (this.maxAfterImages <= 0) {
            this.maxAfterImages = 1;
        }
        this.initialOpacity = math.clamp(this.initialOpacity, 0, 255);
        if (this.velocityThreshold < 0) {
            this.velocityThreshold = 0;
        }
    }
}

コードのポイント解説

  • onLoad
    • アタッチ先ノードから SpriteUITransform を取得。
    • Sprite が無い場合は console.error を出して以降の処理を停止(防御的実装)。
    • 残像生成間隔や寿命、最大数などの値を最低限の範囲に補正。
  • update
    • enabledEffectfalse のときは即リターンして処理を行わない。
    • _updateAfterImages で既存の残像をフェードアウトし、寿命が来たものを削除。
    • _maybeSpawnAfterImage で時間経過&速度判定を行い、必要なときだけ新しい残像を生成。
  • 速度判定
    • 前フレームの位置 (_lastPosition) と現在位置から距離を求め、distance / deltaTime で速度を算出。
    • useVelocityThreshold = true かつ speed < velocityThreshold の場合は残像を生成しない。
  • 残像ノード生成
    • 元ノードと同じ親の子として新しい Node を生成。
    • 位置・回転・スケール・layer をコピーして、見た目と描画レイヤーを一致させる。
    • SpriteUITransform を追加し、元スプライトの設定(spriteFrame, type, fill 設定など)をコピー。
    • tintColor が指定されていればその色を、未指定なら元スプライトの色をベースにし、initialOpacity をアルファとして設定。
    • 内部配列 _afterImages に管理情報として登録し、update で寿命管理。

使用手順と動作確認

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

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

  1. エディタの Assets パネルで、残像スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. 新しく作成されたファイル名を AfterImage.ts に変更します。
  4. AfterImage.ts をダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードをそのまま貼り付けて保存します。

2. テスト用ノード(スプライト)の準備

残像は Sprite コンポーネントを持つノード にアタッチして使用します。

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、テスト用のスプライトノードを作成します。
  2. 作成したスプライトノードを選択し、InspectorSprite コンポーネントの SpriteFrame に任意の画像を設定します。
  3. 同じく InspectorUITransform でサイズやアンカーを適宜調整しておきます。

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

  1. テスト用スプライトノードを選択した状態で、Inspector パネルの下部にある Add Component ボタンをクリックします。
  2. CustomAfterImage を選択して追加します。

4. プロパティの設定例

Inspector 上で AfterImage コンポーネントの各プロパティを次のように設定してみます。

  • Enabled Effect: チェック ON
  • Spawn Interval: 0.05
  • After Image Lifetime: 0.3
  • Initial Opacity: 200
  • Tint Color: 未設定(チェックを外した状態)
    ※ 青や赤の残像にしたい場合は Color ピッカーで色を選択。
  • Max After Images: 20
  • Use Velocity Threshold: 好みで
    • 常に残像を出したい → OFF
    • 高速移動時だけ出したい → ON
  • Velocity Threshold: 300(Use Velocity Threshold を ON にした場合)
  • Inherit Layer And Group: ON(特別な理由がなければ ON 推奨)

5. ノードを動かして残像を確認する

残像は「ノードが動いているとき」にわかりやすく現れます。簡単な動作確認の方法を 2 パターン紹介します。

方法A:単純な移動スクリプトを一時的に追加する

  1. 同じスプライトノードに、テスト用の簡易移動スクリプト(例: MoveTest.ts)を追加して、左右に動かします。
  2. ゲームを再生すると、動いているスプライトの軌跡に沿って残像が生成され、徐々にフェードアウトしていくのが確認できます。

テスト用の移動スクリプト例(任意):


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

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

    private _direction: number = 1;

    update(deltaTime: number) {
        const pos = this.node.position;
        const x = pos.x + this.speed * this._direction * deltaTime;

        if (x > 400) {
            this._direction = -1;
        } else if (x < -400) {
            this._direction = 1;
        }

        this.node.setPosition(new Vec3(x, pos.y, pos.z));
    }
}

このスクリプトを同じノードに追加してゲームを再生すると、左右に往復するスプライトに残像が付いているのを確認できます。

方法B:実際のプレイヤーキャラに適用する

  1. すでに移動処理が実装されているプレイヤーノード(Sprite を持つ)に AfterImage をアタッチします。
  2. ダッシュやスキル発動時だけ残像を出したい場合は、Use Velocity Threshold を ON にし、Velocity Threshold を少し高め(例: 400〜600)に設定します。
  3. ゲームを再生し、プレイヤーを高速で動かして残像の見え方を確認します。

6. よくある調整ポイント

  • 残像が濃すぎる/薄すぎる
    Initial Opacity を 150〜220 の範囲で調整してみてください。
  • 残像が長く残りすぎる/すぐ消える
    After Image Lifetime を 0.2〜0.6 の範囲で調整。
  • 残像が多すぎて重そう
    Spawn Interval を大きくする(0.05 → 0.08 など)、Max After Images を減らす(20 → 10 など)。
  • 常に残像が出てうるさい
    Use Velocity Threshold を ON にして、ダッシュ時の速度より少し低い値を Velocity Threshold に設定。

まとめ

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

  • Sprite を持つ任意のノードにアタッチするだけで残像エフェクトを付与できる
  • 生成間隔・寿命・不透明度・色・速度条件・最大数などを Inspector から柔軟に調整できる
  • 外部の GameManager やシングルトンに依存せず、スクリプト単体で完結している

という特徴を持っています。
プレイヤーのダッシュ、瞬間移動、スキル演出、ボスの高速移動など、さまざまな場面で「スピード感」や「迫力」を簡単に追加できます。

特に、既存のキャラクターにそのままアタッチするだけで使えるため、プロトタイピングや演出調整のスピードが大きく向上します。
本記事のコードをベースに、

  • ブラー風の色味にする(tintColor を青や白に)
  • 寿命に応じてスケールを変化させる(拡大しながら消える)
  • 特定のアニメーション中だけ有効化する(enabledEffect を他スクリプトから切り替える)

といった拡張も簡単に行えます。
プロジェクトに組み込んで、さまざまなキャラクターやエフェクトに残像を試してみてください。