Godot 4 でダッシュや高速移動を実装するとき、つい「プレイヤー専用スクリプト」にガッツリ書き込んでしまいがちですよね。
アニメーション、入力、物理、そして残像エフェクトまで全部入りの巨大スクリプト…。少し仕様変更が入るたびに、どこを触っていいか分からなくなっていきます。
さらに、敵や別キャラにも同じ「残像エフェクト」を付けたくなったとき、コピペ地獄が始まります。
「残像ロジックだけを切り出したいけど、プレイヤーの状態に依存していて難しい…」という状況、経験ないでしょうか。
そこで登場するのが、コンポーネントとしての残像です。
今回紹介する AfterImage コンポーネントをキャラにアタッチするだけで、高速移動中に自動で残像を生成してフェードアウトしてくれるようにしましょう。
【Godot 4】走るだけでカッコよくなる!「AfterImage」コンポーネント
このコンポーネントの思想はシンプルです。
- キャラクター本体には「移動のことだけ」考えさせる
- 残像は
AfterImageコンポーネントが勝手にやる - プレイヤーでも敵でも、Sprite2D でも AnimatedSprite2D でも、同じコンポーネントをポン付けできる
「継承でプレイヤー専用の残像クラスを作る」のではなく、どのノードにも付けられる汎用コンポーネントとして設計していきます。
フルコード:AfterImage.gd
## AfterImage.gd
## 高速移動中に、親ノードの見た目をコピーした残像を生成してフェードアウトさせるコンポーネント
## 想定親ノード: CharacterBody2D / Node2D など(位置と速度を持つノード)
extends Node
class_name AfterImage
## === 設定パラメータ ================================
## 残像を生成する対象のノード
## 通常は自分の親(プレイヤーや敵)をそのまま使えばOK
@export var target_node: Node2D
## 残像の見た目としてコピーする Sprite 系ノード
## Sprite2D / AnimatedSprite2D / Sprite3D など
## 未指定の場合、target_node の子から Sprite2D / AnimatedSprite2D を自動検出
@export var source_sprite: Node2D
## これ以上の速度で動いているときに残像を生成する
@export var speed_threshold: float = 250.0
## 残像を生成する間隔(秒)
@export var spawn_interval: float = 0.05
## 残像が完全に消えるまでの時間(秒)
@export var fade_time: float = 0.3
## 残像の初期不透明度(0〜1)
@export_range(0.0, 1.0, 0.05)
var initial_alpha: float = 0.6
## 残像の色(色調を変えたいときに利用)
@export var tint_color: Color = Color.WHITE
## 残像の親にするノード
## 例えば、ステージのルート(YSort など)を指定すると管理しやすい
## 未指定なら target_node の親にぶら下げる
@export var afterimage_parent: Node2D
## 残像を生成する最大数(パフォーマンス保護用)
@export var max_afterimages: int = 64
## === 内部変数 ======================================
var _timer: float = 0.0
var _active: bool = true
var _queue: Array[Node2D] = []
func _ready() -> void:
## target_node が未指定なら、親を自動設定
if target_node == null:
var parent := get_parent()
if parent is Node2D:
target_node = parent as Node2D
else:
push_warning("AfterImage: 親が Node2D ではありません。target_node を手動で設定してください。")
## source_sprite が未指定なら、target_node の子から自動検出
if source_sprite == null and target_node:
source_sprite = _find_sprite_node(target_node)
if source_sprite == null:
push_warning("AfterImage: Sprite2D / AnimatedSprite2D が見つかりません。残像は生成されません。")
## 残像の親が未指定なら、target_node の親にぶら下げる
if afterimage_parent == null and target_node:
if target_node.get_parent() is Node2D:
afterimage_parent = target_node.get_parent() as Node2D
else:
afterimage_parent = target_node
set_process(true)
func _process(delta: float) -> void:
if not _active:
return
if target_node == null or source_sprite == null:
return
_timer += delta
if _timer >= spawn_interval:
_timer = 0.0
if _is_moving_fast():
_spawn_afterimage()
## === 公開 API ======================================
## 残像エフェクトを一時停止
func pause() -> void:
_active = false
## 残像エフェクトを再開
func resume() -> void:
_active = true
## 一時的にパラメータを変更して、強制的に残像を一発出したいときなどに使える
func spawn_once() -> void:
if target_node and source_sprite:
_spawn_afterimage()
## === 内部処理 ======================================
## target_node が高速移動中かどうかを判定
func _is_moving_fast() -> bool:
## CharacterBody2D の velocity を優先的に見る
if target_node is CharacterBody2D:
var body := target_node as CharacterBody2D
return body.velocity.length() >= speed_threshold
## Node2D の場合は、前フレームとの差分から速度を計算する
## シンプルに "移動距離 / フレーム時間" で近似
## (ここでは簡略化のため、Engine.get_physics_ticks_per_second() を使わず、delta を使いたいところですが
## _process から直接 delta を渡していないので、ここでは distance 判定にしておきます)
## 「一定距離以上動いていたら高速」とみなす簡易チェック
## 実際に速度を厳密に見たい場合は、外部から CharacterBody2D を使うか、ここを改造しましょう。
return false ## デフォルトでは CharacterBody2D 以外は判定しない
## target_node 以下から Sprite2D / AnimatedSprite2D を探す
func _find_sprite_node(root: Node) -> Node2D:
for child in root.get_children():
if child is Sprite2D or child is AnimatedSprite2D:
return child as Node2D
return null
## 残像インスタンスを生成
func _spawn_afterimage() -> void:
if afterimage_parent == null:
return
## 上限を超えたら古いものから削除
if _queue.size() >= max_afterimages:
var old := _queue.pop_front()
if is_instance_valid(old):
old.queue_free()
var ghost := _create_snapshot_node()
if ghost == null:
return
afterimage_parent.add_child(ghost)
ghost.global_position = target_node.global_position
ghost.global_rotation = target_node.global_rotation
ghost.global_scale = target_node.global_scale
_queue.append(ghost)
## フェードアウト処理をコルーチンで開始
_fade_out(ghost)
## 実際の見た目コピー用ノードを作成
func _create_snapshot_node() -> Node2D:
if source_sprite is Sprite2D:
var sprite := source_sprite as Sprite2D
var ghost_sprite := Sprite2D.new()
ghost_sprite.texture = sprite.texture
ghost_sprite.region_enabled = sprite.region_enabled
ghost_sprite.region_rect = sprite.region_rect
ghost_sprite.flip_h = sprite.flip_h
ghost_sprite.flip_v = sprite.flip_v
ghost_sprite.centered = sprite.centered
ghost_sprite.offset = sprite.offset
ghost_sprite.texture_filter = sprite.texture_filter
ghost_sprite.texture_repeat = sprite.texture_repeat
ghost_sprite.modulate = tint_color.with_alpha(initial_alpha)
return ghost_sprite
elif source_sprite is AnimatedSprite2D:
var anim := source_sprite as AnimatedSprite2D
var ghost_anim := Sprite2D.new()
## AnimatedSprite2D の現在フレームをテクスチャとして取得
var frames := anim.sprite_frames
if frames and frames.has_animation(anim.animation):
var tex := frames.get_frame_texture(anim.animation, anim.frame)
ghost_anim.texture = tex
ghost_anim.centered = true
ghost_anim.modulate = tint_color.with_alpha(initial_alpha)
return ghost_anim
push_warning("AfterImage: 未対応の sprite ノードタイプです: %s" % [source_sprite])
return null
## 残像をフェードアウトさせて削除
func _fade_out(ghost: Node2D) -> void:
## コルーチン的に動かすために、別スレッドではなく "async/await" っぽい書き方を使用
## Godot 4 では await が使えるので、Timer を毎回作らなくても OK
## ここではシンプルに Tween を使います。
var tween := create_tween()
tween.tween_property(ghost, "modulate:a", 0.0, fade_time)
tween.tween_callback(Callable(self, "_on_ghost_faded").bind(ghost))
func _on_ghost_faded(ghost: Node2D) -> void:
if is_instance_valid(ghost):
ghost.queue_free()
_queue.erase(ghost)
使い方の手順
ここでは 2D アクションゲームの プレイヤー に残像を付ける例で説明します。敵やダッシュ床などにも同じ要領で付けられます。
手順①:コンポーネントスクリプトを用意する
res://components/AfterImage.gdなど、分かりやすい場所に上記コードを保存します。- Godot エディタを再読み込みすると、ノード追加時のスクリプト一覧に
AfterImageクラス名が出てくるはずです。
手順②:プレイヤーシーンにコンポーネントをアタッチ
例として、シンプルなプレイヤーシーン構成はこんな感じにします。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── AfterImage (Node) ← このノードに AfterImage.gd をアタッチ
Playerシーンを開くPlayerの子としてNodeを追加し、名前をAfterImageに変更- その
AfterImageノードに、スクリプトとしてAfterImage.gdをアタッチ
この構成なら、target_node は自動で親の Player(CharacterBody2D)がセットされ、
source_sprite も Sprite2D を自動検出してくれるので、基本的に何も設定しなくてOKです。
手順③:プレイヤーの移動と速度しきい値を合わせる
プレイヤー側の移動スクリプトは、例えばこんな感じだとします:
# Player.gd
extends CharacterBody2D
@export var move_speed: float = 300.0
@export var dash_speed: float = 600.0
var is_dashing: bool = false
func _physics_process(delta: float) -> void:
var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
if Input.is_action_just_pressed("dash"):
is_dashing = true
var speed := is_dashing ? dash_speed : move_speed
velocity = input_dir * speed
move_and_slide()
## ダッシュ解除のタイミングなどはお好みで
if input_dir == Vector2.ZERO:
is_dashing = false
この場合、AfterImage の speed_threshold を 300.0〜500.0 くらいにしておくと、
通常移動では残像なし、ダッシュ中だけ残像ありといった演出にできます。
- エディタで
AfterImageノードを選択 - インスペクタで
speed_thresholdを400などに設定 spawn_intervalを0.03くらいにすると、かなり濃い残像になりますtint_colorを薄い青や赤にすると、キャラごとに個性が出ます
手順④:敵やギミックにもそのまま使い回す
敵にも残像をつけたい場合、シーン構成はこんなイメージです。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── AfterImage (Node) ← 同じコンポーネントをアタッチ
ダッシュ床などの「高速移動するギミック」にも同じように付けられます。
MovingPlatform (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── AfterImage (Node)
敵や床の移動スクリプトはそれぞれのシーン側に閉じ込めておき、
残像のことは完全に AfterImage に丸投げできるのがポイントです。
メリットと応用
このコンポーネント方式にすることで、いろいろと嬉しい点があります。
- プレイヤー・敵スクリプトがスリムになる
残像用のタイマー、フェードアウト処理、Sprite のコピー処理などを全部コンポーネントに隔離できるので、
移動ロジックに集中できます。 - シーン構造がフラットで見通しが良い
「PlayerWithAfterImage」「EnemyWithAfterImage」みたいな派生シーンを増やさずに、
ベースの Player/Enemy シーンにコンポーネントを 1 個足すだけで済みます。 - レベルデザインがラクになる
ステージごとに「この敵だけ残像を付ける」「このエリアだけ残像を濃くする」といった調整を、
インスペクタ上のパラメータ変更だけで行えます。 - テストがしやすい
残像の挙動だけを単体でテストしたり、別のテストシーンに持っていって動作確認するのも簡単です。
さらに応用として、例えば「ダッシュボタンを押したときだけ残像をオンにする」ような制御も簡単です。
プレイヤー側から pause() / resume() を呼ぶだけですね。
改造案:ボタン押下中だけ残像を有効化する
プレイヤーのスクリプト側に、こんな感じのコードを追加するだけで、
「ダッシュボタンを押している間だけ残像を出す」挙動にできます。
# Player.gd 内
@onready var after_image: AfterImage = $AfterImage
func _physics_process(delta: float) -> void:
var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var is_dash_pressed := Input.is_action_pressed("dash")
var speed := is_dash_pressed ? dash_speed : move_speed
velocity = input_dir * speed
move_and_slide()
# ダッシュボタンの状態に応じて残像コンポーネントを制御
if is_dash_pressed:
after_image.resume()
else:
after_image.pause()
このように、移動ロジックとエフェクトロジックをきれいに分離しておくと、
後から「ダッシュの条件を変えたい」「残像の色だけ変えたい」といった要望にも柔軟に対応できます。
継承ベースでゴリゴリ書き込むよりも、コンポーネントをポン付けして合成していくスタイルに慣れておくと、
プロジェクトが大きくなっても破綻しにくくなります。ぜひ自分のプロジェクト用にカスタマイズしてみてくださいね。
