【Cocos Creator 3.8】FootDust(足元の土煙)の実装:アタッチするだけで「歩行アニメーションに同期した足元の煙エフェクト」を出せる汎用スクリプト

2D/3D問わず、キャラクターが歩いているときに足元から「ポフッ」と土煙が出るだけで、動きに一気に重さと臨場感が出ます。このガイドでは、任意のキャラクターノードにアタッチするだけで、歩行アニメーションのフレームに合わせて足元から煙を出す汎用コンポーネント「FootDust」を実装します。

外部の GameManager やアニメーションイベント用スクリプトには一切依存せず、すべてインスペクタで完結する設計にします。アニメーションのフレーム同期は「ループアニメーションの経過時間から算出」する方式で、アニメーションイベントを別途仕込まなくても使えるようにします。


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

機能要件の整理

  • キャラクターの「歩き」アニメーションに合わせて、一定間隔で足元から土煙を出す。
  • アニメーションは Animation または SkeletalAnimation のどちらにも対応できるようにする。
  • 土煙は Prefab(パーティクルやスプライトアニメなど)として外部から差し替え可能にする。
  • 土煙の出る位置(左右の足元)をローカル座標で調整できるようにする。
  • アニメーションの「歩き状態」のときだけ土煙を出し、停止中は出さない。
  • アニメーションイベントや他スクリプトに依存せず、このコンポーネント単体で完結させる。

外部依存をなくす設計アプローチ

  • アニメーション状態の判定は、Animation/SkeletalAnimation コンポーネントを直接参照し、currentClip 名と再生状態から判断。
  • 「歩きアニメーション」かどうかは、インスペクタから設定する「歩行用クリップ名の配列」で判定する。
  • 足踏みのタイミングは、「アニメーションの経過時間 ÷ 1ステップ時間(秒)」から算出して、
    「新しいステップに入った瞬間」に土煙を1回だけ生成する。
  • 土煙のPrefabは @property(Prefab) で受け取り、instantiate + NodesetParent で生成。
  • 寿命管理用に、生成した土煙ノードは指定秒数後に自動で destroy() する。

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

以下のプロパティを用意します。

  • useSkeletalAnimation: boolean
    false: Animation コンポーネントから状態取得
    true: SkeletalAnimation コンポーネントから状態取得
  • walkClipNames: string[]
    – 「歩き状態」とみなすアニメーションクリップ名のリスト。
    – 例: ["walk", "run"]
    – 現在再生中のクリップ名がこの中に含まれるときだけ土煙を出す。
  • stepInterval: number
    – 1ステップあたりの時間(秒)。
    – 例: 0.2 秒なら、1秒間に5ステップ分(左右合わせて)土煙が出るイメージ。
    – 実際のアニメーションのフレーム数やスピードに合わせて調整。
  • alternateFeet: boolean
    true: ステップごとに左右の足位置を交互に使う。
    false: 常に同じ位置(leftFootOffset を使用)から出す。
  • leftFootOffset: Vec3
    – 左足の土煙のローカル座標オフセット。
    – 例: (-20, -40, 0) など、キャラクターの足元あたり。
  • rightFootOffset: Vec3
    – 右足の土煙のローカル座標オフセット。
    alternateFeettrue のときに使用。
  • dustPrefab: Prefab
    – 実際に生成する土煙のPrefab。
    – パーティクルシステム、スプライトアニメ、単純なフェードアウトなど、任意のPrefabを指定可能。
  • dustLifetime: number
    – 生成した土煙ノードを自動で破棄するまでの時間(秒)。
    – パーティクルの寿命などに合わせて設定。
  • maxDustCount: number
    – 同時に存在してよい土煙ノードの上限。
    – 上限を超えそうなときは新規生成をスキップし、過剰生成による負荷を防ぐ。
  • minSpeedForDust: number
    – (任意・簡易)キャラクターノードの移動速度がこの値未満のときは、
    歩いていないとみなして土煙を出さない。
    – 速度は「前フレームからの位置差分 ÷ dt」で計算。
  • debugLog: boolean
    true にすると、アニメーション未取得やPrefab未設定などのログを詳細に出す。

