Compare commits

...

4 Commits

Author SHA1 Message Date
hypercross ef9abf03c6 docs: add singleton components, commands, and behaviour trees 2026-06-02 00:38:26 +08:00
hypercross 4182bc1578 feat(bt): support generator leaves and delta time
Introduce support for generator functions in leaf tasks, allowing
them to yield for multi-frame execution. The `TaskRunner` now accepts
a delta time (`dt`) parameter, which is passed through to leaf
handlers.

Additionally, a `Cancel` symbol is introduced to allow leaf tasks to
explicitly cancel their subtree via a thrown error.
2026-06-02 00:10:18 +08:00
hypercross ec8f668392 feat(bt): add declarative tree builder
Introduce `buildTree` to allow defining behaviour trees using a
declarative `TreeDef` object instead of manual entity spawning and
relationship wiring. This simplifies tree construction and manages
leaf handlers internally.
2026-06-01 23:57:19 +08:00
hypercross ccd0e3afb4 feat(world): add singleton component support
Introduce a mechanism to manage components on a shared, lazily-created
singleton entity. This simplifies access to global state by providing
dedicated methods for adding, removing, getting, and checking for
singleton components.

Refactor the Tetris example to utilize this new singleton pattern for
game state components like Board, Score, and Piece.
2026-06-01 23:52:47 +08:00
7 changed files with 503 additions and 124 deletions

152
USAGE.md
View File

