Godot 4でキャラの足元に「土煙エフェクト」を付けたいとき、よくある実装はこんな感じですよね。

  • プレイヤーシーンに Particles2DGPUParticles2D を直付けする
  • アニメーションごとに AnimationPlayer からパーティクルの emitting をオンオフする
  • もしくはプレイヤースクリプト内で「歩き中なら土煙ON、止まったらOFF」みたいな状態管理を書く

動きは作れるんですが、だんだんこうなっていきます。

  • プレイヤースクリプトが「移動」と「アニメーション」と「エフェクト制御」でゴチャつく
  • 敵キャラにも同じ土煙ロジックをコピペして、あとから修正が大変
  • ノード階層の中に「Dust」「StepEffect」「WalkSmoke」など似たようなノードが散らばる

そこで今回は、「歩行アニメーションのフレームに合わせて足元から土煙を出す」機能を、完全に独立したコンポーネントとして切り出します。
プレイヤーでも敵でも、動く床でも、「足元からホコリが出そうなもの」にペタッと貼るだけで済むようにしていきましょう。

【Godot 4】歩くたびにホコリが舞う!「FootDust」コンポーネント

今回の FootDust コンポーネントの思想はシンプルです。

  • アニメーション側から「このフレームで足が地面についたよ」と通知する
  • FootDust はその通知を受けて、足元にパーティクル(またはシーン)を生成する
  • プレイヤー本体は「移動」と「入力」に集中できる

つまり、継承ではなく「合成」です。
歩行ロジックは Player.gd に任せて、土煙は FootDust.gd に丸投げしましょう。


フルコード: FootDust.gd


extends Node2D
class_name FootDust
## 足元の土煙エフェクトを管理するコンポーネント
##
## 想定の使い方:
## - 親ノードは Player / Enemy / MovingPlatform など何でもOK
## - AnimationPlayer の足踏みフレームから `foot_dust.emit_left()` / `emit_right()` を呼ぶ
## - または、コードから `_on_landed()` などのタイミングで呼んでもよい

@export_category("Dust Scene / Particles")
## 生成する土煙シーン。
## PackedScene なら、任意のシーン(Particles2D / GPUParticles2D / Spriteアニメなど)を使えます。
@export var dust_scene: PackedScene

## 生成した土煙をどこにぶら下げるか。
## デフォルトはこの FootDust 自身にぶら下げます。
@export var parent_for_dust: NodePath

@export_category("Foot Positions")
## 左足のローカル座標(親ノード基準)
@export var left_foot_offset: Vector2 = Vector2(-8, 16)
## 右足のローカル座標(親ノード基準)
@export var right_foot_offset: Vector2 = Vector2(8, 16)

@export_category("Emission Settings")
## 連続で土煙を出さないようにするクールタイム(秒)
@export_range(0.0, 1.0, 0.01) var cooldown_sec: float = 0.08

## キャラの移動速度がこの値以下なら土煙を出さない(静止時に無駄に出ないように)
@export var min_speed_to_emit: float = 10.0

## X方向の速度を親から自動取得するかどうか。
## true の場合、親が CharacterBody2D / RigidBody2D などで velocity.x を見る。
@export var auto_read_horizontal_speed: bool = true

@export_category("Auto Cleanup")
## 生成した土煙ノードを自動的に削除するまでの秒数。
## 0 なら「シーン側の設定に任せる」(自動削除しない)。
@export_range(0.0, 10.0, 0.1) var auto_free_after: float = 1.5

@export_category("Debug")
## デバッグ用に足位置を表示するか
@export var debug_draw_feet: bool = false

## 内部状態
var _can_emit_left: bool = true
var _can_emit_right: bool = true

var _dust_parent: Node = null


func _ready() -> void:
    # 土煙をぶら下げる親ノードを決定
    if parent_for_dust != NodePath():
        _dust_parent = get_node_or_null(parent_for_dust)
    if _dust_parent == null:
        # 指定がなければ FootDust 自身を使う
        _dust_parent = self

    # 親が移動系ノードでなくても動くように、特別なセットアップはしない


func _process(_delta: float) -> void:
    # デバッグ表示用
    if debug_draw_feet:
        queue_redraw()


func _draw() -> void:
    if not debug_draw_feet:
        return

    # 足の位置を視覚的に確認するためのガイド
    draw_circle(left_foot_offset, 2.0, Color(0, 1, 0, 0.8))
    draw_circle(right_foot_offset, 2.0, Color(0, 0, 1, 0.8))
    draw_line(left_foot_offset, right_foot_offset, Color(1, 1, 0, 0.5), 1.0)


