【Cocos Creator】アタッチするだけ!DashGhost (ダッシュ残像)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】DashGhost(ダッシュ残像)の実装:アタッチするだけで「移動中に残像が伸びる」エフェクトを実現する汎用スクリプト

プレイヤーキャラや敵キャラが高速移動(ダッシュ)するときに、移動軌跡に半透明の残像が残る演出は、アクションゲームやランゲームでよく使われます。本記事では、任意のノードにアタッチするだけで、そのノードの見た目を一定間隔でコピーして「フェードアウトする残像」を生成する汎用コンポーネントを実装します。

この DashGhost コンポーネントは、以下のような場面で役立ちます。

  • プレイヤーのダッシュ/ローリング時のスピード感の強調
  • 敵の瞬間移動や高速移動のエフェクト
  • UIアイコンやエフェクトノードの「軌跡」表現

Sprite でも、3D モデル(SkinnedMeshRenderer)でも動くように、ノードの見た目を RenderTexture に描き出して、それを残像として表示する設計にします。他のカスタムスクリプトには一切依存せず、このコンポーネント単体で完結します。


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

1. 要件整理

  • アタッチされたノードの「現在の見た目」を、一定間隔でコピーして残像として残す。
  • 残像は時間とともにフェードアウトし、最後は自動で破棄される。
  • 2D / 3D 問わず使えるように、カメラでノードだけを撮影し、その画像をスプライトとして表示する方式を採用。
  • ダッシュ中のみ残像を出したい場合に備え、外部からON/OFFできる API を用意する(ただしシングルトン等には依存しない)。
  • パフォーマンスのため、残像オブジェクトはプール(再利用)し、毎回新規生成しない。
  • 外部依存をなくすため、必要なカメラ・親ノードなどは @property で Inspector から設定する。

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

DashGhost コンポーネントに用意するプロパティと役割は次の通りです。

  • enabledOnStart: boolean
    – 初期状態で残像機能を有効にするかどうか。
    true にするとゲーム開始直後から常に残像が出る。
    false にすると、setGhostEnabled(true) を呼ぶまで残像は出ない。
  • spawnInterval: number
    – 残像を生成する時間間隔(秒)。
    – 例: 0.05 なら 0.05 秒ごとに 1 つ残像を出す。
    – 小さくするほど残像が密になり、スピード感が増すが負荷も上がる。
  • ghostLifeTime: number
    – 1 つの残像が完全に消えるまでの時間(秒)。
    – 例: 0.3 なら 0.3 秒かけてフェードアウトし、破棄される。
  • startOpacity: number
    – 残像生成時の不透明度(0〜1)。
    – 例: 0.8 にすると、ほぼ元と同じ明るさで残像が出て、そこから徐々に消えていく。
  • endOpacity: number
    – 残像が消える直前の不透明度(0〜1)。通常は 0(完全に透明)。
  • ghostColor: Color
    – 残像全体に乗せる色。
    – 例: 白(デフォルト)なら元と同じ色合い、青や紫にすると「エネルギー」っぽい表現が可能。
  • maxGhostCount: number
    – 同時に存在させる残像の最大数。
    – これを超えると古い残像から順に再利用する(オブジェクトプール)。
  • useWorldSpace: boolean
    – 残像ノードの座標系。
    true: ワールド座標に残像を配置する(親のスケールや回転の影響を受けにくい)。
    false: 元ノードと同じ親のローカル座標として配置する。
  • ghostParent: Node | null
    – 残像をまとめて配置する親ノード。
    – 未設定(null)の場合は、元ノードと同じ親ノードを使用する。
  • captureCamera: Camera | null
    – 残像用に撮影を行うカメラ。
    – 未設定の場合は、自動的に子ノードとして専用カメラを生成する。
    – 既存の 2D/3D カメラを使いたい場合は、ここにドラッグ&ドロップしてもよい。
  • renderTextureWidth: number, renderTextureHeight: number
    – 残像用の RenderTexture の解像度。
    – あまり大きくしすぎるとメモリと描画負荷が増えるので、キャラの見た目に合わせて調整する。
  • onlyWhenMoving: boolean
    – 一定以上動いている時だけ残像を出すかどうか。
    true の場合、minMoveDistance 以上動いていないと残像を生成しない。
  • minMoveDistance: number
    onlyWhenMovingtrue のときに、前回残像を出した位置からどれだけ移動したら次の残像を出すかの距離(ワールド座標ベース)。