TypeScriptコードの実装

以下が完成した FootDust.ts の全コードです。


import { _decorator, Component, Node, Animation, SkeletalAnimation, Prefab, instantiate, Vec3, math, log, warn } from 'cc';
const { ccclass, property } = _decorator;

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

    @property({
        tooltip: 'true の場合は SkeletalAnimation から状態を取得し、false の場合は Animation から状態を取得します。'
    })
    public useSkeletalAnimation: boolean = false;

    @property({
        tooltip: '「歩き状態」とみなすアニメーションクリップ名のリスト。現在再生中のクリップ名がこの中に含まれるときだけ土煙を出します。'
    })
    public walkClipNames: string[] = ['walk', 'run'];

    @property({
        tooltip: '1ステップあたりの時間(秒)。この時間ごとに土煙を1回生成します。'
    })
    public stepInterval: number = 0.2;

    @property({
        tooltip: 'true の場合、ステップごとに左右の足位置を交互に使用して土煙を出します。false の場合は leftFootOffset のみを使用します。'
    })
    public alternateFeet: boolean = true;

    @property({
        tooltip: '左足の土煙のローカル座標オフセット(キャラクターの原点からの位置)。',
    })
    public leftFootOffset: Vec3 = new Vec3(-15, -40, 0);

    @property({
        tooltip: '右足の土煙のローカル座標オフセット(キャラクターの原点からの位置)。alternateFeet が true のときに使用されます。',
    })
    public rightFootOffset: Vec3 = new Vec3(15, -40, 0);

    @property({
        type: Prefab,
        tooltip: '生成する土煙の Prefab。パーティクルやスプライトアニメなどを設定してください。'
    })
    public dustPrefab: Prefab | null = null;

    @property({
        tooltip: '生成された土煙ノードを自動で破棄するまでの時間(秒)。0 以下の場合は自動破棄しません。'
    })
    public dustLifetime: number = 1.0;

    @property({
        tooltip: '同時に存在してよい土煙ノードの最大数。これを超える場合、新しい土煙は生成されません。'
    })
    public maxDustCount: number = 10;

    @property({
        tooltip: 'キャラクターノードの移動速度がこの値未満の場合、土煙を出しません(静止状態とみなします)。0 で無効。'
    })
    public minSpeedForDust: number = 0.0;

    @property({
        tooltip: 'true の場合、アニメーション取得失敗や土煙生成などのデバッグログを出力します。'
    })
    public debugLog: boolean = false;

    // 内部用フィールド
    private _animation: Animation | null = null;
    private _skeletalAnimation: SkeletalAnimation | null = null;

    private _stepTimer: number = 0;
    private _currentStepIndex: number = 0;  // 0,1,2,... と増加していくステップ番号
    private _dustNodes: Node[] = [];

    private _lastWorldPos: Vec3 = new Vec3();
    private _hasLastWorldPos: boolean = false;

    onLoad() {
        // 必要なコンポーネントを取得
        if (this.useSkeletalAnimation) {
            this._skeletalAnimation = this.getComponent(SkeletalAnimation);
            if (!this._skeletalAnimation) {
                warn('[FootDust] SkeletalAnimation コンポーネントが見つかりません。useSkeletalAnimation=true の場合は追加してください。');
            }
        } else {
            this._animation = this.getComponent(Animation);
            if (!this._animation) {
                warn('[FootDust] Animation コンポーネントが見つかりません。useSkeletalAnimation=false の場合は追加してください。');
            }
        }

        // 初期位置を記録
        this.node.getWorldPosition(this._lastWorldPos);
        this._hasLastWorldPos = true;

        if (!this.dustPrefab) {
            warn('[FootDust] dustPrefab が設定されていません。土煙は生成されません。');
        }
    }

    start() {
        // 起動時にタイマーをリセット
        this._stepTimer = 0;
        this._currentStepIndex = 0;
    }

    update(dt: number) {
        // アニメーションが歩き状態でなければ何もしない
        if (!this._isInWalkState()) {
            this._stepTimer = 0; // 歩き状態を抜けたらタイマーをリセット
            return;
        }

        // 移動速度チェック(任意)
        if (this.minSpeedForDust > 0) {
            if (!this._hasLastWorldPos) {
                this.node.getWorldPosition(this._lastWorldPos);
                this._hasLastWorldPos = true;
            } else {
                const currentPos = new Vec3();
                this.node.getWorldPosition(currentPos);
                const distance = Vec3.distance(currentPos, this._lastWorldPos);
                const speed = distance / dt;
                this._lastWorldPos.set(currentPos);

                if (speed < this.minSpeedForDust) {
                    // ほぼ静止しているので土煙を出さない
                    this._stepTimer = 0;
                    return;
                }
            }
        }

        // ステップタイマー進行
        this._stepTimer += dt;

        // 何ステップ分進んだかを計算
        if (this.stepInterval <= 0) {
            return;
        }

        const stepsPassed = Math.floor(this._stepTimer / this.stepInterval);
        if (stepsPassed > 0) {
            // ステップごとに1回ずつ土煙を出す
            for (let i = 0; i < stepsPassed; i++) {
                this._emitDust();
                this._currentStepIndex++;
            }
            // 経過した分だけタイマーを減算(余り時間を保持)
            this._stepTimer -= stepsPassed * this.stepInterval;
        }

        // 古い土煙ノードのクリーンアップ(destroy 済みなど)
        this._dustNodes = this._dustNodes.filter(n => n && n.isValid);
    }

    /**
     * 現在のアニメーションが「歩き状態」かどうか判定
     */
    private _isInWalkState(): boolean {
        if (this.useSkeletalAnimation) {
            if (!this._skeletalAnimation) {
                return false;
            }
            const state = this._skeletalAnimation.getState(this._skeletalAnimation.defaultClip ? this._skeletalAnimation.defaultClip.name : '');
            // SkeletalAnimation は currentClip を直接取れないので、再生中の state 情報から名前を取得
            const current = this._skeletalAnimation.getCurrent(0);
            if (!current || !current.clip) {
                return false;
            }
            const clipName = current.clip.name;
            const isPlaying = current.isPlaying;
            const isWalk = this.walkClipNames.includes(clipName);
            if (this.debugLog && isWalk && isPlaying) {
                // log(`[FootDust] SkeletalAnimation walk clip detected: ${clipName}`);
            }
            return isPlaying && isWalk;
        } else {
            if (!this._animation) {
                return false;
            }
            const state = this._animation.getState(this._animation.defaultClip ? this._animation.defaultClip.name : '');
            const current = this._animation.getCurrent(0);
            if (!current || !current.clip) {
                return false;
            }
            const clipName = current.clip.name;
            const isPlaying = current.isPlaying;
            const isWalk = this.walkClipNames.includes(clipName);
            if (this.debugLog && isWalk && isPlaying) {
                // log(`[FootDust] Animation walk clip detected: ${clipName}`);
            }
            return isPlaying && isWalk;
        }
    }

    /**
     * 土煙を1つ生成する
     */
    private _emitDust() {
        if (!this.dustPrefab) {
            if (this.debugLog) {
                warn('[FootDust] dustPrefab が未設定のため、土煙を生成できません。');
            }
            return;
        }

        // 上限チェック
        if (this.maxDustCount > 0 && this._dustNodes.length >= this.maxDustCount) {
            if (this.debugLog) {
                log('[FootDust] maxDustCount に達しているため、新しい土煙の生成をスキップしました。');
            }
            return;
        }

        // どちらの足元に出すか決定
        let offset = this.leftFootOffset;
        if (this.alternateFeet) {
            const isEven = (this._currentStepIndex % 2 === 0);
            offset = isEven ? this.leftFootOffset : this.rightFootOffset;
        }

        // ローカル座標からワールド座標へ変換
        const localPos = offset.clone();
        const worldPos = new Vec3();
        this.node.getWorldMatrix().transformPoint(localPos, worldPos);

        // 土煙ノードを生成
        const dustNode = instantiate(this.dustPrefab);
        if (!dustNode) {
            warn('[FootDust] Prefab のインスタンス化に失敗しました。');
            return;
        }

        // 親は同じシーン階層上でわかりやすいように、親ノードの親にぶら下げる
        // (キャラクターと同じスケールに影響されないようにしたい場合は、さらに調整してください)
        const parent = this.node.parent || this.node.scene;
        dustNode.setParent(parent);

        // ワールド座標を反映
        dustNode.setWorldPosition(worldPos);

        // 管理リストに追加
        this._dustNodes.push(dustNode);

        // 一定時間後に自動破棄
        if (this.dustLifetime > 0) {
            this.scheduleOnce(() => {
                if (dustNode && dustNode.isValid) {
                    dustNode.destroy();
                }
            }, this.dustLifetime);
        }

        if (this.debugLog) {
            log('[FootDust] 土煙を生成しました。stepIndex=', this._currentStepIndex, 'worldPos=', worldPos.toString());
        }
    }
}

