UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

274 lines (253 loc) 10.9 kB
import { FunctionCompiler } from "../../../core/function/FunctionCompiler.js"; /** * Base class for synchronous, serializable, replicable game-state mutations. * * Distinct from `core/process/undo/Action.js` (async, designed for editor * undo/redo). `SimAction` is the netcode-tier counterpart: sync `apply`, * no `revert` (rewind is handled by the executor's prior-bytes capture), * and serializable to/from a `BinaryBuffer`. * * **Contract**: any mutation to replicated state goes through a `SimAction` * passed to {@link SimActionExecutor#execute}. Anything else must be a * deterministic consequence of the simulation, or rewind / replay / * replication will desync. * * Wire vs local: actions reference entities by `network_id` (peer-shared); * `executor.slot_table.entity_for(network_id)` translates to the local id. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class SimAction { /** * Stable network identifier for this action class. Set by * {@link SimActionRegistry.register}. Do not assign manually. * @type {number} */ static type_id = -1; /** * Apply this action's effect to the world. The executor has already captured * prior bytes of the components reported by {@link affected_components} before * this is called, so direct mutation of those components is safe. * * @param {EntityComponentDataset} world * @param {SimActionExecutor} executor context — exposes `slot_table` for * network_id ↔ entity_id translation, and other framework state * @returns {void} */ apply(world, executor) { throw new Error("SimAction subclasses must override apply(world, executor)."); } /** * Report each (local_entity_id, component_class) pair this action will mutate. * The executor invokes `callback(local_entity_id, component_class)` for each * and uses the result to capture prior bytes for rewind. * * If your action stores a `network_id` (the canonical wire form), translate it: * ``` * affected_components(cb, executor) { * const local = executor.slot_table.entity_for(this.network_id); * cb(local, Transform); * } * ``` * * Default: no affected components (e.g. for a pure event-style action). * * @param {function(number, Function): void} callback * @param {SimActionExecutor} executor context */ affected_components(callback, executor) { // default: nothing } /** * Serialize this action's forward parameters into the buffer. Do NOT include * prior state — the executor captures that via affected_components+adapters. * * @param {BinaryBuffer} buffer * @returns {void} */ serialize(buffer) { throw new Error("SimAction subclasses must override serialize(buffer)."); } /** * Deserialize forward parameters from the buffer into this instance. * * @param {BinaryBuffer} buffer * @returns {void} */ deserialize(buffer) { throw new Error("SimAction subclasses must override deserialize(buffer)."); } /** * Reset to a blank state for reuse from the registry's pool. * Override if your action holds non-trivial fields. */ reset() { // default: nothing } } /** * Fast type check, no instanceof needed. * @readonly * @type {boolean} */ SimAction.prototype.isSimAction = true; /** * Schema-type → code-gen recipe. Each entry supplies the source-string * fragments {@link SimAction.extend} concatenates into specialised * serialize / deserialize / reset bodies. * * Same pattern as `core/binary/data_view/buildAccessor.js`. */ const SCHEMA_CODEGEN = { uint8: { default_literal: '0', write_expr: (f) => `buffer.writeUint8(this.${f})`, read_expr: () => `buffer.readUint8()` }, uint16: { default_literal: '0', write_expr: (f) => `buffer.writeUint16(this.${f})`, read_expr: () => `buffer.readUint16()` }, uint32: { default_literal: '0', write_expr: (f) => `buffer.writeUint32(this.${f})`, read_expr: () => `buffer.readUint32()` }, int8: { default_literal: '0', write_expr: (f) => `buffer.writeInt8(this.${f})`, read_expr: () => `buffer.readInt8()` }, int16: { default_literal: '0', write_expr: (f) => `buffer.writeInt16(this.${f})`, read_expr: () => `buffer.readInt16()` }, int32: { default_literal: '0', write_expr: (f) => `buffer.writeInt32(this.${f})`, read_expr: () => `buffer.readInt32()` }, uintVar: { default_literal: '0', write_expr: (f) => `buffer.writeUintVar(this.${f})`, read_expr: () => `buffer.readUintVar()` }, float32: { default_literal: '0', write_expr: (f) => `buffer.writeFloat32(this.${f})`, read_expr: () => `buffer.readFloat32()` }, float64: { default_literal: '0', write_expr: (f) => `buffer.writeFloat64(this.${f})`, read_expr: () => `buffer.readFloat64()` }, bool: { default_literal: 'false', write_expr: (f) => `buffer.writeUint8(this.${f} ? 1 : 0)`, read_expr: () => `(buffer.readUint8() !== 0)` }, }; /** * Field names rejected by {@link SimAction.extend} because they collide * with the generated function bodies (which use `buffer` as a parameter) * or with framework methods/properties on `SimAction.prototype`. */ const RESERVED_FIELD_NAMES = new Set([ 'buffer', 'constructor', 'prototype', 'this', 'super', 'apply', 'affected_components', 'serialize', 'deserialize', 'reset', 'isSimAction', ]); /** * Generate a `SimAction` subclass declaratively. The helper writes the * constructor / serialize / deserialize / reset boilerplate via * `FunctionCompiler` so each action ends up with a specialised, straight- * line function instead of a generic schema-loop. * * ```js * const MoveAction = SimAction.extend({ * type: 'Move', * schema: { network_id: 'uintVar', dx: 'float32' }, * affects(executor) { * const entity = executor.slot_table.entity_for(this.network_id); * return entity < 0 ? [] : [[entity, Transform]]; * }, * apply(world, executor) { * const entity = executor.slot_table.entity_for(this.network_id); * if (entity < 0) return; * world.getComponent(entity, Transform).position[0] += this.dx; * }, * }); * * new MoveAction(network_id, dx); // positional, in schema-key order * ``` * * Schema key insertion order is also wire-byte order and constructor * positional order. Schema types must be one of {@link SCHEMA_CODEGEN}'s * keys; for more complex shapes (vec3, quat, bitfields, variable length) * subclass `SimAction` directly. `type` is retained as * `action_type_name` for diagnostics; the wire `type_id` is assigned * by the registry at registration time. * * @param {{ * type: string, * schema?: Object<string, string>, * affects?: (executor: SimActionExecutor) => Array<[number, Function]>, * apply: (world: EntityComponentDataset, executor: SimActionExecutor) => void, * }} spec * @returns {Function} a `SimAction` subclass ready to register */ SimAction.extend = function ({ type, schema = {}, affects, apply }) { if (typeof type !== 'string' || type.length === 0) { throw new Error("SimAction.extend: `type` must be a non-empty string"); } if (typeof apply !== 'function') { throw new Error("SimAction.extend: `apply` must be a function"); } if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) { throw new Error("SimAction.extend: `schema` must be a plain object {field: 'type'}"); } const field_names = Object.keys(schema); for (const name of field_names) { if (typeof name !== 'string' || name.length === 0) { throw new Error("SimAction.extend: schema field name must be a non-empty string"); } if (RESERVED_FIELD_NAMES.has(name)) { throw new Error(`SimAction.extend: '${name}' is a reserved field name and would collide with framework methods/properties`); } if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) { throw new Error(`SimAction.extend: schema field name '${name}' is not a valid JS identifier`); } const type_name = schema[name]; if (!SCHEMA_CODEGEN[type_name]) { throw new Error(`SimAction.extend: unknown schema type '${type_name}' for field '${name}' — supported: ${Object.keys(SCHEMA_CODEGEN).join(', ')}`); } } // Code-gen the four methods as straight-line sequences specialised // for this schema. const serialize_body = field_names .map((name) => `${SCHEMA_CODEGEN[schema[name]].write_expr(name)};`) .join('\n'); const serialize_fn = FunctionCompiler.INSTANCE.compile({ args: ['buffer'], code: serialize_body, name: `${type}__serialize`, }); const deserialize_body = field_names .map((name) => `this.${name} = ${SCHEMA_CODEGEN[schema[name]].read_expr()};`) .join('\n'); const deserialize_fn = FunctionCompiler.INSTANCE.compile({ args: ['buffer'], code: deserialize_body, name: `${type}__deserialize`, }); const reset_body = field_names .map((name) => `this.${name} = ${SCHEMA_CODEGEN[schema[name]].default_literal};`) .join('\n'); const reset_fn = FunctionCompiler.INSTANCE.compile({ args: [], code: reset_body, name: `${type}__reset`, }); // Constructor body: positional args, undefined → default literal. const init_body = field_names .map((name) => { const lit = SCHEMA_CODEGEN[schema[name]].default_literal; return `this.${name} = ${name} !== undefined ? ${name} : ${lit};`; }) .join('\n'); const init_fn = field_names.length === 0 ? null : FunctionCompiler.INSTANCE.compile({ args: field_names, code: init_body, name: `${type}__init`, }); class GeneratedSimAction extends SimAction { constructor(...args) { super(); if (init_fn !== null) { init_fn.apply(this, args); } } apply(world, executor) { return apply.call(this, world, executor); } affected_components(callback, executor) { if (affects === undefined) return; const pairs = affects.call(this, executor); if (!pairs) return; for (let i = 0; i < pairs.length; i++) { callback(pairs[i][0], pairs[i][1]); } } } GeneratedSimAction.prototype.serialize = serialize_fn; GeneratedSimAction.prototype.deserialize = deserialize_fn; GeneratedSimAction.prototype.reset = reset_fn; GeneratedSimAction.action_type_name = type; return GeneratedSimAction; };