Godot 4でアクションゲームを作っていると、「剣を振った軌跡をカッコよく出したい」「弾の通り道を残したい」みたいな欲求が出てきますよね。素直に実装しようとすると、

  • 毎フレーム Line2D を手書きで更新する
  • 消えるタイミングを自前で管理する
  • プレイヤー/敵/弾ごとにスクリプトをコピペしていく

…と、気づけば「軌跡ロジック」があちこちに散らばってしまいます。さらに、継承ベースで PlayerWithTrail とか BulletWithTrail みたいなシーンを増やしていくと、シーンツリーもスクリプトもどんどん肥大化していきます。

そこで今回は、「継承より合成(Composition)」 の流儀で、どのノードにもポン付けできる 「TrailRenderer」コンポーネント を用意しました。剣でも弾でも動く床でも、TrailRenderer をアタッチするだけで、Line2D を使った滑らかな軌跡を描画し、時間経過で自然にフェードアウトしていくようにします。

【Godot 4】どのノードにもポン付け!滑らかに消える「TrailRenderer」コンポーネント

コンポーネントのフルコード


extends Node2D
class_name TrailRenderer
## 任意のノードにアタッチして「軌跡」を描画するコンポーネント
##
## - 親ノードの位置を一定間隔でサンプリングして Line2D に反映
## - 時間経過で自動的にフェードアウト&古いポイントを削除
## - 剣・弾・動く床など、動くものなら何でも使い回せる

@export_group("基本設定")
## 軌跡を描画する Line2D の参照。
## 空の場合は、自動で子として Line2D を生成します。
@export var line: Line2D

## 軌跡を表示するかどうか(ゲーム中に ON/OFF 切り替え可能)
@export var enabled_trail: bool = true

## 軌跡として保持する最大ポイント数。
## 値を大きくするほど長い軌跡になりますが、負荷も上がります。
@export_range(2, 2048, 1)
@export var max_points: int = 64

## 親ノードの位置をサンプリングする間隔(秒)。
## 小さいほどなめらかになりますが、ポイント数が増えやすくなります。
@export_range(0.0, 0.2, 0.005)
@export var sample_interval: float = 0.01

## 親ノードがこれ以上動いていない場合はポイントを追加しない距離しきい値。
## 微妙な揺れでポイントが増え続けるのを防ぎます。
@export_range(0.0, 50.0, 0.5)
@export var min_distance: float = 2.0

@export_group("寿命とフェード")
## 各ポイントが生きていられる時間(秒)。
## この時間を過ぎたポイントは削除されます。
@export_range(0.05, 5.0, 0.05)
@export var point_lifetime: float = 0.6

## 軌跡全体をフェードさせるかどうか。
## ON の場合、古いポイントほど透明になります。
@export var use_fade: bool = true

## フェードのカーブ。x=0 が最新、x=1 が最古。
## 空の場合は線形フェードになります。
@export var fade_curve: Curve

@export_group("見た目")
## 軌跡のベースカラー。アルファは「最大不透明度」として扱われます。
@export var trail_color: Color = Color(1, 1, 1, 0.8)

## 軌跡の太さ。Line2D.width に反映されます。
@export_range(1.0, 64.0, 0.5)
@export var trail_width: float = 8.0

## 親ノードのローカル座標を使うかどうか。
## true: 親ノードの動きに追従するローカルな軌跡(剣のような子ノード向け)
## false: ワールド座標に軌跡を固定(弾など、通った場所に軌跡を残したい場合)
@export var use_local_space: bool = false


## 内部用: 各ポイントの位置と経過時間を保持する構造体的な辞書
var _points: Array = []   # [{pos: Vector2, age: float}, ...]
var _time_accum: float = 0.0
var _last_sample_pos: Vector2


func _ready() -> void:
    # Line2D が未指定なら自動生成
    if line == null:
        line = Line2D.new()
        line.name = "TrailLine2D"
        add_child(line)
    
    # Line2D の基本設定
    line.width = trail_width
    line.default_color = trail_color
    line.clear_points()
    
    # フェード用にグラデーションをセット(必要なら)
    _update_line_gradient()
    
    # 最初のサンプル位置を現在位置で初期化
    _last_sample_pos = _get_target_position()


