UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

974 lines (756 loc) • 30.3 kB
import { assert } from "../../core/assert.js"; import { array_copy_unique } from "../../core/collection/array/array_copy_unique.js"; import Signal from "../../core/events/signal/Signal.js"; import { IllegalStateException } from "../../core/fsm/exceptions/IllegalStateException.js"; import { noop } from "../../core/function/noop.js"; import { ResourceAccessKind } from "../../core/model/ResourceAccessKind.js"; import { computeSystemName } from "./computeSystemName.js"; import { EntityComponentDataset } from "./EntityComponentDataset.js"; import { EntityObserver } from "./EntityObserver.js"; import { System, SystemState } from "./System.js"; import { computeSystemComponentDependencyGraph } from "./system/computeSystemComponentDependencyGraph.js"; import { system_validate_class } from "./system_validate_class.js"; /** * @readonly * @enum {number} */ export const EntityManagerState = { Initial: 0, Starting: 1, Running: 2, Failed: 3, Stopping: 4, Stopped: 5 }; /** * In seconds * @type {number} */ const DEFAULT_SYSTEM_STARTUP_TIMEOUT_CHECK = 5; /** * Brings together {@link System}s and an {@link EntityComponentDataset} * Main entry point into the simulation process. * * @example * const em = new EntityManager() * * em.addSystem(new MySystem()) * em.addSystem(new MyOtherSystem()) * * em.attachDataset(new EntityComponentDataset()) * * em.startup(); * * // .. * * em.simulate(0.016); // advance simulation forward by 16ms * * // .. * * em.shutdown(); * * // .. * * em.detachDataset(); * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class EntityManager { /** * Registered systems * @readonly * @type {System[]} */ systems = []; /** * Current order of execution. * Note: this can be smaller that the number of registered systems, as systems are only added to the execution order once their startup finishes * @readonly * @private * @type {System[]} */ systemsExecutionOrder = []; /** * Observers associated with individual systems, responsible for {@link System.link}/{@link System.unlink} * @readonly * @private * @type {Map<System,EntityObserver>} */ systemObservers = new Map(); /** * @readonly */ on = { systemStarted: new Signal(), systemStopped: new Signal(), /** * @type {Signal<System>} */ systemAdded: new Signal(), systemRemoved: new Signal(), }; /** * * @type {EntityManagerState} */ state = EntityManagerState.Initial; /** * Track remainders of simulation time for fixed step * Needed for accurate time keeping * @private * @readonly * @type {Map<System, number>} */ systemAccumulatedFixedStepTime = new Map(); /** * Value used to execute {@link System.fixedUpdate} * In seconds. * The default is ~60 Hz, which should be sufficient for most use-cases. * Setting this value higher will reduce the total number of steps per second, and typically lead to better performance. * Setting this value lower will increase number of steps per second, increasing simulation accuracy at the cost of performance. * Make sure you understand the implications when modifying this. * A good safe range is [0.001 - 0.2] * @type {number} */ fixedUpdateStepSize = 0.016666666666; /** * How long can any given system run it's {@link System.fixedUpdate}, per simulation update * This is value allows us to avoid cases where {@link System.fixedUpdate} takes longer that its time step and causes a runaway freeze * In milliseconds. * @type {number} */ fixedUpdatePerSystemExecutionTimeLimit = 15; /** * Currently attached dataset. * Do not modify directly, instead use {@link attachDataset} and {@link detachDataset} respectively. * @type {EntityComponentDataset} */ dataset = null; /** * Whenever a system is added or removed, optimal execution plan changes, this flag tells us to rebuild the current plan * see {@link #systemsExecutionOrder} * @type {boolean} * @private */ __execution_order_needs_update = true; /** * Rebuild execution order * @private */ updateExecutionOrder() { // console.time('updateExecutionOrder') const order = this.systemsExecutionOrder; order.splice(0, order.length); const systems = this.systems; const system_count = systems.length; let executable_system_count = 0; for (let i = 0; i < system_count; i++) { const system = systems[i]; if (system.state.get() !== SystemState.RUNNING) { // exclude systems that are not running continue; } if (system.update === noop && system.fixedUpdate === noop) { // not a simulation system continue; } order[executable_system_count++] = system; } // build dependency graphs amongst components const dependency_graph = computeSystemComponentDependencyGraph(executable_system_count, order); /** * * @param {System} system * @returns {number} higher values means it should be scheduled before others */ function scoreSystem(system) { let result = 0; const components = system.referenced_components; const component_count = components.length; for (let i = 0; i < component_count; i++) { const component = components[i]; result += 0.0001; // count number of incoming edges const attached_edge_count = dependency_graph.getNodeContainer(component).getIncomingEdgeCount(); const access = system.getAccessForComponent(component); if ((access & ResourceAccessKind.Create) !== 0) { result += attached_edge_count * 4 } else if ((access & ResourceAccessKind.Write) !== 0) { result += attached_edge_count * 2 } else { result += attached_edge_count; } } return result; } // pre-score systems for faster sorting const scores = new Map(); for (let i = 0; i < executable_system_count; i++) { const system = order[i]; scores.set(system, scoreSystem(system)); } order.sort((a, b) => { return scores.get(b) - scores.get(a); }); // clear update flag this.__execution_order_needs_update = false; // console.timeEnd('updateExecutionOrder') } /** * Get list of all components referenced by active systems * @returns {Class[]} */ getComponentTypeMap() { const componentTypes = []; const systems = this.systems; const systemCount = systems.length; for (let i = 0; i < systemCount; i++) { /** * * @type {System} */ const system = systems[i]; const referencedComponents = system.referenced_components; array_copy_unique(referencedComponents, 0, componentTypes, componentTypes.length, referencedComponents.length); } return componentTypes; } /** * Link a given dataset, will cause associated systems to work with the new dataset as well * @param {EntityComponentDataset} dataset * @throws {Error} if another dataset is attached * @throws {Error} if dataset is incompatible with current system set */ attachDataset(dataset) { assert.defined(dataset, "dataset"); assert.notNull(dataset, "dataset"); assert.equal(dataset.isEntityComponentDataset, true, "dataset must be an instance of EntityComponentDataset"); //check if another dataset is attached if (this.dataset !== null) { if (this.dataset === dataset) { // special case, we already have this dataset attached. Nothing to more to do return; } throw new Error("Illegal status, another dataset is currently attached, you must detach it first"); } const localComponentTypeMap = this.getComponentTypeMap(); // ensure compatibility with system-used component map dataset.registerManyComponentTypes(localComponentTypeMap); this.dataset = dataset; for (const system of this.systems) { if (system.state.get() !== SystemState.RUNNING) { // not ready continue; } system.handleDatasetAttached(dataset); } for (const [s, observer] of this.systemObservers) { if (s.state.get() !== SystemState.RUNNING) { continue; } dataset.addObserver(observer, true); } } /** * Dissociate currently bound dataset, will cause attached systems to unlink entities held in the dataset. * Idempotent, if no dataset is attached - nothing will happen. */ detachDataset() { const dataset = this.dataset; if (dataset === null) { //no dataset attached return; } //remove system observers for (const [_, observer] of this.systemObservers) { if (!observer.isConnected) { continue; } dataset.removeObserver(observer, true); } for (const system of this.systems) { if (system.state.get() !== SystemState.RUNNING) { // not running continue; } system.handleDatasetAttached(dataset); } this.dataset = null; } /** * @template T * @param {Class<T>} systemClass * @returns {boolean} */ hasSystem(systemClass) { return this.getSystem(systemClass) !== null; } /** * @template T * @param {Class<T>} systemClass * @returns {T|null} */ getSystem(systemClass) { assert.isFunction(systemClass, 'systemClass'); const systems = this.systems; const numSystems = systems.length; for (let i = 0; i < numSystems; i++) { const system = systems[i]; if (system instanceof systemClass) { return system; } } //not found return null; } /** * @deprecated use {@link EntityComponentDataset.getComponentClassByName} instead * @template T * @param {string} className * @returns {null|Class<T>} */ getComponentClassByName(className) { assert.isString(className, 'className'); const componentTypes = this.getComponentTypeMap(); let i = 0; const l = componentTypes.length; for (; i < l; i++) { const componentClass = componentTypes[i]; const name = componentClass.typeName; if (name === className) { return componentClass; } } return null; } /** * Advance simulation forward by a specified amount of time * @param {number} timeDelta in seconds */ simulate(timeDelta) { assert.isNumber(timeDelta, 'timeDelta'); assert.notNaN(timeDelta, 'timeDelta'); assert.greaterThanOrEqual(timeDelta, 0, 'timeDelta must be >= 0'); assert.isFinite(timeDelta, 'timeDelta'); if (this.__execution_order_needs_update) { this.updateExecutionOrder(); } /** * * @type {System[]} */ const systems = this.systemsExecutionOrder; const system_count = systems.length; const fixed_step = this.fixedUpdateStepSize; const accumulatedTime = this.systemAccumulatedFixedStepTime; assert.notNaN(fixed_step, 'fixed_step'); assert.greaterThan(fixed_step, 0, 'fixed_step must be greater than 0'); for (let i = 0; i < system_count; i++) { const system = systems[i]; // Perform fixed-step update if (system.fixedUpdate !== noop) { let accumulated_time = accumulatedTime.get(system) + timeDelta; const t0 = performance.now(); while (accumulated_time >= fixed_step) { try { system.fixedUpdate(fixed_step) } catch (e) { console.error(`Failed during fixedUpdate of system '${computeSystemName(system)}': `, e); } accumulated_time -= fixed_step; if (performance.now() - t0 > this.fixedUpdatePerSystemExecutionTimeLimit) { console.warn(`.fixedUpdate of system '${computeSystemName(system)}' is falling behind current clock due to slow execution. Retardation is done to avoid severe performance impact.`); break; } } // record whatever remains accumulatedTime.set(system, accumulated_time); } if (system.update !== noop) { try { system.update(timeDelta); } catch (e) { console.error(`Failed during update of system '${computeSystemName(system)}': `, e); } } } } /** * If the {@link EntityManager} is already started, the system will be started automatically before being added * @param {System} system * @returns {Promise} resolution depends on {@link EntityManager}'s state, if running - promise resolves after system startup. Otherwise, the promise will be resolved immediately. * @throws {IllegalStateException} */ addSystem(system) { assert.defined(system, "system"); assert.isInstanceOf(system, System, 'system', 'System'); const existing = this.getSystem(Object.getPrototypeOf(system).constructor); if (existing !== null) { if (existing === system) { //system already added, do nothing return Promise.resolve(); } else { throw new IllegalStateException(`Another instance of system '${computeSystemName(system)}' is already registered`); } } try { system_validate_class(system); } catch (e) { console.error(`Validation of '${computeSystemName(system)}' failed : `, e, system); } //check system state const systemState = system.state.getValue(); const valid_states = [SystemState.INITIAL, SystemState.STOPPED]; if (valid_states.indexOf(systemState) === -1) { //illegal state throw new IllegalStateException(`System must be in one of these states: [${valid_states.join(",")}], instead was ${systemState}`); } const systems = this.systems; const systemIndex = systems.length; systems[systemIndex] = system; //build observer const entityObserver = new EntityObserver(system.dependencies, system.link, system.unlink, system); this.systemObservers.set(system, entityObserver); let result_promise; if (this.state === EntityManagerState.Running) { //initialize the system result_promise = new Promise((resolve, reject) => { this.startSystem(system, resolve, reject); }); } else { result_promise = Promise.resolve(); } // link dependency components this.dataset?.registerManyComponentTypes(system.referenced_components); this.systemAccumulatedFixedStepTime.set(system, 0); this.on.systemAdded.send1(system); return result_promise; } /** * * @param {System} system * @returns {Promise<boolean>} */ async removeSystem(system) { assert.defined(system, "system"); assert.isInstanceOf(system, System, 'system', 'System'); const systemIndex = this.systems.indexOf(system); if (systemIndex === -1) { //system not found return false; } //unlink system observer const systemObserver = this.systemObservers.get(system); this.systemObservers.delete(system); assert.notEqual(systemObserver, undefined, "System observer is undefined, it was possibly removed illegally or was not created"); //shutdown system this.systems.splice(systemIndex, 1); // request exec order update this.__execution_order_needs_update = true; if (this.dataset !== null) { // remove observed and unlink entities this.dataset.removeObserver(systemObserver, true); system.handleDatasetDetached(this.dataset); } await new Promise((resolve, reject) => { this.stopSystem(system, resolve, (reason) => { reject(`system ${computeSystemName(system)} shutdown failed: ${reason}`); }); }); this.systemAccumulatedFixedStepTime.delete(system); this.on.systemRemoved.send1(system); return true; } /** * @private * @param {System} system * @param {function(system: System)} successCallback * @param {function(reason:*)} errorCallback */ stopSystem(system, successCallback, errorCallback) { const self = this; try { system.state.set(SystemState.STOPPING); } catch (e) { console.error(`Failed to set system state to STOPPING`, e); errorCallback(e); return; } function systemReady() { system.state.set(SystemState.STOPPED); self.on.systemStopped.send1(system); successCallback(system); } function systemFailed(reason) { errorCallback(reason); } try { const promise = system.shutdown(self); assert.defined(promise, "promise"); assert.notNull(promise, "promise"); assert.isFunction(promise.then, "promise.then"); promise.then(systemReady, systemFailed); } catch (e) { console.error(`Failed to execute system shutdown`, e); errorCallback(e); } } /** * @private * @param {System} system * @param {function(system: System)} successCallback * @param {function(reason:*)} errorCallback */ startSystem(system, successCallback, errorCallback) { assert.defined(system, "system"); assert.isFunction(successCallback, "successCallback"); assert.isFunction(errorCallback, "errorCallback"); if (system.state.getValue() === SystemState.RUNNING) { //system is already running console.warn(`System '${computeSystemName(system)}' is already running, nothing to do`); successCallback(system); return; } const self = this; try { system.state.set(SystemState.STARTING); } catch (e) { console.error(`Failed to set system state to STARTING`, e); errorCallback(e); return; } function systemReady() { system.state.set(SystemState.RUNNING); self.on.systemStarted.dispatch(system); const i = self.systems.indexOf(system); assert.notEqual(i, -1, "System was not found in the system list"); if (self.dataset !== null) { system.handleDatasetAttached(self.dataset); } const observer = self.systemObservers.get(system); if (observer !== undefined) { // only link the observer once startup has succeeded self.dataset?.addObserver(observer, true); } // request exec order update self.__execution_order_needs_update = true; successCallback(system); } try { const deprecation_check = () => { throw new Error("'success' callback was deprecated in meep 2.128.0, please use `async` instead."); }; // Link EntityManager if (system.entityManager === null || system.entityManager === undefined) { system.entityManager = this; } else if (system.entityManager !== this) { throw new Error(`System is bound to another EntityManager`); } const promise = system.startup(self, deprecation_check); assert.defined(promise, "promise"); assert.notNull(promise, "promise"); assert.isFunction(promise.then, "promise.then"); const startup_check_timeout = setTimeout( () => { console.warn(`System '${computeSystemName(system)}' failed to complete startup process in ${DEFAULT_SYSTEM_STARTUP_TIMEOUT_CHECK}s, check your code for potential deadlocks.`); }, DEFAULT_SYSTEM_STARTUP_TIMEOUT_CHECK * 1000, ); promise.finally(() => { clearTimeout(startup_check_timeout); }); promise.then(systemReady, errorCallback); } catch (e) { console.error(`Failed to execute system startup`, e); errorCallback(e); } } /** * This method is asynchronous by nature, it has to wait for each system to finish its own startup. * Make sure to register callback to be notified when the startup has finished * @param {function} [readyCallback] executed once entity manager successfully completes startup * @param {function} [errorCallback] executed if entity manager encounters an error during startup */ startup(readyCallback = noop, errorCallback = console.error) { if (this.state === EntityManagerState.Starting) { throw new IllegalStateException(`System is currently in starting state`); } if (this.state === EntityManagerState.Running) { // already running, do nothing readyCallback(); return; } assert.isFunction(readyCallback, 'readyCallback'); assert.isFunction(errorCallback, 'errorCallback'); this.state = EntityManagerState.Starting; const self = this; const systems = this.systems; let readyCount = 0; const expectedReadyCount = systems.length; console.log(`EntityManager startup initialized, starting ${expectedReadyCount} systems.`); function finalizeStartup() { self.state = EntityManagerState.Running; console.log(`EntityManager startup finished, all systems started`); try { readyCallback(); } catch (e) { console.error("All systems were started OK, but readyCallback failed.", e); } } function systemReady() { //startup all systems readyCount++; if (readyCount === expectedReadyCount) { finalizeStartup(); } } let firstFailure = true; function systemError(system, error) { console.error("Failed to start system", system, 'Error:', error); if (firstFailure) { firstFailure = false; self.state = EntityManagerState.Failed; errorCallback(error); } } if (expectedReadyCount === 0) { //no systems registered, we're done finalizeStartup(); return; } for (const system of systems) { //ensure eventManager are there //start the system const cbOK = systemReady.bind(null, system); const cbError = systemError.bind(null, system); this.startSystem(system, cbOK, cbError); } } /** * * @param {Class} systemClass * @returns {Promise.<System>} */ promiseSystem(systemClass) { /** * * @type {EntityManager} */ const em = this; return new Promise(function (resolve, reject) { function systemAdded(s) { if (s instanceof systemClass) { //unregister listener em.on.systemAdded.remove(systemAdded); resolve(s); } } const system = em.getSystem(systemClass); if (system !== null) { resolve(system); } else { em.on.systemAdded.add(systemAdded); } }); } /** * @param {Class} SystemClass * @param {SystemState} state * @returns {Promise.<System>} */ promiseSystemInState(SystemClass, state) { assert.defined(SystemClass, 'SystemClass'); assert.enum(state, SystemState, 'state'); const em = this; return new Promise(function (resolve, reject) { const pSystem = em.promiseSystem(SystemClass); pSystem.then(function (system) { function tryProcessSystem() { if (system.state.get() !== state) { system.state.onChanged.addOne(tryProcessSystem); } else { resolve(system); } } tryProcessSystem(); }, reject); }); } /** * This method is asynchronous by nature, it will not be done until each system has finished its shutdown * Make sure to use callback to be notified when the shutdown has completed * @param {function} [readyCallback] Called when shutdown finishes successfully. defaults to no-operation * @param {function} [errorCallback] Called when an error occurs during the shutdown process. defaults to console error output */ shutdown(readyCallback = noop, errorCallback = console.error) { if (this.state !== EntityManagerState.Running) { throw new IllegalStateException(`System is wrong state, expected '${EntityManagerState.Running}'`); } assert.isFunction(readyCallback, 'readyCallback'); assert.isFunction(errorCallback, 'errorCallback'); this.state = EntityManagerState.Stopping; if (this.dataset !== null) { // a dataset is still attached, to properly dispose of all the component-bound resources, we first detach the dataset this.detachDataset(); } const self = this; const systems = this.systems; let readyCount = 0; const expectedReadyCount = systems.length; function finalizeShutdown() { self.state = EntityManagerState.Stopped; try { readyCallback(); } catch (e) { console.error("All systems were shutdown OK, but readyCallback failed.", e); } } function systemReady(system) { //startup all systems readyCount++; system.state.set(SystemState.STOPPED); self.on.systemStopped.send1(system); if (readyCount === expectedReadyCount) { finalizeShutdown(); } } let firstFailure = true; function systemError(system) { console.error("Failed to shutdown system", system); if (firstFailure) { firstFailure = false; self.state = EntityManagerState.Failed; errorCallback(); } } if (expectedReadyCount === 0) { //no systems registered, we're done finalizeShutdown(); } for (const system of systems) { system.state.set(SystemState.STOPPING); const cbOK = systemReady.bind(null, system); const cbError = systemError.bind(null, system); try { const promise = system.shutdown(this); assert.defined(promise, 'promise'); assert.notNull(promise, 'promise'); assert.isFunction(promise.then, 'promise.then'); // TODO add timeout promise.then(cbOK, cbError); } catch (e) { //failure in shutdown function cbError(e); } } } } /** * @deprecated use lowercase spelling `attachDataset` instead */ EntityManager.prototype.attachDataSet = EntityManager.prototype.attachDataset; /** * @deprecated use lowercase spelling `detachDataset` instead */ EntityManager.prototype.detachDataSet = EntityManager.prototype.detachDataset;