451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
import type { Class } from '@common/types';
|
|
import type { RPGActions, RPGVariables } from '../types';
|
|
import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../utils/decorators';
|
|
|
|
interface EntityEvent<T = unknown> {
|
|
target: Entity;
|
|
data?: T;
|
|
}
|
|
interface WorldEvent<T = unknown> {
|
|
target: World;
|
|
data?: T;
|
|
}
|
|
|
|
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
|
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
|
|
|
export type EvalContext = Entity | World;
|
|
|
|
/** Symbol used by Serialization to access World's entity counter. */
|
|
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
|
|
export const COMPONENT_KEY = Symbol('rpg.component.key');
|
|
|
|
export abstract class Component<TState = Record<string, unknown>> {
|
|
entity!: Entity;
|
|
|
|
private _state!: TState;
|
|
private _key!: string | symbol;
|
|
|
|
constructor(state: TState) {
|
|
this._state = state;
|
|
}
|
|
|
|
get state(): TState { return this._state; }
|
|
protected set state(state: TState) { this._state = state; }
|
|
|
|
get key(): string {
|
|
return typeof this._key === 'symbol'
|
|
? getComponentName(this.constructor) ?? this.constructor.name
|
|
: this._key;
|
|
}
|
|
get [COMPONENT_KEY](): string | symbol { return this._key; }
|
|
set key(key: string | symbol) { this._key = key; }
|
|
|
|
get world(): World { return this.entity.world; }
|
|
|
|
protected emit(event: string, data?: unknown): void {
|
|
const componentKey = this.key;
|
|
const componentName = getComponentName(this.constructor);
|
|
const key = componentName && componentName !== componentKey
|
|
? `${componentName}(${componentKey})`
|
|
: componentKey;
|
|
|
|
this.entity.emit(`${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;
|
|
const stateKeys = meta?.[STATE_KEYS] as Set<string> | undefined;
|
|
const vars: RPGVariables = {};
|
|
if (keys) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
if (stateKeys) {
|
|
for (const key of stateKeys) {
|
|
const v = (this._state as Record<string, unknown>)[String(key)];
|
|
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
|
|
vars[key] = 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;
|
|
}
|
|
}
|
|
|
|
export abstract class System {
|
|
onAdd(_world: World): void { }
|
|
onRemove(_world: World): void { }
|
|
update(_world: World, _dt: number): void { };
|
|
}
|
|
|
|
type ComponentFilter<T> = (component: T) => boolean;
|
|
|
|
export class Entity {
|
|
readonly #components = new Map<string | symbol, Component>();
|
|
|
|
constructor(
|
|
readonly id: string,
|
|
readonly world: World,
|
|
) { }
|
|
|
|
add<T extends Component<any>>(component: T, k?: string): T {
|
|
const key = k ?? Symbol();
|
|
|
|
if (component == null) {
|
|
throw new Error(`Component must be an instance of Component`);
|
|
}
|
|
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>>(component: T, key: string): T {
|
|
const clone = Object.create(component.constructor.prototype) as T;
|
|
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
|
return this.add(clone, key);
|
|
}
|
|
|
|
get<T extends Component<any>>(key: string): 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;
|
|
}
|
|
if (!key) {
|
|
for (const [k, c] of this.#components) {
|
|
// prefer registered without key
|
|
if (typeof k !== 'symbol') continue;
|
|
if (c instanceof ctorOrKey) {
|
|
return c as T;
|
|
}
|
|
}
|
|
}
|
|
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>>(ctor: Class<T>, filter: ComponentFilter<T>): boolean;
|
|
has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): boolean {
|
|
if (typeof ctorOrKey === 'string') {
|
|
return this.#components.has(ctorOrKey);
|
|
}
|
|
if (typeof key === 'string') {
|
|
return this.#components.get(key) instanceof ctorOrKey;
|
|
}
|
|
for (const c of this.#components.values()) {
|
|
if (!(c instanceof ctorOrKey)) continue;
|
|
if (typeof key === 'function' && !key(c)) continue;
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
remove(key: string): void;
|
|
remove<T extends Component<any>>(component: T): void;
|
|
remove<T extends Component<any>>(ctor: Class<T>): void;
|
|
remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
|
|
remove<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): void;
|
|
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string | ComponentFilter<T>): void {
|
|
if (typeof ctorOrKey === 'string') {
|
|
this.#removeByKey(ctorOrKey);
|
|
return;
|
|
}
|
|
if (ctorOrKey instanceof Component) {
|
|
this.#removeByKey(ctorOrKey[COMPONENT_KEY]);
|
|
return;
|
|
}
|
|
if (typeof key === 'string') {
|
|
if (this.#components.get(key) instanceof ctorOrKey) this.#removeByKey(key);
|
|
return;
|
|
}
|
|
for (const [k, c] of this.#components) {
|
|
if (!(c instanceof ctorOrKey)) continue;
|
|
if (typeof key === 'function' && !key(c)) continue;
|
|
|
|
this.#removeByKey(k); return;
|
|
}
|
|
}
|
|
|
|
removeAll<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): void {
|
|
for (const [k, c] of this.#components) {
|
|
if (!(c instanceof ctor)) continue;
|
|
if (typeof filter === 'function' && !filter(c)) continue;
|
|
|
|
this.#removeByKey(k); return;
|
|
}
|
|
}
|
|
|
|
#removeByKey(key: string | symbol): 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.values().map(c => [c.key, c]);
|
|
}
|
|
|
|
/** @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 world() { return 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(clone, key);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
findComponent<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): T | undefined {
|
|
for (const [, , c] of this.query(ctor)) {
|
|
if (!filter || filter(c)) return c;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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<T extends System>(system: T): T {
|
|
this.#systems.push(system);
|
|
system.onAdd(this);
|
|
return system;
|
|
}
|
|
|
|
removeSystem(system: System): void {
|
|
const idx = this.#systems.indexOf(system);
|
|
if (idx !== -1) {
|
|
this.#systems.splice(idx, 1);
|
|
system.onRemove(this);
|
|
}
|
|
}
|
|
|
|
hasSystem(ctor: Class<System>): boolean {
|
|
return this.#systems.some(system => system instanceof ctor);
|
|
}
|
|
|
|
update(dt: number) {
|
|
for (const system of this.#systems) {
|
|
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 const isEvalContext = (x: unknown): x is EvalContext => (
|
|
x instanceof Entity || x instanceof World
|
|
); |