TypeScriptコードの実装

以下が完成した DashGhost コンポーネントの全コードです。


import {
    _decorator,
    Component,
    Node,
    Camera,
    RenderTexture,
    Sprite,
    SpriteFrame,
    UITransform,
    Vec3,
    Color,
    Material,
    math,
    director,
    Layers,
    game,
} from 'cc';
const { ccclass, property } = _decorator;

/**
 * DashGhost
 * 任意のノードにアタッチして使う「残像」生成コンポーネント。
 * - 指定間隔でノードの見た目を RenderTexture に描画し、Sprite として残す
 * - 残像は時間経過でフェードアウトし、自動で破棄される
 * - 2D / 3D どちらでも利用可能(カメラでノードをキャプチャ)
 */
@ccclass('DashGhost')
export class DashGhost extends Component {

    @property({
        tooltip: 'ゲーム開始時に残像機能を有効にするかどうか',
    })
    public enabledOnStart: boolean = true;

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

    @property({
        tooltip: '1つの残像が完全に消えるまでの時間(秒)',
        min: 0.01,
    })
    public ghostLifeTime: number = 0.3;

    @property({
        tooltip: '残像生成時の不透明度(0〜1)',
        range: [0, 1, 0.01],
    })
    public startOpacity: number = 0.8;

    @property({
        tooltip: '残像が消える直前の不透明度(0〜1)。通常は0',
        range: [0, 1, 0.01],
    })
    public endOpacity: number = 0.0;

    @property({
        tooltip: '残像全体に乗せる色。白なら元と同じ色に近く表示される',
    })
    public ghostColor: Color = new Color(255, 255, 255, 255);

    @property({
        tooltip: '同時に存在させる残像の最大数。多すぎると負荷増大に注意',
        min: 1,
    })
    public maxGhostCount: number = 12;

    @property({
        tooltip: 'true: ワールド座標に残像を配置 / false: 親ノードのローカル座標で配置',
    })
    public useWorldSpace: boolean = true;

    @property({
        type: Node,
        tooltip: '残像をまとめて配置する親ノード。未設定なら元ノードと同じ親を使用',
    })
    public ghostParent: Node | null = null;

    @property({
        type: Camera,
        tooltip: '残像キャプチャ用のカメラ。未設定なら自動生成される',
    })
    public captureCamera: Camera | null = null;

    @property({
        tooltip: '残像用のRenderTextureの幅(ピクセル)。キャラサイズに合わせて調整',
        min: 16,
    })
    public renderTextureWidth: number = 256;

    @property({
        tooltip: '残像用のRenderTextureの高さ(ピクセル)。キャラサイズに合わせて調整',
        min: 16,
    })
    public renderTextureHeight: number = 256;

    @property({
        tooltip: 'trueのとき、一定以上動いている場合のみ残像を生成する',
    })
    public onlyWhenMoving: boolean = false;

    @property({
        tooltip: 'onlyWhenMovingがtrueのとき、前回位置からこの距離以上動いたら残像生成',
        min: 0.0,
    })
    public minMoveDistance: number = 5.0;

    // 内部状態管理用
    private _isEnabled: boolean = true;
    private _timer: number = 0;
    private _ghostPool: Node[] = [];
    private _activeGhosts: Node[] = [];
    private _renderTexture: RenderTexture | null = null;
    private _spriteFrame: SpriteFrame | null = null;
    private _lastGhostWorldPos: Vec3 = new Vec3();
    private _hasLastPos: boolean = false;