## 親の「現在の水平速度」を推定する。
## CharacterBody2D / RigidBody2D / Node2D + velocity_x プロパティをざっくり見る。
func _get_parent_horizontal_speed() -> float:
    if not auto_read_horizontal_speed:
        return min_speed_to_emit  # チェックを無効化したいので閾値以上を返す

    var p := get_parent()
    if p == null:
        return 0.0

    # CharacterBody2D / RigidBody2D など Godot 標準の velocity を見る
    if "velocity" in p:
        var v = p.velocity
        if typeof(v) == TYPE_VECTOR2:
            return abs(v.x)

    # カスタムな velocity_x プロパティを持っている場合
    if "velocity_x" in p:
        return abs(p.velocity_x)

    # Node2D の position から速度を計算する…などもできるが、
    # シンプルさ優先でここでは諦める
    return 0.0


## 実際に土煙を生成する共通処理
func _spawn_dust_at(local_pos: Vector2) -> void:
    if dust_scene == null:
        push_warning("FootDust: 'dust_scene' が設定されていません。土煙を生成できません。")
        return

    # 親の速度が遅すぎる場合は土煙を出さない
    var speed := _get_parent_horizontal_speed()
    if speed < min_speed_to_emit:
        return

    var dust = dust_scene.instantiate()
    if not (dust is Node2D):
        push_warning("FootDust: dust_scene は Node2D 派生シーンを想定しています。位置指定がずれる可能性があります。")

    # 親ノードのグローバル座標 + ローカルオフセットで足元の位置を決定
    var parent_node := get_parent() as Node2D
    if parent_node != null and dust is Node2D:
        var world_pos := parent_node.to_global(local_pos)
        (dust as Node2D).global_position = world_pos

    _dust_parent.add_child(dust)

    # Particles2D / GPUParticles2D の場合は emitting をトリガー
    if "emitting" in dust:
        dust.emitting = false
        dust.emitting = true

    # Auto free
    if auto_free_after > 0.0:
        dust.call_deferred("set_process", true)
        var timer := get_tree().create_timer(auto_free_after)
        # Timer が終わったら dust を削除
        timer.timeout.connect(func():
            if is_instance_valid(dust):
                dust.queue_free()
        )


## 左足の接地フレームで呼ぶ
func emit_left() -> void:
    if not _can_emit_left:
        return
    _can_emit_left = false
    _spawn_dust_at(left_foot_offset)
    _start_cooldown(true)


## 右足の接地フレームで呼ぶ
func emit_right() -> void:
    if not _can_emit_right:
        return
    _can_emit_right = false
    _spawn_dust_at(right_foot_offset)
    _start_cooldown(false)


## 左右共通のクールダウン処理
func _start_cooldown(is_left: bool) -> void:
    if cooldown_sec <= 0.0:
        # クールダウンが 0 の場合は即座に再度許可
        if is_left:
            _can_emit_left = true
        else:
            _can_emit_right = true
        return

    var timer := get_tree().create_timer(cooldown_sec)
    timer.timeout.connect(func():
        if is_left:
            _can_emit_left = true
        else:
            _can_emit_right = true)


## スクリプト側から「どっちの足か気にせず」土煙を出したいとき用
## 例: 着地時に一発だけ出す、など
func emit_any() -> void:
    # 左右交互に出す程度のシンプルな実装
    if _can_emit_left:
        emit_left()
    elif _can_emit_right:
        emit_right()
    else:
        # 両方クールダウン中なら何もしない
        pass

使い方の手順

ここでは代表的な例として「2D横スクロールのプレイヤー」に FootDust を付ける手順を説明します。敵キャラや動く床などでも基本は同じです。

手順①: 土煙用シーン(Dust.tscn)を作る

  1. 新規シーンを作成し、ルートに GPUParticles2D を追加します。
  2. 名前を Dust にして、以下のようにざっくり設定します(好みでOK)。
  • amount: 20
  • lifetime: 0.4
  • one_shot: On
  • emitting: Off(初期状態で止めておく)
  • Process Material で速度・重力・スケールなどを調整

シーンを res://effects/Dust.tscn などに保存します。

手順②: プレイヤーシーンに FootDust ノードを追加