@ -10,12 +10,15 @@ Entity-Component-System with an observable-style API for TypeScript. Built for g
- [API](#api) - [API](#api)
- [World](#world) - [World](#world)
- [Components](#components) - [Components](#components)
- [Singleton Components](#singleton-components)
- [Queries](#queries) - [Queries](#queries)
- [Observable Queries](#observable-queries) - [Observable Queries](#observable-queries)
- [Change Tracking](#change-tracking) - [Change Tracking](#change-tracking)
- [Relationships](#relationships) - [Relationships](#relationships)
- [Events](#events) - [Events](#events)
- [Serialization](#serialization) - [Serialization](#serialization)
- [Commands](#commands)
- [Behaviour Trees](#behaviour-trees)
- [TypeScript Inference](#typescript-inference) - [TypeScript Inference](#typescript-inference)
## Installation ## Installation
@ -132,6 +135,33 @@ Get returns a **live mutable reference** — no defensive copies. Mutations are
--- ---
### 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 ### Queries
Create filters with `query()`. Chain `.without()` to exclude components. Create filters with `query()`. Chain `.without()` to exclude components.
@ -324,6 +354,128 @@ 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 ## TypeScript Inference
Components infer their type from the defaults object — no separate type declaration needed. Components infer their type from the defaults object — no separate type declaration needed.

View File

@ -1,8 +1,9 @@
// ── Tetris: BT-driven game loop with command-based input ── // ── Tetris: BT-driven game loop with command-based input ──
// //
// Architecture: // Architecture:
// Behaviour Tree (TaskRunner) — controls game flow: // Behaviour Tree (buildTree) — controls game flow:
// root (sequential, repeat) // root (repeat)
// └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands, mutates state // ├── handleInput (leaf) — reads queued commands, mutates state
// ├── gravityTick (leaf) — auto-drop piece on timer // ├── gravityTick (leaf) — auto-drop piece on timer
// └── render (leaf) — draws via blessed // └── render (leaf) — draws via blessed
@ -11,11 +12,14 @@
// Keyboard → spawn command entities → CommandQueue.execute() // Keyboard → spawn command entities → CommandQueue.execute()
// → handlers mutate game state // → handlers mutate game state
// //
// Singleton components — global state accessed via world.*Singleton():
// Board, Piece, Score, GameOver, Paused, TickTimer
//
// Usage: // Usage:
// npx tsx examples/tetris/main.ts // npx tsx examples/tetris/main.ts
import { World } from "../../src/index"; import { World } from "../../src/index";
import { TaskRunner, Task, ChildOf } from "../../src/bt/index"; import { buildTree } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components"; import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
@ -46,11 +50,10 @@ import { startInput, type Key } from "./input";
// ── Setup ──────────────────────────────────────────── // ── Setup ────────────────────────────────────────────
const world = new World(); const world = new World();
// Create the singleton game entity // Singleton game state — one entity holds all of these
const game = world.spawn(); world.addSingleton(Board);
world.add(game, Board); world.addSingleton(Score);
world.add(game, Score); world.addSingleton(TickTimer);
world.add(game, TickTimer);
// Create blessed UI // Create blessed UI
const ui = createUI(); const ui = createUI();
@ -63,7 +66,7 @@ const commands = new CommandQueue(world);
function spawnPiece(): void { function spawnPiece(): void {
const p = randomPiece(); const p = randomPiece();
world.add(game, Piece, { world.addSingleton(Piece, {
shape: p.shape, shape: p.shape,
color: p.color, color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2), x: Math.floor((BOARD_W - p.shape[0].length) / 2),
@ -72,15 +75,19 @@ function spawnPiece(): void {
} }
function hasActivePiece(): boolean { function hasActivePiece(): boolean {
return world.has(game, Piece); return world.hasSingleton(Piece);
} }
// Move left // Move left
commands.handle(MoveLeft, () => { commands.handle(MoveLeft, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) { if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--; piece.x--;
} }
@ -88,10 +95,14 @@ commands.handle(MoveLeft, () => {
// Move right // Move right
commands.handle(MoveRight, () => { commands.handle(MoveRight, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) { if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++; piece.x++;
} }
@ -99,10 +110,14 @@ commands.handle(MoveRight, () => {
// Rotate // Rotate
commands.handle(Rotate, () => { commands.handle(Rotate, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y); const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
if (result) { if (result) {
piece.shape = result.shape; piece.shape = result.shape;
@ -112,10 +127,14 @@ commands.handle(Rotate, () => {
// Soft drop // Soft drop
commands.handle(SoftDrop, () => { commands.handle(SoftDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++; piece.y++;
} }
@ -123,10 +142,14 @@ commands.handle(SoftDrop, () => {
// Hard drop // Hard drop
commands.handle(HardDrop, () => { commands.handle(HardDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++; piece.y++;
} }
@ -135,101 +158,82 @@ commands.handle(HardDrop, () => {
// Toggle pause // Toggle pause
commands.handle(TogglePause, () => { commands.handle(TogglePause, () => {
if (world.has(game, GameOver)) return; if (world.hasSingleton(GameOver)) return;
if (world.has(game, Paused)) { if (world.hasSingleton(Paused)) {
world.remove(game, Paused); world.removeSingleton(Paused);
} else { } else {
world.add(game, Paused); world.addSingleton(Paused);
} }
}); });
// Restart // Restart
commands.handle(Restart, () => { commands.handle(Restart, () => {
if (!world.has(game, GameOver)) return; if (!world.hasSingleton(GameOver)) return;
const board = world.get(game, Board); const board = world.getSingleton(Board);
for (let r = 0; r < board.grid.length; r++) { for (let r = 0; r < board.grid.length; r++) {
board.grid[r].fill(0); board.grid[r].fill(0);
} }
world.set(game, Score, { points: 0, lines: 0, level: 1 }); world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
world.set(game, TickTimer, { accumulator: 0, interval: 800 }); world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
world.remove(game, GameOver); world.removeSingleton(GameOver);
if (world.has(game, Piece)) world.remove(game, Piece); if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
spawnPiece(); spawnPiece();
}); });
// ── Lock piece & spawn next ────────────────────────── // ── Lock piece & spawn next ──────────────────────────
function lockAndSpawn(): void { function lockAndSpawn(): void {
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y); lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.remove(game, Piece); world.removeSingleton(Piece);
const cleared = clearLines(board.grid); const cleared = clearLines(board.grid);
if (cleared > 0) { if (cleared > 0) {
const score = world.get(game, Score); const score = world.getSingleton(Score);
score.lines += cleared; score.lines += cleared;
score.points += scoreForLines(cleared, score.level); score.points += scoreForLines(cleared, score.level);
score.level = Math.floor(score.lines / 10) + 1; score.level = Math.floor(score.lines / 10) + 1;
const timer = world.get(game, TickTimer); const timer = world.getSingleton(TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70); timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
} }
spawnPiece(); spawnPiece();
const newPiece = world.get(game, Piece); const newPiece = world.getSingleton(Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) { if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.remove(game, Piece); world.removeSingleton(Piece);
world.add(game, GameOver); world.addSingleton(GameOver);
} }
} }
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = new TaskRunner(world); const runner = buildTree(world, {
kind: "repeat",
// Build the BT structure: child: {
// root (repeat) kind: "sequential",
// └── seq (sequential) children: [
// ├── handleInput (leaf) {
// ├── gravityTick (leaf) kind: "leaf",
// └── render (leaf) run: () => {
const root = world.spawn();
world.add(root, Task, { kind: "repeat" });
const seq = world.spawn();
world.add(seq, Task, { kind: "sequential" });
world.relate(seq, ChildOf, root);
const handleInputTask = world.spawn();
world.add(handleInputTask, Task, { kind: "leaf" });
world.relate(handleInputTask, ChildOf, seq);
const gravityTask = world.spawn();
world.add(gravityTask, Task, { kind: "leaf" });
world.relate(gravityTask, ChildOf, seq);
const renderTask = world.spawn();
world.add(renderTask, Task, { kind: "leaf" });
world.relate(renderTask, ChildOf, seq);
// ── Leaf handlers ────────────────────────────────────
runner.onLeaf = (_w, entity) => {
if (entity === handleInputTask) {
commands.execute(); commands.execute();
runner.succeed(entity); },
} else if (entity === gravityTask) { },
if (world.has(game, GameOver) || world.has(game, Paused)) { {
runner.succeed(entity); kind: "leaf",
return; *run() {
while (true) {
const dt: number = yield;
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
continue;
} }
const timer = world.get(game, TickTimer); const timer = world.getSingleton(TickTimer);
timer.accumulator += 16; timer.accumulator += dt;
if (timer.accumulator >= timer.interval) { if (timer.accumulator >= timer.interval) {
timer.accumulator -= timer.interval; timer.accumulator -= timer.interval;
if (hasActivePiece()) { if (hasActivePiece()) {
const piece = world.get(game, Piece); const piece = world.getSingleton(Piece);
const board = world.get(game, Board); const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++; piece.y++;
} else { } else {
@ -237,12 +241,18 @@ runner.onLeaf = (_w, entity) => {
} }
} }
} }
runner.succeed(entity);
} else if (entity === renderTask) {
render(world, game, ui);
runner.succeed(entity);
} }
}; },
},
{
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
});
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = { const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
@ -264,11 +274,11 @@ startInput(ui.screen, (key) => {
}); });
// ── Game loop ──────────────────────────────────────── // ── Game loop ────────────────────────────────────────
runner.schedule(root); runner.schedule((runner as any).root);
const TICK_MS = 16; const TICK_MS = 16;
const interval = setInterval(() => { const interval = setInterval(() => {
runner.tick(); runner.tick(TICK_MS);
}, TICK_MS); }, TICK_MS);
// Cleanup on exit // Cleanup on exit

View File

@ -1,6 +1,6 @@
// ── Terminal rendering via blessed ──────────────────── // ── Terminal rendering via blessed ────────────────────
import blessed from "blessed"; import blessed from "blessed";
import type { World, Entity } from "../../src/index"; import type { World } from "../../src/index";
import { Board, Piece, Score, GameOver, Paused } from "./components"; import { Board, Piece, Score, GameOver, Paused } from "./components";
import { BOARD_W, BOARD_H, ghostY } from "./game"; import { BOARD_W, BOARD_H, ghostY } from "./game";
@ -91,16 +91,12 @@ export function createUI(): {
} }
/** Render the full game state into the blessed UI. */ /** Render the full game state into the blessed UI. */
export function render( export function render(world: World, ui: ReturnType<typeof createUI>): void {
world: World, const board = world.getSingleton(Board);
gameEntity: Entity, const piece = world.tryGetSingleton(Piece);
ui: ReturnType<typeof createUI>, const score = world.tryGetSingleton(Score);
): void { const isOver = world.hasSingleton(GameOver);
const board = world.get(gameEntity, Board); const isPaused = world.hasSingleton(Paused);
const piece = world.tryGet(gameEntity, Piece);
const score = world.tryGet(gameEntity, Score);
const isOver = world.has(gameEntity, GameOver);
const isPaused = world.has(gameEntity, Paused);
// Build display grid // Build display grid
const display = board.grid.map((row) => [...row]); const display = board.grid.map((row) => [...row]);

View File

@ -12,3 +12,6 @@ export type { TaskKind } from "./task";
export { TaskRunner } from "./runner"; export { TaskRunner } from "./runner";
export type { LeafHandler, TerminalHandler } from "./runner"; export type { LeafHandler, TerminalHandler } from "./runner";
export { buildTree, Cancel } from "./tree-def";
export type { TreeDef, LeafFn } from "./tree-def";

View File

@ -13,7 +13,7 @@ import {
// ── Types ───────────────────────────────────────────── // ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */ /** Callback invoked for each leaf task that becomes Scheduled. */
export type LeafHandler = (world: World, entity: Entity) => void; export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
/** Callback invoked when a task reaches a terminal status. */ /** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = ( export type TerminalHandler = (
@ -103,12 +103,14 @@ export class TaskRunner {
* Process all Scheduled tasks. * Process all Scheduled tasks.
* *
* Call once per frame. Only entities with `Scheduled` are touched. * Call once per frame. Only entities with `Scheduled` are touched.
*
* @param dt Delta time in milliseconds since last tick.
*/ */
tick(): void { tick(dt: number = 0): void {
const scheduled = [...this._world.query(query(Task, Scheduled))]; const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) { for (const entity of scheduled) {
this._world.remove(entity, Scheduled); this._world.remove(entity, Scheduled);
this._execute(entity); this._execute(entity, dt);
} }
} }
@ -142,12 +144,12 @@ export class TaskRunner {
// ── Internal execution ──────────────────────────── // ── Internal execution ────────────────────────────
private _execute(entity: Entity): void { private _execute(entity: Entity, dt: number): void {
const t = this._world.get(entity, Task); const t = this._world.get(entity, Task);
switch (t.kind) { switch (t.kind) {
case "leaf": case "leaf":
this._executeLeaf(entity); this._executeLeaf(entity, dt);
break; break;
case "sequential": case "sequential":
this._executeSequential(entity); this._executeSequential(entity);
@ -167,9 +169,9 @@ export class TaskRunner {
} }
} }
private _executeLeaf(entity: Entity): void { private _executeLeaf(entity: Entity, dt: number): void {
this._world.add(entity, Running); this._world.add(entity, Running);
this.onLeaf(this._world, entity); this.onLeaf(this._world, entity, dt);
} }
private _executeSequential(entity: Entity): void { private _executeSequential(entity: Entity): void {

150
src/bt/tree-def.ts Normal file
View File

@ -0,0 +1,150 @@
import type { World, Entity } from "../index";
import { Task, ChildOf } from "./task";
import { TaskRunner } from "./runner";
// ── Cancel ────────────────────────────────────────────
/**
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
*
* @example
* ```ts
* { kind: "leaf", run: () => { throw Cancel; } }
* ```
*/
export const Cancel: unique symbol = Symbol("leaf.cancel");
// ── Tree definition ───────────────────────────────────
/** A leaf function — plain or generator. */
export type LeafFn =
| ((world: World, dt: number) => void)
| (() => Generator<number | void, void, number>);
/** Declarative behaviour-tree definition. */
export type TreeDef =
| { kind: "leaf"; run: LeafFn }
| { kind: "sequential"; children: TreeDef[] }
| { kind: "parallel"; children: TreeDef[] }
| { kind: "selector"; children: TreeDef[] }
| { kind: "random"; children: TreeDef[] }
| { kind: "repeat"; child: TreeDef };
// ── Builder ───────────────────────────────────────────
/**
* Recursively materialize a `TreeDef` into ECS entities and return a
* fully-wired `TaskRunner`.
*
* Leaf `run` functions:
* - **Plain function** runs once per tick. `return` = success. `throw` = fail.
* `throw Cancel` = cancel.
* - **Generator function** each `yield` suspends until next tick. The value
* yielded is the desired delay in ms (or `undefined` for next frame).
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
*
* @example
* ```ts
* const runner = buildTree(world, {
* kind: "repeat",
* child: {
* kind: "sequential",
* children: [
* { kind: "leaf", run: () => { doWork(); } },
* { kind: "leaf", *run() { yield 1000; doLater(); } },
* ],
* },
* });
* runner.schedule(runner.root);
* setInterval(() => runner.tick(16), 16);
* ```
*/
export function buildTree(world: World, def: TreeDef): TaskRunner {
const leafHandlers = new Map<Entity, LeafFn>();
// Track generator iterators for multi-frame leaves
const generators = new Map<Entity, Generator<number | void, void, number>>();
function build(def: TreeDef, parent?: Entity): Entity {
const entity = world.spawn();
if (def.kind === "leaf") {
world.add(entity, Task, { kind: "leaf" });
leafHandlers.set(entity, def.run);
} else if (def.kind === "repeat") {
world.add(entity, Task, { kind: "repeat" });
build(def.child, entity);
} else {
world.add(entity, Task, { kind: def.kind });
for (const child of def.children) {
build(child, entity);
}
}
if (parent) {
world.relate(entity, ChildOf, parent);
}
return entity;
}
const root = build(def);
const runner = new TaskRunner(world);
runner.onLeaf = (_w, entity, dt) => {
const handler = leafHandlers.get(entity);
if (!handler) return;
try {
// Check if this leaf has an active generator
let gen = generators.get(entity);
if (gen) {
// Resume existing generator
const result = gen.next(dt);
if (result.done) {
generators.delete(entity);
runner.succeed(entity);
}
// If not done, leaf stays Running — nothing to do
} else {
// First invocation — call the handler
const ret = handler(_w, dt);
// Check if it returned a generator
if (ret != null && typeof (ret as any).next === "function") {
const gen = ret as Generator<number | void, void, number>;
generators.set(entity, gen);
const result = gen.next(dt);
if (result.done) {
generators.delete(entity);
runner.succeed(entity);
}
// Not done → leaf stays Running
} else {
// Plain function — returned undefined → success
runner.succeed(entity);
}
}
} catch (err) {
// Clean up generator if one was active
generators.delete(entity);
if (err === Cancel) {
runner.cancel(entity);
} else {
runner.fail(entity);
}
}
};
runner.onTerminal = (_w, entity) => {
// Clean up generator when a leaf reaches terminal by external means
generators.delete(entity);
};
// Stash the root entity on the runner for convenience
(runner as any).root = root;
return runner;
}

View File

@ -42,6 +42,9 @@ export class World {
// ── Observable layer ────────────────────────────── // ── Observable layer ──────────────────────────────
private _observable = new ObservableLayer(); private _observable = new ObservableLayer();
// ── Singleton entity ──────────────────────────────
private _singletonEntity: Entity | null = null;
/** Global event stream. */ /** Global event stream. */
get events$(): Observable<WorldEvent> { get events$(): Observable<WorldEvent> {
return this._observable.events$.asObservable(); return this._observable.events$.asObservable();
@ -250,6 +253,69 @@ export class World {
} }
} }
// ── Singleton component access ────────────────────
/**
* Add a singleton component to the world.
*
* A single shared entity is created lazily and reused for all singleton
* components. Returns a mutable reference to the component data.
*/
addSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
init?: Partial<T>,
): T {
if (this._singletonEntity === null) {
this._singletonEntity = this.spawn();
}
return this.add(this._singletonEntity, def, init);
}
/** Remove a singleton component. Destroys the backing entity if it becomes bare. */
removeSingleton(def: ComponentDef<any>): void {
const e = this._singletonEntity;
if (e === null) return;
this.remove(e, def);
if (!this.hasAnyComponent(e)) {
this.destroy(e);
this._singletonEntity = null;
}
}
/** Get a mutable reference to a singleton component. Throws if missing. */
getSingleton<T extends Record<string, any>>(def: ComponentDef<T>): T {
return this.get(this._singletonEntity!, def);
}
/** Try-get a singleton component. Returns undefined if missing. */
tryGetSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
): T | undefined {
const e = this._singletonEntity;
if (e === null) return undefined;
return this.tryGet(e, def);
}
/** Check whether a singleton component is present. */
hasSingleton(def: ComponentDef<any>): boolean {
const e = this._singletonEntity;
if (e === null) return false;
return this.has(e, def);
}
/** Bulk-replace a singleton component's data. Marks dirty. */
setSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
value: T,
): void {
this.set(this._singletonEntity!, def, value);
}
/** Mark a singleton component as dirty for change tracking. */
markDirtySingleton(def: ComponentDef<any>): void {
this.markDirty(this._singletonEntity!, def);
}
// ── Relationships ───────────────────────────────── // ── Relationships ─────────────────────────────────
/** /**