1
0
Fork 0

Serialization

This commit is contained in:
Pabloader 2026-04-29 07:28:16 +00:00
parent ad1da396e6
commit 9bec7701e0
8 changed files with 368 additions and 158 deletions

View File

@ -1,49 +1,50 @@
import type { InventorySlotInput, RPGVariables, SlotId } from "../types"; import type { InventorySlotInput, RPGVariables, SlotId } from "../types";
import { action } from "../utils/decorators"; import { action } from "../utils/decorators";
import { Component, type EvalContext } from "../core/world"; import { Component, type EvalContext } from "../core/world";
import { component } from "../core/registry";
import { Stackable, Usable } from "./item"; import { Stackable, Usable } from "./item";
import { resolveVariables } from "../utils/variables"; import { resolveVariables } from "../utils/variables";
interface SlotEntry { interface SlotRecord {
readonly slotId: SlotId; slotId: SlotId;
readonly limit: number | undefined; limit: number | undefined;
state: { itemId: string; amount: number } | null; contents: { itemId: string; amount: number } | null;
} }
interface SlotUpdateArgs { interface InventoryState {
itemId: string; slots: SlotRecord[];
amount: number;
slotId?: SlotId;
} }
export class Inventory extends Component { @component
private readonly slots: Map<SlotId, SlotEntry>; export class Inventory extends Component<InventoryState> {
constructor(slotDefs: Array<InventorySlotInput>) { constructor(slotDefs: Array<InventorySlotInput>) {
super(); super({
this.slots = new Map( slots: slotDefs.map(def => {
slotDefs.map(def => {
const slotId = typeof def === 'object' ? def.slotId : def; const slotId = typeof def === 'object' ? def.slotId : def;
const limit = typeof def === 'object' ? def.limit : undefined; 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 limitCap = slot.limit ?? Infinity;
const stackable = this.entity.world.getEntity(itemId)?.get(Stackable); const stackable = this.entity.world.getEntity(itemId)?.get(Stackable);
const stackCap = stackable ? stackable.maxStack : 1; const stackCap = stackable ? stackable.maxStack : 1;
return Math.min(limitCap, stackCap); return Math.min(limitCap, stackCap);
} }
private slotRoomFor(slot: SlotEntry, itemId: string): number { #roomFor(slot: SlotRecord, itemId: string): number {
if (slot.state !== null && slot.state.itemId !== itemId) return 0; if (slot.contents !== null && slot.contents.itemId !== itemId) return 0;
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); return this.#capFor(slot, itemId) - (slot.contents?.amount ?? 0);
} }
@action @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 false;
if (amount === 0) return true; if (amount === 0) return true;
@ -53,19 +54,19 @@ export class Inventory extends Component {
} }
if (slotId !== undefined) { if (slotId !== undefined) {
const slot = this.slots.get(slotId); const slot = this.#slot(slotId);
if (!slot) return false; if (!slot) return false;
if (this.slotRoomFor(slot, itemId) < amount) return false; if (this.#roomFor(slot, itemId) < amount) return false;
slot.state = { itemId, amount: (slot.state?.amount ?? 0) + amount }; slot.contents = { itemId, amount: (slot.contents?.amount ?? 0) + amount };
this.emit('add', { itemId, amount, slotIds: [slotId] }); this.emit('add', { itemId, amount, slotIds: [slotId] });
return true; return true;
} }
// Two-phase: pre-check then apply // Two-phase: pre-check then apply
let remaining = amount; let remaining = amount;
for (const slot of this.slots.values()) { for (const slot of this.state.slots) {
if (slot.state === null || slot.state.itemId === itemId) { if (slot.contents === null || slot.contents.itemId === itemId) {
remaining -= Math.min(this.slotRoomFor(slot, itemId), remaining); remaining -= Math.min(this.#roomFor(slot, itemId), remaining);
if (remaining === 0) break; if (remaining === 0) break;
} }
} }
@ -74,24 +75,24 @@ export class Inventory extends Component {
// Apply — fill existing slots for this item first, then empty ones // Apply — fill existing slots for this item first, then empty ones
remaining = amount; remaining = amount;
const slotIds: SlotId[] = []; const slotIds: SlotId[] = [];
for (const [id, slot] of this.slots) { for (const slot of this.state.slots) {
if (slot.state?.itemId === itemId) { if (slot.contents?.itemId === itemId) {
const take = Math.min(this.slotRoomFor(slot, itemId), remaining); const take = Math.min(this.#roomFor(slot, itemId), remaining);
slot.state.amount += take; slot.contents.amount += take;
remaining -= take; remaining -= take;
slotIds.push(id); slotIds.push(slot.slotId);
if (remaining === 0) { if (remaining === 0) {
this.emit('add', { itemId, amount, slotIds }); this.emit('add', { itemId, amount, slotIds });
return true; return true;
} }
} }
} }
for (const [id, slot] of this.slots) { for (const slot of this.state.slots) {
if (slot.state === null) { if (slot.contents === null) {
const take = Math.min(this.slotCapFor(slot, itemId), remaining); const take = Math.min(this.#capFor(slot, itemId), remaining);
slot.state = { itemId, amount: take }; slot.contents = { itemId, amount: take };
remaining -= take; remaining -= take;
slotIds.push(id); slotIds.push(slot.slotId);
if (remaining === 0) { if (remaining === 0) {
this.emit('add', { itemId, amount, slotIds }); this.emit('add', { itemId, amount, slotIds });
return true; return true;
@ -103,16 +104,16 @@ export class Inventory extends Component {
} }
@action @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 false;
if (amount === 0) return true; if (amount === 0) return true;
if (slotId !== undefined) { if (slotId !== undefined) {
const slot = this.slots.get(slotId); const slot = this.#slot(slotId);
if (!slot || slot.state?.itemId !== itemId) return false; if (!slot || slot.contents?.itemId !== itemId) return false;
if (slot.state.amount < amount) return false; if (slot.contents.amount < amount) return false;
slot.state.amount -= amount; slot.contents.amount -= amount;
if (slot.state.amount === 0) slot.state = null; if (slot.contents.amount === 0) slot.contents = null;
this.emit('remove', { itemId, amount, slotIds: [slotId] }); this.emit('remove', { itemId, amount, slotIds: [slotId] });
return true; return true;
} }
@ -121,13 +122,13 @@ export class Inventory extends Component {
let remaining = amount; let remaining = amount;
const slotIds: SlotId[] = []; const slotIds: SlotId[] = [];
for (const [id, slot] of this.slots) { for (const slot of this.state.slots) {
if (slot.state?.itemId === itemId) { if (slot.contents?.itemId === itemId) {
const take = Math.min(slot.state.amount, remaining); const take = Math.min(slot.contents.amount, remaining);
slot.state.amount -= take; slot.contents.amount -= take;
if (slot.state.amount === 0) slot.state = null; if (slot.contents.amount === 0) slot.contents = null;
remaining -= take; remaining -= take;
slotIds.push(id); slotIds.push(slot.slotId);
if (remaining === 0) { if (remaining === 0) {
this.emit('remove', { itemId, amount, slotIds }); this.emit('remove', { itemId, amount, slotIds });
return true; return true;
@ -170,21 +171,22 @@ export class Inventory extends Component {
getAmount(itemId: string, slotId?: SlotId): number { getAmount(itemId: string, slotId?: SlotId): number {
if (slotId !== undefined) { if (slotId !== undefined) {
const slot = this.slots.get(slotId); const slot = this.#slot(slotId);
return slot?.state?.itemId === itemId ? slot.state.amount : 0; return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
} }
let total = 0; let total = 0;
for (const slot of this.slots.values()) { for (const slot of this.state.slots) {
if (slot.state?.itemId === itemId) total += slot.state.amount; if (slot.contents?.itemId === itemId) total += slot.contents.amount;
} }
return total; return total;
} }
getItems(): Map<string, number> { getItems(): Map<string, number> {
const result = new Map<string, number>(); const result = new Map<string, number>();
for (const slot of this.slots.values()) { for (const slot of this.state.slots) {
if (slot.state) { if (slot.contents) {
result.set(slot.state.itemId, (result.get(slot.state.itemId) ?? 0) + slot.state.amount); const { itemId, amount } = slot.contents;
result.set(itemId, (result.get(itemId) ?? 0) + amount);
} }
} }
return result; return result;

View File

@ -1,37 +1,55 @@
import { Component, type EvalContext, type World } from "../core/world"; import { Component, type EvalContext, type World } from "../core/world";
import { component } from "../core/registry";
import type { RPGAction } from "../types"; import type { RPGAction } from "../types";
import { action, variable } from "../utils/decorators"; import { action, variable } from "../utils/decorators";
import { executeAction } from "../utils/variables"; import { executeAction } from "../utils/variables";
export class Item extends Component { interface ItemState {
constructor( name: string;
readonly name: string, description: string;
readonly description: string = '',
) {
super();
}
} }
export class Stackable extends Component { @component
@variable readonly maxStack: number; export class Item extends Component<ItemState> {
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<StackableState> {
constructor(maxStack: number) { constructor(maxStack: number) {
super(); super({ maxStack });
this.maxStack = maxStack;
} }
@variable get maxStack(): number { return this.state.maxStack; }
} }
export class Usable extends Component { interface UsableState {
@variable readonly consumeOnUse: boolean; actions: RPGAction[];
constructor(private readonly actions: RPGAction[], consumeOnUse = true) { consumeOnUse: boolean;
super(); }
this.consumeOnUse = consumeOnUse;
@component
export class Usable extends Component<UsableState> {
constructor(actions: RPGAction[], consumeOnUse = true) {
super({ actions, consumeOnUse });
} }
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
@action @action
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> { async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> {
ctx = arg ?? ctx ?? this.context; ctx = arg ?? ctx ?? this.context;
if (!ctx) return; if (!ctx) return;
for (const action of this.actions) { for (const action of this.state.actions) {
await executeAction(action, ctx); await executeAction(action, ctx);
} }
} }

View File

@ -1,4 +1,5 @@
import { Component, type EvalContext } from "../core/world"; import { Component, type EvalContext } from "../core/world";
import { component } from "../core/registry";
import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types"; import { isQuest, type Quest, type QuestStage, type RPGVariables } from "../types";
import { evaluateCondition } from "../utils/conditions"; import { evaluateCondition } from "../utils/conditions";
@ -14,29 +15,44 @@ export interface QuestEntry {
state: QuestRuntimeState; state: QuestRuntimeState;
} }
export class QuestLog extends Component { interface QuestLogState {
readonly #quests = new Map<string, QuestEntry>(); quests: Record<string, Quest>;
runtimeStates: Record<string, QuestRuntimeState>;
}
@component
export class QuestLog extends Component<QuestLogState> {
constructor(quests: Quest[] = []) {
const questsRecord: Record<string, Quest> = {};
const runtimeStates: Record<string, QuestRuntimeState> = {};
for (const q of quests) {
questsRecord[q.id] = q;
runtimeStates[q.id] = { status: 'inactive', stageIndex: 0 };
}
super({ quests: questsRecord, runtimeStates });
}
addQuest(quest: Quest): void { 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`); console.warn(`[QuestLog] quest '${quest.id}' is already registered, ignoring duplicate`);
return; 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 { #transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean {
const entry = this.#quests.get(questId); const runtimeState = this.state.runtimeStates[questId];
if (!entry) { if (!runtimeState) {
console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`); console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`);
return false; return false;
} }
if (entry.state.status !== from) { if (runtimeState.status !== from) {
console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${entry.state.status}'`); console.warn(`[QuestLog] ${op}: quest '${questId}' cannot transition from status '${runtimeState.status}'`);
return false; return false;
} }
entry.state.status = to; runtimeState.status = to;
if (to === 'active' || to === 'inactive') entry.state.stageIndex = 0; if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0;
this.emit(event, { questId }); this.emit(event, { questId });
return true; return true;
} }
@ -58,21 +74,21 @@ export class QuestLog extends Component {
} }
getState(questId: string): QuestRuntimeState | undefined { getState(questId: string): QuestRuntimeState | undefined {
return this.#quests.get(questId)?.state; return this.state.runtimeStates[questId];
} }
isAvailable(questId: string, ctx: EvalContext): boolean { isAvailable(questId: string, ctx: EvalContext): boolean {
const entry = this.#quests.get(questId); const quest = this.state.quests[questId];
if (!entry) return false; if (!quest) return false;
const { quest } = entry;
if (!quest.conditions?.length) return true; if (!quest.conditions?.length) return true;
return quest.conditions.every(c => evaluateCondition(c, ctx)); return quest.conditions.every(c => evaluateCondition(c, ctx));
} }
getStage(questId: string): QuestStage | undefined { getStage(questId: string): QuestStage | undefined {
const entry = this.#quests.get(questId); const quest = this.state.quests[questId];
if (!entry || entry.state.status !== 'active') return undefined; const runtimeState = this.state.runtimeStates[questId];
return entry.quest.stages[entry.state.stageIndex]; if (!quest || runtimeState?.status !== 'active') return undefined;
return quest.stages[runtimeState.stageIndex];
} }
getObjectiveProgress( getObjectiveProgress(
@ -89,31 +105,32 @@ export class QuestLog extends Component {
} }
/** @internal used by QuestSystem */ /** @internal used by QuestSystem */
entries(): IterableIterator<[string, QuestEntry]> { *entries(): Generator<[string, QuestEntry]> {
return this.#quests.entries(); 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 */ /** @internal called by QuestSystem after stage actions complete */
_advance(questId: string): void { _advance(questId: string): void {
const entry = this.#quests.get(questId); const quest = this.state.quests[questId];
if (!entry) return; const runtimeState = this.state.runtimeStates[questId];
const { quest, state } = entry; if (!quest || !runtimeState) return;
if (state.stageIndex + 1 < quest.stages.length) { if (runtimeState.stageIndex + 1 < quest.stages.length) {
state.stageIndex++; runtimeState.stageIndex++;
this.emit('stage', { questId, index: state.stageIndex, stage: quest.stages[state.stageIndex] }); this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] });
} else { } else {
state.status = 'completed'; runtimeState.status = 'completed';
this.emit('completed', { questId }); this.emit('completed', { questId });
} }
} }
override getActions() { override getActions() {
const result = { ...super.getActions() }; const result = { ...super.getActions() };
for (const questId of this.#quests.keys()) { for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
const entry = this.#quests.get(questId)!; if (runtimeState.status === 'inactive') {
if (entry.state.status === 'inactive') {
result[`${questId}.start`] = this.start.bind(this, questId); 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}.complete`] = this.complete.bind(this, questId);
result[`${questId}.fail`] = this.fail.bind(this, questId); result[`${questId}.fail`] = this.fail.bind(this, questId);
result[`${questId}.abandon`] = this.abandon.bind(this, questId); result[`${questId}.abandon`] = this.abandon.bind(this, questId);
@ -124,9 +141,9 @@ export class QuestLog extends Component {
override getVariables(): RPGVariables { override getVariables(): RPGVariables {
const result: RPGVariables = {}; const result: RPGVariables = {};
for (const [questId, { state }] of this.#quests) { for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
result[`${questId}.status`] = state.status; result[`${questId}.status`] = runtimeState.status;
result[`${questId}.stage`] = state.stageIndex; result[`${questId}.stage`] = runtimeState.stageIndex;
} }
return result; return result;
} }

View File

@ -1,43 +1,49 @@
import { action, variable } from "../utils/decorators"; import { action, variable } from "../utils/decorators";
import { Component } from "../core/world"; import { Component } from "../core/world";
import { component } from "../core/registry";
export class Stat extends Component { interface StatState {
@variable('.') private value: number; value: number;
@variable private max: number | undefined; max: number | undefined;
@variable private min: number | undefined; min: number | undefined;
}
@component
export class Stat extends Component<StatState> {
constructor(value: number, max?: number, min?: number) { constructor(value: number, max?: number, min?: number) {
super(); super({ value, max, min });
this.value = value;
this.max = max;
this.min = 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 @action
update(amount: number) { update(amount: number) {
this.set(this.value + amount); this.set(this.state.value + amount);
} }
@action @action
set(value: number) { set(value: number) {
const prev = this.value; const prev = this.state.value;
this.value = value; this.state.value = value;
if (this.min != null) { if (this.state.min != null) {
this.value = Math.max(this.min, this.value); this.state.value = Math.max(this.state.min, this.state.value);
} }
if (this.max != null) { if (this.state.max != null) {
this.value = Math.min(this.value, this.max); this.state.value = Math.min(this.state.value, this.state.max);
} }
if (prev !== this.value) { if (prev !== this.state.value) {
this.emit('set', { prev, value: this.value }); this.emit('set', { prev, value: this.state.value });
} }
} }
get current(): number { get current(): number {
return this.value; return this.state.value;
} }
} }
@component
export class Health extends Stat { export class Health extends Stat {
constructor(value: number, max?: number, min = 0) { constructor(value: number, max?: number, min = 0) {
super(value, max, min); super(value, max, min);
@ -48,4 +54,4 @@ export class Health extends Stat {
this.set(0); this.set(0);
this.emit('killed'); this.emit('killed');
} }
} }

View File

@ -1,43 +1,51 @@
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { action } from "../utils/decorators"; import { action } from "../utils/decorators";
import { Component } from "../core/world"; import { Component } from "../core/world";
import { component } from "../core/registry";
interface Var { interface Var {
key: string; key: string;
value: RPGVariables[string]; value: RPGVariables[string];
} }
export class Variables extends Component { interface VariablesState {
private readonly variables: RPGVariables = {}; vars: RPGVariables;
}
@component
export class Variables extends Component<VariablesState> {
constructor() {
super({ vars: {} });
}
override getVariables() { override getVariables() {
return this.variables; return this.state.vars;
} }
@action @action
set({ key, value }: Var) { set({ key, value }: Var) {
const prev = this.variables[key]; const prev = this.state.vars[key];
this.variables[key] = value; this.state.vars[key] = value;
this.emit('set', { key, value, prev }); this.emit('set', { key, value, prev });
return this.variables; return this.state.vars;
} }
@action @action
unset(key: string) { unset(key: string) {
const prev = this.variables[key]; const prev = this.state.vars[key];
delete this.variables[key]; delete this.state.vars[key];
this.emit('unset', { key, prev }); this.emit('unset', { key, prev });
return this.variables; return this.state.vars;
} }
@action @action
increment({ key, value }: Var) { increment({ key, value }: Var) {
const currentValue = this.variables[key] ?? 0; const currentValue = this.state.vars[key] ?? 0;
if (typeof currentValue === 'number' && typeof value === 'number') { if (typeof currentValue === 'number' && typeof value === 'number') {
this.set({ key, value: currentValue + value }); this.set({ key, value: currentValue + value });
} else { } else {
console.warn(`[Variables] increment failed: ${key} is not a number`); console.warn(`[Variables] increment failed: ${key} is not a number`);
} }
return this.variables; return this.state.vars;
} }
} }

View File

@ -0,0 +1,33 @@
import type { Component } from './world';
type ComponentConstructor = abstract new (...args: any[]) => Component<any>;
type ComponentDecorator = (target: ComponentConstructor, ctx: ClassDecoratorContext) => void;
const registry = new Map<string, ComponentConstructor>();
const reverseRegistry = new Map<ComponentConstructor, string>();
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);
}

View File

@ -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<string, string | number | boolean | undefined>;
entityCounter: number;
entities: EntityData[];
}
type AnyData = ComponentData | EntityData | WorldData;
function serializeComponent(component: Component<any>): 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<any> {
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<any>;
(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<any>): 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<any> {
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}'`);
}
}
}

View File

@ -19,10 +19,24 @@ export interface EvalContext {
world: World; 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<TState = Record<string, unknown>> {
entity!: Entity; entity!: Entity;
key!: string; key!: string;
protected state: TState;
constructor(state: TState) {
this.state = state;
}
[COMPONENT_STATE](): TState { return this.state; }
protected emit(event: string, data?: unknown): void { protected emit(event: string, data?: unknown): void {
this.entity.emit(`${this.key}.${event}`, data); this.entity.emit(`${this.key}.${event}`, data);
} }
@ -81,7 +95,7 @@ export class Entity {
return { self: this, world: this.world }; return { self: this, world: this.world };
} }
add<T extends Component>(key: string, component: T): T { add<T extends Component<any>>(key: string, component: T): T {
const existing = this.#components.get(key); const existing = this.#components.get(key);
if (existing) existing.onRemove(); if (existing) existing.onRemove();
component.entity = this; component.entity = this;
@ -91,10 +105,10 @@ export class Entity {
return component; return component;
} }
get<T extends Component>(key: string): T | undefined; get<T extends Component<any>>(key: string): T | undefined;
get<T extends Component>(ctor: Class<T>): T | undefined; get<T extends Component<any>>(ctor: Class<T>): T | undefined;
get<T extends Component>(ctor: Class<T>, key: string): T | undefined; get<T extends Component<any>>(ctor: Class<T>, key: string): T | undefined;
get<T extends Component>(ctorOrKey: Class<T> | string, key?: string): T | undefined { get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): T | undefined {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
return this.#components.get(ctorOrKey) as T | undefined; return this.#components.get(ctorOrKey) as T | undefined;
} }
@ -109,9 +123,9 @@ export class Entity {
} }
has(key: string): boolean; has(key: string): boolean;
has<T extends Component>(ctor: Class<T>): boolean; has<T extends Component<any>>(ctor: Class<T>): boolean;
has<T extends Component>(ctor: Class<T>, key: string): boolean; has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
has<T extends Component>(ctorOrKey: Class<T> | string, 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 (typeof ctorOrKey === 'string') return this.#components.has(ctorOrKey);
if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey; if (key !== undefined) return this.#components.get(key) instanceof ctorOrKey;
for (const c of this.#components.values()) { for (const c of this.#components.values()) {
@ -121,9 +135,9 @@ export class Entity {
} }
remove(key: string): void; remove(key: string): void;
remove<T extends Component>(ctor: Class<T>): void; remove<T extends Component<any>>(ctor: Class<T>): void;
remove<T extends Component>(ctor: Class<T>, key: string): void; remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
remove<T extends Component>(ctorOrKey: Class<T> | string, key?: string): void { remove<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string): void {
if (typeof ctorOrKey === 'string') { if (typeof ctorOrKey === 'string') {
this.#removeByKey(ctorOrKey); this.#removeByKey(ctorOrKey);
return; return;
@ -166,12 +180,12 @@ export class Entity {
this.world.destroyEntity(this); this.world.destroyEntity(this);
} }
/** @internal used by World.query and resolveVariables */ /** @internal */
[Symbol.iterator](): IterableIterator<[string, Component]> { [Symbol.iterator](): IterableIterator<[string, Component]> {
return this.#components.entries(); return this.#components.entries();
} }
/** @internal called by World.destroyEntity */ /** @internal */
_destroy(): void { _destroy(): void {
for (const c of this.#components.values()) c.onRemove(); for (const c of this.#components.values()) c.onRemove();
this.#components.clear(); this.#components.clear();
@ -188,6 +202,9 @@ export class World {
readonly #systems: System[] = []; readonly #systems: System[] = [];
#entityCounter = 0; #entityCounter = 0;
get [WORLD_ENTITY_COUNTER](): number { return this.#entityCounter; }
set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; }
createEntity(id?: string): Entity { createEntity(id?: string): Entity {
const entityId = id ?? `entity_${++this.#entityCounter}`; const entityId = id ?? `entity_${++this.#entityCounter}`;
if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`); if (this.#entities.has(entityId)) throw new Error(`Entity '${entityId}' already exists`);
@ -208,7 +225,7 @@ export class World {
} }
} }
*query<T extends Component>(ctor: Class<T>): Generator<[Entity, string, T]> { *query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]> {
for (const entity of this.#entities.values()) { for (const entity of this.#entities.values()) {
for (const [key, component] of entity) { for (const [key, component] of entity) {
if (component instanceof ctor) { if (component instanceof ctor) {
@ -238,11 +255,7 @@ export class World {
emit(entityId: string, event: string, data?: unknown): void { emit(entityId: string, event: string, data?: unknown): void {
const entity = this.getEntity(entityId); const entity = this.getEntity(entityId);
if (!entity) return; 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 { emitGlobal(event: string, data?: unknown): void {
@ -282,7 +295,11 @@ export class World {
return unsub; return unsub;
} }
#addHandler<T extends WorldEventHandler | EntityEventHandler>(map: Map<string, Set<T>>, key: string, handler: T): () => void { #addHandler<T extends WorldEventHandler | EntityEventHandler>(
map: Map<string, Set<T>>,
key: string,
handler: T,
): () => void {
if (!map.has(key)) map.set(key, new Set()); if (!map.has(key)) map.set(key, new Set());
map.get(key)!.add(handler); map.get(key)!.add(handler);
return () => map.get(key)?.delete(handler); return () => map.get(key)?.delete(handler);