1
0
Fork 0
tsgames/src/common/rpg/core/world.ts

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