【Cocos Creator 3.8】FootstepParticles の実装:アタッチするだけで「歩くたびに足元から土煙・足跡パーティクル」を出せる汎用スクリプト

キャラクターが床の上を歩いているときに、足元から煙や砂ぼこり、足跡などのパーティクルを出したい場面は多いです。本記事では、任意のノードにアタッチするだけで移動量に応じて自動的に足元からパーティクルを発生させる汎用コンポーネント FootstepParticles を実装します。

外部の GameManager や入力スクリプトに依存せず、このコンポーネント単体で完結する設計になっているため、どのプロジェクトにも簡単に持ち込んで再利用できます。


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

機能要件の整理

  • 任意のノード(プレイヤー・敵・動くオブジェクトなど)にアタッチして使える。
  • ノードが「床の上を歩いている状態」を簡易的に判定するために、水平移動量を元に歩行とみなす。
  • 一定距離以上移動するごとに、足元からパーティクルを 1 回発生させる。
  • パーティクルは Prefab で差し替え可能(砂煙・水しぶき・雪煙など自由に変更)。
  • パーティクルの発生位置は「足元オフセット」で調整できる。
  • 走っているときは歩いているときよりも発生間隔を短くできるよう、距離・速度しきい値を調整可能にする。
  • 移動していても、空中にいるとき(ジャンプ中など)は発生させないようにするための簡易フラグを提供。
  • 外部スクリプトに依存せず、インスペクタのプロパティだけで制御できる。

ここでは「床判定」のために物理コリジョンやレイキャストまでは行わず、水平移動していて、かつユーザーが「地面にいる」とマークしているときに足跡を出す、というシンプルな設計にします。

例えば以下のような使い方が可能です:

  • 2D アクションゲームのプレイヤーにアタッチし、移動入力を行っている間だけ isGroundedtrue にしておく。
  • 物理挙動を使っている場合は、別のスクリプトで onBeginContact/onEndContact などから isGrounded を更新し、このコンポーネントに値を渡す。
  • シンプルに「常に地面にいるキャラ」の場合は、alwaysGroundedtrue にしておけば、特に何もせず歩行で足煙が出る。

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

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

  • particlePrefabPrefab | null
    • 足元に出したいパーティクル(例:ParticleSystem2DParticleSystem を含む Prefab)を指定。
    • 必須。未設定の場合はエラーをログに出し、パーティクル生成を行わない。
  • spawnOffsetVec3
    • 親ノードのローカル座標からのオフセット位置。
    • 例:(0, -40, 0) など、キャラクターの足元の位置に合わせて調整。
  • minStepDistancenumber
    • 1 回の足跡を出すのに必要な水平移動距離(ワールド座標ベース)。
    • キャラのスケールやスピードに応じて、例:3080 程度で調整。
  • maxSpawnRatenumber
    • パーティクル発生の最小間隔(秒)。
    • 連続で高速移動していても、ここで指定した秒数より短い間隔では発生しない。
  • speedThresholdnumber
    • 「歩行」とみなすための最低水平速度(単位:ユニット/秒)。
    • この値より遅い移動は「静止または微動」とみなし、足跡を出さない。
  • autoDestroyParticleboolean
    • true の場合、生成したパーティクルノードを particleLifetime 秒後に自動破棄。
    • Prefab 内で自動的に消える設定にしている場合でも、保険として使える。
  • particleLifetimenumber
    • autoDestroyParticletrue のときに使用する寿命(秒)。
    • パーティクルの見た目に合わせて調整(例:1.02.0)。
  • alwaysGroundedboolean
    • true の場合、「常に地面にいる」とみなして足煙を出す。
    • ジャンプなどを実装していないシンプルなキャラや、常に床と接しているオブジェクト向け。
  • isGroundedboolean
    • 外部から「今、地面にいるかどうか」を指定するためのフラグ。
    • alwaysGroundedtrue の場合は無視される。
    • ジャンプや落下中は false にすることで、その間は足煙が出なくなる。
  • flipWithParentScaleXboolean
    • true の場合、親ノードの scale.x が負のときに足元オフセットの X を自動で反転。
    • 左右反転で向きを変えているキャラに対応するためのオプション。
  • debugLogboolean
    • 有効にすると、パーティクル発生時やエラー時にコンソールへログ出力。
    • 挙動確認用。最終的なビルドではオフにしておくとよい。

