Godot 4 で「歩くと足元からちょっとした砂煙や土ぼこりを出したいな〜」と思ったとき、CPUParticles2DGPUParticles2D を毎回プレイヤーや敵のシーンに仕込んで、アニメーションからトリガーして…という実装をしていると、だんだんシーンがごちゃごちゃしてきますよね。

さらに、

  • プレイヤー、敵、動く床など「歩く/動くもの」それぞれで別々に実装してしまう
  • 足音や足煙のタイミングを変えたくなると、あちこちのスクリプトを修正しないといけない
  • パーティクルのプリセットを変えたくなると、シーンを開いてポチポチ設定し直す必要がある

…といった「継承&個別実装地獄」に陥りがちです。

そこで今回は、「歩行中の足元パーティクル」を 1 コンポーネントに閉じ込めて、好きなノードにペタっと貼るだけで済ませる FootstepParticles コンポーネントを作ってみましょう。
親ノード(プレイヤーや敵など)の速度や接地状態を監視して、自動で足元からパーティクルを発生させる仕組みです。

【Godot 4】走れば舞う足煙!「FootstepParticles」コンポーネント

以下がコピペで動く、FootstepParticles.gd のフルコードです。
2D 用(CharacterBody2DRigidBody2D を想定)にしています。


extends Node2D
class_name FootstepParticles
## 親が床の上を歩いているときに、足元からパーティクルを発生させるコンポーネント。
## 「継承」ではなく「合成」で、どんな動くノードにも後付けできます。

@export_group("基本設定")
## 足煙を出す対象のノード。
## 未設定の場合は、自分の親ノードを自動で参照します。
@export var target_body: NodePath

## 「歩いている」とみなす最低速度(ピクセル/秒)。
## これより遅いと足煙は出ません。
@export var min_speed: float = 20.0

## 足煙を出す間隔(秒)。
## 小さいほど連続して足煙が出ます。
@export var spawn_interval: float = 0.2

## 足煙を出すときのオフセット(ローカル座標)。
## だいたい足元の位置になるように調整しましょう。
@export var foot_offset: Vector2 = Vector2(0, 8)

@export_group("パーティクル設定")
## インスタンス化するパーティクルシーン。
## CPUParticles2D / GPUParticles2D を含んだシーンを指定してください。
@export var particles_scene: PackedScene

## パーティクルをどの親にぶら下げるか。
## 未設定の場合は、このコンポーネント自身にぶら下げます。
@export var particles_parent_override: NodePath

## パーティクルの向きを進行方向に合わせるかどうか。
@export var align_to_velocity: bool = true

## パーティクルをワールド座標で動かしたい場合は true。
## 親が動いても、足煙がその場に残ります。
@export var particles_as_top_level: bool = true

@export_group("デバッグ")
## 現在の速度や接地状態をログに出したいときに使うフラグ。
@export var debug_print: bool = false


var _body: Node = null
var _velocity_getter: Callable
var _is_on_floor_getter: Callable
var _time_accum: float = 0.0


func _ready() -> void:
    # 対象のボディを解決
    if target_body.is_empty():
        _body = get_parent()
    else:
        _body = get_node_or_null(target_body)

    if _body == null:
        push_warning("FootstepParticles: target_body が見つかりません。親ノードを対象にします。")
        _body = get_parent()

    # 対象が 2D かつ、velocity / is_on_floor を持っているか確認して、
    # それらを取得する Callable をセットアップします。
    _setup_body_accessors()

    if particles_scene == null:
        push_warning("FootstepParticles: particles_scene が設定されていません。パーティクルは発生しません。")


func _physics_process(delta: float) -> void:
    if _body == null:
        return
    if particles_scene == null:
        return

    var velocity: Vector2 = _get_velocity()
    var speed := velocity.length()
    var on_floor := _get_is_on_floor()

    if debug_print:
        print("[FootstepParticles] speed=", speed, " on_floor=", on_floor)

    # 床にいない、または遅すぎるときはリセットして終了
    if not on_floor or speed < min_speed:
        _time_accum = 0.0
        return

    # 一定間隔でパーティクルを出す
    _time_accum += delta
    if _time_accum >= spawn_interval:
        _time_accum -= spawn_interval
        _spawn_particles(velocity)


