mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 08:00:53 +00:00
feat(time scale): adjust time scale logic, add rawDelta fix(migrateEntityId): update $ceMap on entity id change chore(tests): update tests chore(README): update readme to reflect these new changes
628 lines
19 KiB
TypeScript
628 lines
19 KiB
TypeScript
import { getCreateId } from "./ids";
|
|
|
|
const createId = getCreateId({ init: 0, len: 4 });
|
|
|
|
/**
|
|
* entity.id -> component.name -> index of component in entity.components
|
|
*
|
|
* This map stores indices of components in the entity component array.
|
|
* The purpose of this map is to allow for fast lookups of components in the
|
|
* entity.components array (e.g. entity.getComponent()).
|
|
*/
|
|
export const $eciMap = Symbol();
|
|
|
|
/**
|
|
* component.name -> array of entity.ids that have this component
|
|
*/
|
|
export const $ceMap = Symbol();
|
|
export const $eMap = Symbol();
|
|
export const $queryResults = Symbol();
|
|
export const $dirtyQueries = Symbol();
|
|
export const $queryDependencies = Symbol();
|
|
export const $systems = Symbol();
|
|
export const $running = Symbol();
|
|
export const $onEntityCreated = Symbol();
|
|
export const $mainLoop = Symbol();
|
|
|
|
export type Component = () => {};
|
|
export type ComponentInstance = () => {
|
|
__ngn__?: {
|
|
parent: string;
|
|
name: string;
|
|
};
|
|
} & {
|
|
[key: string]: any;
|
|
};
|
|
|
|
export type QueryConfig = Readonly<
|
|
Partial<{
|
|
/** Matches entities as long as the entity has all the components in the provided array. */
|
|
and: Component[];
|
|
/** Matches entities as long as the entity has at least one of the components in the provided array. */
|
|
or: Component[];
|
|
/** Matches entities as long as the entity has none of the components in the provided array. */
|
|
not: Component[];
|
|
/** Matches entities that have any of these tag strings. */
|
|
tag: string[];
|
|
}>
|
|
>;
|
|
|
|
export type Entity = Readonly<{
|
|
id: string;
|
|
components: ReturnType<ComponentInstance>[];
|
|
addTag: (tag: string) => Entity;
|
|
removeTag: () => Entity;
|
|
getTag: () => string;
|
|
addComponent: (component: Component, defaults?: object) => Entity;
|
|
removeComponent: (component: Component) => Entity;
|
|
getComponent: <T extends ComponentInstance>(arg: T) => ReturnType<T>;
|
|
hasComponent: (component: Component) => boolean;
|
|
destroy: () => void;
|
|
}>;
|
|
|
|
export type QueryResults = {
|
|
results: {
|
|
entity: Entity;
|
|
[componentName: string]: any;
|
|
}[];
|
|
};
|
|
|
|
export type SystemFn = (w: WorldState) => void;
|
|
export type SystemCls = { update: (w: WorldState) => void };
|
|
export type System = SystemCls | SystemFn;
|
|
|
|
export type WorldState = {
|
|
[$eciMap]: { [key: number]: { [componentName: string]: number } };
|
|
[$ceMap]: { [key: string]: string[] };
|
|
[$eMap]: { [key: number]: any };
|
|
[$dirtyQueries]: Set<string>;
|
|
[$queryDependencies]: Map<string, Set<string>>;
|
|
[$queryResults]: { [key: string]: QueryResults };
|
|
[$systems]: ((w: WorldState) => void)[];
|
|
[$mainLoop]: (w: WorldState) => void;
|
|
time: {
|
|
/** The total elapsed time in seconds since the game loop started. */
|
|
elapsed: number;
|
|
/** The time in milliseconds since the last frame, scaled by time.scale. */
|
|
delta: number;
|
|
/** The raw, unscaled time in milliseconds since the last frame. */
|
|
rawDelta: number;
|
|
/** The time in milliseconds since the last time the main loop was called. */
|
|
loopDelta: number;
|
|
/** The time in milliseconds of the last call to the main loop. */
|
|
lastLoopDelta: number;
|
|
/** The timescale of the game loop. */
|
|
scale: number;
|
|
/** The current frames per second. */
|
|
fps: number;
|
|
};
|
|
[$running]: boolean;
|
|
[$onEntityCreated]: ((e: Entity) => void)[];
|
|
};
|
|
|
|
export const createWorld = () => {
|
|
const state: WorldState = {
|
|
[$eciMap]: {},
|
|
[$ceMap]: {},
|
|
[$eMap]: {},
|
|
[$dirtyQueries]: new Set(),
|
|
[$queryDependencies]: new Map(),
|
|
[$queryResults]: {},
|
|
[$systems]: [],
|
|
[$mainLoop]: null,
|
|
time: {
|
|
elapsed: 0,
|
|
delta: 0,
|
|
rawDelta: 0,
|
|
loopDelta: 0,
|
|
lastLoopDelta: 0,
|
|
scale: 1,
|
|
fps: 0,
|
|
},
|
|
[$running]: false,
|
|
[$onEntityCreated]: [],
|
|
};
|
|
|
|
const defineMain = (callback: (w?: WorldState) => void) => {
|
|
state[$mainLoop] = callback;
|
|
};
|
|
|
|
/**
|
|
* start - starts the game loop.
|
|
* @returns - a function to stop the loop.
|
|
*/
|
|
const start = () => {
|
|
let then = 0;
|
|
let accumulator = 0;
|
|
const boundLoop = handler.bind(start);
|
|
let loopHandler = -1;
|
|
const { time } = state;
|
|
time.delta = 0;
|
|
time.rawDelta = 0;
|
|
time.elapsed = 0;
|
|
time.fps = 0;
|
|
state[$running] = true;
|
|
|
|
let raf: ((cb: FrameRequestCallback) => number) | null = null;
|
|
let craf: ((handle: number) => void) | null = null;
|
|
|
|
/**
|
|
* Fake requestAnimationFrame and cancelAnimationFrame
|
|
* so that we can run tests for this in node.
|
|
*/
|
|
if (typeof window !== "undefined") {
|
|
let now = performance.now();
|
|
raf = (cb: FrameRequestCallback): number => {
|
|
return requestAnimationFrame((timestamp: number) => {
|
|
now = timestamp;
|
|
cb(now);
|
|
});
|
|
};
|
|
craf = cancelAnimationFrame;
|
|
} else {
|
|
let now = 0;
|
|
raf = (cb: FrameRequestCallback): number => {
|
|
return setTimeout(() => {
|
|
now += 16.67;
|
|
cb(now);
|
|
}, 16.67) as unknown as number;
|
|
};
|
|
|
|
craf = (id: number) => {
|
|
clearTimeout(id);
|
|
};
|
|
}
|
|
|
|
let xfps = 1;
|
|
const xtimes = [];
|
|
|
|
function handler(now: number) {
|
|
if (!state[$running]) return craf(loopHandler);
|
|
|
|
while (xtimes.length > 0 && xtimes[0] <= now - 1000) {
|
|
xtimes.shift();
|
|
}
|
|
|
|
xtimes.push(now);
|
|
xfps = xtimes.length;
|
|
time.fps = xfps;
|
|
|
|
// Store the raw, unscaled delta time
|
|
time.rawDelta = now - then;
|
|
then = now;
|
|
|
|
// Apply time scale to delta - this represents the simulation time that has passed
|
|
time.delta = time.rawDelta * time.scale;
|
|
|
|
// Use the raw delta for accumulation (behavior remains the same)
|
|
accumulator += time.rawDelta * time.scale;
|
|
|
|
// Calculate the threshold for stepping the world based on the current frame rate
|
|
const stepThreshold = 1000 / (time.fps || 60);
|
|
|
|
// Add a maximum number of iterations to prevent spiral of death
|
|
const maxSteps = 5; // Limit the catch-up to prevent freezing
|
|
let steps = 0;
|
|
|
|
// Step the world only when the accumulated scaled time exceeds the threshold
|
|
while (accumulator >= stepThreshold && steps < maxSteps) {
|
|
time.loopDelta = now - time.lastLoopDelta;
|
|
time.lastLoopDelta = now;
|
|
|
|
state[$mainLoop](state);
|
|
accumulator -= stepThreshold;
|
|
steps++;
|
|
}
|
|
|
|
// If we hit the max steps, discard remaining accumulator time
|
|
if (steps >= maxSteps) {
|
|
accumulator = 0;
|
|
}
|
|
|
|
// Use the scaled delta for elapsed time calculation
|
|
time.elapsed += time.delta * 0.001;
|
|
|
|
loopHandler = raf(boundLoop);
|
|
}
|
|
|
|
loopHandler = raf(boundLoop);
|
|
|
|
return () => (state[$running] = false);
|
|
};
|
|
|
|
const stop = () => {
|
|
state[$running] = false;
|
|
};
|
|
|
|
function step() {
|
|
for (const system of state[$systems]) {
|
|
if (system(state) === null) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds one or more systems to the ECS world.
|
|
* A system can be either a @see SystemFn or a @see SystemCls.
|
|
* @param systems An array of system classes or functions.
|
|
* @throws {Error} If a system is not a valid system class or function.
|
|
*/
|
|
function addSystem(...systems: (SystemCls | SystemFn)[]) {
|
|
for (const system of systems) {
|
|
// If the system is a function, add it to the world systems array
|
|
if (typeof system === "function") {
|
|
state[$systems].push(system);
|
|
// If the system has an `update` method, add that method to the world systems array
|
|
} else if (system.update && typeof system.update === "function") {
|
|
state[$systems].push(system.update);
|
|
// If the system is not a valid system class or function, throw an error
|
|
} else {
|
|
throw new Error(`Not a valid system: ${JSON.stringify(system)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes one or more systems from the world.
|
|
*
|
|
* @param {...(SystemCls | SystemFn)[]} systems - The system or systems to remove.
|
|
* @throws {TypeError} Throws an error if the system parameter is not a function or an object with an update function.
|
|
* @returns {void}
|
|
*/
|
|
function removeSystem(...systems: (SystemCls | SystemFn)[]): void {
|
|
for (const system of systems) {
|
|
if (typeof system === "function") {
|
|
state[$systems] = state[$systems].filter((s) => s !== system);
|
|
} else if (system.update && typeof system.update === "function") {
|
|
state[$systems] = state[$systems].filter((s) => s !== system.update);
|
|
} else {
|
|
throw new TypeError("Parameter must be a function or an object with an update function.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves query results based on the given configuration and query name.
|
|
* If non-dirty query results exist for this queryName, returns them. Otherwise, filters entities based on the queryConfig
|
|
* and updates the state with the new query results before returning them.
|
|
*
|
|
* @param {QueryConfig} queryConfig - The configuration object containing 'and', 'or', 'not' and 'tag' arrays of component names.
|
|
* @param {string} queryName - The name of the query to retrieve or update results for.
|
|
* @returns {any[]} An array of result objects, each containing an entity and its components as properties.
|
|
*/
|
|
const getQuery = (queryConfig: QueryConfig, queryName: string): any[] => {
|
|
// If we have non-dirty query results for this queryName, return them
|
|
if (!state[$dirtyQueries].has(queryName) && state[$queryResults][queryName]) {
|
|
return state[$queryResults][queryName].results;
|
|
}
|
|
|
|
const { and = [], or = [], not = [], tag = [] } = queryConfig;
|
|
const entities: Entity[] = Object.values(state[$eMap]).filter((entity) => {
|
|
return (
|
|
(!not.length || !not.some((component) => entity.hasComponent(component))) &&
|
|
(!and.length || and.every((component) => entity.hasComponent(component))) &&
|
|
(!or.length || or.some((component) => entity.hasComponent(component))) &&
|
|
(!tag.length || tag.some((t) => entity.tag === t))
|
|
);
|
|
});
|
|
|
|
state[$queryResults][queryName] = {
|
|
results: entities.map((entity) => {
|
|
const result: any = { entity };
|
|
|
|
entity.components.forEach((component) => {
|
|
result[component.__ngn__.name] = component;
|
|
});
|
|
|
|
return result;
|
|
}),
|
|
};
|
|
|
|
state[$dirtyQueries].delete(queryName);
|
|
|
|
return state[$queryResults][queryName].results;
|
|
};
|
|
|
|
const markQueryDirty = (queryName: string) => {
|
|
state[$dirtyQueries].add(queryName);
|
|
};
|
|
|
|
/**
|
|
* Defines a query for filtering entities based on a combination of criteria.
|
|
* @param queryConfig The configuration for the query. Contains and, or, not and tag criteria.
|
|
* @throws {Error} Invalid query if any criteria in the query config does not have a 'name' property.
|
|
* @returns A function that takes a query implementation and returns the results of the query.
|
|
*/
|
|
const query = ({ and = [], or = [], not = [], tag = [] }: QueryConfig) => {
|
|
// Checks if a criteria object has a 'name' property
|
|
const validQuery = (c: Component) => Object.prototype.hasOwnProperty.call(c, "name");
|
|
|
|
// Throws an error if any criteria object in the query config does not have a 'name' property
|
|
if (![...and, ...or, ...not].every(validQuery)) throw new Error("Invalid query");
|
|
|
|
// Constructs a string representing the query name based on the criteria in the query config
|
|
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
|
|
|
|
// Component dependencies
|
|
[...and, ...or, ...not].forEach((c) => {
|
|
const dependencies = state[$queryDependencies].get(c.name) || new Set();
|
|
dependencies.add(queryName);
|
|
state[$queryDependencies].set(c.name, dependencies);
|
|
});
|
|
|
|
// Tag dependencies
|
|
tag.forEach((t) => {
|
|
const tagKey = `tag:${t}`;
|
|
const dependencies = state[$queryDependencies].get(tagKey) || new Set();
|
|
dependencies.add(queryName);
|
|
state[$queryDependencies].set(tagKey, dependencies);
|
|
});
|
|
|
|
return (queryImpl: (results: { entity: Entity }[]) => void) => queryImpl(getQuery({ and, or, not, tag }, queryName));
|
|
};
|
|
|
|
function destroyEntity(e: Entity) {
|
|
const exists = state[$eMap][e.id];
|
|
|
|
if (!exists) return false;
|
|
|
|
const componentsToRemove: string[] = Object.keys(state[$eciMap][e.id]);
|
|
|
|
componentsToRemove.forEach((componentName) => {
|
|
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
|
|
});
|
|
|
|
delete state[$eciMap][e.id];
|
|
delete state[$eMap][e.id];
|
|
|
|
componentsToRemove.forEach((componentName) => {
|
|
const affectedQueries = state[$queryDependencies].get(componentName);
|
|
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
function onEntityCreated(fn: any) {
|
|
if (typeof fn !== "function") return;
|
|
|
|
state[$onEntityCreated].push(fn);
|
|
|
|
return () => {
|
|
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a new component for the given entity and adds it to the world.
|
|
* @param entity The entity to add the component to.
|
|
* @param component The component function to add.
|
|
* @param defaults (optional) Default values to apply to the component.
|
|
* @returns The modified entity with the new component added.
|
|
*/
|
|
function createComponent(entity: Entity, component: Function, defaults: object = {}): Entity {
|
|
// If the entity already has this component, return the unmodified entity.
|
|
if (state[$eciMap]?.[entity.id]?.[component.name] !== undefined) return entity;
|
|
|
|
const affectedQueries = state[$queryDependencies].get(component.name);
|
|
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
|
|
const componentInstance = component();
|
|
|
|
if (componentInstance.onAttach && typeof componentInstance.onAttach === "function") {
|
|
componentInstance.onAttach(entity);
|
|
}
|
|
|
|
// Create the component, assigning defaults and a reference to the parent entity.
|
|
entity.components.push(
|
|
Object.assign(
|
|
{},
|
|
{
|
|
...componentInstance,
|
|
...defaults,
|
|
__ngn__: {
|
|
parent: entity.id,
|
|
name: component.name,
|
|
},
|
|
},
|
|
) as ComponentInstance,
|
|
);
|
|
|
|
// Add the component index to the entity's index map.
|
|
state[$eciMap][entity.id] = state[$eciMap][entity.id] || {};
|
|
state[$eciMap][entity.id][component.name] = entity.components.length - 1;
|
|
|
|
// Add the entity to the component's entity map.
|
|
state[$ceMap][component.name] = state[$ceMap][component.name] || [];
|
|
state[$ceMap][component.name].push(entity.id);
|
|
|
|
return entity;
|
|
}
|
|
|
|
/**
|
|
* Creates an entity with the given specification object.
|
|
* @param {object} spec - Optional data to be stored on the entity.
|
|
* @returns {any} - Returns the created entity.
|
|
*/
|
|
function createEntity<T>(spec: T & { id?: string } = {} as T): T & Entity {
|
|
const id = spec.id ?? createId();
|
|
const components: any[] = [];
|
|
|
|
const tagKey = (t: string) => `tag:${t}`;
|
|
|
|
function updateTagQueries(tagKey: string) {
|
|
const affectedQueries = state[$queryDependencies].get(tagKey);
|
|
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
}
|
|
|
|
function addTag(t: string): Entity {
|
|
const previousTagKey = tagKey(this.tag);
|
|
|
|
this.tag = t;
|
|
|
|
updateTagQueries(tagKey(t));
|
|
updateTagQueries(previousTagKey);
|
|
|
|
return this;
|
|
}
|
|
|
|
function removeTag(): Entity {
|
|
const previousTagKey = tagKey(this.tag);
|
|
this.tag = "";
|
|
|
|
updateTagQueries(previousTagKey);
|
|
|
|
return this;
|
|
}
|
|
|
|
function getTag() {
|
|
return this.tag;
|
|
}
|
|
|
|
function addComponent(c: Component, defaults = {}) {
|
|
return createComponent(this, c, defaults);
|
|
}
|
|
|
|
function hasComponent(component: Component) {
|
|
return state[$eciMap]?.[id]?.[component.name] !== undefined;
|
|
}
|
|
|
|
function getComponent<T extends ComponentInstance>(arg: T): ReturnType<T> {
|
|
const index = state[$eciMap][id][arg.name];
|
|
return components[index];
|
|
}
|
|
|
|
/**
|
|
* Removes the specified component from the entity and updates the world state accordingly.
|
|
*
|
|
* @param component The component to remove from the entity.
|
|
* @returns The modified entity.
|
|
*/
|
|
function removeComponent(component: Component | string): Entity {
|
|
const name = typeof component === "string" ? component : component.name;
|
|
|
|
const componentInstance = getComponent(typeof component === "string" ? ({ name } as any) : component);
|
|
|
|
if (componentInstance && componentInstance.onDetach && typeof componentInstance.onDetach === "function") {
|
|
componentInstance.onDetach(this);
|
|
}
|
|
|
|
const affectedQueries = state[$queryDependencies].get(name);
|
|
|
|
if (affectedQueries) {
|
|
affectedQueries.forEach(markQueryDirty);
|
|
}
|
|
|
|
// Set the entity's component index for the specified component to undefined.
|
|
state[$eciMap][id][name] = undefined;
|
|
|
|
// Remove the entity's ID from the component's entity list.
|
|
state[$ceMap][name] = state[$ceMap][name].filter((e) => e !== id);
|
|
|
|
// Remove the component from the entity's component list.
|
|
const index = state[$eciMap][id][name];
|
|
components.splice(index, 1);
|
|
|
|
// Update the entity's component indices for all components after the removed component.
|
|
Object.keys(state[$eciMap][id]).forEach((componentName) => {
|
|
if (state[$eciMap][id][componentName] > components.findIndex((c) => c.name === componentName)) {
|
|
state[$eciMap][id][componentName]--;
|
|
}
|
|
});
|
|
|
|
// Return the modified entity.
|
|
return this;
|
|
}
|
|
|
|
function destroy() {
|
|
return destroyEntity(this);
|
|
}
|
|
|
|
const entity = Object.assign({}, spec, {
|
|
id,
|
|
components,
|
|
addTag,
|
|
removeTag,
|
|
getTag,
|
|
addComponent,
|
|
hasComponent,
|
|
getComponent,
|
|
removeComponent,
|
|
destroy,
|
|
});
|
|
|
|
// If we are forcing a specific entity id, we need to migrate any
|
|
// entity that might already occupy this space.
|
|
if (spec.id !== undefined && state[$eMap][spec.id]) {
|
|
migrateEntityId(spec.id, createId());
|
|
}
|
|
|
|
state[$eMap][id] = entity;
|
|
state[$eciMap][id] = {};
|
|
|
|
state[$onEntityCreated].forEach((fn) => {
|
|
fn(entity);
|
|
});
|
|
|
|
return entity as unknown as T & Entity;
|
|
}
|
|
|
|
/**
|
|
* migrateEntityId updates the id of an entity in the world, and all
|
|
* associated world maps.
|
|
* @param oldId The id of the entity to migrate.
|
|
* @param newId The id to migrate the entity to.
|
|
*/
|
|
function migrateEntityId(oldId: string, newId: string) {
|
|
const entity = state[$eMap][oldId];
|
|
|
|
if (!entity) return;
|
|
|
|
entity.id = newId;
|
|
|
|
state[$eMap][newId] = entity;
|
|
delete state[$eMap][oldId];
|
|
|
|
state[$eciMap][newId] = state[$eciMap][oldId];
|
|
delete state[$eciMap][oldId];
|
|
|
|
// Update component-to-entity mappings, because otherwise queries that
|
|
// rely on state[$ceMap] would still reference the old entity ID,
|
|
// causing inconsistencies when trying to find entities with specific
|
|
// components after ID migration.
|
|
Object.keys(state[$ceMap]).forEach((componentName) => {
|
|
if (state[$ceMap][componentName].includes(oldId)) {
|
|
state[$ceMap][componentName] = state[$ceMap][componentName].map((id) => (id === oldId ? newId : id));
|
|
}
|
|
});
|
|
}
|
|
|
|
function getEntity(id: string): Entity {
|
|
return state[$eMap][id];
|
|
}
|
|
|
|
return {
|
|
state,
|
|
query,
|
|
createEntity,
|
|
getEntity,
|
|
onEntityCreated,
|
|
addSystem,
|
|
removeSystem,
|
|
start,
|
|
stop,
|
|
step,
|
|
defineMain,
|
|
};
|
|
};
|