TypeScriptコードの実装

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


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

/**
 * FootstepParticles
 * 親ノードの移動量に応じて足元からパーティクルを発生させる汎用コンポーネント。
 *
 * - 他のカスタムスクリプトへの依存は一切なし。
 * - particlePrefab に任意のパーティクル Prefab を指定して使用する。
 */
@ccclass('FootstepParticles')
export class FootstepParticles extends Component {

    @property({
        type: Prefab,
        tooltip: '足元に生成するパーティクルの Prefab。\n' +
                 '例: ParticleSystem2D / ParticleSystem を含むノード。\n' +
                 '未設定の場合はパーティクルを生成しません。'
    })
    public particlePrefab: Prefab | null = null;

    @property({
        tooltip: '足元パーティクルの生成位置(親ノードのローカル座標からのオフセット)。\n' +
                 '例: (0, -40, 0) など、キャラクターの足元に合わせて調整します。'
    })
    public spawnOffset: Vec3 = new Vec3(0, -40, 0);

    @property({
        tooltip: '1 回の足跡を出すために必要な水平移動距離(ワールド座標ベース)。\n' +
                 'キャラクターのスピードやスケールに応じて 30〜80 程度で調整してください。'
    })
    public minStepDistance: number = 40;

    @property({
        tooltip: 'パーティクル生成の最小間隔(秒)。\n' +
                 '高速移動していても、この値より短い間隔では生成されません。'
    })
    public maxSpawnRate: number = 0.12;

    @property({
        tooltip: '「歩行」とみなすための最低水平速度(単位: ユニット/秒)。\n' +
                 'この値より遅い移動では足跡を生成しません。'
    })
    public speedThreshold: number = 10;

    @property({
        tooltip: 'true の場合、生成したパーティクルノードを particleLifetime 秒後に自動で破棄します。\n' +
                 'Prefab 側ですでに自動消滅する設定でも、保険として利用できます。'
    })
    public autoDestroyParticle: boolean = true;

    @property({
        tooltip: 'autoDestroyParticle が true のときに使用するパーティクルノードの寿命(秒)。'
    })
    public particleLifetime: number = 1.5;

    @property({
        tooltip: 'true の場合、「常に地面にいる」とみなして足煙を出します。\n' +
                 'ジャンプ等を実装していないシンプルなキャラに便利です。'
    })
    public alwaysGrounded: boolean = true;

    @property({
        tooltip: '外部から「今、地面にいるかどうか」を指定するためのフラグです。\n' +
                 'alwaysGrounded が true の場合は無視されます。\n' +
                 'ジャンプ中や落下中は false にすることで、その間は足煙を出さないようにできます。'
    })
    public isGrounded: boolean = true;

    @property({
        tooltip: 'true の場合、親ノードの scale.x が負のときに spawnOffset.x を自動で反転します。\n' +
                 '左右反転で向きを変えているキャラクターに対応するためのオプションです。'
    })
    public flipWithParentScaleX: boolean = true;

    @property({
        tooltip: 'true にすると、パーティクル生成やエラー時にデバッグログを出力します。'
    })
    public debugLog: boolean = false;

    // 内部状態管理用
    private _lastWorldPos: Vec3 = new Vec3();
    private _distanceAccum: number = 0;
    private _lastSpawnTime: number = 0;
    private _lastFrameTime: number = 0;
    private _hasWarnedNoPrefab: boolean = false;

    onLoad() {
        // 初期位置を記録
        this.node.getWorldPosition(this._lastWorldPos);

        // 時間管理用の初期化
        this._lastSpawnTime = 0;
        this._lastFrameTime = director.getTotalTime() / 1000;

        if (!this.particlePrefab) {
            // Prefab が未設定の場合は一度だけ警告
            this._logError('particlePrefab が設定されていません。足元パーティクルは生成されません。');
            this._hasWarnedNoPrefab = true;
        }
    }

    start() {
        // start では特に処理は行わないが、
        // 将来的に初期化を追加したい場合に備えてメソッドを用意しておく。
    }