func _setup_body_accessors() -> void:
    ## 対象のボディが持っているプロパティやメソッドに応じて、
    ## 速度と接地状態を取得する Callable を設定します。
    ##
    ## - CharacterBody2D: velocity / is_on_floor()
    ## - RigidBody2D: linear_velocity / (接地判定は衝突情報から自前で実装推奨)
    ## - それ以外: 速度を 0、接地は常に true として扱う(最低限動くフォールバック)

    if _body == null:
        _velocity_getter = func() -> Vector2:
            return Vector2.ZERO
        _is_on_floor_getter = func() -> bool:
            return true
        return

    # CharacterBody2D 対応
    if _body is CharacterBody2D:
        var cb := _body as CharacterBody2D
        _velocity_getter = func() -> Vector2:
            return cb.velocity
        _is_on_floor_getter = func() -> bool:
            return cb.is_on_floor()
        return

    # RigidBody2D 対応(簡易版)
    if _body is RigidBody2D:
        var rb := _body as RigidBody2D
        _velocity_getter = func() -> Vector2:
            return rb.linear_velocity
        # RigidBody2D には is_on_floor がないので、ここでは常に true として扱う。
        # 必要なら、別のコンポーネントで接地判定を行い、
        # FootstepParticles にフラグを渡すように改造するとよいです。
        _is_on_floor_getter = func() -> bool:
            return true
        return

    # フォールバック: 速度 0 / 常に接地
    _velocity_getter = func() -> Vector2:
        return Vector2.ZERO
    _is_on_floor_getter = func() -> bool:
        return true


func _get_velocity() -> Vector2:
    if _velocity_getter.is_valid():
        return _velocity_getter.call()
    return Vector2.ZERO


func _get_is_on_floor() -> bool:
    if _is_on_floor_getter.is_valid():
        return _is_on_floor_getter.call()
    return true


func _spawn_particles(velocity: Vector2) -> void:
    var particles := particles_scene.instantiate()
    if not (particles is Node2D):
        push_warning("FootstepParticles: particles_scene は Node2D 系のシーンを指定してください。")
        return

    var p2d := particles as Node2D

    # どこにぶら下げるか決定
    var parent_for_particles: Node = self
    if not particles_parent_override.is_empty():
        var override_parent := get_node_or_null(particles_parent_override)
        if override_parent:
            parent_for_particles = override_parent

    parent_for_particles.add_child(p2d)

    # 位置を足元にセット
    # コンポーネントのグローバル位置 + 足元オフセットで決めます。
    var spawn_pos := global_position + foot_offset
    p2d.global_position = spawn_pos

    # 進行方向に回転させたい場合
    if align_to_velocity and velocity.length() > 0.1:
        p2d.rotation = velocity.angle()

    # 親の動きに影響されないように top_level にするオプション
    if particles_as_top_level:
        p2d.set_as_top_level(true)

    # パーティクルが CPUParticles2D / GPUParticles2D のとき、
    # 1 回だけ放出して自動的に消えるように設定しておくと便利です。
    _configure_particles_node(p2d)


func _configure_particles_node(particles_node: Node2D) -> void:
    ## パーティクルシーンのルートが CPUParticles2D / GPUParticles2D の場合、
    ## 「一度だけ放出して自動削除する」ような挙動にセットアップします。
    if particles_node is CPUParticles2D:
        var cpu := particles_node as CPUParticles2D
        cpu.emitting = true
        # ワンショットにしたい場合は true に。
        cpu.one_shot = true
        # ライフタイム + 余裕を見て削除
        var lifetime := cpu.lifetime + cpu.lifetime_randomness * cpu.lifetime
        _queue_free_later(particles_node, lifetime + 0.5)
    elif particles_node is GPUParticles2D:
        var gpu := particles_node as GPUParticles2D
        gpu.emitting = true
        gpu.one_shot = true
        var lifetime2 := gpu.lifetime
        _queue_free_later(particles_node, lifetime2 + 0.5)
    else:
        # それ以外の場合は、適当に 2 秒後に消す
        _queue_free_later(particles_node, 2.0)


func _queue_free_later(node: Node, delay: float) -> void:
    ## 指定秒数後に node.queue_free() する簡易ヘルパー。
    var timer := Timer.new()
    timer.one_shot = true
    timer.wait_time = max(delay, 0.01)
    add_child(timer)
    timer.timeout.connect(func():
        if is_instance_valid(node):
            node.queue_free()
        timer.queue_free()
    )

使い方の手順

ここでは典型的な 3 パターンの例を挙げます。

  1. プレイヤー(CharacterBody2D)に足煙を付ける
  2. 敵キャラ(CharacterBody2D)に同じ足煙を再利用する
  3. 動く床(Node2D)に「滑走エフェクト」として応用する

手順①:パーティクルシーンを用意する

  1. 新しいシーンを作成し、ルートに CPUParticles2D(または GPUParticles2D)を追加します。
  2. マテリアルやテクスチャ、重力、速度などを調整して、好みの足煙エフェクトを作ります。
  3. シーンを res://effects/footstep_dust.tscn のような名前で保存します。

このシーンを FootstepParticles.particles_scene に割り当てます。

