Compare commits
4 Commits
efa92be5ab
...
ef9abf03c6
| Author | SHA1 | Date |
|---|---|---|
|
|
ef9abf03c6 | |
|
|
4182bc1578 | |
|
|
ec8f668392 | |
|
|
ccd0e3afb4 |
152
USAGE.md
152
USAGE.md
|
|
@ -10,12 +10,15 @@ Entity-Component-System with an observable-style API for TypeScript. Built for g
|
|||
- [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
|
||||
|
|
@ -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
|
||||
|
||||
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
|
||||
|
||||
Components infer their type from the defaults object — no separate type declaration needed.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
// ── Tetris: BT-driven game loop with command-based input ──
|
||||
//
|
||||
// Architecture:
|
||||
// Behaviour Tree (TaskRunner) — controls game flow:
|
||||
// root (sequential, repeat)
|
||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
||||
// └── render (leaf) — draws via blessed
|
||||
// Behaviour Tree (buildTree) — controls game flow:
|
||||
// root (repeat)
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
||||
// └── render (leaf) — draws via blessed
|
||||
//
|
||||
// CommandQueue — processes input:
|
||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||
// → handlers mutate game state
|
||||
//
|
||||
// Singleton components — global state accessed via world.*Singleton():
|
||||
// Board, Piece, Score, GameOver, Paused, TickTimer
|
||||
//
|
||||
// Usage:
|
||||
// npx tsx examples/tetris/main.ts
|
||||
|
||||
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 { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
|
||||
|
|
@ -46,11 +50,10 @@ import { startInput, type Key } from "./input";
|
|||
// ── Setup ────────────────────────────────────────────
|
||||
const world = new World();
|
||||
|
||||
// Create the singleton game entity
|
||||
const game = world.spawn();
|
||||
world.add(game, Board);
|
||||
world.add(game, Score);
|
||||
world.add(game, TickTimer);
|
||||
// Singleton game state — one entity holds all of these
|
||||
world.addSingleton(Board);
|
||||
world.addSingleton(Score);
|
||||
world.addSingleton(TickTimer);
|
||||
|
||||
// Create blessed UI
|
||||
const ui = createUI();
|
||||
|
|
@ -63,7 +66,7 @@ const commands = new CommandQueue(world);
|
|||
|
||||
function spawnPiece(): void {
|
||||
const p = randomPiece();
|
||||
world.add(game, Piece, {
|
||||
world.addSingleton(Piece, {
|
||||
shape: p.shape,
|
||||
color: p.color,
|
||||
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
||||
|
|
@ -72,15 +75,19 @@ function spawnPiece(): void {
|
|||
}
|
||||
|
||||
function hasActivePiece(): boolean {
|
||||
return world.has(game, Piece);
|
||||
return world.hasSingleton(Piece);
|
||||
}
|
||||
|
||||
// Move left
|
||||
commands.handle(MoveLeft, () => {
|
||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
||||
piece.x--;
|
||||
}
|
||||
|
|
@ -88,10 +95,14 @@ commands.handle(MoveLeft, () => {
|
|||
|
||||
// Move right
|
||||
commands.handle(MoveRight, () => {
|
||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
||||
piece.x++;
|
||||
}
|
||||
|
|
@ -99,10 +110,14 @@ commands.handle(MoveRight, () => {
|
|||
|
||||
// Rotate
|
||||
commands.handle(Rotate, () => {
|
||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
||||
if (result) {
|
||||
piece.shape = result.shape;
|
||||
|
|
@ -112,10 +127,14 @@ commands.handle(Rotate, () => {
|
|||
|
||||
// Soft drop
|
||||
commands.handle(SoftDrop, () => {
|
||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
|
|
@ -123,10 +142,14 @@ commands.handle(SoftDrop, () => {
|
|||
|
||||
// Hard drop
|
||||
commands.handle(HardDrop, () => {
|
||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
|
|
@ -135,114 +158,101 @@ commands.handle(HardDrop, () => {
|
|||
|
||||
// Toggle pause
|
||||
commands.handle(TogglePause, () => {
|
||||
if (world.has(game, GameOver)) return;
|
||||
if (world.has(game, Paused)) {
|
||||
world.remove(game, Paused);
|
||||
if (world.hasSingleton(GameOver)) return;
|
||||
if (world.hasSingleton(Paused)) {
|
||||
world.removeSingleton(Paused);
|
||||
} else {
|
||||
world.add(game, Paused);
|
||||
world.addSingleton(Paused);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart
|
||||
commands.handle(Restart, () => {
|
||||
if (!world.has(game, GameOver)) return;
|
||||
const board = world.get(game, Board);
|
||||
if (!world.hasSingleton(GameOver)) return;
|
||||
const board = world.getSingleton(Board);
|
||||
for (let r = 0; r < board.grid.length; r++) {
|
||||
board.grid[r].fill(0);
|
||||
}
|
||||
world.set(game, Score, { points: 0, lines: 0, level: 1 });
|
||||
world.set(game, TickTimer, { accumulator: 0, interval: 800 });
|
||||
world.remove(game, GameOver);
|
||||
if (world.has(game, Piece)) world.remove(game, Piece);
|
||||
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
|
||||
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
|
||||
world.removeSingleton(GameOver);
|
||||
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
|
||||
spawnPiece();
|
||||
});
|
||||
|
||||
// ── Lock piece & spawn next ──────────────────────────
|
||||
function lockAndSpawn(): void {
|
||||
const piece = world.get(game, Piece);
|
||||
const board = world.get(game, Board);
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
|
||||
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
||||
world.remove(game, Piece);
|
||||
world.removeSingleton(Piece);
|
||||
|
||||
const cleared = clearLines(board.grid);
|
||||
if (cleared > 0) {
|
||||
const score = world.get(game, Score);
|
||||
const score = world.getSingleton(Score);
|
||||
score.lines += cleared;
|
||||
score.points += scoreForLines(cleared, score.level);
|
||||
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);
|
||||
}
|
||||
|
||||
spawnPiece();
|
||||
|
||||
const newPiece = world.get(game, Piece);
|
||||
const newPiece = world.getSingleton(Piece);
|
||||
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
||||
world.remove(game, Piece);
|
||||
world.add(game, GameOver);
|
||||
world.removeSingleton(Piece);
|
||||
world.addSingleton(GameOver);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Behaviour Tree ───────────────────────────────────
|
||||
const runner = new TaskRunner(world);
|
||||
|
||||
// Build the BT structure:
|
||||
// root (repeat)
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf)
|
||||
// ├── gravityTick (leaf)
|
||||
// └── render (leaf)
|
||||
|
||||
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();
|
||||
runner.succeed(entity);
|
||||
} else if (entity === gravityTask) {
|
||||
if (world.has(game, GameOver) || world.has(game, Paused)) {
|
||||
runner.succeed(entity);
|
||||
return;
|
||||
}
|
||||
const timer = world.get(game, TickTimer);
|
||||
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);
|
||||
}
|
||||
};
|
||||
const runner = buildTree(world, {
|
||||
kind: "repeat",
|
||||
child: {
|
||||
kind: "sequential",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
commands.execute();
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
*run() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||
continue;
|
||||
}
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.accumulator += dt;
|
||||
if (timer.accumulator >= timer.interval) {
|
||||
timer.accumulator -= timer.interval;
|
||||
if (hasActivePiece()) {
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
} else {
|
||||
lockAndSpawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
render(world, ui);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// ── Input → Command mapping ──────────────────────────
|
||||
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
||||
|
|
@ -264,11 +274,11 @@ startInput(ui.screen, (key) => {
|
|||
});
|
||||
|
||||
// ── Game loop ────────────────────────────────────────
|
||||
runner.schedule(root);
|
||||
runner.schedule((runner as any).root);
|
||||
|
||||
const TICK_MS = 16;
|
||||
const interval = setInterval(() => {
|
||||
runner.tick();
|
||||
runner.tick(TICK_MS);
|
||||
}, TICK_MS);
|
||||
|
||||
// Cleanup on exit
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// ── Terminal rendering via 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_W, BOARD_H, ghostY } from "./game";
|
||||
|
||||
|
|
@ -91,16 +91,12 @@ export function createUI(): {
|
|||
}
|
||||
|
||||
/** Render the full game state into the blessed UI. */
|
||||
export function render(
|
||||
world: World,
|
||||
gameEntity: Entity,
|
||||
ui: ReturnType<typeof createUI>,
|
||||
): void {
|
||||
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);
|
||||
export function render(world: World, ui: ReturnType<typeof createUI>): void {
|
||||
const board = world.getSingleton(Board);
|
||||
const piece = world.tryGetSingleton(Piece);
|
||||
const score = world.tryGetSingleton(Score);
|
||||
const isOver = world.hasSingleton(GameOver);
|
||||
const isPaused = world.hasSingleton(Paused);
|
||||
|
||||
// Build display grid
|
||||
const display = board.grid.map((row) => [...row]);
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ export type { TaskKind } from "./task";
|
|||
|
||||
export { TaskRunner } from "./runner";
|
||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
||||
|
||||
export { buildTree, Cancel } from "./tree-def";
|
||||
export type { TreeDef, LeafFn } from "./tree-def";
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
|
||||
// ── Types ─────────────────────────────────────────────
|
||||
/** 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. */
|
||||
export type TerminalHandler = (
|
||||
|
|
@ -103,12 +103,14 @@ export class TaskRunner {
|
|||
* Process all Scheduled tasks.
|
||||
*
|
||||
* 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))];
|
||||
for (const entity of scheduled) {
|
||||
this._world.remove(entity, Scheduled);
|
||||
this._execute(entity);
|
||||
this._execute(entity, dt);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,12 +144,12 @@ export class TaskRunner {
|
|||
|
||||
// ── Internal execution ────────────────────────────
|
||||
|
||||
private _execute(entity: Entity): void {
|
||||
private _execute(entity: Entity, dt: number): void {
|
||||
const t = this._world.get(entity, Task);
|
||||
|
||||
switch (t.kind) {
|
||||
case "leaf":
|
||||
this._executeLeaf(entity);
|
||||
this._executeLeaf(entity, dt);
|
||||
break;
|
||||
case "sequential":
|
||||
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.onLeaf(this._world, entity);
|
||||
this.onLeaf(this._world, entity, dt);
|
||||
}
|
||||
|
||||
private _executeSequential(entity: Entity): void {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
66
src/world.ts
66
src/world.ts
|
|
@ -42,6 +42,9 @@ export class World {
|
|||
// ── Observable layer ──────────────────────────────
|
||||
private _observable = new ObservableLayer();
|
||||
|
||||
// ── Singleton entity ──────────────────────────────
|
||||
private _singletonEntity: Entity | null = null;
|
||||
|
||||
/** Global event stream. */
|
||||
get events$(): Observable<WorldEvent> {
|
||||
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 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue