【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: Vec3とtime: 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.onでKEY_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())。 - 中途半端な時間だけ戻る場合は、
prevとlastの間を線形補間して位置を計算。 - 履歴が1件以下になったら、
stopWhenNoHistoryに応じて自動停止。
startRewind()/stopRewind()- ゲーム側のスクリプトからも呼べるよう、公開メソッドとして実装。
- 「トラップに触れたら自動で巻き戻す」などの演出を組みやすい。
_onKeyDown/_onKeyUpenableRewindKeyがtrueのときだけ動作。- 指定キーが押されている間だけ時間逆行し、離したら通常状態に戻る。
_createDebugGraphics()/_updateDebugDraw()- エディタ上で履歴の軌跡を確認したいとき用の補助機能。
- 本番ビルドでは通常オフにしておくことを推奨。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタ上部メニューまたは Assets パネルで、
Assets パネルを右クリック → Create → TypeScript を選択します。 - 新しく作成されたスクリプトに
TimeRewind.tsという名前を付けます。 - ダブルクリックしてエディタ(VS Code など)で開き、本文をすべて削除して、前述の
TimeRewindクラスのコードをそのまま貼り付けて保存します。
2. テスト用ノードの作成
ここでは、2Dゲームを想定して Sprite を動かしながら時間逆行させてみます。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、テスト用のスプライトノードを作成します。
- 名前は分かりやすく
Playerなどにしておきます。
- 名前は分かりやすく
- 作成したノードを選択し、Inspector パネル の
Spriteコンポーネントから任意の画像を設定します。 - 同じく Inspector で、Add Component → Custom → TimeRewind を選択して、先ほど作成した
TimeRewindコンポーネントをアタッチします。
3. TimeRewind のプロパティ設定
アタッチしたノードを選択し、Inspector 上の TimeRewind コンポーネントのプロパティを確認・設定します。
- Max Record Time(
maxRecordTime)- 例:
5.0秒(5秒前まで戻れる)。
- 例:
- Record Interval(
recordInterval)- 例:
0.05秒(約20fps相当)。 - より滑らかにしたい場合は
0.02などに変更。
- 例:
- Rewind Speed(
rewindSpeed)- 例:
1.0(等速で巻き戻し)。 - 素早く巻き戻したい場合は
2.0や3.0にしてもよい。
- 例:
- Auto Record(
autoRecord)- ON のままで OK(常に位置を記録)。
- Enable Rewind Key(
enableRewindKey)- ON にすると、指定キーを押している間だけ時間逆行します。
- Rewind Key Code(
rewindKeyCode)- デフォルトは
SHIFT_LEFT。 - 好みに応じて
Zキーなど(KeyCode.KEY_Z)に変更してもかまいません。
- デフォルトは
- Stop When No History(
stopWhenNoHistory)- ON のままで OK。履歴を使い切ったら自動で時間逆行が止まります。
- Debug Draw Path(
debugDrawPath)- 開発中に軌跡を確認したいときだけ ON にしてください。
- Scene ビュー上で、水色のラインとして移動履歴が表示されます。
4. 動かしてみる(手動でノードを動かす簡易テスト)
まずはシンプルに、「Sceneビュー上でノードをドラッグして動かし、時間逆行で戻るか」を確認します。
- ツールバーの Play in Editor(再生) ボタンを押して、ゲームを実行します。
- 実行中、Sceneビュー上で
Playerノードをマウスでドラッグして、適当に動かしてみます。- 数秒間あちこちに動かしてみてください。
- 次に、設定した「時間逆行キー」(例: 左Shift)を押し続けてみます。
- ノードが、さきほど動かした軌跡を逆再生するように、過去の位置へ戻っていくはずです。
- キーを離すと、その位置で時間逆行が止まり、再び位置履歴の記録が再開されます。
Debug Draw Pathを ON にしている場合、Sceneビューに水色のラインで移動履歴が描かれます。- 時間逆行中は、このラインをなぞるようにノードが戻っていきます。
5. 簡単なプレイヤー移動スクリプトと組み合わせてテスト
より実戦に近いテストとして、「カーソルキーでプレイヤーを移動させ、Shift で巻き戻す」動作を試してみましょう。
- Assets パネルで右クリック → Create → TypeScript を選択し、
SimpleMove.tsというスクリプトを作成します。 - 以下のような、非常に簡単な移動スクリプトを記述します(参考用・外部依存なし)。
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;
}
}
}
PlayerノードにSimpleMoveをアタッチし、speedを 300〜500 程度に設定します。- ゲームを再生し、カーソルキーでプレイヤーを動かしてみます。
- プレイヤーを少し動かしたあと、Shift キー(または設定した
rewindKeyCode)を押し続けると、通ってきた軌跡を逆再生して戻るはずです。
このように、TimeRewind は他のスクリプトに依存せず、アタッチするだけで時間逆行機能を付与できます。
まとめ
TimeRewindコンポーネントは、任意のノードにアタッチするだけで「過去数秒分の位置を記録し、キー入力やメソッド呼び出しで巻き戻す」機能を提供します。- 記録時間・サンプリング間隔・巻き戻し速度・キー入力の有無などをすべてインスペクタから調整できるため、ゲームのテンポや演出に合わせたチューニングが容易です。
- 外部の GameManager やシングルトンに依存しない完全な独立コンポーネントなので、プロジェクト間のコピペや既存プロジェクトへの導入も簡単です。
- 応用例としては、以下のような使い方が考えられます。
- プレイヤーキャラの「数秒だけ巻き戻し」スキル。
- 敵やギミックが「時間を巻き戻す攻撃」をしたときの演出。
- リプレイやゴースト表示機能のベースとしての利用。
今回の実装をベースに、「位置だけでなく回転やスケールも巻き戻す」「物理挙動と組み合わせる」「特定イベントで自動発動する」など、プロジェクトに合わせて拡張してみてください。
TimeRewind.ts 単体で完結しているので、まずはそのまま導入し、挙動を確認しながらカスタマイズしていくのがおすすめです。