    update(deltaTime: number) {
        // deltaTime を使わず director から時間を取得する場合に備えて保持しておく
        const currentTime = director.getTotalTime() / 1000;
        const frameDelta = currentTime - this._lastFrameTime;
        this._lastFrameTime = currentTime;

        // Prefab が設定されていなければ何もしない
        if (!this.particlePrefab) {
            if (!this._hasWarnedNoPrefab) {
                this._logError('particlePrefab が未設定のため、FootstepParticles は動作しません。');
                this._hasWarnedNoPrefab = true;
            }
            return;
        }

        // 地面にいるかどうかの判定
        const grounded = this.alwaysGrounded || this.isGrounded;
        if (!grounded) {
            // 空中にいるときは距離カウントをリセットしておくと、着地後に素早く反応しやすい
            this._distanceAccum = 0;
            // 位置だけ更新して終了
            this.node.getWorldPosition(this._lastWorldPos);
            return;
        }

        // 現在位置を取得
        const currentPos = new Vec3();
        this.node.getWorldPosition(currentPos);

        // 水平距離のみを計算(Y, Z は無視)
        const dx = currentPos.x - this._lastWorldPos.x;
        const horizontalDistance = Math.abs(dx);

        // 水平速度を算出(ユニット/秒)
        const speed = frameDelta > 0 ? horizontalDistance / frameDelta : 0;

        // 「歩行」とみなす速度を満たしていなければ、位置だけ更新して終了
        if (speed < this.speedThreshold) {
            this._distanceAccum = 0;
            this._lastWorldPos.set(currentPos);
            return;
        }

        // 移動距離を加算
        this._distanceAccum += horizontalDistance;
        this._lastWorldPos.set(currentPos);

        // 一定距離に達しているか、かつ最小間隔を満たしているかをチェック
        const timeSinceLastSpawn = currentTime - this._lastSpawnTime;
        if (this._distanceAccum >= this.minStepDistance && timeSinceLastSpawn >= this.maxSpawnRate) {
            this._spawnFootstepParticle();
            this._distanceAccum = 0;
            this._lastSpawnTime = currentTime;
        }
    }

    /**
     * 足元にパーティクルを生成する処理
     */
    private _spawnFootstepParticle() {
        if (!this.particlePrefab) {
            // 念のため再チェック
            this._logError('particlePrefab が未設定のため、パーティクルを生成できません。');
            return;
        }

        // 親ノードのワールド位置を取得
        const parentWorldPos = new Vec3();
        this.node.getWorldPosition(parentWorldPos);

        // オフセットを計算(必要に応じて左右反転)
        const offset = new Vec3(this.spawnOffset.x, this.spawnOffset.y, this.spawnOffset.z);

        if (this.flipWithParentScaleX) {
            const scale = this.node.scale;
            if (scale.x < 0) {
                offset.x = -offset.x;
            }
        }

        // 足元のワールド座標 = 親のワールド座標 + オフセット(ローカルオフセットをワールド空間に近似)
        // キャラクターが回転する場合など、より厳密に行いたいときは
        // this.node.getWorldMatrix() を使って変換してもよい。
        const spawnWorldPos = new Vec3(
            parentWorldPos.x + offset.x,
            parentWorldPos.y + offset.y,
            parentWorldPos.z + offset.z
        );

        // パーティクルノードを生成
        const particleNode = instantiate(this.particlePrefab);
        if (!particleNode) {
            this._logError('particlePrefab からノードを生成できませんでした。');
            return;
        }

        // 親は同じシーンのルートに付けるか、親ノードの親に付けるのが扱いやすい。
        // ここでは「親ノードと同じ親」をデフォルトとする。
        const parent = this.node.parent;
        if (!parent) {
            this._logError('FootstepParticles の親ノードに親が存在しません。シーンに配置されているか確認してください。');
            return;
        }

        particleNode.parent = parent;
        particleNode.setWorldPosition(spawnWorldPos);

        if (this.debugLog) {
            this._log(`パーティクル生成: ${spawnWorldPos.toString()}`);
        }

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

    private _log(message: string) {
        if (this.debugLog) {
            console.log(`[FootstepParticles] ${message}`);
        }
    }

    private _logError(message: string) {
        console.error(`[FootstepParticles] ${message}`);
    }
}

コードの要点解説

