import { ACTION_KEYS, VARIABLE_KEYS } from '../utils/decorators'; import type { RPGActions, RPGVariables } from '../types'; interface EntityEvent { target: Entity; data?: T; } interface WorldEvent { target: World; data?: T; } type Class = abstract new (...args: any[]) => T; type EntityEventHandler = (event: EntityEvent) => void; type WorldEventHandler = (event: WorldEvent) => 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> { 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 | undefined; if (!keys) return {}; const vars: RPGVariables = {}; for (const [methodKey, exportName] of keys) { const v = (this as Record)[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 | undefined; if (!keys) return {}; const actions: RPGActions = {}; for (const key of keys) { const fn = (this as Record)[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 { }; } type ComponentFilter = (component: T) => boolean; export class Entity { readonly #components = new Map(); constructor( readonly id: string, readonly world: World, ) { } get context(): EvalContext { return { self: this, world: this.world }; } add>(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>(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>(key: string): T | undefined; get>(ctor: Class): T | undefined; get>(ctor: Class, key: string): T | undefined; get>(ctor: Class, filter: ComponentFilter): T | undefined; get>(ctorOrKey: Class | string, key?: string | ComponentFilter): 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>(ctor: Class): 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>(ctor: Class): boolean; has>(ctor: Class, key: string): boolean; has>(ctorOrKey: Class | 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>(ctor: Class): void; remove>(component: T): void; remove>(ctor: Class, key: string): void; remove>(ctorOrKey: Class | 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(); readonly #handlers = new Map>(); readonly #globalHandlers = new Map>(); readonly #onceWrappers = new Map(); 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; (clone as unknown as { state: unknown }).state = structuredClone(component.state); target.add(key, clone); } return target; } query>(ctor: Class): Generator<[Entity, string, T]>; query, B extends Component>(ctorA: Class, ctorB: Class): Generator<[Entity, A, B]>; query, B extends Component, C extends Component>(ctorA: Class, ctorB: Class, ctorC: Class): Generator<[Entity, A, B, C]>; *query(...ctors: Class>[]): Generator { 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 { 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( map: Map>, 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; }