手順②:プレイヤーにコンポーネントを追加する

プレイヤーシーンの構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FootstepParticles (Node2D)
  1. プレイヤーシーンを開き、ルート(Player: CharacterBody2D)の子として Node2D を追加します。
  2. 追加した Node2D に、上記の FootstepParticles.gd をスクリプトとしてアタッチします。
  3. インスペクタで以下のように設定します:
    • target_body:空のまま(親である Player を自動で参照)
    • min_speed:例)30.0
    • spawn_interval:例)0.15
    • foot_offset:例)(0, 10)(Sprite の足元あたり)
    • particles_scene:先ほど作成した footstep_dust.tscn
    • particles_parent_override:空のまま(コンポーネント自身にぶら下げる)
    • align_to_velocitytrue
    • particles_as_top_leveltrue(足煙がその場に残るように)

これだけで、Player が床の上を一定以上の速度で移動している間、自動で足元からパーティクルが出るようになります。
アニメーションや入力処理とは完全に分離されているので、プレイヤーのロジックをいじらずに足煙だけ調整できるのがポイントです。

手順③:敵キャラに再利用する

敵キャラのシーン構成例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FootstepParticles (Node2D)
  1. 敵キャラのシーンを開き、プレイヤーと同様に FootstepParticles ノードを子として追加します。
  2. 同じ footstep_dust.tscnparticles_scene にセットします。
  3. min_speedspawn_interval を敵用に微調整しても OK です。

こうすることで、プレイヤーと敵が同じ足煙エフェクトを共有しつつ、それぞれの移動ロジックは一切いじらなくて済むようになります。
コンポーネント指向の美味しいところですね。

手順④:動く床に「滑走エフェクト」として付ける

動く床のシーン構成例:

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── FootstepParticles (Node2D)

この例では CharacterBody2D ではないので、velocityis_on_floor() を持っていません。
そのため、上記の実装では「速度 0 / 常に接地」として扱われます。

簡単に動かしたいだけであれば、別のコンポーネントで速度を管理して FootstepParticles を改造して連携するのがおすすめですが、まずは最小限の例として:

  • min_speed を 0 にする
  • spawn_interval を固定値にする(例:0.3)

とすれば、「常に一定間隔で床の端から煙が出る」ような表現にも流用できます。
このあたりは後述の「改造案」で少し触れます。

メリットと応用

FootstepParticles コンポーネントを使うメリットは主に次のような点です。

  • シーン構造がスッキリ:プレイヤーや敵のシーンに、パーティクル用のノードやアニメーションイベントをベタ書きしなくて済みます。
  • ロジックの再利用性が高い:移動ロジックとは完全に分離されているので、どんな CharacterBody2D にも後付け可能です。
  • パラメータ調整が一箇所で済む:速度しきい値、出現間隔、オフセット、パーティクルの親などをインスペクタからまとめて調整できます。
  • 「継承の罠」からの解放PlayerWithFootstepEnemyWithFootstep のような派生クラスを増やさなくて済み、合成(Composition)で機能を足していくスタイルが取りやすくなります。

応用例としては:

  • ダッシュ中だけ足煙の間隔を短くする(spawn_interval を動的に変更)
  • 地形の種類(砂・雪・水たまり)によって particles_scene を差し替える
  • ジャンプ着地時だけ別の着地エフェクトを追加する(別コンポーネントと連携)

などが考えられます。

改造案:外部から「接地フラグ」を上書きできるようにする

たとえば RigidBody2D でしっかり接地判定をしたい場合や、別コンポーネントで「水上を歩いている」などの状態を管理している場合
FootstepParticles に「強制的に on_floor を指定する」フックを追加すると便利です。

以下のようなメソッドを足すだけで、外部から接地状態を上書きできるようになります。


## 外部コンポーネントから接地状態を上書きするためのオプション。
var _override_on_floor: bool = false
var _use_override_on_floor: bool = false

func set_override_on_floor(enabled: bool, value: bool = true) -> void:
    ## enabled = true のとき、is_on_floor() の結果を value で上書きします。
    _use_override_on_floor = enabled
    _override_on_floor = value


func _get_is_on_floor() -> bool:
    if _use_override_on_floor:
        return _override_on_floor
    if _is_on_floor_getter.is_valid():
        return _is_on_floor_getter.call()
    return true

これで、別のスクリプトから


$FootstepParticles.set_override_on_floor(true, my_custom_floor_check())

のように呼び出して、独自の接地判定ロジックと連携させることができます。
こうやって小さなコンポーネント同士をつないでいくと、Godot プロジェクト全体がだんだん「継承ツリー」ではなく「機能ブロックの組み合わせ」として整理されていくので、後々の拡張もしやすくなりますね。