From 9bec7701e0349ee14f272dbd90e066c3f2850365 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 29 Apr 2026 07:28:16 +0000 Subject: [PATCH] Serialization --- src/common/rpg/components/inventory.ts | 116 +++++++++++++------------ src/common/rpg/components/item.ts | 52 +++++++---- src/common/rpg/components/questLog.ts | 83 +++++++++++------- src/common/rpg/components/stat.ts | 44 ++++++---- src/common/rpg/components/variables.ts | 30 ++++--- src/common/rpg/core/registry.ts | 33 +++++++ src/common/rpg/core/serialization.ts | 109 +++++++++++++++++++++++ src/common/rpg/core/world.ts | 59 ++++++++----- 8 files changed, 368 insertions(+), 158 deletions(-) create mode 100644 src/common/rpg/core/registry.ts create mode 100644 src/common/rpg/core/serialization.ts diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index 06b511b..fc3bdf7 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -1,49 +1,50 @@ import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; import { action } from "../utils/decorators"; import { Component, type EvalContext } from "../core/world"; +import { component } from "../core/registry"; import { Stackable, Usable } from "./item"; import { resolveVariables } from "../utils/variables"; -interface SlotEntry { - readonly slotId: SlotId; - readonly limit: number | undefined; - state: { itemId: string; amount: number } | null; +interface SlotRecord { + slotId: SlotId; + limit: number | undefined; + contents: { itemId: string; amount: number } | null; } -interface SlotUpdateArgs { - itemId: string; - amount: number; - slotId?: SlotId; +interface InventoryState { + slots: SlotRecord[]; } -export class Inventory extends Component { - private readonly slots: Map; - +@component +export class Inventory extends Component { constructor(slotDefs: Array) { - super(); - this.slots = new Map( - slotDefs.map(def => { + super({ + slots: slotDefs.map(def => { const slotId = typeof def === 'object' ? def.slotId : def; const limit = typeof def === 'object' ? def.limit : undefined; - return [slotId, { slotId, limit, state: null }]; - }) - ); + return { slotId, limit, contents: null }; + }), + }); } - private slotCapFor(slot: SlotEntry, itemId: string): number { + #slot(slotId: SlotId): SlotRecord | undefined { + return this.state.slots.find(s => s.slotId === slotId); + } + + #capFor(slot: SlotRecord, itemId: string): number { const limitCap = slot.limit ?? Infinity; const stackable = this.entity.world.getEntity(itemId)?.get(Stackable); const stackCap = stackable ? stackable.maxStack : 1; return Math.min(limitCap, stackCap); } - private slotRoomFor(slot: SlotEntry, itemId: string): number { - if (slot.state !== null && slot.state.itemId !== itemId) return 0; - return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); + #roomFor(slot: SlotRecord, itemId: string): number { + if (slot.contents !== null && slot.contents.itemId !== itemId) return 0; + return this.#capFor(slot, itemId) - (slot.contents?.amount ?? 0); } @action - add({ itemId, amount, slotId }: SlotUpdateArgs): boolean { + add({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean { if (amount < 0) return false; if (amount === 0) return true; @@ -53,19 +54,19 @@ export class Inventory extends Component { } if (slotId !== undefined) { - const slot = this.slots.get(slotId); + const slot = this.#slot(slotId); if (!slot) return false; - if (this.slotRoomFor(slot, itemId) < amount) return false; - slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount }; + if (this.#roomFor(slot, itemId) < amount) return false; + slot.contents = { itemId, amount: (slot.contents?.amount ?? 0) + amount }; this.emit('add', { itemId, amount, slotIds: [slotId] }); return true; } // Two-phase: pre-check then apply let remaining = amount; - for (const slot of this.slots.values()) { - if (slot.state === null || slot.state.itemId === itemId) { - remaining -= Math.min(this.slotRoomFor(slot, itemId), remaining); + for (const slot of this.state.slots) { + if (slot.contents === null || slot.contents.itemId === itemId) { + remaining -= Math.min(this.#roomFor(slot, itemId), remaining); if (remaining === 0) break; } } @@ -74,24 +75,24 @@ export class Inventory extends Component { // Apply — fill existing slots for this item first, then empty ones remaining = amount; const slotIds: SlotId[] = []; - for (const [id, slot] of this.slots) { - if (slot.state?.itemId === itemId) { - const take = Math.min(this.slotRoomFor(slot, itemId), remaining); - slot.state.amount += take; + for (const slot of this.state.slots) { + if (slot.contents?.itemId === itemId) { + const take = Math.min(this.#roomFor(slot, itemId), remaining); + slot.contents.amount += take; remaining -= take; - slotIds.push(id); + slotIds.push(slot.slotId); if (remaining === 0) { this.emit('add', { itemId, amount, slotIds }); return true; } } } - for (const [id, slot] of this.slots) { - if (slot.state === null) { - const take = Math.min(this.slotCapFor(slot, itemId), remaining); - slot.state = { itemId, amount: take }; + for (const slot of this.state.slots) { + if (slot.contents === null) { + const take = Math.min(this.#capFor(slot, itemId), remaining); + slot.contents = { itemId, amount: take }; remaining -= take; - slotIds.push(id); + slotIds.push(slot.slotId); if (remaining === 0) { this.emit('add', { itemId, amount, slotIds }); return true; @@ -103,16 +104,16 @@ export class Inventory extends Component { } @action - remove({ itemId, amount, slotId }: SlotUpdateArgs): boolean { + remove({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean { if (amount < 0) return false; if (amount === 0) return true; if (slotId !== undefined) { - const slot = this.slots.get(slotId); - if (!slot || slot.state?.itemId !== itemId) return false; - if (slot.state.amount < amount) return false; - slot.state.amount -= amount; - if (slot.state.amount === 0) slot.state = null; + const slot = this.#slot(slotId); + if (!slot || slot.contents?.itemId !== itemId) return false; + if (slot.contents.amount < amount) return false; + slot.contents.amount -= amount; + if (slot.contents.amount === 0) slot.contents = null; this.emit('remove', { itemId, amount, slotIds: [slotId] }); return true; } @@ -121,13 +122,13 @@ export class Inventory extends Component { let remaining = amount; const slotIds: SlotId[] = []; - for (const [id, slot] of this.slots) { - if (slot.state?.itemId === itemId) { - const take = Math.min(slot.state.amount, remaining); - slot.state.amount -= take; - if (slot.state.amount === 0) slot.state = null; + for (const slot of this.state.slots) { + if (slot.contents?.itemId === itemId) { + const take = Math.min(slot.contents.amount, remaining); + slot.contents.amount -= take; + if (slot.contents.amount === 0) slot.contents = null; remaining -= take; - slotIds.push(id); + slotIds.push(slot.slotId); if (remaining === 0) { this.emit('remove', { itemId, amount, slotIds }); return true; @@ -170,21 +171,22 @@ export class Inventory extends Component { getAmount(itemId: string, slotId?: SlotId): number { if (slotId !== undefined) { - const slot = this.slots.get(slotId); - return slot?.state?.itemId === itemId ? slot.state.amount : 0; + const slot = this.#slot(slotId); + return slot?.contents?.itemId === itemId ? slot.contents.amount : 0; } let total = 0; - for (const slot of this.slots.values()) { - if (slot.state?.itemId === itemId) total += slot.state.amount; + for (const slot of this.state.slots) { + if (slot.contents?.itemId === itemId) total += slot.contents.amount; } return total; } getItems(): Map { const result = new Map(); - for (const slot of this.slots.values()) { - if (slot.state) { - result.set(slot.state.itemId, (result.get(slot.state.itemId) ?? 0) + slot.state.amount); + for (const slot of this.state.slots) { + if (slot.contents) { + const { itemId, amount } = slot.contents; + result.set(itemId, (result.get(itemId) ?? 0) + amount); } } return result; diff --git a/src/common/rpg/components/item.ts b/src/common/rpg/components/item.ts index f343310..b728102 100644 --- a/src/common/rpg/components/item.ts +++ b/src/common/rpg/components/item.ts @@ -1,37 +1,55 @@ import { Component, type EvalContext, type World } from "../core/world"; +import { component } from "../core/registry"; import type { RPGAction } from "../types"; import { action, variable } from "../utils/decorators"; import { executeAction } from "../utils/variables"; -export class Item extends Component { - constructor( - readonly name: string, - readonly description: string = '', - ) { - super(); - } +interface ItemState { + name: string; + description: string; } -export class Stackable extends Component { - @variable readonly maxStack: number; +@component +export class Item extends Component { + constructor(name: string, description = '') { + super({ name, description }); + } + + get name(): string { return this.state.name; } + get description(): string { return this.state.description; } +} + +interface StackableState { + maxStack: number; +} + +@component +export class Stackable extends Component { constructor(maxStack: number) { - super(); - this.maxStack = maxStack; + super({ maxStack }); } + + @variable get maxStack(): number { return this.state.maxStack; } } -export class Usable extends Component { - @variable readonly consumeOnUse: boolean; - constructor(private readonly actions: RPGAction[], consumeOnUse = true) { - super(); - this.consumeOnUse = consumeOnUse; +interface UsableState { + actions: RPGAction[]; + consumeOnUse: boolean; +} + +@component +export class Usable extends Component { + constructor(actions: RPGAction[], consumeOnUse = true) { + super({ actions, consumeOnUse }); } + @variable get consumeOnUse(): boolean { return this.state.consumeOnUse; } + @action async use(arg?: EvalContext, ctx?: EvalContext): Promise { ctx = arg ?? ctx ?? this.context; if (!ctx) return; - for (const action of this.actions) { + for (const action of this.state.actions) { await executeAction(action, ctx); } } diff --git a/src/common/rpg/components/questLog.ts b/src/common/rpg/components/questLog.ts index 1318c93..9891976 100644 --- a/src/common/rpg/components/questLog.ts +++ b/src/common/rpg/components/questLog.ts @@ -1,4 +1,5 @@ import { Component, type EvalContext } from "../core/world"; +import { component } from "../core/registry"; import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; import { evaluateCondition } from "../utils/conditions"; @@ -14,29 +15,44 @@ export interface QuestEntry { state: QuestRuntimeState; } -export class QuestLog extends Component { - readonly #quests = new Map(); +interface QuestLogState { + quests: Record; + runtimeStates: Record; +} + +@component +export class QuestLog extends Component { + constructor(quests: Quest[] = []) { + const questsRecord: Record = {}; + const runtimeStates: Record = {}; + for (const q of quests) { + questsRecord[q.id] = q; + runtimeStates[q.id] = { status: 'inactive', stageIndex: 0 }; + } + super({ quests: questsRecord, runtimeStates }); + } addQuest(quest: Quest): void { - if (this.#quests.has(quest.id)) { + if (this.state.quests[quest.id]) { console.warn(`[QuestLog] quest '${quest.id}' is already registered, ignoring duplicate`); return; } - this.#quests.set(quest.id, { quest, state: { status: 'inactive', stageIndex: 0 } }); + this.state.quests[quest.id] = quest; + this.state.runtimeStates[quest.id] = { status: 'inactive', stageIndex: 0 }; } #transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean { - const entry = this.#quests.get(questId); - if (!entry) { + const runtimeState = this.state.runtimeStates[questId]; + if (!runtimeState) { console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`); return false; } - if (entry.state.status !== from) { - console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${entry.state.status}'`); + if (runtimeState.status !== from) { + console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${runtimeState.status}'`); return false; } - entry.state.status = to; - if (to === 'active' || to === 'inactive') entry.state.stageIndex = 0; + runtimeState.status = to; + if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0; this.emit(event, { questId }); return true; } @@ -58,21 +74,21 @@ export class QuestLog extends Component { } getState(questId: string): QuestRuntimeState | undefined { - return this.#quests.get(questId)?.state; + return this.state.runtimeStates[questId]; } isAvailable(questId: string, ctx: EvalContext): boolean { - const entry = this.#quests.get(questId); - if (!entry) return false; - const { quest } = entry; + const quest = this.state.quests[questId]; + if (!quest) return false; if (!quest.conditions?.length) return true; return quest.conditions.every(c => evaluateCondition(c, ctx)); } getStage(questId: string): QuestStage | undefined { - const entry = this.#quests.get(questId); - if (!entry || entry.state.status !== 'active') return undefined; - return entry.quest.stages[entry.state.stageIndex]; + const quest = this.state.quests[questId]; + const runtimeState = this.state.runtimeStates[questId]; + if (!quest || runtimeState?.status !== 'active') return undefined; + return quest.stages[runtimeState.stageIndex]; } getObjectiveProgress( @@ -89,31 +105,32 @@ export class QuestLog extends Component { } /** @internal used by QuestSystem */ - entries(): IterableIterator<[string, QuestEntry]> { - return this.#quests.entries(); + *entries(): Generator<[string, QuestEntry]> { + for (const [id, quest] of Object.entries(this.state.quests)) { + yield [id, { quest, state: this.state.runtimeStates[id]! }]; + } } /** @internal called by QuestSystem after stage actions complete */ _advance(questId: string): void { - const entry = this.#quests.get(questId); - if (!entry) return; - const { quest, state } = entry; - if (state.stageIndex + 1 < quest.stages.length) { - state.stageIndex++; - this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] }); + const quest = this.state.quests[questId]; + const runtimeState = this.state.runtimeStates[questId]; + if (!quest || !runtimeState) return; + if (runtimeState.stageIndex + 1 < quest.stages.length) { + runtimeState.stageIndex++; + this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] }); } else { - state.status = 'completed'; + runtimeState.status = 'completed'; this.emit('completed', { questId }); } } override getActions() { const result = { ...super.getActions() }; - for (const questId of this.#quests.keys()) { - const entry = this.#quests.get(questId)!; - if (entry.state.status === 'inactive') { + for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) { + if (runtimeState.status === 'inactive') { result[`${questId}.start`] = this.start.bind(this, questId); - } else if (entry.state.status === 'active') { + } else if (runtimeState.status === 'active') { result[`${questId}.complete`] = this.complete.bind(this, questId); result[`${questId}.fail`] = this.fail.bind(this, questId); result[`${questId}.abandon`] = this.abandon.bind(this, questId); @@ -124,9 +141,9 @@ export class QuestLog extends Component { override getVariables(): RPGVariables { const result: RPGVariables = {}; - for (const [questId, { state }] of this.#quests) { - result[`${questId}.status`] = state.status; - result[`${questId}.stage`] = state.stageIndex; + for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) { + result[`${questId}.status`] = runtimeState.status; + result[`${questId}.stage`] = runtimeState.stageIndex; } return result; } diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts index 8d0cab6..f402ab3 100644 --- a/src/common/rpg/components/stat.ts +++ b/src/common/rpg/components/stat.ts @@ -1,43 +1,49 @@ import { action, variable } from "../utils/decorators"; import { Component } from "../core/world"; +import { component } from "../core/registry"; -export class Stat extends Component { - @variable('.') private value: number; - @variable private max: number | undefined; - @variable private min: number | undefined; +interface StatState { + value: number; + max: number | undefined; + min: number | undefined; +} +@component +export class Stat extends Component { constructor(value: number, max?: number, min?: number) { - super(); - this.value = value; - this.max = max; - this.min = min; + super({ value, max, min }); } + @variable('.') get value(): number { return this.state.value; } + @variable get max(): number | undefined { return this.state.max; } + @variable get min(): number | undefined { return this.state.min; } + @action update(amount: number) { - this.set(this.value + amount); + this.set(this.state.value + amount); } @action set(value: number) { - const prev = this.value; - this.value = value; - if (this.min != null) { - this.value = Math.max(this.min, this.value); + const prev = this.state.value; + this.state.value = value; + if (this.state.min != null) { + this.state.value = Math.max(this.state.min, this.state.value); } - if (this.max != null) { - this.value = Math.min(this.value, this.max); + if (this.state.max != null) { + this.state.value = Math.min(this.state.value, this.state.max); } - if (prev !== this.value) { - this.emit('set', { prev, value: this.value }); + if (prev !== this.state.value) { + this.emit('set', { prev, value: this.state.value }); } } get current(): number { - return this.value; + return this.state.value; } } +@component export class Health extends Stat { constructor(value: number, max?: number, min = 0) { super(value, max, min); @@ -48,4 +54,4 @@ export class Health extends Stat { this.set(0); this.emit('killed'); } -} \ No newline at end of file +} diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts index 8a0993d..532915f 100644 --- a/src/common/rpg/components/variables.ts +++ b/src/common/rpg/components/variables.ts @@ -1,43 +1,51 @@ import type { RPGVariables } from "../types"; import { action } from "../utils/decorators"; import { Component } from "../core/world"; +import { component } from "../core/registry"; interface Var { key: string; value: RPGVariables[string]; } -export class Variables extends Component { - private readonly variables: RPGVariables = {}; +interface VariablesState { + vars: RPGVariables; +} + +@component +export class Variables extends Component { + constructor() { + super({ vars: {} }); + } override getVariables() { - return this.variables; + return this.state.vars; } @action set({ key, value }: Var) { - const prev = this.variables[key]; - this.variables[key] = value; + const prev = this.state.vars[key]; + this.state.vars[key] = value; this.emit('set', { key, value, prev }); - return this.variables; + return this.state.vars; } @action unset(key: string) { - const prev = this.variables[key]; - delete this.variables[key]; + const prev = this.state.vars[key]; + delete this.state.vars[key]; this.emit('unset', { key, prev }); - return this.variables; + return this.state.vars; } @action increment({ key, value }: Var) { - const currentValue = this.variables[key] ?? 0; + const currentValue = this.state.vars[key] ?? 0; if (typeof currentValue === 'number' && typeof value === 'number') { this.set({ key, value: currentValue + value }); } else { console.warn(`[Variables] increment failed: ${key} is not a number`); } - return this.variables; + return this.state.vars; } } diff --git a/src/common/rpg/core/registry.ts b/src/common/rpg/core/registry.ts new file mode 100644 index 0000000..6c47b96 --- /dev/null +++ b/src/common/rpg/core/registry.ts @@ -0,0 +1,33 @@ +import type { Component } from './world'; + +type ComponentConstructor = abstract new (...args: any[]) => Component; +type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void; + +const registry = new Map(); +const reverseRegistry = new Map(); + +function register(name: string, ctor: ComponentConstructor): void { + registry.set(name, ctor); + reverseRegistry.set(ctor, name); +} + +export function getComponentClass(name: string): ComponentConstructor | undefined { + return registry.get(name); +} + +export function getComponentName(ctor: Function): string | undefined { + return reverseRegistry.get(ctor as ComponentConstructor); +} + +export function component(target: ComponentConstructor, ctx: ClassDecoratorContext): void; +export function component(name: string): ComponentDecorator; +export function component( + nameOrTarget: string | ComponentConstructor, + ctx?: ClassDecoratorContext, +): void | ComponentDecorator { + if (typeof nameOrTarget === 'string') { + const name = nameOrTarget; + return (target: ComponentConstructor) => register(name, target); + } + register(String(ctx!.name), nameOrTarget); +} diff --git a/src/common/rpg/core/serialization.ts b/src/common/rpg/core/serialization.ts new file mode 100644 index 0000000..7c4e2e7 --- /dev/null +++ b/src/common/rpg/core/serialization.ts @@ -0,0 +1,109 @@ +import { World, Entity, Component, COMPONENT_STATE, WORLD_ENTITY_COUNTER } from './world'; +import { getComponentClass, getComponentName } from './registry'; + +interface ComponentData { + type: 'component'; + name: string; + key: string; + state: unknown; +} + +interface EntityData { + type: 'entity'; + id: string; + components: ComponentData[]; +} + +interface WorldData { + type: 'world'; + globals: Record; + entityCounter: number; + entities: EntityData[]; +} + +type AnyData = ComponentData | EntityData | WorldData; + +function serializeComponent(component: Component): ComponentData { + const name = getComponentName(component.constructor); + if (!name) { + throw new Error( + `Component '${component.constructor.name}' is not registered. ` + + `Add @component to the class declaration.` + ); + } + return { type: 'component', name, key: component.key, state: component[COMPONENT_STATE]() }; +} + +function serializeEntity(entity: Entity): EntityData { + const components: ComponentData[] = []; + for (const [, component] of entity) { + components.push(serializeComponent(component)); + } + return { type: 'entity', id: entity.id, components }; +} + +function serializeWorld(world: World): WorldData { + const entities: EntityData[] = []; + for (const entity of world) { + entities.push(serializeEntity(entity)); + } + return { + type: 'world', + globals: { ...world.globals }, + entityCounter: world[WORLD_ENTITY_COUNTER], + entities, + }; +} + +function deserializeComponent(data: ComponentData): Component { + const ComponentClass = getComponentClass(data.name); + if (!ComponentClass) { + throw new Error(`Unknown component '${data.name}'. Ensure it is imported so @component runs.`); + } + // Bypass constructor: create a bare instance and restore state directly. + // This is safe because constructors must only call super(state) — all + // initialization logic goes in onAdd(), which entity.add() calls after this. + const instance = Object.create(ComponentClass.prototype) as Component; + (instance as unknown as { state: unknown }).state = data.state; + return instance; +} + +function deserializeEntity(data: EntityData, world: World): Entity { + const entity = world.createEntity(data.id); + for (const componentData of data.components) { + entity.add(componentData.key, deserializeComponent(componentData)); + } + return entity; +} + +function deserializeWorld(data: WorldData): World { + const world = new World(); + Object.assign(world.globals, data.globals); + world[WORLD_ENTITY_COUNTER] = data.entityCounter; + for (const entityData of data.entities) { + deserializeEntity(entityData, world); + } + return world; +} + +export namespace Serialization { + export function serialize(x: World | Entity | Component): string { + if (x instanceof World) return JSON.stringify(serializeWorld(x)); + if (x instanceof Entity) return JSON.stringify(serializeEntity(x)); + return JSON.stringify(serializeComponent(x)); + } + + export function deserialize(s: string): World | Entity | Component { + const data = JSON.parse(s) as AnyData; + switch (data.type) { + case 'world': return deserializeWorld(data); + case 'entity': { + // A standalone entity needs a world to live in. + const world = new World(); + return deserializeEntity(data, world); + } + case 'component': return deserializeComponent(data); + default: throw new Error(`Unknown serialized type: '${(data as AnyData).type}'`); + } + } +} diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 1339154..a2cafea 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -19,10 +19,24 @@ export interface EvalContext { world: World; } -export abstract class Component { +/** 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> { 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); } @@ -81,7 +95,7 @@ export class Entity { return { self: this, world: this.world }; } - add(key: string, component: T): T { + add>(key: string, component: T): T { const existing = this.#components.get(key); if (existing) existing.onRemove(); component.entity = this; @@ -91,10 +105,10 @@ export class Entity { return component; } - get(key: string): T | undefined; - get(ctor: Class): T | undefined; - get(ctor: Class, key: string): T | undefined; - get(ctorOrKey: Class | string, key?: string): T | undefined { + get>(key: string): T | undefined; + get>(ctor: Class): T | undefined; + get>(ctor: Class, key: string): T | undefined; + get>(ctorOrKey: Class | string, key?: string): T | undefined { if (typeof ctorOrKey === 'string') { return this.#components.get(ctorOrKey) as T | undefined; } @@ -109,9 +123,9 @@ export class Entity { } has(key: string): boolean; - has(ctor: Class): boolean; - has(ctor: Class, key: string): boolean; - has(ctorOrKey: Class | string, 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()) { @@ -121,9 +135,9 @@ export class Entity { } remove(key: string): void; - remove(ctor: Class): void; - remove(ctor: Class, key: string): void; - remove(ctorOrKey: Class | string, key?: string): void { + remove>(ctor: Class): void; + remove>(ctor: Class, key: string): void; + remove>(ctorOrKey: Class | string, key?: string): void { if (typeof ctorOrKey === 'string') { this.#removeByKey(ctorOrKey); return; @@ -166,12 +180,12 @@ export class Entity { this.world.destroyEntity(this); } - /** @internal used by World.query and resolveVariables */ + /** @internal */ [Symbol.iterator](): IterableIterator<[string, Component]> { return this.#components.entries(); } - /** @internal called by World.destroyEntity */ + /** @internal */ _destroy(): void { for (const c of this.#components.values()) c.onRemove(); this.#components.clear(); @@ -188,6 +202,9 @@ export class World { readonly #systems: System[] = []; #entityCounter = 0; + get [WORLD_ENTITY_COUNTER](): number { return this.#entityCounter; } + set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; } + createEntity(id?: string): Entity { const entityId = id ?? `entity_${++this.#entityCounter}`; if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`); @@ -208,7 +225,7 @@ export class World { } } - *query(ctor: Class): Generator<[Entity, string, T]> { + *query>(ctor: Class): Generator<[Entity, string, T]> { for (const entity of this.#entities.values()) { for (const [key, component] of entity) { if (component instanceof ctor) { @@ -238,11 +255,7 @@ export class World { 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, - })); + this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data })); } emitGlobal(event: string, data?: unknown): void { @@ -282,7 +295,11 @@ export class World { return unsub; } - #addHandler(map: Map>, key: string, handler: T): () => void { + #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);