diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..da1f3d2 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,344 @@ +# ecs-observable + +Entity-Component-System with an observable-style API for TypeScript. Built for games and simulations. + +## Table of Contents + +- [Installation](#installation) +- [Concepts](#concepts) +- [Quick Start](#quick-start) +- [API](#api) + - [World](#world) + - [Components](#components) + - [Queries](#queries) + - [Observable Queries](#observable-queries) + - [Change Tracking](#change-tracking) + - [Relationships](#relationships) + - [Events](#events) + - [Serialization](#serialization) +- [TypeScript Inference](#typescript-inference) + +## Installation + +```bash +npm install ecs-observable +``` + +## Concepts + +| Concept | Purpose | +|-------------|---------| +| **Entity** | Opaque `number` handle. Has no data of its own. | +| **Component** | Plain object attached to an entity. Defined via `defineComponent()`. | +| **Query** | Filter that finds entities matching a component signature. | +| **Relationship** | Directed edge between two entities (e.g. `ChildOf`, `Targeting`). | +| **World** | Central container that stores entities, components, and relationships. | + +## Quick Start + +```ts +import { World, defineComponent, query } from "ecs-observable"; + +// 1. Define components +const Position = defineComponent("position", { x: 0, y: 0 }); +const Velocity = defineComponent("velocity", { vx: 0, vy: 0 }); + +// 2. Create world +const world = new World(); + +// 3. Spawn entities and add components +const player = world.spawn(); +world.add(player, Position, { x: 100, y: 200 }); +world.add(player, Velocity, { vx: 2, vy: 0 }); + +const npc = world.spawn(); +world.add(npc, Position, { x: 300, y: 150 }); + +// 4. Iterate with queries +for (const e of world.query(query(Position, Velocity))) { + const pos = world.get(e, Position); + const vel = world.get(e, Velocity); + pos.x += vel.vx; + pos.y += vel.vy; +} +``` + +--- + +## API + +### World + +```ts +const world = new World(); +``` + +#### Entity Lifecycle + +```ts +// Create +const e = world.spawn(); + +// Check +world.isAlive(e); // true + +// Destroy (removes all components and relationships) +world.destroy(e); +world.isAlive(e); // false + +// Count +world.entityCount; // number of live entities +``` + +Destroyed slots are recycled (with a generation bump) so stale entity handles do not match new ones. + +--- + +### Components + +Define a component with a name and defaults. The defaults provide the TypeScript shape and initial values. + +```ts +const Health = defineComponent("health", { current: 100, max: 100 }); +type Health = typeof Health.type; // → { current: number; max: number } +``` + +#### CRUD + +```ts +const e = world.spawn(); + +// Add — returns a mutable reference initialized from defaults +const h = world.add(e, Health, { current: 50 }); // override defaults +h.current; // 50 + +// Get — returns the same mutable reference (throws if missing) +world.get(e, Health).current = 75; + +// Try-get — safe access +const val = world.tryGet(e, Health); // Health | undefined + +// Has — check presence +world.has(e, Health); // true + +// Set — bulk replace (marks dirty automatically) +world.set(e, Health, { current: 90, max: 90 }); + +// Remove +world.remove(e, Health); +``` + +Get returns a **live mutable reference** — no defensive copies. Mutations are visible immediately. + +--- + +### Queries + +Create filters with `query()`. Chain `.without()` to exclude components. + +```ts +const Position = defineComponent("position", { x: 0, y: 0 }); +const Velocity = defineComponent("velocity", { vx: 0, vy: 0 }); +const Dead = defineComponent("dead", { timestamp: 0 }); + +// Entities with Position AND Velocity +query(Position, Velocity) + +// Entities with Position but NOT Dead +query(Position).without(Dead) +``` + +#### Synchronous iteration + +```ts +for (const e of world.query(query(Position, Velocity))) { + const pos = world.get(e, Position); + const vel = world.get(e, Velocity); + // update pos from vel... +} +``` + +Efficient: iterates the smallest component store and cross-checks the others. + +--- + +### Observable Queries + +Subscribe to get live diffs when the result set changes. + +```ts +world.observe(query(Position, Velocity)).subscribe(update => { + // update.added — Entity[] + // update.removed — Entity[] + // update.changed — Entity[] (only after flush, see below) + + for (const e of update.added) { /* e now matches */ } + for (const e of update.removed) { /* e no longer matches */ } +}); +``` + +Common pattern — maintain a rendering list: + +```ts +const movers = new Set(); + +world.observe(query(Position, Velocity)).subscribe(update => { + for (const e of update.added) movers.add(e); + for (const e of update.removed) movers.delete(e); +}); +``` + +Subscriptions are **seeded** on creation: existing matches are tracked without emitting spurious added events. + +--- + +### Change Tracking + +For observable queries to emit `changed`, you must explicitly mark dirty and flush. + +```ts +const log: QueryUpdate[] = []; +world.observe(query(Position)).subscribe(u => log.push(u)); + +const e = world.spawn(); +world.add(e, Position); + +// Mutate and flush +world.get(e, Position).x = 99; +world.markDirty(e, Position); +world.flush(); +// → log contains { added: [], removed: [], changed: [e] } +``` + +**`world.set()` marks dirty for you**, so `markDirty` is only needed after direct mutation via `world.get()`. + +Flush is batched — call it once per frame after all systems have run. + +--- + +### Relationships + +Directed edges between entities. A source can target at most one entity per relationship type. + +```ts +const ChildOf = defineRelationship("childOf"); +const Parent = defineRelationship("parent"); +``` + +```ts +const parent = world.spawn(); +const child = world.spawn(); + +// Create +world.relate(child, ChildOf, parent); + +// Read +world.getRelated(child, ChildOf); // → parent +world.getRelatedTo(parent, ChildOf); // → [child] + +// Replace +world.relate(child, ChildOf, otherParent); // old edge removed automatically + +// Remove +world.unrelate(child, ChildOf); +``` + +Destroying an entity cleans up all its edges bidirectionally. + +#### Observable relationships + +```ts +world.observeRelated(ChildOf).subscribe(update => { + // update.added — { source: Entity; target: Entity }[] + // update.removed — { source: Entity; target: Entity }[] + for (const { source, target } of update.added) { + console.log("new child relationship"); + } +}); +``` + +Like query observers, these are seeded with current edges on subscription. + +--- + +### Events + +The global event stream gives full visibility into everything happening in the world. + +```ts +world.events$.subscribe(ev => { + switch (ev.type) { + case "spawned": + case "destroyed": + // ev.entity + break; + case "componentAdded": + case "componentRemoved": + case "componentChanged": + // ev.entity, ev.component + break; + case "relationshipAdded": + case "relationshipRemoved": + // ev.source, ev.target, ev.relationship + break; + } +}); +``` + +Events fire **immediately** on mutation (synchronous), before `flush()`. + +--- + +### Serialization + +```ts +// Export +const snapshot = world.toJSON(); +const json = JSON.stringify(snapshot); + +// Import — must supply the known component/relationship definitions +const components: ComponentDef[] = [Position, Velocity, Health]; +const relationships: RelationshipDef[] = [ChildOf]; + +const loaded = World.fromJSON(JSON.parse(json), components, relationships); +``` + +Snapshots use stable string IDs (`"e0"`, `"e1"`, …). Bare entities (no components) are preserved. Holes from destroyed entities are collapsed. + +**Round-trip example:** + +```ts +const world = new World(); +const e = world.spawn(); +world.add(e, Position, { x: 42, y: 99 }); +world.add(e, Health, { current: 75, max: 100 }); + +const snap = world.toJSON(); +const world2 = World.fromJSON(snap, [Position, Health]); + +world2.entityCount; // 1 +const e2 = [...world2.query(query(Position))][0]; +world2.get(e2, Position); // { x: 42, y: 99 } +world2.get(e2, Health); // { current: 75, max: 100 } +``` + +--- + +## TypeScript Inference + +Components infer their type from the defaults object — no separate type declaration needed. + +```ts +const Inventory = defineComponent("inventory", { + items: [] as string[], + gold: 0, +}); + +// world.add returns the typed component +const inv = world.add(e, Inventory, { gold: 42 }); +inv.items.push("sword"); // ✅ string[] +inv.gold = 100; // ✅ number + +// world.get also returns typed +world.get(e, Inventory).items; // string[] +``` \ No newline at end of file