Unityを触り始めた頃って、つい何でもかんでも Update() に書きがちですよね。移動処理も入力処理もエフェクトも、そして今回のような「時間逆行」まで全部1クラスに押し込んでしまうと、以下のような問題が出てきます。

  • 処理が肥大化して、どこを直せばいいのか分からなくなる
  • 別のオブジェクトにも同じ機能を付けたいときに、コピペ地獄になる
  • バグが出たときに原因の切り分けが非常にしづらい

そこでこの記事では、「時間逆行」だけに責務を絞ったコンポーネント TimeRewind を用意して、プレイヤーや敵、動くギミックなど、好きなオブジェクトにポン付けできる形にしていきます。

やりたいことはシンプルです。

  • 一定時間分の座標(と回転)を履歴として記録しておく
  • 任意のタイミングで「逆再生モード」に入り、履歴を巻き戻しながら位置と回転を戻していく

これを1つの大きな「プレイヤー制御スクリプト」に押し込まず、時間逆行だけを担当する小さなコンポーネントとして切り出すことで、テストも再利用も圧倒的に楽になります。

【Unity】過去を巻き戻す時間ギミック!「TimeRewind」コンポーネント

以下が、Unity6(C#)で動作する TimeRewind コンポーネントのフルコードです。Rigidbody がある場合は物理挙動も止めて安全に巻き戻すようにしています。


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 一定時間分のTransform履歴を記録し、
/// トリガーで「時間逆行(位置・回転の巻き戻し)」を行うコンポーネント。
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(Transform))]
public class TimeRewind : MonoBehaviour
{
    /// <summary>
    /// 何秒前までの履歴を保持するか。
    /// 例: 5 にすると、最大で過去5秒分まで巻き戻せる。
    /// </summary>
    [SerializeField] private float recordDurationSeconds = 5f;

    /// <summary>
    /// 何フレームごとに履歴を記録するか。
    /// 1 = 毎フレーム、2 = 2フレームに1回 ... のように調整できる。
    /// </summary>
    [SerializeField] private int recordIntervalFrames = 1;

    /// <summary>
    /// 巻き戻し中の再生速度倍率。
    /// 1.0 で等速、2.0 で2倍速(早く戻る)。
    /// </summary>
    [SerializeField] private float rewindSpeedMultiplier = 1f;

    /// <summary>
    /// 巻き戻し中にRigidbodyを一時停止するかどうか。
    /// 物理挙動を使うオブジェクトは true 推奨。
    /// </summary>
    [SerializeField] private bool freezeRigidbodyOnRewind = true;

    /// <summary>
    /// デバッグ用に現在の状態をインスペクタに表示したい場合に使う。
    /// </summary>
    [SerializeField] private bool showDebugInfoInInspector = false;

    /// <summary>
    /// 内部で使う Transform キャッシュ。
    /// </summary>
    private Transform cachedTransform;

    /// <summary>
    /// 物理挙動を止める対象の Rigidbody(任意)。
    /// </summary>
    private Rigidbody cachedRigidbody;

    /// <summary>
    /// 1フレームごとの履歴データ。
    /// </summary>
    private struct TransformSnapshot
    {
        public Vector3 position;
        public Quaternion rotation;
        public float deltaTime;   // 記録時のデルタタイム(巻き戻し速度調整に利用)

        public TransformSnapshot(Vector3 pos, Quaternion rot, float dt)
        {
            position = pos;
            rotation = rot;
            deltaTime = dt;
        }
    }

    /// <summary>
    /// Transform履歴を保持するリスト。
    /// 一番新しいデータが末尾になるように追加していく。
    /// </summary>
    private readonly List<TransformSnapshot> snapshots = new List<TransformSnapshot>();

    /// <summary>
    /// 現在、時間逆行中かどうか。
    /// </summary>
    public bool IsRewinding { get; private set; }

    /// <summary>
    /// 記録に使う内部フレームカウンタ。
    /// </summary>
    private int frameCounter;

    /// <summary>
    /// Awakeでコンポーネントの参照をキャッシュ。
    /// </summary>
    private void Awake()
    {
        cachedTransform = transform;
        cachedRigidbody = GetComponent<Rigidbody>();
    }