コードのポイント解説

  • onLoad
    useSkeletalAnimation の値に応じて Animation または SkeletalAnimation を取得。
    – 見つからない場合は warn ログを出して、エディタ側で追加するよう促す。
    – 初期位置を記録し、移動速度判定に備える。
    dustPrefab 未設定時も warn を出す。
  • start
    – ステップタイマーとステップインデックスをリセット。
  • update(dt)
    _isInWalkState() で「歩き状態」かチェック。そうでなければタイマーをリセットして終了。
    minSpeedForDust が設定されている場合は、前フレームとの位置差分から移動速度を計算し、閾値未満なら土煙を出さない。
    stepInterval に基づいて _stepTimer を進め、経過したステップ数ぶん _emitDust() を呼び出す。
    – 余り時間を _stepTimer に残し、フレームレートに依存しないタイミング制御にしている。
    – 破棄済みノードを _dustNodes から除外してクリーンアップ。
  • _isInWalkState()
    Animation / SkeletalAnimation 共通のロジックで、
    「現在再生中のクリップ名が walkClipNames に含まれていて、かつ再生中」であれば true を返す。
    debugLog が有効なときは、検出したクリップ名をログに出せるようにしている(コメントアウトしている log を必要に応じて有効化)。
  • _emitDust()
    dustPrefab 未設定時や maxDustCount 超過時は安全にスキップ。
    alternateFeet_currentStepIndex に応じて、左右どちらの offset を使うか決定。
    – キャラクターのローカル座標からワールド座標に変換し、その位置に土煙ノードを生成。
    – 親ノードはキャラクターノードの親(またはシーン)にして、スケールの影響をある程度分離。
    dustLifetime 秒後に destroy() して自動的に消えるようにしている。