    onLoad() {
        // 残像機能の初期状態
        this._isEnabled = this.enabledOnStart;

        // カメラが未設定なら自動生成
        if (!this.captureCamera) {
            this._createCaptureCamera();
        }

        // RenderTexture と SpriteFrame を初期化
        this._initRenderTexture();

        // ghostParent が未設定なら親ノードを利用
        if (!this.ghostParent) {
            if (this.node.parent) {
                this.ghostParent = this.node.parent;
            } else {
                console.warn('[DashGhost] ghostParent が設定されておらず、かつノードに親がありません。残像ノードはルートに配置されます。');
                this.ghostParent = director.getScene();
            }
        }

        // 最初の位置を記録
        this.node.getWorldPosition(this._lastGhostWorldPos);
        this._hasLastPos = true;

        // 防御的ログ:カメラが最終的に存在しているか確認
        if (!this.captureCamera) {
            console.error('[DashGhost] captureCamera の取得に失敗しました。残像は生成されません。Inspector で Camera を設定するか、ノードの子に Camera を追加してください。');
        }
    }

    start() {
        // 特に処理は不要だが、ライフサイクル上ここで後処理を追加可能
    }

    update(deltaTime: number) {
        if (!this._isEnabled || !this.captureCamera || !this._spriteFrame) {
            return;
        }

        // 移動量チェック(onlyWhenMoving が true の場合)
        if (this.onlyWhenMoving) {
            const currentPos = new Vec3();
            this.node.getWorldPosition(currentPos);
            if (this._hasLastPos) {
                const dist = Vec3.distance(currentPos, this._lastGhostWorldPos);
                if (dist < this.minMoveDistance) {
                    // 移動距離が足りない場合はタイマーだけ進めて終了
                    this._timer += deltaTime;
                    return;
                }
            }
            this._lastGhostWorldPos.set(currentPos);
            this._hasLastPos = true;
        }

        // 残像生成タイミング管理
        this._timer += deltaTime;
        if (this._timer >= this.spawnInterval) {
            this._timer = 0;
            this._spawnGhost();
        }

        // 既存の残像のフェード処理
        this._updateGhosts(deltaTime);
    }

    /**
     * 外部から残像機能のON/OFFを切り替えるためのAPI。
     * 例: ダッシュ開始時に true、終了時に false を指定する。
     */
    public setGhostEnabled(enabled: boolean) {
        this._isEnabled = enabled;
        if (!enabled) {
            this._timer = 0;
        }
    }

    /**
     * カメラが未設定の場合に、自動で子ノードにキャプチャ用カメラを生成する。
     */
    private _createCaptureCamera() {
        const cameraNode = new Node('DashGhost_Camera');
        cameraNode.layer = this.node.layer; // 元ノードと同じLayerを使用
        cameraNode.parent = this.node;      // 対象ノードの子として配置

        const camera = cameraNode.addComponent(Camera);
        // ここではデフォルト設定を利用。必要に応じてプロジェクション等を調整可能。
        camera.priority = 100; // 他カメラより後で描画したい場合は値を調整

        this.captureCamera = camera;
    }

    /**
     * RenderTexture と SpriteFrame を初期化する。
     */
    private _initRenderTexture() {
        if (!this.captureCamera) {
            return;
        }

        const rt = new RenderTexture();
        rt.reset({
            width: this.renderTextureWidth,
            height: this.renderTextureHeight,
        });

        this.captureCamera.targetTexture = rt;
        this._renderTexture = rt;

        const sf = new SpriteFrame();
        sf.texture = rt;
        this._spriteFrame = sf;
    }