    /// <summary>
    /// Updateでは「記録」か「巻き戻し」かを切り替える。
    /// 入力は外部のスクリプトから呼んでもらう想定だが、
    /// サンプルとしてキーボード入力も用意している。
    /// </summary>
    private void Update()
    {
        // --- サンプル入力 ---
        // 左Shiftキーを押している間だけ時間逆行する例。
        // 本番では外部のInputスクリプトから StartRewind / StopRewind を呼び出す形を推奨。
        if (Input.GetKeyDown(KeyCode.LeftShift))
        {
            StartRewind();
        }
        if (Input.GetKeyUp(KeyCode.LeftShift))
        {
            StopRewind();
        }
        // --- ここまでサンプル入力 ---

        if (IsRewinding)
        {
            RewindUpdate();
        }
        else
        {
            RecordUpdate();
        }
    }

    /// <summary>
    /// 通常時の記録処理。
    /// 一定フレームごとに位置と回転を保存し、古いデータは破棄する。
    /// </summary>
    private void RecordUpdate()
    {
        frameCounter++;

        // 記録間隔が1以下にならないようにガード
        int interval = Mathf.Max(1, recordIntervalFrames);

        // 指定フレームごとに記録
        if (frameCounter % interval == 0)
        {
            var snapshot = new TransformSnapshot(
                cachedTransform.position,
                cachedTransform.rotation,
                Time.deltaTime
            );
            snapshots.Add(snapshot);

            // recordDurationSeconds を超える古い履歴を削る
            TrimSnapshotsByDuration(recordDurationSeconds);
        }
    }

    /// <summary>
    /// 設定された秒数を超える古い履歴を削除する。
    /// 合計時間が recordDurationSeconds を超えないように調整するイメージ。
    /// </summary>
    /// <param name="maxDurationSeconds">保持したい最大時間(秒)</param>
    private void TrimSnapshotsByDuration(float maxDurationSeconds)
    {
        float total = 0f;

        // 後ろ(最新)から時間を積算して、超えたぶんを前から削る
        for (int i = snapshots.Count - 1; i >= 0; i--)
        {
            total += snapshots[i].deltaTime;

            if (total > maxDurationSeconds)
            {
                // i より前の要素をすべて削除
                // RemoveRange(開始インデックス, 要素数)
                if (i > 0)
                {
                    snapshots.RemoveRange(0, i);
                }
                break;
            }
        }
    }

    /// <summary>
    /// 巻き戻し中の処理。
    /// 最新の履歴から順に取り出して、Transform を過去の状態に戻していく。
    /// </summary>
    private void RewindUpdate()
    {
        if (snapshots.Count == 0)
        {
            // 戻す履歴がなくなったら自動で停止
            StopRewind();
            return;
        }

        // 巻き戻し速度に応じて、1フレームで何ステップ分戻すかを決める
        float speed = Mathf.Max(0.01f, rewindSpeedMultiplier);
        int steps = Mathf.CeilToInt(speed);

        for (int i = 0; i < steps; i++)
        {
            if (snapshots.Count == 0)
            {
                StopRewind();
                return;
            }

            // 一番新しい履歴を取得して適用
            int lastIndex = snapshots.Count - 1;
            TransformSnapshot snapshot = snapshots[lastIndex];
            snapshots.RemoveAt(lastIndex);

            cachedTransform.position = snapshot.position;
            cachedTransform.rotation = snapshot.rotation;
        }
    }

    /// <summary>
    /// 時間逆行を開始する。
    /// 外部スクリプトからも呼び出せるように public にしてある。
    /// </summary>
    public void StartRewind()
    {
        if (IsRewinding)
        {
            return;
        }

        IsRewinding = true;

        // 物理挙動を一時停止(任意)
        if (freezeRigidbodyOnRewind && cachedRigidbody != null)
        {
            cachedRigidbody.isKinematic = true;
            cachedRigidbody.velocity = Vector3.zero;
            cachedRigidbody.angularVelocity = Vector3.zero;
        }
    }

    /// <summary>
    /// 時間逆行を停止する。
    /// </summary>
    public void StopRewind()
    {
        if (!IsRewinding)
        {
            return;
        }

        IsRewinding = false;

        // 物理挙動を再開(任意)
        if (freezeRigidbodyOnRewind && cachedRigidbody != null)
        {
            cachedRigidbody.isKinematic = false;
        }
    }

    /// <summary>
    /// 外部から履歴をクリアしたい場合に使う補助メソッド。
    /// 例: ステージ切り替え時、リスポーン時など。
    /// </summary>
    public void ClearHistory()
    {
        snapshots.Clear();
    }

    /// <summary>
    /// デバッグ用情報をインスペクタに表示。
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        if (!showDebugInfoInInspector || snapshots == null || snapshots.Count == 0)
        {
            return;
        }

        // シーンビュー上に履歴の軌跡を描画してみる(デバッグ用)
        Gizmos.color = Color.cyan;
        for (int i = 1; i < snapshots.Count; i++)
        {
            Gizmos.DrawLine(snapshots[i - 1].position, snapshots[i].position);
        }
    }
}

