UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

174 lines (160 loc) 6.48 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; /** * Per-player ring of input bytes, one frame per slot. * * An "input" is a game-defined blob of bytes representing a single tick's * worth of player intent (button bits, mouse deltas, view angles, etc.). The * application owns the encoding; the ring just stores and indexes by frame. * * Used for: * - **Input redundancy**: the most recent N unacked frames of input get * packed into every outgoing packet from the client, so a single dropped * packet doesn't cause a stutter. * - **Reconciliation replay**: after the client rewinds to an authoritative * state, it replays inputs from `[acked + 1, current]` through the * simulation to catch back up to "now". * * Distinct from {@link ActionLog} despite a similar shape: inputs are raw * player intent (sender side), actions are state mutations (sim side). One * input may produce zero or many actions when the simulation processes it. * * Sized to `frame_capacity` frames; oldest is overwritten on wrap. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class InputRing { #buffers; #frame_numbers; #write_ends; /** * @param {{ frame_capacity: number, initial_buffer_size?: number }} options */ constructor({ frame_capacity, initial_buffer_size = 64 }) { if (!Number.isInteger(frame_capacity) || frame_capacity <= 0) { throw new Error(`InputRing: frame_capacity must be a positive integer, got ${frame_capacity}`); } /** * @readonly * @type {number} */ this.frame_capacity = frame_capacity; /** * Per-frame input buffers. Index by `frame % frame_capacity`. * @type {BinaryBuffer[]} * @private */ this.#buffers = new Array(frame_capacity); /** * Per-slot absolute frame number; -1 means slot is empty. * @type {Int32Array} * @private */ this.#frame_numbers = new Int32Array(frame_capacity).fill(-1); /** * Per-slot byte length of valid input data. * @type {Int32Array} * @private */ this.#write_ends = new Int32Array(frame_capacity); for (let i = 0; i < frame_capacity; i++) { this.#buffers[i] = new BinaryBuffer(); this.#buffers[i].setCapacity(initial_buffer_size); } } /** * Write input bytes for a given frame. The callback receives the buffer * positioned at 0 and is expected to write input bytes via the buffer's * `writeXxx` methods. The buffer's `position` after the callback is the * recorded length; data beyond that is ignored on read. * * Overwrites any prior data for that frame slot. * * **Writer must not throw.** The slot's buffer has already been positioned * at 0 by the time the writer runs, and the prior frame's bytes are being * overwritten in-place. If the writer throws partway, the slot's metadata * (frame number, length) still reflects the previous occupant while the * leading bytes have been clobbered with the partial new write — a torn * state that downstream readers cannot detect. A try/catch wrapper here * would force a V8 deopt on the hot write path, which is a worse trade * than the "writer is trusted" contract; treat any throw in a writer * callback as a programming error. * * @param {number} frame * @param {function(BinaryBuffer): void} writer */ write(frame, writer) { assert.isNonNegativeInteger(frame, 'frame'); assert.isFunction(writer, 'writer'); const slot = frame % this.frame_capacity; const buffer = this.#buffers[slot]; buffer.position = 0; writer(buffer); this.#write_ends[slot] = buffer.position; this.#frame_numbers[slot] = frame; } /** * @param {number} frame * @returns {boolean} */ has(frame) { assert.isNonNegativeInteger(frame, 'frame'); const slot = frame % this.frame_capacity; return this.#frame_numbers[slot] === frame; } /** * Read-only access to a frame's buffer. Throws if not present. * Caller must respect the byte length reported by {@link write_end_for}. * * @param {number} frame * @returns {BinaryBuffer} */ buffer_for(frame) { assert.isNonNegativeInteger(frame, 'frame'); if (!this.has(frame)) { throw new Error(`InputRing.buffer_for: frame ${frame} is not present`); } const slot = frame % this.frame_capacity; const buffer = this.#buffers[slot]; buffer.position = 0; return buffer; } /** * @param {number} frame * @returns {number} */ write_end_for(frame) { assert.isNonNegativeInteger(frame, 'frame'); if (!this.has(frame)) { throw new Error(`InputRing.write_end_for: frame ${frame} is not present`); } const slot = frame % this.frame_capacity; return this.#write_ends[slot]; } /** * Iterate frames in `[start_frame, end_frame]` (inclusive) that are present * in the ring. Order is ascending by frame. * * @param {number} start_frame * @param {number} end_frame * @param {function(number, BinaryBuffer, number): void} fn callback receives (frame, buffer_at_pos_0, byte_length) */ for_each_in_range(start_frame, end_frame, fn) { assert.isNonNegativeInteger(start_frame, 'start_frame'); assert.isNonNegativeInteger(end_frame, 'end_frame'); assert.isFunction(fn, 'fn'); // Clamp `start_frame` so we never iterate further back than the ring // could possibly retain. A caller asking for `[0, current_frame]` after // a long disconnect would otherwise walk millions of frames just to // call `has()` on each — the ring only holds the last `frame_capacity`. const earliest_possible = end_frame - this.frame_capacity + 1; const lo = start_frame < earliest_possible ? earliest_possible : start_frame; for (let f = lo; f <= end_frame; f++) { if (this.has(f)) { const buffer = this.buffer_for(f); fn(f, buffer, this.write_end_for(f)); } } } }