    /**
     * 残像ノードを生成またはプールから取得し、現在のノードの見た目をコピーして配置する。
     */
    private _spawnGhost() {
        if (!this._spriteFrame || !this.ghostParent) {
            return;
        }

        // プールから取得 or 新規生成
        let ghostNode: Node;
        if (this._ghostPool.length > 0) {
            ghostNode = this._ghostPool.pop() as Node;
        } else {
            ghostNode = new Node('DashGhost_Ghost');
            ghostNode.layer = this.node.layer;

            const sprite = ghostNode.addComponent(Sprite);
            sprite.spriteFrame = this._spriteFrame;
            sprite.sizeMode = Sprite.SizeMode.RAW;

            // UITransform を追加してサイズを管理
            const uiTrans = ghostNode.getOrAddComponent(UITransform);
            uiTrans.setContentSize(this.renderTextureWidth, this.renderTextureHeight);

            // マテリアルを複製してカラー・透明度を制御
            const mat = sprite.getMaterial(0);
            if (mat) {
                const instMat = mat.clone();
                sprite.setMaterial(instMat, 0);
            }
        }

        // 親ノードにアタッチ
        ghostNode.parent = this.ghostParent;

        // 位置・回転・スケールをコピー
        if (this.useWorldSpace) {
            const worldPos = new Vec3();
            const worldRot = new math.Quat();
            const worldScale = new Vec3();

            this.node.getWorldPosition(worldPos);
            this.node.getWorldRotation(worldRot);
            this.node.getWorldScale(worldScale);

            ghostNode.setWorldPosition(worldPos);
            ghostNode.setWorldRotation(worldRot);
            ghostNode.setWorldScale(worldScale);
        } else {
            ghostNode.setPosition(this.node.position);
            ghostNode.setRotation(this.node.rotation);
            ghostNode.setScale(this.node.scale);
        }

        // 残像のカラー・不透明度初期化
        const sprite = ghostNode.getComponent(Sprite);
        if (!sprite) {
            console.error('[DashGhost] 残像ノードにSpriteコンポーネントがありません。内部生成処理を確認してください。');
            return;
        }

        // カラーを設定(アルファは startOpacity によって制御)
        const color = new Color(
            this.ghostColor.r,
            this.ghostColor.g,
            this.ghostColor.b,
            255
        );
        sprite.color = color;

        // マテリアルにアルファを渡せるように、色のアルファを利用
        sprite.opacity = Math.floor(this.startOpacity * 255);

        // 残像の寿命情報をカスタムプロパティとして保持
        // any キャストで Node に動的プロパティを付与
        (ghostNode as any)._dg_life = this.ghostLifeTime;
        (ghostNode as any)._dg_elapsed = 0;

        // アクティブリストに追加
        this._activeGhosts.push(ghostNode);

        // 最大数を超えていたら、一番古いものをプールに戻す
        if (this._activeGhosts.length > this.maxGhostCount) {
            const old = this._activeGhosts.shift() as Node;
            this._recycleGhost(old);
        }
    }

    /**
     * すべてのアクティブな残像の経過時間を更新し、フェードアウトさせる。
     */
    private _updateGhosts(deltaTime: number) {
        for (let i = this._activeGhosts.length - 1; i >= 0; i--) {
            const ghost = this._activeGhosts[i];
            const life = (ghost as any)._dg_life as number;
            let elapsed = (ghost as any)._dg_elapsed as number;
            elapsed += deltaTime;
            (ghost as any)._dg_elapsed = elapsed;

            const t = math.clamp01(elapsed / life);

            // 不透明度の線形補間
            const alpha = math.lerp(this.startOpacity, this.endOpacity, t);
            const sprite = ghost.getComponent(Sprite);
            if (sprite) {
                sprite.opacity = Math.floor(alpha * 255);
            }

            // 寿命が尽きたらプールに戻す
            if (elapsed >= life) {
                this._activeGhosts.splice(i, 1);
                this._recycleGhost(ghost);
            }
        }
    }

    /**
     * 残像ノードを非表示にしてプールへ戻す。
     */
    private _recycleGhost(ghost: Node) {
        ghost.active = false;
        ghost.removeFromParent();
        this._ghostPool.push(ghost);
    }

