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

339 lines
11 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 read component state. */
export const COMPONENT_STATE = Symbol('rpg.component.state');
/** 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;
protected state: TState;
constructor(state: TState) {
this.state = state;
}
[COMPONENT_STATE](): TState { return this.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 { }
update(_world: World, _dt: number): void { };
}
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;
}
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>>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
if (typeof ctorOrKey === 'string') {
return this.#components.get(ctorOrKey) as T | undefined;
}
if (key !== undefined) {
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) return c as T;
}
return undefined;
}
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>>(ctor: Class<T>, key: string): void;
remove<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): void {
if (typeof ctorOrKey === 'string') {
this.#removeByKey(ctorOrKey);
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 #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);
}
}
*query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]> {
for (const entity of this.#entities.values()) {
for (const [key, component] of entity) {
if (component instanceof ctor) {
yield [entity, key, component as T]
}
}
}
}
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);
}
}
update(dt: number): void {
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') {
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(arg3!);
} else {
this.#globalHandlers.get(arg1)?.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 wrapped: EntityEventHandler = data => { unsub(); arg3!(data); };
const unsub = this.on(arg1, arg2, wrapped);
return unsub;
}
const h = arg2;
const wrapped: WorldEventHandler = data => { unsub(); h(data); };
const unsub = this.on(arg1, wrapped);
return 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;
}