@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
138 lines (126 loc) • 4.29 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js";
/**
* Append-only record of action bytes per frame. Useful for offline replay,
* bug reproduction, and demos.
*
* Each entry stores a copy of the action bytes for one frame. The bytes
* typically come from the per-frame `ActionLog` buffer (the action portion
* after `Replicator.pack_for_peer` strips prior-state). The format on the wire
* is the same one `Replicator.unpack_from_peer` consumes, so a recorded
* session can be replayed by feeding the bytes back through a peer.
*
* Persistence to disk / IndexedDB / network is left to the application —
* `serialize_to_buffer` produces a single contiguous `BinaryBuffer` snapshot.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class ReplayLog {
#entries;
constructor() {
/**
* @type {{frame: number, bytes: Uint8Array}[]}
* @private
*/
this.#entries = [];
}
/**
* Append a frame's bytes. The source array is copied; caller may reuse it.
*
* @param {number} frame
* @param {Uint8Array} bytes
* @param {number} length
*/
record(frame, bytes, length) {
assert.isNonNegativeInteger(frame, 'frame');
assert.isNonNegativeInteger(length, 'length');
const copy = new Uint8Array(length);
copy.set(bytes.subarray(0, length));
this.#entries.push({ frame, bytes: copy });
}
/**
* @returns {number}
*/
size() {
return this.#entries.length;
}
/**
* Iterate frames in `[start_frame, end_frame]` (inclusive).
* @param {number} start_frame
* @param {number} end_frame
* @param {function(number, Uint8Array): void} callback receives (frame, bytes)
*/
for_each_in_range(start_frame, end_frame, callback) {
assert.isNonNegativeInteger(start_frame, 'start_frame');
assert.isNonNegativeInteger(end_frame, 'end_frame');
assert.isFunction(callback, 'callback');
for (let i = 0; i < this.#entries.length; i++) {
const e = this.#entries[i];
if (e.frame >= start_frame && e.frame <= end_frame) {
callback(e.frame, e.bytes);
}
}
}
/**
* Drop all entries.
*/
clear() {
this.#entries.length = 0;
}
/**
* Frame number of the first entry, or -1 if empty.
*/
earliest_frame() {
return this.#entries.length === 0 ? -1 : this.#entries[0].frame;
}
/**
* Frame number of the last entry, or -1 if empty.
*/
latest_frame() {
return this.#entries.length === 0 ? -1 : this.#entries[this.#entries.length - 1].frame;
}
/**
* Serialize the entire log to a fresh buffer. Format:
* ```
* varint: entry_count
* loop:
* varint: frame
* varint: bytes_length
* bytes: payload
* ```
*
* @param {BinaryBuffer} [buffer] if provided, written into; otherwise a fresh one is created
* @returns {BinaryBuffer}
*/
serialize_to_buffer(buffer) {
const buf = buffer === undefined ? new BinaryBuffer() : buffer;
buf.writeUintVar(this.#entries.length);
for (let i = 0; i < this.#entries.length; i++) {
const e = this.#entries[i];
buf.writeUintVar(e.frame);
buf.writeUintVar(e.bytes.length);
buf.writeBytes(e.bytes, 0, e.bytes.length);
}
return buf;
}
/**
* Build a ReplayLog from a previously-serialized buffer. Reads from the
* buffer's current position; advances the position past the consumed bytes.
*
* @param {BinaryBuffer} buffer
* @returns {ReplayLog}
*/
static deserialize_from_buffer(buffer) {
const log = new ReplayLog();
const count = buffer.readUintVar();
for (let i = 0; i < count; i++) {
const frame = buffer.readUintVar();
const length = buffer.readUintVar();
const bytes = new Uint8Array(length);
buffer.readUint8Array(bytes, 0, length);
log.#entries.push({ frame, bytes });
}
return log;
}
}