Godot 4 で「時間を巻き戻す」ギミックを作ろうとすると、ついプレイヤー用・敵用などそれぞれのシーンで個別に「履歴管理」「巻き戻し処理」を書いてしまいがちですよね。
さらに、Player.gd を継承して RewindablePlayer.gd を作る…みたいな継承ツリーが増えると、「あのキャラには巻き戻し機能を付けたいけど、継承構造どうしよう…」と悩みがちです。
そこで今回は、「どんなノードにもポン付けで時間逆行機能を生やせる」コンポーネントとして TimeRewind を用意しました。
ノード階層を深くせず、コンポーネントを 1 個アタッチするだけで「過去数秒分の座標を記録→トリガーで逆再生して戻る」挙動を実装していきましょう。
【Godot 4】巻き戻しはコンポーネントに丸投げ!「TimeRewind」コンポーネント
このコンポーネントは、以下のようなシンプルな思想です:
- 位置情報(
global_position/global_transform)を一定間隔で記録しておく - 「巻き戻し中フラグ」が立ったら、記録を逆順に再生して座標を戻していく
- プレイヤーでも敵でも動く床でも、どのノードにもアタッチして使える
「時間逆行キャラ用の基底クラス」を作るのではなく、「TimeRewind コンポーネント」を付ければ時間逆行可能、という構成にすることで、シーン構造がかなりスッキリします。
フルコード: TimeRewind.gd
extends Node
class_name TimeRewind
## 任意のノードにアタッチして「時間逆行(位置の巻き戻し)」を付与するコンポーネント。
##
## ・親ノードの global_position / global_rotation を一定間隔で記録
## ・rewind() を呼ぶと、記録を逆順に再生して過去の位置へ巻き戻す
## ・どんな Node2D / Node3D / キャラクターにもアタッチ可能(2D/3D切り替え式)
@export_group("基本設定")
## 何秒前まで記録するか(履歴の長さ)。
@export_range(0.5, 30.0, 0.5)
var record_duration_seconds: float = 5.0
## 何秒ごとにサンプリングするか(小さいほど滑らか&重い)。
@export_range(0.01, 0.5, 0.01)
var record_interval: float = 0.05
## true の間は常に記録する。false にすると記録を一時停止。
@export
var auto_record: bool = true
@export_group("対象設定")
## 2D ノードを対象にするか 3D ノードを対象にするか。
@export_enum("Node2D", "Node3D")
var space_dimension: int = 0
## 明示的に対象を指定したい場合に使う。
## 空なら自分の親ノード(get_parent())を対象とする。
@export
var target_node_path: NodePath
@export_group("巻き戻し設定")
## 巻き戻し時の再生速度倍率。1.0 で等速、2.0 で2倍速巻き戻し。
@export_range(0.1, 5.0, 0.1)
var rewind_speed_scale: float = 1.0
## 巻き戻し中に対象ノードの物理挙動を止めるかどうか。
## CharacterBody2D / RigidBody2D などに対して velocity を 0 にしたりする。
@export
var freeze_physics_on_rewind: bool = true
@export_group("デバッグ表示")
## デバッグ用に、現在の履歴数や状態をエディタ上に表示するか。
@export
var debug_print_state: bool = false
# 内部用: 記録用の構造体もどき
class PositionRecord:
var position
var rotation
var time: float
func _init(p_position, p_rotation, p_time):
position = p_position
rotation = p_rotation
time = p_time
# 内部状態
var _target: Node = null
var _history: Array[PositionRecord] = []
var _record_timer: float = 0.0
var _is_rewinding: bool = false
var _rewind_index: int = -1
var _rewind_elapsed: float = 0.0
func _ready() -> void:
# 対象ノードの解決
if target_node_path != NodePath():
_target = get_node_or_null(target_node_path)
else:
_target = get_parent()
if _target == null:
push_warning("TimeRewind: 対象ノードが見つかりません。親ノードが存在しないか、target_node_path が不正です。")
# 履歴の最大件数を計算(秒数 ÷ サンプリング間隔)
_update_history_capacity()
set_process(true)
func _update_history_capacity() -> void:
# 記録秒数とインターバルから最大記録数を求める
if record_interval <= 0.0:
record_interval = 0.05
var max_count := int(ceil(record_duration_seconds / record_interval))
# 実際には配列のサイズを固定しないが、この値を目安として使う
# (古いものから順に削除していく)
# 特に何も保持しないが、デバッグ用に出力することも可能
if debug_print_state:
print("TimeRewind: 最大履歴数の目安 = ", max_count)
func _process(delta: float) -> void:
if _target == null:
return
if _is_rewinding:
_process_rewind(delta)
else:
_process_record(delta)
if debug_print_state:
_debug_state_print(delta)
func _process_record(delta: float) -> void:
if not auto_record:
return
_record_timer += delta
if _record_timer >= record_interval:
_record_timer = 0.0
_push_current_state()
# 古い履歴を削除して、record_duration_seconds の範囲に収める
_trim_old_history()
func _push_current_state() -> void:
var now: float = Time.get_ticks_msec() / 1000.0
match space_dimension:
0: # Node2D
if _target is Node2D:
var t := _target as Node2D
var rec := PositionRecord.new(t.global_position, t.global_rotation, now)
_history.append(rec)
1: # Node3D
if _target is Node3D:
var t3 := _target as Node3D
# 3D では位置と回転(Y 軸中心など)を記録。ここでは basis から yaw を取るのではなく、
# 単純に global_transform.basis.get_euler() を使う。
var pos := t3.global_transform.origin
var rot := t3.global_transform.basis.get_euler()
var rec3 := PositionRecord.new(pos, rot, now)
_history.append(rec3)
func _trim_old_history() -> void:
if _history.is_empty():
return
var newest_time: float = _history[_history.size() - 1].time
var threshold_time: float = newest_time - record_duration_seconds
# 先頭から threshold_time より古いものを削除
while _history.size() > 0 and _history[0].time < threshold_time:
_history.pop_front()
func _process_rewind(delta: float) -> void:
if _history.is_empty():
stop_rewind()
return
if _rewind_index < 0:
stop_rewind()
return
# rewind_speed_scale に応じて、どれくらいのペースでインデックスを戻すか
_rewind_elapsed += delta * rewind_speed_scale
# record_interval ごとに 1 ステップ戻すイメージ
var steps := int(_rewind_elapsed / record_interval)
if steps <= 0:
# まだ次のステップに進まない
_apply_record(_history[_rewind_index])
return
# 経過分だけインデックスを戻す
_rewind_elapsed = 0.0
_rewind_index -= steps
if _rewind_index < 0:
# 履歴の先頭まで戻りきったら巻き戻し終了
_rewind_index = 0
_apply_record(_history[_rewind_index])
stop_rewind()
return
_apply_record(_history[_rewind_index])
func _apply_record(record: PositionRecord) -> void:
match space_dimension:
0: # Node2D
if _target is Node2D:
var t := _target as Node2D
t.global_position = record.position
t.global_rotation = record.rotation
if freeze_physics_on_rewind:
_freeze_2d_physics(t)
1: # Node3D
if _target is Node3D:
var t3 := _target as Node3D
var tr := t3.global_transform
tr.origin = record.position
tr.basis = Basis.from_euler(record.rotation)
t3.global_transform = tr
if freeze_physics_on_rewind:
_freeze_3d_physics(t3)
func _freeze_2d_physics(node: Node2D) -> void:
# よくある物理系ノードに対して velocity を 0 にする
if node is CharacterBody2D:
(node as CharacterBody2D).velocity = Vector2.ZERO
elif node is RigidBody2D:
var rb := node as RigidBody2D
rb.linear_velocity = Vector2.ZERO
rb.angular_velocity = 0.0
func _freeze_3d_physics(node: Node3D) -> void:
if node is CharacterBody3D:
(node as CharacterBody3D).velocity = Vector3.ZERO
elif node is RigidBody3D:
var rb := node as RigidBody3D
rb.linear_velocity = Vector3.ZERO
rb.angular_velocity = Vector3.ZERO
func _debug_state_print(delta: float) -> void:
# 毎フレーム出すとうるさいので、たまに出すなら工夫してもOK
# ここではシンプルに出力
print_debug("TimeRewind: history=", _history.size(),
" is_rewinding=", _is_rewinding,
" auto_record=", auto_record)
# --- 公開 API ---------------------------------------------------------
## 巻き戻しを開始する。
## ・現在の履歴の一番新しい位置から逆再生を始める
## ・auto_record は自動的に OFF になる(必要なら自前で ON に戻す)
func start_rewind() -> void:
if _history.is_empty():
return
_is_rewinding = true
auto_record = false
_rewind_index = _history.size() - 1
_rewind_elapsed = 0.0
# 巻き戻し開始時に即座に適用
_apply_record(_history[_rewind_index])
## 巻き戻しを停止する。
## 停止後に auto_record を再開するかどうかは呼び出し側で制御してOK。
func stop_rewind() -> void:
_is_rewinding = false
_rewind_index = -1
_rewind_elapsed = 0.0
## 巻き戻し中かどうかを返す。
func is_rewinding() -> bool:
return _is_rewinding
## 記録中かどうかを返す(auto_record が true かどうか)。
func is_recording() -> bool:
return auto_record
## 履歴をすべてクリアする。
func clear_history() -> void:
_history.clear()
## 設定値を変更したあとに履歴キャパシティを再計算したい場合に呼ぶ。
func refresh_settings() -> void:
_update_history_capacity()
使い方の手順
ここからは、2D のプレイヤーに時間逆行を付ける例で説明します。敵や動く床でも同じ要領です。
手順①: コンポーネントスクリプトを用意する
- 上記の
TimeRewind.gdをプロジェクトのどこか(例:res://components/TimeRewind.gd)に保存します。 - Godot エディタで開き、エラーがないか確認しておきましょう。
手順②: プレイヤーシーンにアタッチする
例として、こんなシーン構成の 2D プレイヤーがあるとします。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── TimeRewind (Node)
- Player (CharacterBody2D) … 移動ロジックなどが書かれた本体
- TimeRewind (Node) … 新しく追加する子ノード。スクリプトに
TimeRewind.gdをアタッチ
手順:
- Player シーンを開く
- Player の子として
Nodeを追加し、名前をTimeRewindに変更 - その Node に
TimeRewind.gdをアタッチ - インスペクタで以下を設定:
space_dimension=Node2Drecord_duration_seconds= 5.0(好みで)record_interval= 0.05(滑らかさと負荷のバランスで調整)auto_record= ON(デフォルトのままでOK)target_node_pathは空のまま(親の Player を自動対象にします)
手順③: 入力で巻き戻しをトリガーする
次に、プレイヤースクリプトから TimeRewind を呼び出します。
例として、"rewind" という Input Action を押している間、巻き戻すようにします。
# Player.gd (CharacterBody2D にアタッチされているとする)
extends CharacterBody2D
@onready var time_rewind: TimeRewind = $TimeRewind
func _process(delta: float) -> void:
# 巻き戻し入力チェック
var rewind_pressed := Input.is_action_pressed("rewind")
if rewind_pressed:
# まだ巻き戻し中でなければ開始
if not time_rewind.is_rewinding():
time_rewind.start_rewind()
else:
# 入力を離したら停止
if time_rewind.is_rewinding():
time_rewind.stop_rewind()
# 停止後に記録を再開
time_rewind.auto_record = true
# 通常の移動処理は、巻き戻し中は無効化したい場合が多い
if time_rewind.is_rewinding():
return
_process_movement(delta)
func _process_movement(delta: float) -> void:
# ここにプレイヤーの通常移動処理を書く
var dir := Input.get_axis("ui_left", "ui_right")
velocity.x = dir * 200.0
velocity.y += 800.0 * delta
move_and_slide()
これで、"rewind" アクション(例: キーボードの R キー)を押している間だけ、プレイヤーが「過去数秒間の移動」を逆再生しながら戻っていくようになります。
手順④: 敵や動く床にも同じコンポーネントを使い回す
同じ要領で、敵や動く床にも時間逆行を付けられます。
敵の例:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── TimeRewind (Node)
動く床の例:
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── TimeRewind (Node)
それぞれのスクリプト側で、何らかの条件(HP が 0 になったとき、スイッチを押したときなど)で start_rewind() / stop_rewind() を呼べば OK です。
「時間逆行のロジック」はすべて TimeRewind コンポーネントに閉じ込めているので、各キャラのスクリプトはとてもシンプルなまま保てます。
メリットと応用
この TimeRewind コンポーネントを使うことで、以下のようなメリットがあります。
- 継承ツリーを増やさなくて済む
「巻き戻し可能なプレイヤー」「巻き戻し可能な敵」用の派生クラスを作る必要がありません。
どのシーンでもTimeRewindノードを 1 個足すだけで済むので、クラス構造がシンプルになります。 - シーン構造がフラットで読みやすい
Godot 標準の「ノードにスクリプトを貼って、さらに子ノードにロジックを追加して…」というパターンは、気を抜くとすぐに深いツリーになります。
TimeRewind は 1 ノード・1責務なので、「このノードは時間逆行だけ担当」とパッと見て分かります。 - レベルデザイン時の使い回しが楽
「このギミックだけ時間逆行できるようにしたい」「この敵だけ巻き戻し不可にしたい」といった調整も、TimeRewind ノードを付ける/外す、あるいはauto_recordを OFF にするだけで制御できます。 - 2D / 3D 両対応
space_dimensionを切り替えるだけで 3D シーンにも流用できます。
コンポーネントを 1 本メンテするだけで、2D/3D 両方の時間逆行機能が手に入ります。
さらに応用として、「巻き戻し中は画面を白黒にする」「軌跡をパーティクルで表示する」などの演出系は、別コンポーネントとして切り出しても良いですね。
TimeRewind はあくまで「座標を巻き戻すだけ」に責務を絞っているので、演出やサウンドは別コンポーネントに生やすと、よりコンポジション志向の設計になります。
改造案: 巻き戻し終了位置に「到達イベント」を飛ばす
巻き戻しが終わったタイミングで、「到達しましたよ」というシグナルを出して、そこから何かイベントを起こしたいことがあります。
例えば「巻き戻し後にスローモーションを入れる」などですね。
以下のように、TimeRewind にシグナルとフック関数を追加する改造が考えられます。
# TimeRewind.gd 内に追記
signal rewind_finished
func stop_rewind() -> void:
_is_rewinding = false
_rewind_index = -1
_rewind_elapsed = 0.0
emit_signal("rewind_finished")
これで、プレイヤー側では次のように接続して使えます。
# Player.gd の _ready などで
func _ready() -> void:
$TimeRewind.rewind_finished.connect(_on_rewind_finished)
func _on_rewind_finished() -> void:
print("巻き戻し終了!ここでエフェクトや演出を入れる")
このように、コンポーネントは「小さく作って、必要に応じてシグナルで拡張する」スタイルにすると、他のシーンでも再利用しやすくなります。
継承より合成、どんどん実践していきましょう。




