UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

183 lines (164 loc) 6.78 kB
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; } }