func _process(delta: float) -> void:
    if not enabled_trail:
        # 無効化中も、既存のポイントだけは寿命管理してフェードアウトさせる
        if _points.size() == 0:
            return
        _update_points(delta, add_new_point := false)
        return
    
    _time_accum += delta
    
    # ポイントの寿命管理&フェード更新
    _update_points(delta, add_new_point := false)
    
    # 一定間隔ごとに新しいポイントを追加
    if _time_accum >= sample_interval:
        _time_accum = 0.0
        var current_pos := _get_target_position()
        if _points.size() == 0 or current_pos.distance_to(_last_sample_pos) >= min_distance:
            _add_point(current_pos)
            _last_sample_pos = current_pos
    
    # Line2D にポイントを反映
    _sync_line_points()


func _update_points(delta: float, add_new_point: bool) -> void:
    # 各ポイントの age を加算し、寿命を過ぎたものを削除
    var i := 0
    while i < _points.size():
        _points[i].age += delta
        if _points[i].age > point_lifetime:
            _points.remove_at(i)
            continue
        i += 1
    
    # 最大ポイント数を超えたら古いものから削除
    while _points.size() > max_points:
        _points.pop_front()


func _add_point(pos: Vector2) -> void:
    _points.append({
        "pos": pos,
        "age": 0.0,
    })


func _sync_line_points() -> void:
    line.clear_points()
    if _points.is_empty():
        return
    
    # Line2D にポイントをセット
    for p in _points:
        line.add_point(p.pos)
    
    # フェード用グラデーションを更新
    _update_line_gradient()


func _update_line_gradient() -> void:
    if not use_fade or _points.size() == 0:
        # フェードしない場合は単色
        var grad := Gradient.new()
        grad.colors = PackedColorArray([trail_color])
        var tex := GradientTexture2D.new()
        tex.gradient = grad
        line.gradient = grad
        return
    
    # 古いポイントほど透明になるようにグラデーションを構築
    var grad := Gradient.new()
    
    # ポイントが少ない場合でも最低2点は用意する
    var colors := PackedColorArray()
    var offsets := PackedFloat32Array()
    
    var max_age := max(point_lifetime, 0.001)
    
    for i in range(_points.size()):
        var t := float(i) / max(1, _points.size() - 1)  # 0.0 (最新) ~ 1.0 (最古)
        var age_ratio := _points[i].age / max_age      # 0.0 ~ 1.0
        
        var alpha_factor := 1.0
        if fade_curve:
            alpha_factor = clamp(fade_curve.sample(age_ratio), 0.0, 1.0)
        else:
            # デフォルトは線形フェード(若いほど不透明、古いほど透明)
            alpha_factor = 1.0 - age_ratio
        
        var c := trail_color
        c.a = trail_color.a * alpha_factor
        
        colors.append(c)
        offsets.append(t)
    
    grad.colors = colors
    grad.offsets = offsets
    line.gradient = grad


func _get_target_position() -> Vector2:
    # 親ノードの位置を取得。use_local_space によってローカル or グローバルを切り替え。
    var parent := get_parent()
    if parent == null:
        return global_position
    
    if use_local_space:
        # 親のローカル座標系での位置
        # 親が Node2D なら position、そうでなければ global_position をそのまま使う
        if parent is Node2D:
            return (parent as Node2D).to_local(global_position)
        return global_position
    else:
        # ワールド座標に固定
        return global_position


## 軌跡を一時停止する(既存の軌跡はフェードアウトさせたい場合に)
func pause_trail() -> void:
    enabled_trail = false


## 軌跡を再開する
func resume_trail() -> void:
    enabled_trail = true


## すべてのポイントを即座にクリアする
func clear_trail() -> void:
    _points.clear()
    if line:
        line.clear_points()

使い方の手順

基本の流れはどのケースでも同じです。

  1. シーンに TrailRenderer を追加する(プレイヤー/剣/弾などにアタッチ)。
  2. TrailRenderer見た目・寿命・サンプリング間隔 をインスペクタで調整。
  3. 必要なら use_local_space を切り替えて、「ローカル追従」か「ワールド固定」かを選ぶ。
  4. ゲーム中に pause_trail() / resume_trail() / clear_trail() を呼んで制御する。