例として、こんなプレイヤー構成を想定します。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── FootDust (Node2D)
  1. Player シーンを開き、ルート(CharacterBody2D)の子として Node2D を追加し、名前を FootDust に変更します。
  2. その FootDust ノードに、先ほどの FootDust.gd スクリプトをアタッチします。
  3. インスペクタで以下を設定します。
    • dust_scene : さきほど作った Dust.tscn を指定
    • left_foot_offset / right_foot_offset : Sprite の足の位置に合わせて調整
    • auto_read_horizontal_speed : Player が CharacterBody2D ならそのままでOK
    • debug_draw_feet : 足位置の確認をしたいときは On にしておく

手順③: AnimationPlayer から FootDust を呼び出す

歩行アニメーションの中で「足が地面に着いたフレーム」に、アニメーション通知(Call Method Track)を仕込みます。

  1. AnimationPlayer を選択し、「walk」などの歩きアニメを開きます。
  2. トラック追加ボタンから「呼び出しトラック(Call Method Track)」を追加し、対象ノードに FootDust ノードを指定します。
  3. 左足が接地するフレームにキーを追加し、メソッド名に emit_left を指定します。
  4. 右足が接地するフレームにキーを追加し、メソッド名に emit_right を指定します。

これで、アニメーションが再生されるたびに FootDust コンポーネントが呼ばれ、左右の足元に土煙が出るようになります。

手順④: 敵キャラや動く床にも再利用する

同じ FootDust コンポーネントは、敵キャラや動く床にもそのまま流用できます。

例: 敵キャラ

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── FootDust (Node2D)

例: 動く床(踏むとホコリが舞う床)

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Tween
 └── FootDust (Node2D)
  • 敵キャラ: 歩行アニメのフレームから emit_left / emit_right を呼ぶ。
  • 動く床: 「端に着いたとき」「プレイヤーが乗ったとき」などのタイミングで、コードから foot_dust.emit_any() を呼ぶ。

どちらもロジックは FootDust に閉じ込められているので、本体スクリプトはほとんど汚れません。


メリットと応用

この FootDust コンポーネントを使うメリットを整理しておきましょう。

  • シーン構造がスッキリ
    土煙の設定やクールダウン、速度判定などは FootDust にまとまっているので、Player.gd / Enemy.gd は「移動」と「AI」に集中できます。
  • 再利用性が高い
    FootDust.gd と Dust.tscn をプロジェクト内で共有すれば、どのキャラにも同じ土煙表現を簡単に適用できます。
    あとから「ちょっと土煙を大きくしよう」となっても、Dust.tscn をいじるだけで全キャラに反映されます。
  • アニメーションとの結合度が低い
    AnimationPlayer 側は「このフレームで emit_left を呼ぶ」だけ。
    土煙の見た目やクールダウンの調整は FootDust 側で完結しているので、アニメーションを作り直してもロジックを触る必要がありません。
  • コンポーネント指向での拡張がしやすい
    「土煙の代わりに水しぶき」「雪煙」「火花」など、Dust.tscn を差し替えるだけでバリエーションを作れます。

さらに、FootDust 自体もコンポーネントなので、ちょっとした改造も簡単です。
例えば「ダッシュ中は土煙を大きくしたい」という場合、_spawn_dust_at 内で親の速度を見てスケールを変えるだけでOKです。

改造案: 速く動いているほど土煙を大きくする

以下のような関数を FootDust に追加して、_spawn_dust_at の中から呼ぶと、速度に応じてスケールを変えられます。


## 親の速度に応じて土煙のスケールを調整する例
func _scale_dust_by_speed(dust: Node) -> void:
    var speed := _get_parent_horizontal_speed()
    # 0 ~ 300 くらいの速度を 1.0 ~ 2.0 のスケールにマッピング
    var t := clamp(speed / 300.0, 0.0, 1.0)
    var scale_factor := lerp(1.0, 2.0, t)

    if dust is Node2D:
        (dust as Node2D).scale = Vector2.ONE * scale_factor

そして _spawn_dust_at の中で、インスタンス生成後に


_scale_dust_by_speed(dust)

を呼べば、歩きよりダッシュのほうが土煙が派手になる、という表現も簡単に実現できます。

こうやって「エフェクトは FootDust に」「移動は Player に」と役割を分けておくと、あとからどんどんコンポーネントを足していっても破綻しにくくなります。
継承より合成、どんどん進めていきましょう。