1. 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 をプロジェクトに追加

  1. プロジェクト内で res://components/GhostTrail.gd など、好きな場所に上記コードを保存します。
  2. Godot エディタを再読み込みすると、ノード追加ダイアログで GhostTrail クラスがサジェストされるようになります。

手順② プレイヤーシーンにコンポーネントをアタッチ

例えば、こんなプレイヤーシーンを想定します:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── GhostTrail (Node2D)
  1. Player シーンを開きます。
  2. Player(親ノード)を右クリック → 「子ノードを追加」。
  3. 検索欄に GhostTrail と入力し、追加します(Node2D として追加されます)。
  4. 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

といった形で、「ダッシュ開始時の一発演出+ダッシュ中の連続残像」が簡単に実現できます。
このように、コンポーネントとして独立していると、あとからの改造・差し替えもやりやすいですね。