401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators';
|
|
import type { RPGActions, RPGVariables } from '../types';
|
|
|
|
interface EntityEvent<T = unknown> {
|
|
target: Entity;
|
|
data?: T;
|
|
}
|
|
interface WorldEvent<T = unknown> {
|
|
target: World;
|
|
data?: T;
|
|
}
|
|
|
|
type Class<T> = abstract new (...args: any[]) => T;
|
|
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
|
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
|
|
|
export interface EvalContext {
|
|
self: Entity | World;
|
|
world: World;
|
|
}
|
|
|
|
/** Symbol used by Serialization to access World's entity counter. */
|
|
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
|
|
|
|
export abstract class Component<TState = Record<string, unknown>> {
|
|
entity!: Entity;
|
|
key!: string;
|
|
|
|
private _state!: TState;
|
|
|
|
constructor(state: TState) {
|
|
this._state = state;
|
|
}
|
|
|
|
get state(): TState { return this._state; }
|
|
protected set state(state: TState) { this._state = state; }
|
|
|
|
protected emit(event: string, data?: unknown): void {
|
|
this.entity.emit(`${this.key}.${event}`, data);
|
|
}
|
|
|
|
onAdd(): void { }
|
|
onRemove(): void { }
|
|
|
|
getVariables(): RPGVariables {
|
|
const meta = (this.constructor as Function)[Symbol.metadata];
|
|
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
|
if (!keys) return {};
|
|
const vars: RPGVariables = {};
|
|
for (const [methodKey, exportName] of keys) {
|
|
const v = (this as Record<string, unknown>)[String(methodKey)];
|
|
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
|
|
vars[exportName] = v;
|
|
}
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
getActions(): RPGActions {
|
|
const meta = (this.constructor as Function)[Symbol.metadata];
|
|
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
|
|
if (!keys) return {};
|
|
const actions: RPGActions = {};
|
|
for (const key of keys) {
|
|
const fn = (this as Record<string, unknown>)[String(key)];
|
|
if (typeof fn === 'function') {
|
|
actions[String(key)] = fn.bind(this);
|
|
}
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
get context(): EvalContext {
|
|
return this.entity.context;
|
|
}
|
|
}
|
|
|
|
export abstract class System {
|
|
onAdd(_world: World): void { }
|
|
onRemove(_world: World): void { }
|
|
async update(_world: World, _dt: number): Promise<void> { };
|
|
}
|
|
|
|
type ComponentFilter<T> = (component: T) => boolean;
|
|
|
|
export class Entity {
|
|
readonly #components = new Map<string, Component>();
|
|
|
|
constructor(
|
|
readonly id: string,
|
|
readonly world: World,
|
|
) { }
|
|
|
|
get context(): EvalContext {
|
|
return { self: this, world: this.world };
|
|
}
|
|
|
|
add<T extends Component<any>>(key: string, component: T): T {
|
|
const existing = this.#components.get(key);
|
|
if (existing) existing.onRemove();
|
|
component.entity = this;
|
|
component.key = key;
|
|
this.#components.set(key, component);
|
|
component.onAdd();
|
|
return component;
|
|
}
|
|
|
|
clone<T extends Component<any>>(key: string, component: T): T {
|
|
const clone = Object.create(component.constructor.prototype) as T;
|
|
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
|
return this.add(key, clone);
|
|
}
|
|
|
|
get<T extends Component<any>>(key: string): T | undefined;
|
|
get<T extends Component<any>>(ctor: Class<T>): T | undefined;
|
|
get<T extends Component<any>>(ctor: Class<T>, key: string): T | undefined;
|
|
get<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): T | undefined;
|
|
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): T | undefined {
|
|
if (typeof ctorOrKey === 'string') {
|
|
return this.#components.get(ctorOrKey) as T | undefined;
|
|
}
|
|
if (typeof key === 'string') {
|
|
const c = this.#components.get(key);
|
|
return c instanceof ctorOrKey ? c as T : undefined;
|
|
}
|
|
for (const c of this.#components.values()) {
|
|
if (!(c instanceof ctorOrKey)) continue;
|
|
if (typeof key === 'function' && !key(c)) continue;
|
|
|
|
return c as T;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
getAll<T extends Component<any>>(ctor: Class<T>): T[] {
|
|
const result: T[] = [];
|
|
for (const c of this.#components.values()) {
|
|
if (c instanceof ctor) result.push(c as T);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
has(key: string): boolean;
|
|
has<T extends Component<any>>(ctor: Class<T>): boolean;
|
|
has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
|
|
has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): boolean {
|
|
if (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
|
|
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
|
|
for (const c of this.#components.values()) {
|
|
if (c instanceof ctorOrKey) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
remove(key: string): void;
|
|
remove<T extends Component<any>>(ctor: Class<T>): void;
|
|
remove<T extends Component<any>>(component: T): void;
|
|
remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
|
|
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string): void {
|
|
if (typeof ctorOrKey === 'string') {
|
|
this.#removeByKey(ctorOrKey);
|
|
return;
|
|
}
|
|
if (ctorOrKey instanceof Component) {
|
|
this.#removeByKey(ctorOrKey.key);
|
|
return;
|
|
}
|
|
if (key !== undefined) {
|
|
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
|
|
return;
|
|
}
|
|
for (const [k, c] of this.#components) {
|
|
if (c instanceof ctorOrKey) { this.#removeByKey(k); return; }
|
|
}
|
|
}
|
|
|
|
#removeByKey(key: string): void {
|
|
const c = this.#components.get(key);
|
|
if (c) { c.onRemove(); this.#components.delete(key); }
|
|
}
|
|
|
|
emit(event: string, data?: unknown): void {
|
|
this.world.emit(this.id, event, data);
|
|
}
|
|
|
|
emitGlobal(event: string, data?: unknown): void {
|
|
this.world.emitGlobal(event, data);
|
|
}
|
|
|
|
on(event: string, handler: EntityEventHandler): () => void {
|
|
return this.world.on(this.id, event, handler);
|
|
}
|
|
|
|
off(event: string, handler: EntityEventHandler): void {
|
|
this.world.off(this.id, event, handler);
|
|
}
|
|
|
|
once(event: string, handler: EntityEventHandler): () => void {
|
|
return this.world.once(this.id, event, handler);
|
|
}
|
|
|
|
destroy(): void {
|
|
this.world.destroyEntity(this);
|
|
}
|
|
|
|
/** @internal */
|
|
[Symbol.iterator](): IterableIterator<[string, Component]> {
|
|
return this.#components.entries();
|
|
}
|
|
|
|
/** @internal */
|
|
_destroy(): void {
|
|
for (const c of this.#components.values()) c.onRemove();
|
|
this.#components.clear();
|
|
}
|
|
}
|
|
|
|
export class World {
|
|
/** World-level variables, accessible in conditions via the $. prefix */
|
|
readonly globals: RPGVariables = {};
|
|
|
|
readonly #entities = new Map<string, Entity>();
|
|
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
|
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
|
readonly #onceWrappers = new Map<Function, Function>();
|
|
readonly #systems: System[] = [];
|
|
#entityCounter = 0;
|
|
|
|
get [WORLD_ENTITY_COUNTER](): number { return this.#entityCounter; }
|
|
set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; }
|
|
|
|
get id() { return 'world'; }
|
|
|
|
get context(): EvalContext {
|
|
return { self: this, world: this };
|
|
}
|
|
|
|
/**
|
|
* Create a new entity and add it to the world.
|
|
*
|
|
* @param id - How the entity ID is determined:
|
|
* - Omitted → auto-generated: `entity_1`, `entity_2`, …
|
|
* - Plain string → used as-is: `createEntity('player')` → `'player'`
|
|
* - Template with `*` → `*` is replaced by the auto-incremented counter:
|
|
* `createEntity('enemy_*')` → `'enemy_1'`, `'enemy_2'`, …
|
|
* @throws If an entity with the resolved ID already exists.
|
|
*/
|
|
createEntity(id?: string): Entity {
|
|
const entityId = id == null
|
|
? `entity_${++this.#entityCounter}`
|
|
: id.includes('*') ? id.replace('*', String(++this.#entityCounter)) : id;
|
|
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
|
|
const entity = new Entity(entityId, this);
|
|
this.#entities.set(entityId, entity);
|
|
return entity;
|
|
}
|
|
|
|
getEntity(id: string): Entity | undefined {
|
|
return this.#entities.get(id);
|
|
}
|
|
|
|
destroyEntity(entity: Entity): void {
|
|
entity._destroy();
|
|
this.#entities.delete(entity.id);
|
|
for (const key of this.#handlers.keys()) {
|
|
if (key.startsWith(`${entity.id}\0`)) this.#handlers.delete(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new entity whose components are deep copies of `source`'s components.
|
|
* The clone is immediately live in the world and `onAdd()` fires for each component.
|
|
* @param source - Entity to clone.
|
|
* @param newId - ID for the clone; follows the same rules as {@link createEntity}.
|
|
*/
|
|
cloneEntity(source: Entity, newId?: string): Entity {
|
|
const target = this.createEntity(newId);
|
|
for (const [key, component] of source) {
|
|
const clone = Object.create(component.constructor.prototype) as Component<any>;
|
|
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
|
target.add(key, clone);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]>;
|
|
query<A extends Component<any>, B extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>): Generator<[Entity, A, B]>;
|
|
query<A extends Component<any>, B extends Component<any>, C extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>, ctorC: Class<C>): Generator<[Entity, A, B, C]>;
|
|
*query(...ctors: Class<Component<any>>[]): Generator<unknown[]> {
|
|
const entities = this.#entities;
|
|
if (ctors.length === 1) {
|
|
const ctor = ctors[0];
|
|
for (const entity of entities.values()) {
|
|
for (const [key, component] of entity) {
|
|
if (component instanceof ctor) yield [entity, key, component];
|
|
}
|
|
}
|
|
} else {
|
|
for (const entity of entities.values()) {
|
|
const components = ctors.map(ctor => entity.get(ctor));
|
|
if (components.every(c => c !== undefined)) yield [entity, ...components];
|
|
}
|
|
}
|
|
}
|
|
|
|
addSystem(system: System): void {
|
|
this.#systems.push(system);
|
|
system.onAdd(this);
|
|
}
|
|
|
|
removeSystem(system: System): void {
|
|
const idx = this.#systems.indexOf(system);
|
|
if (idx !== -1) {
|
|
this.#systems.splice(idx, 1);
|
|
system.onRemove(this);
|
|
}
|
|
}
|
|
|
|
async update(dt: number): Promise<void> {
|
|
for (const system of this.#systems) await system.update(this, dt);
|
|
}
|
|
|
|
emit(entityId: string, event: string, data?: unknown): void {
|
|
const entity = this.getEntity(entityId);
|
|
if (!entity) return;
|
|
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data }));
|
|
}
|
|
|
|
emitGlobal(event: string, data?: unknown): void {
|
|
this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data }));
|
|
}
|
|
|
|
on(event: string, handler: WorldEventHandler): () => void;
|
|
on(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
|
on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
|
if (typeof arg2 === 'string') {
|
|
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
|
|
}
|
|
return this.#addHandler(this.#globalHandlers, arg1, arg2);
|
|
}
|
|
|
|
off(event: string, handler: WorldEventHandler): void;
|
|
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
|
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
|
if (typeof arg2 === 'string') {
|
|
const handler = (this.#onceWrappers.get(arg3!) ?? arg3!) as EntityEventHandler;
|
|
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(handler);
|
|
this.#onceWrappers.delete(arg3!);
|
|
} else {
|
|
const handler = (this.#onceWrappers.get(arg2) ?? arg2) as WorldEventHandler;
|
|
this.#globalHandlers.get(arg1)?.delete(handler);
|
|
this.#onceWrappers.delete(arg2);
|
|
}
|
|
}
|
|
|
|
once(event: string, handler: WorldEventHandler): () => void;
|
|
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
|
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
|
if (typeof arg2 === 'string') {
|
|
const original = arg3!;
|
|
const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
|
this.#onceWrappers.set(original, wrapped);
|
|
const unsub = this.on(arg1, arg2, wrapped);
|
|
return () => { this.#onceWrappers.delete(original); unsub(); };
|
|
}
|
|
const original = arg2;
|
|
const wrapped: WorldEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
|
this.#onceWrappers.set(original, wrapped);
|
|
const unsub = this.on(arg1, wrapped);
|
|
return () => { this.#onceWrappers.delete(original); unsub(); };
|
|
}
|
|
|
|
#addHandler<T extends WorldEventHandler | EntityEventHandler>(
|
|
map: Map<string, Set<T>>,
|
|
key: string,
|
|
handler: T,
|
|
): () => void {
|
|
if (!map.has(key)) map.set(key, new Set());
|
|
map.get(key)!.add(handler);
|
|
return () => map.get(key)?.delete(handler);
|
|
}
|
|
|
|
*[Symbol.iterator]() {
|
|
yield* this.#entities.values();
|
|
}
|
|
}
|
|
|
|
export function isEvalContext(v: unknown): v is EvalContext {
|
|
return typeof v === 'object' && v != null
|
|
&& (
|
|
(v as EvalContext).self instanceof Entity
|
|
|| (v as EvalContext).self instanceof World
|
|
)
|
|
&& (v as EvalContext).world instanceof World;
|
|
}
|
|
|
|
/** Narrows an {@link EvalContext} to one where `self` is an `Entity`, not a `World`. */
|
|
export function isEntityContext(ctx: EvalContext): ctx is { self: Entity; world: World } {
|
|
return ctx.self instanceof Entity;
|
|
}
|