【Cocos Creator 3.8】TimeRewind の実装:アタッチするだけで「数秒前の位置まで巻き戻す時間逆行」を実現する汎用スクリプト

本記事では、任意のノードにアタッチするだけで「過去数秒間の位置を記録し、キー入力やメソッド呼び出しで逆再生して戻る」挙動を実現する TimeRewind コンポーネントを実装します。

プレイヤーキャラや敵、ギミックなどにそのまま付けるだけで、「数秒前の座標に戻る」「ミスを少しだけ巻き戻す」といった時間逆行演出を簡単に導入できるように設計します。


コンポーネントの設計方針

機能要件の整理

  • このコンポーネントをアタッチしたノードの「過去数秒間の位置(worldPosition または position)」を一定間隔で記録する。
  • 「時間逆行中」は、記録された履歴を古い順にたどるのではなく「逆順に再生」して、ノードを過去の位置へ戻していく。
  • 記録する時間の長さ(例: 3秒〜10秒)をインスペクタから調整可能にする。
  • 記録の更新間隔(サンプリング間隔)を調整可能にして、精度とメモリ使用量のバランスを取れるようにする。
  • 時間逆行の再生速度(実時間より早送り/等速/スロー)を調整できるようにする。
  • テストしやすいように、デフォルトで特定キー(例: 左Shift)を押している間だけ逆行し、離すと通常状態に戻る挙動を実装する。
  • ゲーム側からスクリプトで制御したいケース向けに、startRewind() / stopRewind() の公開メソッドも用意する。
  • 他のカスタムスクリプトには一切依存しない。必要な情報はすべてインスペクタの @property から設定する。

座標の扱い方

今回は「そのノード自身のローカル座標(node.position)」を記録する実装にします。理由:

  • 親ノードごと動かしたいケースが多く、ローカル座標の方が扱いやすい。
  • 同じコンポーネントを複数のノードに付けても挙動が直感的になる。

必要に応じて、useWorldPosition のようなフラグを追加する拡張も容易です。

インスペクタで設定可能なプロパティ設計

以下のプロパティを @property で公開し、インスペクタから調整できるようにします。

  • maxRecordTime: number
    • ツールチップ: 「何秒前まで位置を記録するか(履歴の長さ)」
    • 単位: 秒
    • 例: 3.0, 5.0, 10.0 など
    • 値が大きいほど、より過去まで戻せるが、履歴が増えてメモリを多く使う。
  • recordInterval: number
    • ツールチップ: 「位置を何秒ごとに記録するか(サンプリング間隔)」
    • 単位: 秒
    • 例: 0.02(約50fps相当), 0.05, 0.1 など
    • 小さいほど滑らかに戻るが、履歴数が増える。
  • rewindSpeed: number
    • ツールチップ: 「時間逆行の再生速度倍率。1で等速、2で2倍速で巻き戻す」
    • 単位: 倍率
    • 例: 1.0(等速), 2.0(2倍速), 0.5(スロー)
  • autoRecord: boolean
    • ツールチップ: 「有効な間、常に位置履歴を記録し続ける」
    • 通常は true のままでよい。
  • enableRewindKey: boolean
    • ツールチップ: 「エディタ上でテストしやすいよう、指定キーで時間逆行をオン/オフするか」
    • true の場合、指定キーを押している間だけ時間逆行する。
  • rewindKeyCode: KeyCode
    • ツールチップ: 「時間逆行を発動するキー」
    • デフォルト: KeyCode.SHIFT_LEFT など。
    • テスト時に好みのキーに変更可能。
  • stopWhenNoHistory: boolean
    • ツールチップ: 「履歴を使い切ったら自動で時間逆行を停止するか」
    • true の場合、過去位置がなくなると自動で通常状態に戻る。
  • debugDrawPath: boolean
    • ツールチップ: 「Sceneビュー上で、記録された軌跡を簡易的にラインで可視化する(エディタ専用)」
    • 開発・デバッグ時のみ有効にして、軌跡を確認する用途。

内部的には、以下のような情報を保持します。

  • 位置履歴の配列
    • 各要素に position: Vec3time: number(記録時刻)を持たせる。
    • maxRecordTime を超えた古い履歴は随時破棄する。
  • 記録用タイマー
    • recordInterval ごとに現在位置を履歴へ追加する。
  • 現在が「通常状態」か「時間逆行状態」かを示すフラグ。