    onDestroy() {
        // RenderTexture を解放
        if (this._renderTexture) {
            this._renderTexture.destroy();
            this._renderTexture = null;
        }
        this._spriteFrame = null;

        // 残像ノードをすべて破棄
        for (const g of this._activeGhosts) {
            g.destroy();
        }
        for (const g of this._ghostPool) {
            g.destroy();
        }
        this._activeGhosts.length = 0;
        this._ghostPool.length = 0;
    }
}

コードの要点解説

  • onLoad
    enabledOnStart に応じて内部フラグ _isEnabled を初期化。
    captureCamera が未設定なら _createCaptureCamera() で子ノードにカメラを自動生成。
    _initRenderTexture() で RenderTexture と SpriteFrame を作成し、カメラの targetTexture に割り当てる。
    ghostParent が未設定なら、元ノードの親を利用(親が無い場合はシーンルートに配置)。
  • update
    – 残像機能が有効&カメラ&SpriteFrame が揃っている場合のみ処理。
    onlyWhenMoving が有効なら、前回位置との距離 minMoveDistance を超えたときだけ残像生成。
    spawnInterval ごとに _spawnGhost() を呼び出して残像を生成。
    – すでに生成済みの残像は _updateGhosts() で時間経過に応じてフェードアウトし、寿命が尽きたらプールに戻す。
  • _spawnGhost
    – プールに残像ノードがあれば再利用、なければ新規作成。
    – 新規作成時に Sprite と UITransform を追加し、RenderTexture を貼り付ける。
    – 元ノードの位置・回転・スケールをコピーして配置。
    – 残像の初期カラー・不透明度を設定し、寿命情報を Node のカスタムプロパティとして保持。
  • _updateGhosts
    – 各残像の経過時間から 0〜1 の t を計算し、startOpacity から endOpacity へ線形補間して不透明度を更新。
    – 寿命を超えた残像は _recycleGhost() で非表示&プール行き。
  • setGhostEnabled
    – 外部からダッシュ開始/終了に合わせて残像機能を ON/OFF できる API。
    – 例: プレイヤーのダッシュ処理スクリプトから呼び出す。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を DashGhost.ts にします。
  3. 作成した DashGhost.ts をダブルクリックで開き、本文のコードをすべてコピー&ペーストして保存します。

2. テスト用ノードの準備(2D Sprite 例)

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノード(例: Player)を作成します。
  2. Inspector の Sprite コンポーネントで、任意の画像(キャラの立ち絵など)を SpriteFrame に設定します。
  3. 同じく Inspector の UITransform でサイズを調整し、ゲームビューで見やすい大きさにします。

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

  1. Hierarchy で先ほど作成した Player ノードを選択します。
  2. Inspector の下部で Add ComponentCustomDashGhost を選択して追加します。
  3. 追加された DashGhost コンポーネントの各プロパティを設定します。まずは以下のような値を推奨します:
    • Enabled On Start: true
    • Spawn Interval: 0.05
    • Ghost Life Time: 0.3
    • Start Opacity: 0.8
    • End Opacity: 0.0
    • Ghost Color: White(デフォルトのまま)
    • Max Ghost Count: 12
    • Use World Space: true
    • Ghost Parent: 空欄(自動的に Player の親が使われます)
    • Capture Camera: 空欄(自動生成されます)
    • Render Texture Width / Height: 256 / 256
    • Only When Moving: false(まずは常に残像が出る状態で確認)

4. 簡単な移動スクリプトで動作確認(任意)

残像の効果を分かりやすく見るために、Player ノードを左右に動かす簡単なスクリプトを追加してもよいです(任意)。ここでは DashGhost に依存しない、単純な移動だけの例を示します。


import { _decorator, Component, Vec3, input, Input, EventKeyboard, KeyCode } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('SimpleMove')
export class SimpleMove extends Component {
    @property
    public speed: number = 300;

    private _dir: number = 0;

    onLoad() {
        input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
    }

