Compare commits

..

No commits in common. "ef9abf03c63a3bd743e8cdad92b7a828a4f4de86" and "efa92be5ab15b5899e72ee9817f0ef1dee5a827b" have entirely different histories.

7 changed files with 124 additions and 503 deletions

152
USAGE.md
View File

@ -10,15 +10,12 @@ 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
@ -135,33 +132,6 @@ 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.
@ -354,128 +324,6 @@ 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,25 +1,21 @@
// ── Tetris: BT-driven game loop with command-based input ── // ── Tetris: BT-driven game loop with command-based input ──
// //
// Architecture: // Architecture:
// Behaviour Tree (buildTree) — controls game flow: // Behaviour Tree (TaskRunner) — controls game flow:
// root (repeat) // root (sequential, 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
// //
// CommandQueue — processes input: // CommandQueue — processes input:
// 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 { buildTree } from "../../src/bt/index"; import { TaskRunner, Task, ChildOf } 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";
@ -50,10 +46,11 @@ import { startInput, type Key } from "./input";
// ── Setup ──────────────────────────────────────────── // ── Setup ────────────────────────────────────────────
const world = new World(); const world = new World();
// Singleton game state — one entity holds all of these // Create the singleton game entity
world.addSingleton(Board); const game = world.spawn();
world.addSingleton(Score); world.add(game, Board);
world.addSingleton(TickTimer); world.add(game, Score);
world.add(game, TickTimer);
// Create blessed UI // Create blessed UI
const ui = createUI(); const ui = createUI();
@ -66,7 +63,7 @@ const commands = new CommandQueue(world);
function spawnPiece(): void { function spawnPiece(): void {
const p = randomPiece(); const p = randomPiece();
world.addSingleton(Piece, { world.add(game, 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),
@ -75,19 +72,15 @@ function spawnPiece(): void {
} }
function hasActivePiece(): boolean { function hasActivePiece(): boolean {
return world.hasSingleton(Piece); return world.has(game, Piece);
} }
// Move left // Move left
commands.handle(MoveLeft, () => { commands.handle(MoveLeft, () => {
if ( if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, 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--;
} }
@ -95,14 +88,10 @@ commands.handle(MoveLeft, () => {
// Move right // Move right
commands.handle(MoveRight, () => { commands.handle(MoveRight, () => {
if ( if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, 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++;
} }
@ -110,14 +99,10 @@ commands.handle(MoveRight, () => {
// Rotate // Rotate
commands.handle(Rotate, () => { commands.handle(Rotate, () => {
if ( if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, 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;
@ -127,14 +112,10 @@ commands.handle(Rotate, () => {
// Soft drop // Soft drop
commands.handle(SoftDrop, () => { commands.handle(SoftDrop, () => {
if ( if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, 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++;
} }
@ -142,14 +123,10 @@ commands.handle(SoftDrop, () => {
// Hard drop // Hard drop
commands.handle(HardDrop, () => { commands.handle(HardDrop, () => {
if ( if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return; return;
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, 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++;
} }
@ -158,101 +135,114 @@ commands.handle(HardDrop, () => {
// Toggle pause // Toggle pause
commands.handle(TogglePause, () => { commands.handle(TogglePause, () => {
if (world.hasSingleton(GameOver)) return; if (world.has(game, GameOver)) return;
if (world.hasSingleton(Paused)) { if (world.has(game, Paused)) {
world.removeSingleton(Paused); world.remove(game, Paused);
} else { } else {
world.addSingleton(Paused); world.add(game, Paused);
} }
}); });
// Restart // Restart
commands.handle(Restart, () => { commands.handle(Restart, () => {
if (!world.hasSingleton(GameOver)) return; if (!world.has(game, GameOver)) return;
const board = world.getSingleton(Board); const board = world.get(game, 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.setSingleton(Score, { points: 0, lines: 0, level: 1 }); world.set(game, Score, { points: 0, lines: 0, level: 1 });
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 }); world.set(game, TickTimer, { accumulator: 0, interval: 800 });
world.removeSingleton(GameOver); world.remove(game, GameOver);
if (world.hasSingleton(Piece)) world.removeSingleton(Piece); if (world.has(game, Piece)) world.remove(game, Piece);
spawnPiece(); spawnPiece();
}); });
// ── Lock piece & spawn next ────────────────────────── // ── Lock piece & spawn next ──────────────────────────
function lockAndSpawn(): void { function lockAndSpawn(): void {
const piece = world.getSingleton(Piece); const piece = world.get(game, Piece);
const board = world.getSingleton(Board); const board = world.get(game, Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y); lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.removeSingleton(Piece); world.remove(game, Piece);
const cleared = clearLines(board.grid); const cleared = clearLines(board.grid);
if (cleared > 0) { if (cleared > 0) {
const score = world.getSingleton(Score); const score = world.get(game, 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.getSingleton(TickTimer); const timer = world.get(game, 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.getSingleton(Piece); const newPiece = world.get(game, Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) { if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.removeSingleton(Piece); world.remove(game, Piece);
world.addSingleton(GameOver); world.add(game, GameOver);
} }
} }
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, { const runner = new TaskRunner(world);
kind: "repeat",
child: { // Build the BT structure:
kind: "sequential", // root (repeat)
children: [ // └── seq (sequential)
{ // ├── handleInput (leaf)
kind: "leaf", // ├── gravityTick (leaf)
run: () => { // └── render (leaf)
commands.execute();
}, const root = world.spawn();
}, world.add(root, Task, { kind: "repeat" });
{
kind: "leaf", const seq = world.spawn();
*run() { world.add(seq, Task, { kind: "sequential" });
while (true) { world.relate(seq, ChildOf, root);
const dt: number = yield;
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { const handleInputTask = world.spawn();
continue; world.add(handleInputTask, Task, { kind: "leaf" });
} world.relate(handleInputTask, ChildOf, seq);
const timer = world.getSingleton(TickTimer);
timer.accumulator += dt; const gravityTask = world.spawn();
if (timer.accumulator >= timer.interval) { world.add(gravityTask, Task, { kind: "leaf" });
timer.accumulator -= timer.interval; world.relate(gravityTask, ChildOf, seq);
if (hasActivePiece()) {
const piece = world.getSingleton(Piece); const renderTask = world.spawn();
const board = world.getSingleton(Board); world.add(renderTask, Task, { kind: "leaf" });
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { world.relate(renderTask, ChildOf, seq);
piece.y++;
} else { // ── Leaf handlers ────────────────────────────────────
lockAndSpawn(); runner.onLeaf = (_w, entity) => {
} if (entity === handleInputTask) {
} commands.execute();
} runner.succeed(entity);
} } else if (entity === gravityTask) {
}, if (world.has(game, GameOver) || world.has(game, Paused)) {
}, runner.succeed(entity);
{ return;
kind: "leaf", }
run: () => { const timer = world.get(game, TickTimer);
render(world, ui); timer.accumulator += 16;
}, if (timer.accumulator >= timer.interval) {
}, timer.accumulator -= timer.interval;
], if (hasActivePiece()) {
}, const piece = world.get(game, Piece);
}); const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
lockAndSpawn();
}
}
}
runner.succeed(entity);
} else if (entity === renderTask) {
render(world, game, ui);
runner.succeed(entity);
}
};
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = { const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
@ -274,11 +264,11 @@ startInput(ui.screen, (key) => {
}); });
// ── Game loop ──────────────────────────────────────── // ── Game loop ────────────────────────────────────────
runner.schedule((runner as any).root); runner.schedule(root);
const TICK_MS = 16; const TICK_MS = 16;
const interval = setInterval(() => { const interval = setInterval(() => {
runner.tick(TICK_MS); runner.tick();
}, 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 } from "../../src/index"; import type { World, Entity } 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,12 +91,16 @@ export function createUI(): {
} }
/** Render the full game state into the blessed UI. */ /** Render the full game state into the blessed UI. */
export function render(world: World, ui: ReturnType<typeof createUI>): void { export function render(
const board = world.getSingleton(Board); world: World,
const piece = world.tryGetSingleton(Piece); gameEntity: Entity,
const score = world.tryGetSingleton(Score); ui: ReturnType<typeof createUI>,
const isOver = world.hasSingleton(GameOver); ): void {
const isPaused = world.hasSingleton(Paused); const board = world.get(gameEntity, Board);
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,6 +12,3 @@ 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, dt: number) => void; export type LeafHandler = (world: World, entity: Entity) => 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,14 +103,12 @@ 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(dt: number = 0): void { tick(): 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, dt); this._execute(entity);
} }
} }
@ -144,12 +142,12 @@ export class TaskRunner {
// ── Internal execution ──────────────────────────── // ── Internal execution ────────────────────────────
private _execute(entity: Entity, dt: number): void { private _execute(entity: Entity): 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, dt); this._executeLeaf(entity);
break; break;
case "sequential": case "sequential":
this._executeSequential(entity); this._executeSequential(entity);
@ -169,9 +167,9 @@ export class TaskRunner {
} }
} }
private _executeLeaf(entity: Entity, dt: number): void { private _executeLeaf(entity: Entity): void {
this._world.add(entity, Running); this._world.add(entity, Running);
this.onLeaf(this._world, entity, dt); this.onLeaf(this._world, entity);
} }
private _executeSequential(entity: Entity): void { private _executeSequential(entity: Entity): void {

View File

@ -1,150 +0,0 @@
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,9 +42,6 @@ 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();
@ -253,69 +250,6 @@ 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 ─────────────────────────────────
/** /**