例1: プレイヤーの剣に軌跡をつける

剣を子ノードとして持っているプレイヤーの例です。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Sword (Node2D)
      ├── Sprite2D
      └── TrailRenderer (Node2D)

ポイント:

  • Sword の下に TrailRenderer を追加。
  • TrailRenderer.use_local_space = true にすると、剣の動きにピッタリ追従するローカルな軌跡になります。
  • 攻撃中だけ軌跡を出したい場合は、プレイヤーの攻撃アニメーションに合わせて ON/OFF します。

# Player.gd の一部サンプル
@onready var sword_trail: TrailRenderer = $Sword/TrailRenderer

func _on_attack_started() -> void:
    sword_trail.clear_trail()
    sword_trail.resume_trail()

func _on_attack_finished() -> void:
    # 攻撃が終わったら追加を止めて、既存の軌跡だけフェードアウトさせる
    sword_trail.pause_trail()

例2: 弾の通り道に軌跡を残す(ワールド固定)

弾が通った「線」を残したい場合は、ワールド座標に固定するのが自然です。

Bullet (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TrailRenderer (Node2D)
  • TrailRenderer.use_local_space = false(デフォルト)にしておくと、弾が移動した位置に軌跡が残ります。
  • 弾が消えたあとも、軌跡だけが時間差でフェードアウトしていきます。

# Bullet.gd の一部サンプル
extends Area2D

@onready var trail: TrailRenderer = $TrailRenderer

func _ready() -> void:
    # 弾が生成された時に軌跡をクリアして開始
    trail.clear_trail()
    trail.resume_trail()

func _on_life_time_timeout() -> void:
    # 弾本体を消す前に、軌跡だけが残るようにする例
    trail.pause_trail()
    queue_free()

例3: 動く床の通り道を見せる

パズルやギミック系で、「この床はどこを動くのか」をプレイヤーに見せたい場合にも使えます。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── TrailRenderer (Node2D)
  • プラットフォームが一定のルートをぐるぐる回ると、その通り道が軌跡として残ります。
  • ルートのガイドとしても使えるので、デバッグにも便利です。

メリットと応用

TrailRenderer をコンポーネントとして切り出しておくと、次のようなメリットがあります。

  • どのノードにも後付けできる
    プレイヤー、敵、弾、ギミック…すべて同じ TrailRenderer をアタッチするだけで統一した軌跡表現が使えます。継承ツリーを増やす必要がありません。
  • シーン構造がシンプルになる
    「軌跡を出すかどうか」は TrailRenderer の有無で一目瞭然。ロジックもこのコンポーネントに閉じ込められているので、メインのスクリプトがスッキリします。
  • ビジュアル調整が一箇所で完結する
    太さ・色・寿命・サンプリング間隔などはすべてエクスポート変数で調整可能。アーティストやレベルデザイナがインスペクタから直接いじれます。
  • 再利用性が高い
    プロジェクトをまたいでも、この1ファイルを持っていくだけで軌跡機能を移植できます。まさに「合成で機能を足す」スタイルですね。

改造案: 速度によって軌跡の長さを変える

例えば「速く動いている時だけ軌跡を長くしたい」というニーズがあるなら、親ノードの速度を見て max_pointspoint_lifetime を動的に変えるのもアリです。


## 親ノードの速度に応じて軌跡の長さをスケールさせる例
func update_trail_by_speed(speed: float, max_speed: float = 600.0) -> void:
    var ratio := clamp(speed / max_speed, 0.0, 1.0)
    
    # 最小値~最大値の間で補間
    var min_points := 8
    var max_points_local := 128
    max_points = int(lerp(min_points, max_points_local, ratio))
    
    var min_life := 0.2
    var max_life := 1.0
    point_lifetime = lerp(min_life, max_life, ratio)

この関数をプレイヤーや弾側から毎フレーム呼んであげれば、「ゆっくり動くと短い軌跡、ダッシュすると長い軌跡」 みたいな表現も簡単に作れます。

継承ベースで「速いプレイヤー用クラス」「遅いプレイヤー用クラス」を量産するより、こういう小さなコンポーネントを合成していく方が、後々の拡張もしやすいですね。ぜひ自分のプロジェクト用にカスタマイズしてみてください。