- Godot 4でアクションゲームやダッシュ演出を作っていると、
- プレイヤーのダッシュ中だけ残像を出したい
- ボスの高速移動に「分身っぽい」エフェクトを付けたい
- でも毎回シーンを継承して、アニメーション付きの残像ノードを用意するのはダルい…
となりがちですよね。
典型的な実装だと、
- プレイヤーシーンの中に「残像用の子ノードシーン」を仕込む
- プレイヤー側のスクリプトから、その子ノードに対して「今の見た目で残像を出して!」とメソッド呼び出し
- キャラごとに実装がバラバラになって、あとから共通化しにくい
という「継承&密結合地獄」に入りがちです。
そこでこの記事では、どのノードにもポン付けできるコンポーネントとして、GhostTrail(残像)コンポーネントを作っていきます。
親ノードが動いている間だけ、スプライトのコピーを背後に生成してフェードアウトさせる、という機能を1スクリプトに完結させます。
【Godot 4】走るだけでカッコよくなる!「GhostTrail」コンポーネント
このコンポーネントは、
- 親ノードの位置・向き・スプライトの見た目をコピーした「残像スプライト」を一定間隔で生成
- 生成された残像は自動でフェードアウトして消える
- 「移動中だけ」「ダッシュ中だけ」のような条件も、シグナルまたはフラグで制御可能
という、かなり汎用的な「残像エフェクト生成機」です。
プレイヤーでも敵でも動く床でも、Sprite2D/AnimatedSprite2D を持っていれば基本なんでもいけます。
フルコード:GhostTrail.gd
extends Node2D
class_name GhostTrail
## 親が移動中にスプライトの残像を生成してフェードアウトさせるコンポーネント
##
## 想定:
## - 親ノードの子としてアタッチする
## - 親に Sprite2D または AnimatedSprite2D があること
## - 2D専用(3Dは別実装推奨)
@export var enabled: bool = true:
set(value):
enabled = value
# 無効化時に内部タイマーをリセット
_time_accum = 0.0
## 残像を生成する最小間隔(秒)
@export_range(0.01, 1.0, 0.01)
var spawn_interval: float = 0.06
## 一定以上の移動速度がないと残像を出さない(0で常に出す)
@export_range(0.0, 2000.0, 1.0)
var min_speed_for_trail: float = 50.0
## 残像が完全に消えるまでの時間(秒)
@export_range(0.05, 2.0, 0.05)
var trail_lifetime: float = 0.4
## 残像の初期不透明度(0〜1)
@export_range(0.0, 1.0, 0.05)
var initial_alpha: float = 0.8
## 残像の色(null の場合は元の色をそのまま使う)
@export var tint_color: Color = Color(1, 1, 1, 1)
## 残像を生成するときに、Sprite2D の scale をコピーするか
@export var copy_scale: bool = true
## 残像を生成するときに、Sprite2D の flip_h / flip_v をコピーするか
@export var copy_flip: bool = true
## 親のどのスプライトを元にするか(パス指定)
## 空文字のときは、親直下から最初に見つかった Sprite2D / AnimatedSprite2D を使用
@export_node_path("Node2D")
var sprite_path: NodePath
## 親が「動いているかどうか」を外部から制御したい場合に使うフラグ
## 例: ダッシュ中だけ trail_active = true にする
@export var trail_active: bool = true
## 内部状態
var _time_accum: float = 0.0
var _last_global_position: Vector2
var _sprite_node: Node2D
func _ready() -> void:
# 親の Sprite2D / AnimatedSprite2D を取得
if sprite_path != NodePath():
_sprite_node = get_node_or_null(sprite_path)
else:
_sprite_node = _find_sprite_node()
if _sprite_node == null:
push_warning("GhostTrail: 親に Sprite2D / AnimatedSprite2D が見つかりませんでした。残像は生成されません。")
_last_global_position = global_position
func _process(delta: float) -> void:
if not enabled:
return
if _sprite_node == null:
return
if not trail_active:
# 外部から無効化されている場合
_time_accum = 0.0
_last_global_position = global_position
return
_time_accum += delta
# 移動速度を計算(親のグローバル座標から)
var current_pos := global_position
var distance := current_pos.distance_to(_last_global_position)
var speed := distance / max(delta, 0.0001)
_last_global_position = current_pos
# 一定速度以下なら残像を出さない
if min_speed_for_trail > 0.0 and speed < min_speed_for_trail:
return
# 一定間隔ごとに残像を生成
if _time_accum >= spawn_interval:
_time_accum = 0.0
_spawn_trail()
func _find_sprite_node() -> Node2D:
# 親の直下から Sprite2D / AnimatedSprite2D を探す
var parent := get_parent()
if parent == null:
return null
for child in parent.get_children():
if child is Sprite2D or child is AnimatedSprite2D:
return child as Node2D
return null
func _spawn_trail() -> void:
if _sprite_node == null:
return
# 元スプライトの種類に応じて、残像用スプライトを作る
var ghost_sprite: Node2D
if _sprite_node is Sprite2D:
ghost_sprite = _create_ghost_from_sprite2d(_sprite_node as Sprite2D)
elif _sprite_node is AnimatedSprite2D:
ghost_sprite = _create_ghost_from_animated_sprite2d(_sprite_node as AnimatedSprite2D)
else:
return
# 親のシーン階層に直接ぶら下げる(親と同じレベル)
var parent := get_parent()
if parent == null:
return
parent.add_child(ghost_sprite)
ghost_sprite.owner = parent.get_owner() # シーン保存時のため
# 残像の位置・回転を親に合わせる(スプライト自身のオフセットも含む)
ghost_sprite.global_position = _sprite_node.global_position
ghost_sprite.global_rotation = _sprite_node.global_rotation
if copy_scale:
ghost_sprite.global_scale = _sprite_node.global_scale
# 残像のフェードアウト処理を開始
_fade_and_free(ghost_sprite)
func _create_ghost_from_sprite2d(src: Sprite2D) -> Sprite2D:
var ghost := Sprite2D.new()
ghost.texture = src.texture
ghost.region_enabled = src.region_enabled
ghost.region_rect = src.region_rect
ghost.hframes = src.hframes
ghost.vframes = src.vframes
ghost.frame = src.frame
ghost.centered = src.centered
ghost.offset = src.offset
ghost.z_index = src.z_index - 1 # 元より少し手前/奥にしたいなら調整
ghost.modulate = _make_initial_color(src.modulate)
if copy_flip:
ghost.flip_h = src.flip_h
ghost.flip_v = src.flip_v
return ghost
func _create_ghost_from_animated_sprite2d(src: AnimatedSprite2D) -> Sprite2D:
# AnimatedSprite2D から現在のフレームの Texture を取り出して Sprite2D にする
var ghost := Sprite2D.new()
var sprite_frames := src.sprite_frames
if sprite_frames:
var anim := src.animation
var frame := src.frame
var tex := sprite_frames.get_frame_texture(anim, frame)
ghost.texture = tex
ghost.centered = src.centered
ghost.offset = src.offset
ghost.z_index = src.z_index - 1
ghost.modulate = _make_initial_color(src.modulate)
if copy_flip:
ghost.flip_h = src.flip_h
ghost.flip_v = src.flip_v
return ghost
func _make_initial_color(base: Color) -> Color:
var c := tint_color if tint_color != null else Color(1, 1, 1, 1)
# 元の modulate と掛け合わせつつ、アルファだけ initial_alpha に差し替える
var result := Color(
base.r * c.r,
base.g * c.g,
base.b * c.b,
initial_alpha
)
return result
func _fade_and_free(ghost_sprite: Node2D) -> void:
# Tween を使ってアルファを 0 にしてから削除
var tween := create_tween()
tween.set_parallel(false)
tween.tween_property(
ghost_sprite,
"modulate:a", # アルファだけをフェードアウト
0.0,
trail_lifetime
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
tween.tween_callback(ghost_sprite.queue_free)
使い方の手順
ここでは典型的な 2D アクションゲームのプレイヤーに残像を付ける例で説明します。
手順① GhostTrail.gd をプロジェクトに追加
- プロジェクト内で
res://components/GhostTrail.gdなど、好きな場所に上記コードを保存します。 - Godot エディタを再読み込みすると、ノード追加ダイアログで
GhostTrailクラスがサジェストされるようになります。
手順② プレイヤーシーンにコンポーネントをアタッチ
例えば、こんなプレイヤーシーンを想定します:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── GhostTrail (Node2D)
- Player シーンを開きます。
- Player(親ノード)を右クリック → 「子ノードを追加」。
- 検索欄に
GhostTrailと入力し、追加します(Node2D として追加されます)。 - GhostTrail のインスペクターで、必要に応じて以下を調整します:
spawn_interval: 残像の間隔(数値を小さくするとモクモク出る)trail_lifetime: 残像が消えるまでの時間min_speed_for_trail: この速度以上で動いたときだけ残像を出すtint_color: 残像の色。例えば薄い水色にするとクール
Sprite2D が 1 つだけなら、sprite_path は空のままで OK です。
もし「メインスプライトがもう一段下の子ノードにある」ような構造なら、sprite_path でそのスプライトを指定してください。
手順③ ダッシュ中だけ残像を出したい場合
常に残像が出ていてもいいですが、多くの場合は「ダッシュ中だけ」など条件付きにしたいですよね。
その場合は、プレイヤー側のスクリプトから trail_active を操作します。
# Player.gd (CharacterBody2D などにアタッチされているとする)
extends CharacterBody2D
@onready var ghost_trail: GhostTrail = $GhostTrail
var is_dashing: bool = false
func _process(delta: float) -> void:
# ダッシュ入力を拾う(例: Shift キー)
if Input.is_action_just_pressed("dash"):
is_dashing = true
if Input.is_action_just_released("dash"):
is_dashing = false
# ダッシュ中だけ trail_active を ON にする
ghost_trail.trail_active = is_dashing
これで、「ダッシュ中だけ残像が出る」プレイヤーが完成します。min_speed_for_trail も効いているので、立ち止まっているときは残像は出ません。
手順④ 敵や動く床にもそのまま使い回す
コンポーネントなので、敵やギミックにも同じようにアタッチできます。
例えば、高速で左右に動く敵:
FastEnemy (CharacterBody2D) ├── AnimatedSprite2D ├── CollisionShape2D └── GhostTrail (Node2D)
AnimatedSprite2D を使っていても、GhostTrail が現在のフレームを拾って残像を作ってくれます。
何も考えずに GhostTrail を付けるだけで、「高速移動してる感」が一気に増します。
あるいは、動く床にも:
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── GhostTrail (Node2D)
この場合は trail_active を常に true のままにして、min_speed_for_trail だけで制御してもOKです。
レールに沿って動く床に残像を付けると、SFっぽい雰囲気が簡単に出せますね。
メリットと応用
この GhostTrail コンポーネントを使うメリットは、ざっくり言うと次の通りです。
- 継承しないで済む
「残像付きプレイヤー」「残像付き敵」といった専用シーンを増やさなくていいので、シーンツリーがシンプルになります。 - シーン構造がスッキリする
残像用のシーンやノードを別途用意せず、GhostTrail 1 ノードで完結します。 - どのオブジェクトにもコピペで使える
Sprite2D / AnimatedSprite2D を持っていれば、プレイヤーでも敵でもギミックでもそのまま使い回し可能です。 - チューニングが簡単
「残像の間隔」「寿命」「色」「速度しきい値」などをインスペクターから変えられるので、レベルデザイナもいじりやすいです。
特に「継承より合成(Composition)」のスタイルに寄せたい場合、
「移動ロジック」「アニメーション」「残像エフェクト」をそれぞれ別コンポーネントとして分離できるのは大きな利点です。
簡単な改造案:ダッシュ開始時にだけ一発ド派手な残像を出す
例えば、通常の残像とは別に「ダッシュ開始瞬間だけ、ちょっと濃い残像を1枚出したい」という場合、GhostTrail にこんなメソッドを追加してもいいですね。
func spawn_strong_trail() -> void:
# 一瞬だけ濃い残像を出す
if _sprite_node == null:
return
var original_alpha := initial_alpha
initial_alpha = 1.0 # しっかり見える濃さ
_spawn_trail()
initial_alpha = original_alpha
これをプレイヤーの _on_dash_started() などから呼び出せば、
func _on_dash_started() -> void:
ghost_trail.spawn_strong_trail()
ghost_trail.trail_active = true
といった形で、「ダッシュ開始時の一発演出+ダッシュ中の連続残像」が簡単に実現できます。
このように、コンポーネントとして独立していると、あとからの改造・差し替えもやりやすいですね。