使い方の手順

ここでは、プレイヤーキャラクターに時間逆行を付ける例をベースに、敵や動く床などへの応用も含めて手順を解説します。

  1. TimeRewind.cs を作成してアタッチ
    • Unity の Project ビューで TimeRewind.cs を作成し、上記コードをコピペします。
    • 時間逆行させたい GameObject(例: Player、Enemy、MovingPlatform)に TimeRewind コンポーネントをアタッチします。
    • Rigidbody を使っている場合は、同じオブジェクトに Rigidbody を追加しておきましょう。
  2. インスペクタでパラメータを調整
    • Record Duration Seconds: 何秒前まで戻したいか(例: 3〜10秒)。長くするとメモリ使用量が増えるので注意。
    • Record Interval Frames: 1 なら毎フレーム記録。2〜3 にすると負荷を下げつつ、ある程度なめらかに戻せます。
    • Rewind Speed Multiplier: 1.0 で等速、2.0 で2倍速巻き戻し。
    • Freeze Rigidbody On Rewind: 物理挙動を止めてから巻き戻したいなら ON のままにしておきます。
  3. 入力と連携する(例: プレイヤー)

    サンプルとして Update() 内で LeftShift キーを見ていますが、実際には入力専用コンポーネントから呼び出すように分離すると綺麗です。

    例: プレイヤー入力コンポーネントからの制御

    
    using UnityEngine;
    
    [RequireComponent(typeof(TimeRewind))]
    public class PlayerRewindInput : MonoBehaviour
    {
        [SerializeField] private KeyCode rewindKey = KeyCode.LeftShift;
    
        private TimeRewind timeRewind;
    
        private void Awake()
        {
            timeRewind = GetComponent<TimeRewind>();
        }
    
        private void Update()
        {
            // キーの押し下げで開始
            if (Input.GetKeyDown(rewindKey))
            {
                timeRewind.StartRewind();
            }
    
            // キーを離したら停止
            if (Input.GetKeyUp(rewindKey))
            {
                timeRewind.StopRewind();
            }
        }
    }
        

    これで、プレイヤー本体の移動スクリプトは「移動だけ」、この PlayerRewindInput は「入力から時間逆行を発火するだけ」、TimeRewind は「巻き戻しだけ」を担当する、きれいな分業になります。

  4. 敵や動く床にもポン付けする
    • 敵キャラに TimeRewind を付けると、「プレイヤーがスキルを発動したときに敵だけ巻き戻す」といった演出が簡単にできます。
    • 動く床に付ければ、「スイッチを押すと床の動きが巻き戻る」パズルギミックも作れます。
    • どの場合も、共通して TimeRewind を使えるので、プレハブを量産するだけで時間ギミック対応オブジェクトを増やせます。

メリットと応用

TimeRewind をコンポーネントとして切り出しておくと、プレハブ管理やレベルデザインがかなり楽になります。

  • プレハブの再利用性が高い
    「時間逆行できるプレイヤー」「時間逆行できる敵」「時間逆行できるギミック」といったバリエーションを、TimeRewind を付ける/外すだけで切り替えられます。
  • レベルデザイナーがインスペクタで調整しやすい
    「この敵は2秒しか戻れない」「この床は10秒戻したい」といった細かい調整を、コードを書かずに Record Duration Seconds をいじるだけで実現できます。
  • 責務が明確でテストしやすい
    巻き戻し挙動にバグがあっても、TimeRewind だけ見ればよいので、巨大な God クラスをデバッグするより遥かに楽です。

さらに、応用として「巻き戻し開始時にエフェクトを出す」「履歴が尽きたら自動的にクールダウンに入る」といった演出やゲーム性も簡単に追加できます。

例えば、巻き戻し開始時にパーティクルを再生する改造案はこんな感じです。


[SerializeField] private ParticleSystem rewindEffect;

/// <summary>時間逆行を開始するときにエフェクトを再生する例。</summary>
public void PlayRewindEffect()
{
    if (rewindEffect == null)
    {
        return;
    }

    // 既に再生中なら一度止めてから再生し直す
    if (rewindEffect.isPlaying)
    {
        rewindEffect.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
    }

    rewindEffect.Play();
}

この関数を StartRewind() の中から呼べば、「時間逆行の開始と同時にエフェクトが再生される」といった演出が簡単に追加できます。こうした小さな機能も、専用のコンポーネントとして切り出すかどうかを都度検討しながら、Godクラスを避ける設計を意識していきましょう。