TypeScriptコードの実装


import { _decorator, Component, Node, Vec3, input, Input, KeyCode, EventKeyboard, director, Color, Graphics, UITransform } from 'cc';
const { ccclass, property } = _decorator;

/**
 * TimeRewind
 * 任意のノードにアタッチするだけで、
 * 過去数秒間の位置を記録し、時間逆行(巻き戻し)を行う汎用コンポーネント。
 */
@ccclass('TimeRewind')
export class TimeRewind extends Component {

    @property({
        tooltip: '何秒前まで位置を記録するか(履歴の長さ)。大きいほど過去まで戻れるがメモリ使用量が増えます。',
        min: 0.5,
        max: 60,
        step: 0.5
    })
    public maxRecordTime: number = 5.0;

    @property({
        tooltip: '位置を何秒ごとに記録するか(サンプリング間隔)。小さいほど滑らかに戻りますが履歴数が増えます。',
        min: 0.01,
        max: 0.5,
        step: 0.01
    })
    public recordInterval: number = 0.05;

    @property({
        tooltip: '時間逆行の再生速度倍率。1で等速、2で2倍速、0.5でスロー巻き戻しになります。',
        min: 0.1,
        max: 5.0,
        step: 0.1
    })
    public rewindSpeed: number = 1.0;

    @property({
        tooltip: '有効な間、常に位置履歴を記録し続けます。通常はONのままで問題ありません。'
    })
    public autoRecord: boolean = true;

    @property({
        tooltip: 'テスト用に、指定キーを押している間だけ時間逆行する機能を有効にします。'
    })
    public enableRewindKey: boolean = true;

    @property({
        tooltip: '時間逆行を発動するキー(押している間だけ巻き戻し)。',
        visible() {
            return this.enableRewindKey;
        }
    })
    public rewindKeyCode: KeyCode = KeyCode.SHIFT_LEFT;

    @property({
        tooltip: '履歴を使い切ったら自動で時間逆行を停止するかどうか。'
    })
    public stopWhenNoHistory: boolean = true;

    @property({
        tooltip: 'Sceneビュー上で、記録された軌跡を簡易的にラインで可視化します(エディタ用)。'
    })
    public debugDrawPath: boolean = false;

    // ---- 内部状態 ----

    // 位置履歴1件分の型
    private _history: { time: number; pos: Vec3 }[] = [];

    // 記録用の経過時間カウンタ
    private _recordTimer: number = 0;

    // 現在、時間逆行中かどうか
    private _isRewinding: boolean = false;

    // デバッグ描画用のGraphics(任意)
    private _debugGraphics: Graphics | null = null;

    onLoad() {
        // 入力イベントの登録(キーで時間逆行を制御する場合)
        input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this._onKeyUp, this);

