diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index bca26bb..8c2e63b 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -1,7 +1,7 @@ // ── Tetris: BT-driven game loop with command-based input ── // // Architecture: -// Behaviour Tree (TaskRunner) — controls game flow: +// Behaviour Tree (buildTree) — controls game flow: // root (repeat) // └── seq (sequential) // ├── handleInput (leaf) — reads queued commands, mutates state @@ -19,7 +19,7 @@ // 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"; @@ -208,64 +208,48 @@ function lockAndSpawn(): void { } // ── 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.hasSingleton(GameOver) || world.hasSingleton(Paused)) { - runner.succeed(entity); - return; - } - const timer = world.getSingleton(TickTimer); - timer.accumulator += 16; - 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(); - } - } - } - runner.succeed(entity); - } else if (entity === renderTask) { - render(world, ui); - runner.succeed(entity); - } -}; +const runner = buildTree(world, { + kind: "repeat", + child: { + kind: "sequential", + children: [ + { + kind: "leaf", + run: () => { + commands.execute(); + }, + }, + { + kind: "leaf", + run: () => { + if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { + return; + } + const timer = world.getSingleton(TickTimer); + timer.accumulator += 16; + 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> = { @@ -287,7 +271,7 @@ startInput(ui.screen, (key) => { }); // ── Game loop ──────────────────────────────────────── -runner.schedule(root); +runner.schedule((runner as any).root); const TICK_MS = 16; const interval = setInterval(() => { diff --git a/src/bt/index.ts b/src/bt/index.ts index 597742b..910ef3e 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -12,3 +12,6 @@ export type { TaskKind } from "./task"; export { TaskRunner } from "./runner"; export type { LeafHandler, TerminalHandler } from "./runner"; + +export { buildTree } from "./tree-def"; +export type { TreeDef, LeafResult } from "./tree-def"; diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts new file mode 100644 index 0000000..c48ddc8 --- /dev/null +++ b/src/bt/tree-def.ts @@ -0,0 +1,91 @@ +import type { World, Entity } from "../index"; +import { Task, ChildOf } from "./task"; +import { TaskRunner } from "./runner"; + +// ── Types ───────────────────────────────────────────── + +/** Return value from a leaf's `run` function. */ +export type LeafResult = "success" | "fail" | "cancel" | void; + +/** Declarative behaviour-tree definition. */ +export type TreeDef = + | { kind: "leaf"; run: (world: World) => LeafResult } + | { 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 are called each tick the leaf is active: + * - `"success"` → leaf succeeds + * - `"fail"` → leaf fails + * - `"cancel"` → leaf (and subtree) is cancelled + * - `undefined` → leaf stays Running, re-invoked next tick + * + * @example + * ```ts + * const runner = buildTree(world, { + * kind: "repeat", + * child: { + * kind: "sequential", + * children: [ + * { kind: "leaf", run: () => { doWork(); } }, + * { kind: "leaf", run: () => { render(); } }, + * ], + * }, + * }); + * runner.schedule(runner.root); + * setInterval(() => runner.tick(), 16); + * ``` + */ +export function buildTree(world: World, def: TreeDef): TaskRunner { + const leafHandlers = new Map LeafResult>(); + + 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) => { + const handler = leafHandlers.get(entity); + if (!handler) return; + const result = handler(_w); + if (result === "success") runner.succeed(entity); + else if (result === "fail") runner.fail(entity); + else if (result === "cancel") runner.cancel(entity); + // undefined → leaf stays Running, re-invoked next tick + }; + + // Stash the root entity on the runner for convenience + (runner as any).root = root; + + return runner; +}