@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
183 lines (164 loc) • 6.78 kB
JavaScript
import { assert } from "../../../core/assert.js";
/**
* Walks the {@link ActionLog} backwards to restore prior component state.
*
* For each frame to undo, the engine:
* 1. Parses the frame's records forward to find record start positions
* (a record's full length isn't fixed; it's discovered by reading headers).
* 2. Iterates record positions in reverse.
* 3. For each record, reads its prior_state entries and writes them back into
* live components via the replication adapter for that component class.
*
* Reverse iteration is necessary because multiple actions in the same frame may
* have mutated the same component; the most recent mutation must be undone
* first, then the next-most-recent, and so on, ending at the start-of-frame state.
*
* Forward replay is just running the existing simulation tick again — that's the
* orchestrator's job. RewindEngine only handles the backward direction.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class RewindEngine {
#record_starts;
/**
* @param {{
* action_log: ActionLog,
* world: EntityComponentDataset,
* component_registry: ReplicatedComponentRegistry,
* }} options
*/
constructor({ action_log, world, component_registry }) {
/**
* @type {ActionLog}
*/
this.action_log = action_log;
/**
* @type {EntityComponentDataset}
*/
this.world = world;
/**
* @type {ReplicatedComponentRegistry}
*/
this.component_registry = component_registry;
/**
* Pre-allocated scratch for record-start positions. Grown on demand.
* @type {Int32Array}
* @private
*/
this.#record_starts = new Int32Array(64);
}
/**
* Undo all action records in a single frame, in reverse order.
* Throws if the frame is not present in the log.
*
* @param {number} frame
*/
undo_frame(frame) {
assert.isNonNegativeInteger(frame, 'frame');
if (!this.action_log.has_frame(frame)) {
throw new Error(`RewindEngine.undo_frame: frame ${frame} is not present in the action log`);
}
const buffer = this.action_log.buffer_for(frame);
const end = this.action_log.write_end_for(frame);
// 1. First pass: walk forward to find record boundaries.
let record_count = 0;
while (buffer.position < end) {
if (record_count >= this.#record_starts.length) {
this.#grow_record_starts();
}
this.#record_starts[record_count++] = buffer.position;
this.#skip_record(buffer);
}
// 2. Second pass: iterate records in reverse, restoring prior state.
for (let i = record_count - 1; i >= 0; i--) {
buffer.position = this.#record_starts[i];
this.#restore_prior_state(buffer);
// No need to do anything with the action portion — it stays unapplied.
}
}
/**
* Undo every frame in `(target_frame, current_frame]` so that the world is
* left in start-of-`target_frame + 1` state. Equivalently: the world ends
* up as it was at the END of `target_frame`.
*
* Throws if any frame in the range is missing from the log.
*
* @param {number} current_frame the most recent frame whose actions are applied
* @param {number} target_frame the frame whose end-state we want to restore to
*/
rewind_to(current_frame, target_frame) {
assert.isNonNegativeInteger(current_frame, 'current_frame');
assert.isInteger(target_frame, 'target_frame'); // -1 is valid: "rewind everything"
if (target_frame > current_frame) {
throw new Error(`RewindEngine.rewind_to: target_frame (${target_frame}) > current_frame (${current_frame})`);
}
for (let f = current_frame; f > target_frame; f--) {
this.undo_frame(f);
}
}
/**
* Walk past a single record without restoring anything. Buffer position
* advances to the start of the next record (or to `end` if this was the last).
*
* @param {BinaryBuffer} buffer
* @private
*/
#skip_record(buffer) {
// Skip prior_state entries.
const prior_count = buffer.readUintVar();
for (let i = 0; i < prior_count; i++) {
buffer.readUintVar(); // entity_id
buffer.readUint8(); // component_type_id
const len = buffer.readUint32();
buffer.position += len; // skip prior payload
}
// Skip action.
buffer.readUint8(); // action_type_id
buffer.readUint8(); // sender_id
const action_len = buffer.readUint32();
buffer.position += action_len; // skip action payload
}
/**
* Read the prior_state entries of the record at the buffer's current position
* and write them back into the live components. Buffer position advances past
* the prior_state section (the action portion is left unread).
*
* @param {BinaryBuffer} buffer
* @private
*/
#restore_prior_state(buffer) {
const prior_count = buffer.readUintVar();
for (let i = 0; i < prior_count; i++) {
const entity_id = buffer.readUintVar();
const component_type_id = buffer.readUint8();
const payload_len = buffer.readUint32();
const klass = this.component_registry.class_of(component_type_id);
const adapter = this.component_registry.adapter_for_id(component_type_id);
if (klass === undefined || adapter === undefined) {
// Unknown component type — skip the payload but warn loudly.
buffer.position += payload_len;
console.warn(`RewindEngine: unknown component_type_id ${component_type_id} in action log`);
continue;
}
const component = this.world.getComponent(entity_id, klass);
if (component === undefined) {
// Component was removed since the action was logged. Skip the payload.
// (A future "lifecycle" extension would re-attach it; current model
// doesn't track structural changes.)
buffer.position += payload_len;
continue;
}
adapter.deserialize(buffer, component);
}
}
/**
* @private
*/
#grow_record_starts() {
const old = this.#record_starts;
const next = new Int32Array(old.length * 2);
next.set(old);
this.#record_starts = next;
}
}