@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
974 lines (756 loc) • 30.3 kB
JavaScript
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;