    private _onKeyDown(event: EventKeyboard) {
        if (event.keyCode === KeyCode.ARROW_LEFT) {
            this._dir = -1;
        } else if (event.keyCode === KeyCode.ARROW_RIGHT) {
            this._dir = 1;
        }
    }

    private _onKeyUp(event: EventKeyboard) {
        if (event.keyCode === KeyCode.ARROW_LEFT || event.keyCode === KeyCode.ARROW_RIGHT) {
            this._dir = 0;
        }
    }

    update(deltaTime: number) {
        if (this._dir !== 0) {
            const pos = this.node.position.clone();
            pos.x += this._dir * this.speed * deltaTime;
            this.node.setPosition(pos);
        }
    }
}
  1. Assets パネルで SimpleMove.ts を作成し、上記コードを貼り付けます。
  2. Player ノードに SimpleMove コンポーネントを追加し、speed300600 程度に設定します。
  3. ゲームを再生し、左右キー(← →)を押すと Player が移動し、その軌跡に半透明の残像が残ることを確認できます。

5. ダッシュ時だけ残像を出したい場合の使い方

DashGhost は setGhostEnabled(true/false) を公開しているので、ダッシュ開始時に ON、終了時に OFF するような制御が可能です。例として、プレイヤーのダッシュスクリプトからの呼び出しイメージを示します。


import { _decorator, Component } from 'cc';
import { DashGhost } from './DashGhost';
const { ccclass, property } = _decorator;

@ccclass('PlayerDash')
export class PlayerDash extends Component {
    private _dashGhost: DashGhost | null = null;

    onLoad() {
        this._dashGhost = this.getComponent(DashGhost);
        if (!this._dashGhost) {
            console.warn('[PlayerDash] DashGhost コンポーネントが見つかりません。残像は出ません。');
        }
    }

    private _onDashStart() {
        if (this._dashGhost) {
            this._dashGhost.setGhostEnabled(true);
        }
    }

    private _onDashEnd() {
        if (this._dashGhost) {
            this._dashGhost.setGhostEnabled(false);
        }
    }
}

このように、DashGhost 自体は他のスクリプトに依存していませんが、外部から簡単に ON/OFF を制御できるように設計してあります。

6. 3D キャラクターで使う場合のポイント

  • 3D キャラのルートノード(アニメーションで動いているノード)に DashGhost をアタッチします。
  • キャラの見た目全体がカメラに収まるように、renderTextureWidth/Height を少し大きめに設定します(例: 512×512)。
  • 自動生成カメラの位置や向きが合わない場合は、Inspector で Capture Camera に自前のカメラを指定し、そのカメラでキャラが正しく映るように調整します。
  • 3D の場合も Use World Spacetrue にしておくと、親のスケール影響を避けやすくなります。

まとめ

本記事では、Cocos Creator 3.8 / TypeScript で、任意のノードにアタッチするだけで使える汎用残像コンポーネント DashGhost を実装しました。

  • RenderTexture と専用カメラを使うことで、2D/3D を問わずノードの「見た目そのもの」をコピーできる。
  • Inspector のプロパティから、生成間隔・寿命・透明度・色・最大数・移動時のみ生成など、演出と負荷のバランスを細かく調整可能。
  • プール設計により、残像ノードを再利用してパフォーマンスを確保。
  • setGhostEnabled() によって、ダッシュやスキル発動などのタイミングだけ残像を出す制御が簡単。
  • 他のカスタムスクリプトやシングルトンに依存せず、このコンポーネント単体で完結する設計になっている。

この DashGhost をベースに、色を時間で変化させたり、スケールを徐々に大きくする、ブラー風のマテリアルを適用するなど、演出を拡張していくことで、アクションゲームやランゲームの「スピード感」「迫力」を手軽に強化できます。プロジェクトの共通ユーティリティとして 1 つ用意しておけば、どのキャラにも簡単に残像エフェクトを付けられるようになり、ゲーム開発の効率が大きく向上します。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!