        // デバッグ描画用Graphicsを準備(必要な場合のみ)
        if (this.debugDrawPath) {
            this._createDebugGraphics();
        }
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
    }

    start() {
        // 開始時点の位置も履歴に入れておく
        this._pushCurrentPosition(0);
    }

    update(deltaTime: number) {
        // 記録処理
        if (this.autoRecord && !this._isRewinding) {
            this._updateRecording(deltaTime);
        }

        // 時間逆行処理
        if (this._isRewinding) {
            this._updateRewind(deltaTime);
        }

        // デバッグ描画
        if (this.debugDrawPath) {
            this._updateDebugDraw();
        }
    }

    // ===== 公開API =====

    /**
     * 時間逆行を開始する。
     * (ゲーム側のスクリプトからも呼び出せるように公開)
     */
    public startRewind(): void {
        if (this._history.length <= 1) {
            // 戻るための履歴がない
            return;
        }
        this._isRewinding = true;
    }

    /**
     * 時間逆行を停止する。
     */
    public stopRewind(): void {
        this._isRewinding = false;
    }

    /**
     * 現在の履歴を全てクリアする。
     */
    public clearHistory(): void {
        this._history.length = 0;
        this._recordTimer = 0;
    }

    /**
     * 現在、時間逆行中かどうかを返す。
     */
    public get isRewinding(): boolean {
        return this._isRewinding;
    }

    // ===== 内部処理:記録 =====

    private _updateRecording(deltaTime: number): void {
        this._recordTimer += deltaTime;
        if (this._recordTimer >= this.recordInterval) {
            this._recordTimer -= this.recordInterval;
            this._pushCurrentPosition(director.getTotalTime() / 1000); // 秒に変換
            this._trimOldHistory();
        }
    }

    private _pushCurrentPosition(timeSec: number): void {
        const pos = this.node.position.clone();
        this._history.push({ time: timeSec, pos });
    }

    private _trimOldHistory(): void {
        if (this._history.length <= 1) {
            return;
        }
        const newestTime = this._history[this._history.length - 1].time;
        const threshold = newestTime - this.maxRecordTime;

        // 古い履歴を先頭から削除
        let removeCount = 0;
        for (let i = 0; i < this._history.length; i++) {
            if (this._history[i].time < threshold) {
                removeCount++;
            } else {
                break;
            }
        }
        if (removeCount > 0) {
            this._history.splice(0, removeCount);
        }
    }

    // ===== 内部処理:時間逆行 =====

    private _updateRewind(deltaTime: number): void {
        if (this._history.length <= 1) {
            if (this.stopWhenNoHistory) {
                this.stopRewind();
            }
            return;
        }

        // rewindSpeed倍で履歴をさかのぼる
        let remaining = deltaTime * this.rewindSpeed;

        while (remaining > 0 && this._history.length > 1) {
            const last = this._history[this._history.length - 1];
            const prev = this._history[this._history.length - 2];

            const dt = last.time - prev.time;
            if (dt <= 0) {
                // 時間差がない/異常な場合は1つ削除して続行
                this._history.pop();
                continue;
            }

            if (remaining >= dt) {
                // まとめて1ステップ戻れる
                this.node.setPosition(prev.pos);
                this._history.pop(); // 最後の要素を削除
                remaining -= dt;
            } else {
                // 残り時間分だけ補間して戻る
                const t = (dt - remaining) / dt; // 0..1
                const interpolated = new Vec3(
                    prev.pos.x + (last.pos.x - prev.pos.x) * t,
                    prev.pos.y + (last.pos.y - prev.pos.y) * t,
                    prev.pos.z + (last.pos.z - prev.pos.z) * t,
                );
                this.node.setPosition(interpolated);

                // 時刻も巻き戻すイメージで更新
                last.time -= remaining;
                remaining = 0;
            }
        }

        if (this._history.length <= 1 && this.stopWhenNoHistory) {
            this.stopRewind();
        }
    }

    // ===== 入力処理(テスト用) =====

    private _onKeyDown(event: EventKeyboard): void {
        if (!this.enableRewindKey) return;
        if (event.keyCode === this.rewindKeyCode) {
            this.startRewind();
        }
    }

    private _onKeyUp(event: EventKeyboard): void {
        if (!this.enableRewindKey) return;
        if (event.keyCode === this.rewindKeyCode) {
            this.stopRewind();
        }
    }

    // ===== デバッグ描画 =====

    private _createDebugGraphics(): void {
        // 自ノードの子としてGraphicsを作成し、軌跡を描画する
        const gNode = new Node('TimeRewindDebugPath');
        this.node.addChild(gNode);

        const uiTrans = gNode.addComponent(UITransform);
        uiTrans.setContentSize(1, 1); // 適当なサイズ

        this._debugGraphics = gNode.addComponent(Graphics);
        this._debugGraphics.lineWidth = 2;
        this._debugGraphics.strokeColor = new Color(0, 255, 255, 255);
    }

    private _updateDebugDraw(): void {
        if (!this._debugGraphics) {
            this._createDebugGraphics();
            if (!this._debugGraphics) return;
        }

        const g = this._debugGraphics;
        g.clear();

        if (this._history.length < 2) return;

        // ローカル座標の履歴をそのまま描画
        const first = this._history[0].pos;
        g.moveTo(first.x, first.y);
        for (let i = 1; i < this._history.length; i++) {
            const p = this._history[i].pos;
            g.lineTo(p.x, p.y);
        }
        g.stroke();
    }
}

