feat: add world serialization support

Introduce `toJSON` and `fromJSON` methods to the `World` class to
allow saving and restoring world states. This requires components and
relationships to have human-readable names for stable serialization.
This commit is contained in:
2026-05-31 16:10:19 +08:00
parent d0bb119911
commit 1c55485f9f
8 changed files with 311 additions and 19 deletions
+9 -5
View File
@@ -5,13 +5,15 @@
*
* @example
* ```ts
* const Position = defineComponent({ x: 0, y: 0 });
* const Position = defineComponent('position', { x: 0, y: 0 });
* type Position = typeof Position.type;
* ```
*/
export interface ComponentDef<T extends Record<string, any>> {
/** Unique symbol used as the storage key. */
readonly _key: symbol;
/** Human-readable name, used for serialization. */
readonly name: string;
/** Default values applied when a component is first added. */
readonly defaults: T;
/** Phantom type for inference. */
@@ -19,15 +21,17 @@ export interface ComponentDef<T extends Record<string, any>> {
}
/**
* Define a component type. The argument provides both default values and the
* TypeScript shape.
* Define a component type. The name is used for serialization.
* The defaults object provides both the TypeScript shape and initial values.
*/
export function defineComponent<T extends Record<string, any>>(
defaults: T
name: string,
defaults: T,
): ComponentDef<T> {
return {
_key: Symbol(),
name,
defaults: { ...defaults },
type: undefined as unknown as T, // phantom; never read at runtime
type: undefined as unknown as T,
};
}
+1
View File
@@ -14,3 +14,4 @@ export type {
QueryUpdate,
RelationshipUpdate,
} from "./observable/events";
export type { WorldSnapshot } from "./serialization";
+5 -3
View File
@@ -5,18 +5,20 @@
*
* @example
* ```ts
* const ChildOf = defineRelationship();
* const ChildOf = defineRelationship('childOf');
* world.relate(child, ChildOf, parent);
* ```
*/
export interface RelationshipDef {
/** Unique symbol used as the storage key. */
readonly _key: symbol;
/** Human-readable name, used for serialization. */
readonly name: string;
}
/**
* Define a named relationship between entities.
*/
export function defineRelationship(): RelationshipDef {
return { _key: Symbol() };
export function defineRelationship(name: string): RelationshipDef {
return { _key: Symbol(), name };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Plain JSON-compatible representation of a World.
* Returned by `world.toJSON()`, consumed by `World.fromJSON()`.
*/
export interface WorldSnapshot {
/** Entity stable ID → component map (component name → data). */
entities: Record<string, Record<string, unknown>>;
/** Relationship name → (source ID → target ID). */
relationships: Record<string, Record<string, string>>;
}
+114
View File
@@ -7,6 +7,7 @@ import { ObservableLayer } from "./observable/observe";
import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
import type { RelationshipDef } from "./relationship";
import { Observable } from "rxjs";
import type { WorldSnapshot } from "./serialization";
// ── World ─────────────────────────────────────────────
/**
@@ -364,6 +365,119 @@ export class World {
return count;
}
// ── Serialization ────────────────────────────────
/**
* Serialize the entire world to a plain JSON-compatible object.
*
* Each entity gets a stable string ID ("e0", "e1", …).
* Components are keyed by their `name`. Relationships are keyed
* by their `name` with entity references using the same stable IDs.
*/
toJSON(): WorldSnapshot {
// Build entity index → string id mapping
const ids: string[] = [];
let nextId = 0;
const entities: Record<string, Record<string, unknown>> = {};
for (let i = 0; i < this._generations.length; i++) {
if (this._generations[i] === 0 || this._free.includes(i)) continue;
const strId = `e${nextId++}`;
ids[i] = strId;
const comps: Record<string, unknown> = {};
for (const [key, store] of this._components) {
if (store.has(i)) {
const def = this._keyToDef.get(key)!;
comps[def.name] = store.get(i);
}
}
if (Object.keys(comps).length > 0) {
entities[strId] = comps;
} else {
// Still record bare entities
entities[strId] = {};
}
}
// Relationships
const relationships: Record<string, Record<string, string>> = {};
for (const [key, fwd] of this._relForward) {
const rel = this._relKeyToDef.get(key)!;
const edges: Record<string, string> = {};
for (const [si, target] of fwd.entries()) {
const ti = entityIndex(target);
if (ids[si] !== undefined && ids[ti] !== undefined) {
edges[ids[si]] = ids[ti];
}
}
if (Object.keys(edges).length > 0) {
relationships[rel.name] = edges;
}
}
return { entities, relationships };
}
/**
* Deserialize a world from a snapshot.
*
* @param data The output of `world.toJSON()`.
* @param components All ComponentDefs that may appear in the snapshot.
* @param relationships All RelationshipDefs that may appear in the snapshot.
*/
static fromJSON(
data: WorldSnapshot,
components: ComponentDef<any>[],
relationships?: RelationshipDef[],
): World {
const world = new World();
const compByName = new Map(components.map((c) => [c.name, c]));
const relByName = new Map((relationships ?? []).map((r) => [r.name, r]));
// Map string ids → real Entity handles
const idToEntity = new Map<string, Entity>();
for (const [strId, comps] of Object.entries(data.entities)) {
const entity = world.spawn();
idToEntity.set(strId, entity);
for (const [compName, value] of Object.entries(comps)) {
const def = compByName.get(compName);
if (!def) {
throw new Error(
`Unknown component "${compName}" in snapshot. ` +
`Pass it in the components array.`,
);
}
world.add(entity, def, value as any);
}
}
// Restore relationships
for (const [relName, edges] of Object.entries(data.relationships)) {
const rel = relByName.get(relName);
if (!rel) {
throw new Error(
`Unknown relationship "${relName}" in snapshot. ` +
`Pass it in the relationships array.`,
);
}
for (const [srcId, tgtId] of Object.entries(edges)) {
const source = idToEntity.get(srcId);
const target = idToEntity.get(tgtId);
if (source && target) {
world.relate(source, rel, target);
}
}
}
return world;
}
// ── Internals ─────────────────────────────────────
private _emit(event: import("./observable/events").WorldEvent): void {