prsm/packages/ngn/src/ngn.ts
nvms 6e063101cc feat(input): improve safety check of button assignment
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
2025-03-27 17:50:10 -04:00

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,
};
};