主要メソッドの解説

  • onLoad()
    • 時間逆行をキー入力でテストできるよう、input.onKEY_DOWN / KEY_UP を登録。
    • debugDrawPath が有効な場合は、軌跡描画用の Graphics コンポーネントを子ノードに自動生成。
  • start()
    • 開始時の位置も履歴に入れておくことで、「ゲーム開始直後から」巻き戻せるようにする。
  • update(deltaTime)
    • 通常状態(_isRewinding === false)かつ autoRecord === true のとき、_updateRecording で一定間隔ごとに位置を履歴へ追加。
    • 時間逆行中(_isRewinding === true)は、_updateRewind で履歴を逆再生してノード位置を巻き戻す。
    • debugDrawPath が有効な場合は、毎フレーム軌跡を再描画。
  • _updateRecording(deltaTime)
    • recordInterval ごとに現在位置を履歴に追加。
    • 履歴が maxRecordTime を超えないように、古い履歴を先頭から削除。
  • _updateRewind(deltaTime)
    • rewindSpeed 倍で履歴をさかのぼるように、remaining(残り巻き戻し時間)をループで消費していく。
    • 1ステップ分の履歴時間 dt を丸ごと消費できる場合は、その分だけ履歴を1つ戻す(pop())。
    • 中途半端な時間だけ戻る場合は、prevlast の間を線形補間して位置を計算。
    • 履歴が1件以下になったら、stopWhenNoHistory に応じて自動停止。
  • startRewind() / stopRewind()
    • ゲーム側のスクリプトからも呼べるよう、公開メソッドとして実装。
    • 「トラップに触れたら自動で巻き戻す」などの演出を組みやすい。
  • _onKeyDown / _onKeyUp
    • enableRewindKeytrue のときだけ動作。
    • 指定キーが押されている間だけ時間逆行し、離したら通常状態に戻る。
  • _createDebugGraphics() / _updateDebugDraw()
    • エディタ上で履歴の軌跡を確認したいとき用の補助機能。
    • 本番ビルドでは通常オフにしておくことを推奨。

使用手順と動作確認

1. スクリプトファイルの作成

  1. エディタ上部メニューまたは Assets パネルで、
    Assets パネルを右クリック → Create → TypeScript を選択します。
  2. 新しく作成されたスクリプトに TimeRewind.ts という名前を付けます。
  3. ダブルクリックしてエディタ(VS Code など)で開き、本文をすべて削除して、前述の TimeRewind クラスのコードをそのまま貼り付けて保存します。

2. テスト用ノードの作成

ここでは、2Dゲームを想定して Sprite を動かしながら時間逆行させてみます。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノードを作成します。
    • 名前は分かりやすく Player などにしておきます。
  2. 作成したノードを選択し、Inspector パネルSprite コンポーネントから任意の画像を設定します。
  3. 同じく Inspector で、Add Component → Custom → TimeRewind を選択して、先ほど作成した TimeRewind コンポーネントをアタッチします。

3. TimeRewind のプロパティ設定

アタッチしたノードを選択し、Inspector 上の TimeRewind コンポーネントのプロパティを確認・設定します。

  • Max Record TimemaxRecordTime
    • 例: 5.0 秒(5秒前まで戻れる)。
  • Record IntervalrecordInterval
    • 例: 0.05 秒(約20fps相当)。
    • より滑らかにしたい場合は 0.02 などに変更。
  • Rewind SpeedrewindSpeed
    • 例: 1.0(等速で巻き戻し)。
    • 素早く巻き戻したい場合は 2.03.0 にしてもよい。
  • Auto RecordautoRecord
    • ON のままで OK(常に位置を記録)。
  • Enable Rewind KeyenableRewindKey
    • ON にすると、指定キーを押している間だけ時間逆行します。
  • Rewind Key CoderewindKeyCode
    • デフォルトは SHIFT_LEFT
    • 好みに応じて Z キーなど(KeyCode.KEY_Z)に変更してもかまいません。
  • Stop When No HistorystopWhenNoHistory
    • ON のままで OK。履歴を使い切ったら自動で時間逆行が止まります。
  • Debug Draw PathdebugDrawPath
    • 開発中に軌跡を確認したいときだけ ON にしてください。
    • Scene ビュー上で、水色のラインとして移動履歴が表示されます。

4. 動かしてみる(手動でノードを動かす簡易テスト)