使用手順と動作確認

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

  1. Editor の Assets パネルで右クリックします。
  2. Create → TypeScript を選択し、ファイル名を FootDust.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の FootDust コードを丸ごと貼り付けて保存します。

2. 土煙用 Prefab の用意

土煙の見た目は自由ですが、ここでは簡単な例を紹介します。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を DustSprite などにします。
  2. Inspector で Sprite コンポーネントの SpriteFrame に、土煙っぽい画像(小さな丸やモクモクしたテクスチャ)を設定します。
  3. 必要に応じて OpacityColor を調整し、少し透明な灰色にします。
  4. フェードアウトさせたい場合は、UI → Widget や Animation などで簡単なアニメーションをつけても構いません。
  5. このノードを Assets パネルにドラッグ&ドロップして Prefab として保存します(例: Dust.prefab)。
  6. Hierarchy 上の元の DustSprite ノードは、不要であれば削除して構いません。

3. キャラクターノードへ FootDust をアタッチ

  1. Hierarchy で、歩行アニメーションを持つキャラクターノードを選択します。
    – 例: PlayerEnemy など。
  2. このノードに Animation または SkeletalAnimation コンポーネントがアタッチされていることを確認します。
    – もし無ければ、Inspector の Add Component から追加し、歩行アニメーションのクリップを設定してください。
  3. 同じく Inspector で Add Component → Custom → FootDust を選択し、FootDust コンポーネントを追加します。