  • onLoad
    • コンポーネントがロードされた時点で、ノードの初期ワールド位置を _lastWorldPos に保存します。
    • particlePrefab が未設定の場合は一度だけエラーログを出します。
    • 時間管理用に _lastFrameTime を初期化します。
  • update
    • 毎フレーム、親ノードのワールド位置を取得し、前フレームからの水平移動距離水平速度を計算します。
    • alwaysGrounded または isGroundedtrue のときだけ「歩行中」とみなします。
    • 速度が speedThreshold 未満の場合は足跡を出さず、距離カウンタをリセットします。
    • 速度がしきい値以上の場合、_distanceAccum に移動距離を加算し、minStepDistancemaxSpawnRate を満たしたときに _spawnFootstepParticle() を呼び出します。
  • _spawnFootstepParticle
    • 親ノードのワールド座標に spawnOffset を加算して、足元のワールド座標を算出します。
    • flipWithParentScaleXtrue かつ scale.x < 0 のとき、オフセットの X を反転させて左右反転に対応します。
    • instantiateparticlePrefab からノードを生成し、親ノードと同じ親にぶら下げてワールド座標をセットします。
    • autoDestroyParticletrue の場合、scheduleOnceparticleLifetime 秒後に destroy() します。
  • 防御的実装
    • particlePrefab 未設定時はエラーログを出し、実行時例外を避けています。
    • 親ノードに親がいない(シーンに正しく配置されていない)場合もエラーログを出して処理を中断します。

使用手順と動作確認

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

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

  1. Assets パネルで任意のフォルダ(例:assets/scripts)を右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を FootstepParticles.ts に変更します。
  4. 作成された FootstepParticles.ts をダブルクリックしてエディタで開き、中身をすべて削除して、前章の TypeScript コードをそのまま貼り付けて保存します。

2. 足元パーティクル用 Prefab の用意

足煙や足跡に使うパーティクル Prefab を用意します。ここでは 2D の例で説明しますが、3D でも同様です。

  1. Assets パネルで右クリック → Create → Prefab を選択し、FootstepDust.prefab などの名前を付けます。
  2. 作成した Prefab をダブルクリックして Prefab 編集モードに入ります。
  3. Prefab のルートノードに、用途に応じて以下いずれかのコンポーネントを追加します。
    • 2D の場合:ParticleSystem2D
    • 3D の場合:ParticleSystem
  4. パーティクルの見た目(テクスチャ、色、寿命、発生数など)を好みに合わせて調整します。
  5. パーティクルが一定時間で自動的に消えるようにしたい場合は、ParticleSystem の設定で Duration や Auto Remove などを適切に設定しておきます。
  6. Prefab 編集モードを終了し、シーンに戻ります。

3. テスト用キャラクターノードの作成

ここでは 2D の例として、スプライトを動かしながら足煙を確認してみます。

  1. Hierarchy パネルで Create → 2D Object → Sprite を選択し、Player などの名前を付けます。
  2. Inspector で Sprite の画像(テクスチャ)を設定し、キャラクターの見た目を整えます。
  3. この Player ノードに対して、移動用の簡単なスクリプト(例:矢印キーで左右に動く)を追加しておくと、動作確認がしやすくなります。
    • ※本記事のコンポーネントは移動ロジックに依存しないため、ここは任意です。

4. FootstepParticles コンポーネントのアタッチ

  1. Hierarchy で先ほど作成した Player ノードを選択します。
  2. Inspector の下部で Add Component → Custom → FootstepParticles を選択します。
    • Custom カテゴリの中に FootstepParticles が表示されているはずです。

5. Inspector プロパティの設定

Player ノードにアタッチされた FootstepParticles コンポーネントの各プロパティを設定します。

