UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

207 lines (181 loc) 8.22 kB
import { assert } from "../../../core/assert.js"; /** * Initial-sync state stream generator. * * When a new peer joins, it needs the current state of the world before any * incremental {@link ActionLog} updates make sense. Snapshotter walks every * entity in scope, asks each replicated component for its serialized state, * and writes a flat record stream into a `BinaryBuffer`. The receiver reads * the stream, creates local entities, attaches components, and registers each * in the local {@link ReplicationSlotTable} at the matching network_id. * * This is a one-shot operation, separate from the per-tick action machinery: * - Uses the same {@link BinaryClassSerializationAdapter}s as the action log. * - Does NOT go through the executor — initial sync isn't a reversible mutation. * - Wire format is minimal; no action_type tags, no prior state. * * Wire format: * ``` * varint: entity_count * loop entity_count times: * varint: network_id * uint8: component_count * loop component_count times: * uint8: component_type_id * uint32: payload_len * bytes: payload (adapter.serialize) * ``` * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ /** * Walk every entity in the scope and write a snapshot stream into `buffer`. * * @param {{ * buffer: BinaryBuffer, * world: EntityComponentDataset, * slot_table: ReplicationSlotTable, * component_registry: ReplicatedComponentRegistry, * entity_iter: function(function(number): void): void, * }} options where `entity_iter(cb)` invokes `cb(entity_id)` for each in-scope entity */ export function snapshotter_emit({ buffer, world, slot_table, component_registry, entity_iter }) { assert.isFunction(entity_iter, 'entity_iter'); // Two passes: first count entities (so we can write the count up front), // then write each. Single-pass with back-patch would also work; counting // is cheap and keeps the wire format simpler to read. const entity_ids = []; entity_iter(id => entity_ids.push(id)); buffer.writeUintVar(entity_ids.length); for (let i = 0; i < entity_ids.length; i++) { const entity_id = entity_ids[i]; const network_id = slot_table.network_for(entity_id); if (network_id < 0) { throw new Error(`snapshotter_emit: entity ${entity_id} is not registered in the slot table`); } // Determine how many replicated components this entity has. const type_count = component_registry.type_count(); const present_type_ids = []; for (let type_id = 0; type_id < type_count; type_id++) { const klass = component_registry.class_of(type_id); if (world.hasComponent(entity_id, klass)) { present_type_ids.push(type_id); } } buffer.writeUintVar(network_id); buffer.writeUint8(present_type_ids.length); for (let j = 0; j < present_type_ids.length; j++) { const type_id = present_type_ids[j]; const klass = component_registry.class_of(type_id); const adapter = component_registry.adapter_for_id(type_id); const component = world.getComponent(entity_id, klass); buffer.writeUint8(type_id); const len_position = buffer.position; buffer.writeUint32(0); const payload_start = buffer.position; adapter.serialize(buffer, component); const payload_end = buffer.position; buffer.position = len_position; buffer.writeUint32(payload_end - payload_start); buffer.position = payload_end; } } } /** * Apply a snapshot stream to a (typically empty) world, creating each entity * and attaching components. * * @param {{ * buffer: BinaryBuffer, * buffer_end: number, * world: EntityComponentDataset, * slot_table: ReplicationSlotTable, * component_registry: ReplicatedComponentRegistry, * }} options buffer is read starting from its current position; reading stops at `buffer_end` */ export function snapshotter_apply({ buffer, buffer_end, world, slot_table, component_registry }) { assert.isNonNegativeInteger(buffer_end, 'buffer_end'); const entity_count = buffer.readUintVar(); for (let i = 0; i < entity_count; i++) { const network_id = buffer.readUintVar(); const component_count = buffer.readUint8(); const local_entity_id = world.createEntity(); slot_table.allocate_at(network_id, local_entity_id); for (let j = 0; j < component_count; j++) { const type_id = buffer.readUint8(); const payload_len = buffer.readUint32(); const klass = component_registry.class_of(type_id); const adapter = component_registry.adapter_for_id(type_id); if (klass === undefined || adapter === undefined) { // Unknown component type — skip this payload to stay aligned. buffer.position += payload_len; continue; } // Allocate a fresh component instance via the class's no-arg constructor. // This relies on the engine convention that components are constructible without args. const component = new klass(); adapter.deserialize(buffer, component); world.addComponentToEntity(local_entity_id, component); } if (buffer.position > buffer_end) { throw new Error(`snapshotter_apply: read past buffer_end at network_id ${network_id}`); } } } /** * Apply a snapshot stream to existing entities, updating their components in * place via `adapter.deserialize`. Wire format is identical to {@link snapshotter_emit}; * only the apply semantics differ. * * Used by the recovery flow: server emits the current state of a list of * mutated entities; client applies those bytes to its already-existing local * counterparts. Entities the receiver doesn't know about are skipped silently * (their payload bytes are still consumed so the parser stays aligned). * * Skipped also: unknown `component_type_id` (forward-compat with newer servers * that replicate components the receiver doesn't recognise), and components * the local entity doesn't currently have (the receiver hasn't been told to * attach this component — recovery doesn't change entity composition, only state). * * @param {{ * buffer: BinaryBuffer, * buffer_end: number, * world: EntityComponentDataset, * slot_table: ReplicationSlotTable, * component_registry: ReplicatedComponentRegistry, * }} options */ export function snapshotter_apply_to_existing({ buffer, buffer_end, world, slot_table, component_registry }) { assert.isNonNegativeInteger(buffer_end, 'buffer_end'); const entity_count = buffer.readUintVar(); for (let i = 0; i < entity_count; i++) { const network_id = buffer.readUintVar(); const component_count = buffer.readUint8(); const local_entity_id = slot_table.entity_for(network_id); for (let j = 0; j < component_count; j++) { const type_id = buffer.readUint8(); const payload_len = buffer.readUint32(); const klass = component_registry.class_of(type_id); const adapter = component_registry.adapter_for_id(type_id); // Three skip cases — all consume the payload to keep the parser aligned. if (klass === undefined || adapter === undefined) { buffer.position += payload_len; continue; } if (local_entity_id < 0) { buffer.position += payload_len; continue; } const component = world.getComponent(local_entity_id, klass); if (component === undefined) { buffer.position += payload_len; continue; } adapter.deserialize(buffer, component); } if (buffer.position > buffer_end) { throw new Error(`snapshotter_apply_to_existing: read past buffer_end at network_id ${network_id}`); } } }