4. FootDust プロパティの設定

Inspector 上で FootDust の各プロパティを次のように設定してみましょう。

  • useSkeletalAnimation:
    – キャラクターが Animation を使っている場合 → false
    SkeletalAnimation を使っている場合 → true
  • walkClipNames:
    – 例: ["walk"] または ["walk", "run"]
    – 実際にプロジェクトで使っているクリップ名と一致させてください。
  • stepInterval:
    – 例: 0.20.3 秒程度から試してみると良いです。
    – アニメーションが速ければ小さく、遅ければ大きくするイメージ。
  • alternateFeet:
    – 左右の足元から交互に出したい場合は true
    – 片足だけでよければ false にして leftFootOffset のみ使用。
  • leftFootOffset / rightFootOffset:
    – キャラクターの原点(通常は腰や足元)から見た、左右の足の位置をローカル座標で指定します。
    – 2D 横スクロールのキャラなら、例えば:
    leftFootOffset = (-15, -40, 0)
    rightFootOffset = (15, -40, 0)
    – 実際のスプライトに合わせて数値を微調整してください。
  • dustPrefab:
    – 先ほど作成した Dust.prefab をドラッグ&ドロップで指定します。
  • dustLifetime:
    – 例: 0.51.0 秒。
    – パーティクルの寿命やアニメーションに合わせて調整。
  • maxDustCount:
    – 例: 1020 程度。
    – 画面に出る土煙の最大数を制限して、パフォーマンスを守ります。
  • minSpeedForDust:
    – 例: 1030 程度。
    – 歩行アニメーション中でも、ほぼ動いていないときには土煙を止めたい場合に設定します。
    – キャラが常に動いているゲームなら 0 のままで構いません。
  • debugLog:
    – 最初の調整時は true にしておくと、コンソールに情報が出てデバッグしやすくなります。
    – 問題なく動作が確認できたら false にしてログを減らすと良いです。

5. 再生して動作確認

  1. Editor 上部の Play(▶) ボタンを押してゲームを実行します。
  2. キャラクターに「歩き」アニメーションを再生させます。
    – キー入力や自動移動など、既存の仕組みで構いません。
  3. 歩行中に、キャラクターの足元から一定間隔で土煙が出ているか確認します。
  4. 土煙の位置がずれている場合は、ゲームを停止し、leftFootOffset / rightFootOffset を少しずつ調整して再度確認します。
  5. 土煙が多すぎ/少なすぎる場合は、stepIntervaldustLifetimemaxDustCount を調整してバランスを取ります。

まとめ

この「FootDust」コンポーネントを使うことで、

  • 任意のキャラクターノードにアタッチするだけで、歩行アニメーションに同期した足元の土煙を自動生成できる。
  • アニメーションイベントや外部の GameManager に依存せず、スクリプト単体で完結した再利用可能なエフェクトとして扱える。
  • 歩きアニメーション名・ステップ間隔・左右オフセット・Prefab・寿命などをすべてインスペクタから調整できるため、アーティストやレベルデザイナーもコードに触れずにチューニング可能

同じ設計パターンを応用すれば、

  • 走り時だけ砂煙を濃くするコンポーネント
  • 着地時にだけ大きな土煙を出すコンポーネント
  • 水面を歩くときに水しぶきエフェクトを出すコンポーネント

なども、すべて「アタッチしてプロパティを設定するだけ」で量産できます。
FootDust をベースに、自分のゲームに合った足元エフェクトをどんどん追加してみてください。