まずはシンプルに、「Sceneビュー上でノードをドラッグして動かし、時間逆行で戻るか」を確認します。

  1. ツールバーの Play in Editor(再生) ボタンを押して、ゲームを実行します。
  2. 実行中、Sceneビュー上で Player ノードをマウスでドラッグして、適当に動かしてみます。
    • 数秒間あちこちに動かしてみてください。
  3. 次に、設定した「時間逆行キー」(例: 左Shift)を押し続けてみます。
    • ノードが、さきほど動かした軌跡を逆再生するように、過去の位置へ戻っていくはずです。
    • キーを離すと、その位置で時間逆行が止まり、再び位置履歴の記録が再開されます。
  4. Debug Draw Path を ON にしている場合、Sceneビューに水色のラインで移動履歴が描かれます。
    • 時間逆行中は、このラインをなぞるようにノードが戻っていきます。

5. 簡単なプレイヤー移動スクリプトと組み合わせてテスト

より実戦に近いテストとして、「カーソルキーでプレイヤーを移動させ、Shift で巻き戻す」動作を試してみましょう。

  1. Assets パネルで右クリック → Create → TypeScript を選択し、SimpleMove.ts というスクリプトを作成します。
  2. 以下のような、非常に簡単な移動スクリプトを記述します(参考用・外部依存なし)。

import { _decorator, Component, Vec3, input, Input, EventKeyboard, KeyCode } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('SimpleMove')
export class SimpleMove extends Component {
    @property({ tooltip: '移動速度(単位: 単位/秒)' })
    public speed: number = 300;

    private _dirX = 0;
    private _dirY = 0;

    onLoad() {
        input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this._onKeyUp, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this._onKeyUp, this);
    }

    update(deltaTime: number) {
        const pos = this.node.position;
        const move = new Vec3(
            this._dirX * this.speed * deltaTime,
            this._dirY * this.speed * deltaTime,
            0
        );
        this.node.setPosition(pos.add(move));
    }

    private _onKeyDown(e: EventKeyboard) {
        switch (e.keyCode) {
            case KeyCode.ARROW_LEFT:  this._dirX = -1; break;
            case KeyCode.ARROW_RIGHT: this._dirX =  1; break;
            case KeyCode.ARROW_UP:    this._dirY =  1; break;
            case KeyCode.ARROW_DOWN:  this._dirY = -1; break;
        }
    }

    private _onKeyUp(e: EventKeyboard) {
        switch (e.keyCode) {
            case KeyCode.ARROW_LEFT:
            case KeyCode.ARROW_RIGHT:
                this._dirX = 0;
                break;
            case KeyCode.ARROW_UP:
            case KeyCode.ARROW_DOWN:
                this._dirY = 0;
                break;
        }
    }
}
  1. Player ノードに SimpleMove をアタッチし、speed を 300〜500 程度に設定します。
  2. ゲームを再生し、カーソルキーでプレイヤーを動かしてみます。
  3. プレイヤーを少し動かしたあと、Shift キー(または設定した rewindKeyCode)を押し続けると、通ってきた軌跡を逆再生して戻るはずです。

このように、TimeRewind は他のスクリプトに依存せず、アタッチするだけで時間逆行機能を付与できます。


まとめ

  • TimeRewind コンポーネントは、任意のノードにアタッチするだけで「過去数秒分の位置を記録し、キー入力やメソッド呼び出しで巻き戻す」機能を提供します。
  • 記録時間・サンプリング間隔・巻き戻し速度・キー入力の有無などをすべてインスペクタから調整できるため、ゲームのテンポや演出に合わせたチューニングが容易です。
  • 外部の GameManager やシングルトンに依存しない完全な独立コンポーネントなので、プロジェクト間のコピペや既存プロジェクトへの導入も簡単です。
  • 応用例としては、以下のような使い方が考えられます。
    • プレイヤーキャラの「数秒だけ巻き戻し」スキル。
    • 敵やギミックが「時間を巻き戻す攻撃」をしたときの演出。
    • リプレイやゴースト表示機能のベースとしての利用。

今回の実装をベースに、「位置だけでなく回転やスケールも巻き戻す」「物理挙動と組み合わせる」「特定イベントで自動発動する」など、プロジェクトに合わせて拡張してみてください。
TimeRewind.ts 単体で完結しているので、まずはそのまま導入し、挙動を確認しながらカスタマイズしていくのがおすすめです。