496 lines
12 KiB
Markdown
496 lines
12 KiB
Markdown
# 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)
|
|
- [Singleton Components](#singleton-components)
|
|
- [Queries](#queries)
|
|
- [Observable Queries](#observable-queries)
|
|
- [Change Tracking](#change-tracking)
|
|
- [Relationships](#relationships)
|
|
- [Events](#events)
|
|
- [Serialization](#serialization)
|
|
- [Commands](#commands)
|
|
- [Behaviour Trees](#behaviour-trees)
|
|
- [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.
|
|
|
|
---
|
|
|
|
### Singleton Components
|
|
|
|
For global state (score, board, config) that doesn't need per-entity tracking. A single internal entity is created lazily and reused for all singleton components.
|
|
|
|
```ts
|
|
const Score = defineComponent("score", { points: 0, level: 1 });
|
|
|
|
// Add (auto-creates the backing entity on first call)
|
|
world.addSingleton(Score);
|
|
|
|
// Get / set / check — no entity argument needed
|
|
world.getSingleton(Score).points += 100;
|
|
world.hasSingleton(Score); // true
|
|
world.setSingleton(Score, { points: 0, level: 2 });
|
|
|
|
// Try-get
|
|
const s = world.tryGetSingleton(Score); // Score | undefined
|
|
|
|
// Mark dirty for change tracking
|
|
world.markDirtySingleton(Score);
|
|
|
|
// Remove (destroys the backing entity if it becomes bare)
|
|
world.removeSingleton(Score);
|
|
```
|
|
|
|
---
|
|
|
|
### 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<number>();
|
|
|
|
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<any>[] = [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 }
|
|
```
|
|
|
|
---
|
|
|
|
### Commands
|
|
|
|
Decouple input from game logic. Define command components, register handlers, spawn command entities from input — the `CommandQueue` drains and dispatches them each frame.
|
|
|
|
```ts
|
|
import { CommandQueue } from "ecs-observable/commands";
|
|
|
|
const MoveLeft = defineComponent("moveLeft", {});
|
|
const MoveRight = defineComponent("moveRight", {});
|
|
|
|
const queue = new CommandQueue(world);
|
|
|
|
// Register handlers
|
|
queue.handle(MoveLeft, () => {
|
|
player.x -= 1;
|
|
});
|
|
|
|
queue.handle(MoveRight, () => {
|
|
player.x += 1;
|
|
});
|
|
|
|
// Input → spawn command entities
|
|
onKey("ArrowLeft", () => {
|
|
const cmd = world.spawn();
|
|
world.add(cmd, MoveLeft);
|
|
});
|
|
|
|
// Each frame — drain and dispatch
|
|
queue.execute();
|
|
```
|
|
|
|
Command entities are automatically destroyed after processing if they become bare.
|
|
|
|
---
|
|
|
|
### Behaviour Trees
|
|
|
|
Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data.
|
|
|
|
`buildTree()` takes a declarative definition and materializes it into entities, returning a fully-wired `TaskRunner`.
|
|
|
|
```ts
|
|
import { buildTree, Cancel } from "ecs-observable/bt";
|
|
```
|
|
|
|
#### Leaf patterns
|
|
|
|
**One-shot** — just return. Implicit success.
|
|
```ts
|
|
{ kind: "leaf", run: () => { doWork(); } }
|
|
```
|
|
|
|
**Fail** — throw any error.
|
|
```ts
|
|
{ kind: "leaf", run: () => { throw new Error("bad"); } }
|
|
```
|
|
|
|
**Cancel** — throw the `Cancel` symbol.
|
|
```ts
|
|
{ kind: "leaf", run: () => { throw Cancel; } }
|
|
```
|
|
|
|
**Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success.
|
|
```ts
|
|
{ kind: "leaf", *run() {
|
|
while (true) {
|
|
const dt: number = yield; // delta time from runner.tick(dt)
|
|
timer.accumulator += dt;
|
|
if (timer.accumulator >= timer.interval) {
|
|
// ... act ...
|
|
}
|
|
}
|
|
} }
|
|
```
|
|
|
|
#### Composite nodes
|
|
|
|
```ts
|
|
{ kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed
|
|
{ kind: "selector", children: [a, b, c] } // left-to-right, first success wins
|
|
{ kind: "parallel", children: [a, b, c] } // all at once, all must succeed
|
|
{ kind: "random", children: [a, b, c] } // pick one child each activation
|
|
{ kind: "repeat", child: a } // decorator — re-run child forever
|
|
```
|
|
|
|
#### Full example
|
|
|
|
```ts
|
|
const runner = buildTree(world, {
|
|
kind: "parallel",
|
|
children: [
|
|
{
|
|
kind: "leaf",
|
|
*run() {
|
|
while (true) {
|
|
const dt: number = yield;
|
|
updatePhysics(dt);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
kind: "repeat",
|
|
child: {
|
|
kind: "sequential",
|
|
children: [
|
|
{ kind: "leaf", run: () => { handleInput(); } },
|
|
{ kind: "leaf", run: () => { render(); } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
// Kick off
|
|
runner.schedule((runner as any).root);
|
|
|
|
// Game loop
|
|
setInterval(() => runner.tick(16), 16);
|
|
```
|
|
|
|
---
|
|
|
|
## 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[]
|
|
``` |