  • Particle Prefab
    • 先ほど作成した FootstepDust.prefab をドラッグ&ドロップで割り当てます。
  • Spawn Offset
    • 例として、キャラクターの足元が Sprite の中心より少し下にある場合:
      • X: 0
      • Y: -40(キャラサイズに合わせて調整)
      • Z: 0
  • Min Step Distance
    • 例:40
    • キャラクターが 40 ユニット進むごとに足煙を 1 回出すイメージです。
  • Max Spawn Rate
    • 例:0.12
    • 0.12 秒より短い間隔では連続して出さないように制限します。
  • Speed Threshold
    • 例:10
    • ゆっくり動かしても足煙が出ない場合は、この値を小さくします(例:5)。
  • Auto Destroy Particle
    • Prefab 側で自動削除をしていない場合は true のままで OK。
  • Particle Lifetime
    • 例:1.5 秒(パーティクルが完全に消えるまでの時間に合わせて調整)。
  • Always Grounded
    • ジャンプなどをまだ実装しておらず、常に地面の上を移動しているだけなら true のままで構いません。
    • 後からジャンプや落下を実装したら、false にして、別スクリプトから isGrounded を制御する運用に変えることもできます。
  • Is Grounded
    • Always Groundedtrue のときは無視されます。
    • テストでは true にしておきます。
  • Flip With Parent ScaleX
    • キャラクターの左右反転に scale.x = -1 などを使っている場合は true にしておきます。
    • 常に正のスケールの場合や、アニメーションで向きを変えているだけなら false でも構いません。
  • Debug Log
    • 挙動を確認したいときは true にして、コンソールに出るログを見てみましょう。
    • 問題なさそうであれば、最終的には false に戻しておくとよいです。

6. 実行して動作確認

  1. エディタ右上の Play ボタンを押してゲームをプレビューします。
  2. キーボード入力などで Player ノードを左右に動かしてみてください。
  3. 一定距離ごとに、足元から FootstepDust のパーティクルが発生していれば成功です。
  4. 足煙の間隔が広すぎる/狭すぎる場合は:
    • Min Step Distance を小さくすると「歩幅」が短くなり、頻度が増えます。
    • Max Spawn Rate を小さくすると、高速移動時の発生間隔が短くなります。
  5. ゆっくり動かしたときに足煙が出ない場合は:
    • Speed Threshold を小さくして、低速でも「歩行」とみなされるように調整します。

7. ジャンプなどでの応用(オプション)

本コンポーネントは外部スクリプトに依存しませんが、isGrounded プロパティを他のスクリプトから書き換えることで、よりリッチな挙動を実現できます。

  • プレイヤー制御スクリプトで、地面との接触を検知したときに isGrounded = true、ジャンプキーを押したときに isGrounded = false にする。
  • 落下中は isGrounded = false にしておけば、空中では足煙が出ず、着地してから再び歩き始めたときだけ足煙が出るようになります。

このように、FootstepParticles 自体はどのスクリプトにも依存せず、必要に応じて外部からフラグを操作するだけで連携できるようになっています。


まとめ

本記事では、Cocos Creator 3.8.7 と TypeScript を用いて、

  • 任意のノードにアタッチするだけで、
  • 移動距離と速度に応じて足元からパーティクルを発生させる、
  • 外部スクリプトに一切依存しない汎用コンポーネント FootstepParticles

を実装しました。

主なポイントは次の通りです。

  • Prefab 差し替え可能:砂煙、雪煙、水しぶき、足跡など、シーンに合わせて自由に変更可能。
  • インスペクタだけで完結:歩幅(minStepDistance)、速度しきい値(speedThreshold)、左右反転対応(flipWithParentScaleX)などを全て Inspector から調整できる。
  • 外部依存ゼロ:GameManager や入力スクリプトに依存せず、このコンポーネント単体で機能する。
  • 防御的実装:Prefab 未設定や親ノードの不備に対してエラーログを出し、実行時エラーを防いでいる。

このような「アタッチするだけでリッチな表現を追加できる汎用コンポーネント」をプロジェクト内にストックしておくと、

  • 新しいキャラクターを追加するときも、コンポーネントをポンと付けるだけで足煙演出が完成する
  • パラメータを変えるだけで、重量感のある足音・軽やかな足音などを簡単に表現できる
  • アニメーションや入力ロジックと独立しているので、後から差し替えや調整がしやすい

といったメリットがあります。

ぜひこの FootstepParticles をベースに、着地時だけ強い砂煙を出す特定のマテリアルの床の上では別のパーティクルを使う など、プロジェクトに合わせて拡張してみてください。