Godot 4 で「歩くと足元からちょっとした砂煙や土ぼこりを出したいな〜」と思ったとき、CPUParticles2D や GPUParticles2D を毎回プレイヤーや敵のシーンに仕込んで、アニメーションからトリガーして…という実装をしていると、だんだんシーンがごちゃごちゃしてきますよね。
さらに、
- プレイヤー、敵、動く床など「歩く/動くもの」それぞれで別々に実装してしまう
- 足音や足煙のタイミングを変えたくなると、あちこちのスクリプトを修正しないといけない
- パーティクルのプリセットを変えたくなると、シーンを開いてポチポチ設定し直す必要がある
…といった「継承&個別実装地獄」に陥りがちです。
そこで今回は、「歩行中の足元パーティクル」を 1 コンポーネントに閉じ込めて、好きなノードにペタっと貼るだけで済ませる FootstepParticles コンポーネントを作ってみましょう。
親ノード(プレイヤーや敵など)の速度や接地状態を監視して、自動で足元からパーティクルを発生させる仕組みです。
【Godot 4】走れば舞う足煙!「FootstepParticles」コンポーネント
以下がコピペで動く、FootstepParticles.gd のフルコードです。
2D 用(CharacterBody2D や RigidBody2D を想定)にしています。
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 パターンの例を挙げます。
- プレイヤー(
CharacterBody2D)に足煙を付ける - 敵キャラ(
CharacterBody2D)に同じ足煙を再利用する - 動く床(
Node2D)に「滑走エフェクト」として応用する
手順①:パーティクルシーンを用意する
- 新しいシーンを作成し、ルートに
CPUParticles2D(またはGPUParticles2D)を追加します。 - マテリアルやテクスチャ、重力、速度などを調整して、好みの足煙エフェクトを作ります。
- シーンを
res://effects/footstep_dust.tscnのような名前で保存します。
このシーンを FootstepParticles.particles_scene に割り当てます。
手順②:プレイヤーにコンポーネントを追加する
プレイヤーシーンの構成例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── FootstepParticles (Node2D)
- プレイヤーシーンを開き、ルート(
Player: CharacterBody2D)の子としてNode2Dを追加します。 - 追加した
Node2Dに、上記のFootstepParticles.gdをスクリプトとしてアタッチします。 - インスペクタで以下のように設定します:
- 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_velocity:
true - particles_as_top_level:
true(足煙がその場に残るように)
- target_body:空のまま(親である
これだけで、Player が床の上を一定以上の速度で移動している間、自動で足元からパーティクルが出るようになります。
アニメーションや入力処理とは完全に分離されているので、プレイヤーのロジックをいじらずに足煙だけ調整できるのがポイントです。
手順③:敵キャラに再利用する
敵キャラのシーン構成例:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── FootstepParticles (Node2D)
- 敵キャラのシーンを開き、プレイヤーと同様に
FootstepParticlesノードを子として追加します。 - 同じ
footstep_dust.tscnをparticles_sceneにセットします。 min_speedやspawn_intervalを敵用に微調整しても OK です。
こうすることで、プレイヤーと敵が同じ足煙エフェクトを共有しつつ、それぞれの移動ロジックは一切いじらなくて済むようになります。
コンポーネント指向の美味しいところですね。
手順④:動く床に「滑走エフェクト」として付ける
動く床のシーン構成例:
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── FootstepParticles (Node2D)
この例では CharacterBody2D ではないので、velocity や is_on_floor() を持っていません。
そのため、上記の実装では「速度 0 / 常に接地」として扱われます。
簡単に動かしたいだけであれば、別のコンポーネントで速度を管理して FootstepParticles を改造して連携するのがおすすめですが、まずは最小限の例として:
min_speedを 0 にするspawn_intervalを固定値にする(例:0.3)
とすれば、「常に一定間隔で床の端から煙が出る」ような表現にも流用できます。
このあたりは後述の「改造案」で少し触れます。
メリットと応用
FootstepParticles コンポーネントを使うメリットは主に次のような点です。
- シーン構造がスッキリ:プレイヤーや敵のシーンに、パーティクル用のノードやアニメーションイベントをベタ書きしなくて済みます。
- ロジックの再利用性が高い:移動ロジックとは完全に分離されているので、どんな
CharacterBody2Dにも後付け可能です。 - パラメータ調整が一箇所で済む:速度しきい値、出現間隔、オフセット、パーティクルの親などをインスペクタからまとめて調整できます。
- 「継承の罠」からの解放:
PlayerWithFootstepやEnemyWithFootstepのような派生クラスを増やさなくて済み、合成(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 プロジェクト全体がだんだん「継承ツリー」ではなく「機能ブロックの組み合わせ」として整理されていくので、後々の拡張もしやすくなりますね。
