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 アクションゲームの プレイヤー に残像を付ける例で説明します。敵やダッシュ床などにも同じ要領で付けられます。

手順①:コンポーネントスクリプトを用意する

  1. res://components/AfterImage.gd など、分かりやすい場所に上記コードを保存します。
  2. Godot エディタを再読み込みすると、ノード追加時のスクリプト一覧に AfterImage クラス名が出てくるはずです。

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

例として、シンプルなプレイヤーシーン構成はこんな感じにします。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── AfterImage (Node)  ← このノードに AfterImage.gd をアタッチ
  1. Player シーンを開く
  2. Player の子として Node を追加し、名前を AfterImage に変更
  3. その AfterImage ノードに、スクリプトとして AfterImage.gd をアタッチ

この構成なら、target_node は自動で親の Player(CharacterBody2D)がセットされ、
source_spriteSprite2D を自動検出してくれるので、基本的に何も設定しなくて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

この場合、AfterImagespeed_threshold300.0〜500.0 くらいにしておくと、
通常移動では残像なし、ダッシュ中だけ残像ありといった演出にできます。

  • エディタで AfterImage ノードを選択
  • インスペクタで speed_threshold400 などに設定
  • spawn_interval0.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()

このように、移動ロジックとエフェクトロジックをきれいに分離しておくと、
後から「ダッシュの条件を変えたい」「残像の色だけ変えたい」といった要望にも柔軟に対応できます。

継承ベースでゴリゴリ書き込むよりも、コンポーネントをポン付けして合成していくスタイルに慣れておくと、
プロジェクトが大きくなっても破綻しにくくなります。ぜひ自分のプロジェクト用にカスタマイズしてみてくださいね。