diff --git a/USAGE.md b/USAGE.md index e782004..94b4dc9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -393,82 +393,88 @@ Command entities are automatically destroyed after processing if they become bar 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`. +`buildTree()` takes a task entity definition and materializes it into entities, returning a fully-wired `TaskRunner`. Task definitions are created with factories, and non-task child entities can be mixed in as metadata; the runner ignores those non-task children during execution. ```ts -import { buildTree, Cancel } from "ecs-observable/bt"; +import { defineComponent, entity } from "ecs-observable"; +import { buildTree, Cancel, leaf, parallel, repeat, sequential } from "ecs-observable/bt"; ``` #### Leaf patterns **One-shot** — just return. Implicit success. ```ts -{ kind: "leaf", run: () => { doWork(); } } +leaf(() => { doWork(); }) ``` **Fail** — throw any error. ```ts -{ kind: "leaf", run: () => { throw new Error("bad"); } } +leaf(() => { throw new Error("bad"); }) ``` **Cancel** — throw the `Cancel` symbol. ```ts -{ kind: "leaf", run: () => { throw Cancel; } } +leaf(() => { 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 ... - } +leaf(function* tickTimer() { + 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 +sequential([a, b, c]) // left-to-right, all must succeed +selector([a, b, c]) // left-to-right, first success wins +parallel([a, b, c]) // all at once, all must succeed +random([a, b, c]) // pick one child each activation +repeat(a) // decorator — re-run child forever +``` + +Non-task entity children are materialized but ignored by the runner: + +```ts +const Label = defineComponent("label", { value: "" }); + +sequential([ + entity(Label, { value: "main sequence" }), + leaf(handleInput), + leaf(render), +]) ``` #### 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(); } }, - ], - }, - }, - ], -}); +const runner = buildTree( + world, + parallel([ + leaf(function* physicsLoop() { + while (true) { + const dt: number = yield; + updatePhysics(dt); + } + }), + repeat( + sequential([ + leaf(() => { handleInput(); }), + leaf(() => { render(); }), + ]), + ), + ]), +); // Kick off -runner.schedule((runner as any).root); +runner.schedule(runner.root!); // Game loop setInterval(() => runner.tick(16), 16); diff --git a/examples/blackjack/main.ts b/examples/blackjack/main.ts index 8f1a2d1..3b5f61f 100644 --- a/examples/blackjack/main.ts +++ b/examples/blackjack/main.ts @@ -23,7 +23,13 @@ // npx tsx examples/blackjack/main.ts import { World } from "../../src/index"; -import { buildTree } from "../../src/bt/index"; +import { + buildTree, + leaf, + parallel, + repeat, + sequential, +} from "../../src/bt/index"; import { CommandQueue } from "../../src/commands/index"; import { @@ -63,50 +69,37 @@ const commands = new CommandQueue(world); registerCommands(world, commands, cards); // ── Behaviour Tree ─────────────────────────────────── -const runner = buildTree(world, { - kind: "parallel", - children: [ - { - kind: "leaf", - *run() { - while (true) { - const dt: number = yield; - const phase = world.getSingleton(GamePhase); - if (phase.phase !== "dealerTurn") continue; +const runner = buildTree( + world, + parallel([ + leaf(function* dealerPlay() { + while (true) { + yield; + const phase = world.getSingleton(GamePhase); + if (phase.phase !== "dealerTurn") continue; - if (dealerShouldHit(cards.getHand(InDealerHand))) { - const cardEntity = cards.drawCard(); - if (cardEntity) { - cards.dealTo(cardEntity, InDealerHand); - } - } else { - resolveRound(world, cards); + if (dealerShouldHit(cards.getHand(InDealerHand))) { + const cardEntity = cards.drawCard(); + if (cardEntity) { + cards.dealTo(cardEntity, InDealerHand); } + } else { + resolveRound(world, cards); } - }, - }, - { - kind: "repeat", - child: { - kind: "sequential", - children: [ - { - kind: "leaf", - run: () => { - commands.execute(); - }, - }, - { - kind: "leaf", - run: () => { - render(world, ui); - }, - }, - ], - }, - }, - ], -}); + } + }), + repeat( + sequential([ + leaf(() => { + commands.execute(); + }), + leaf(() => { + render(world, ui); + }), + ]), + ), + ]), +); // ── Input → Command mapping ────────────────────────── const keyToCommand: Partial> = { diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 4648147..562ad29 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -20,7 +20,13 @@ // npx tsx examples/tetris/main.ts import { World } from "../../src/index"; -import { buildTree } from "../../src/bt/index"; +import { + buildTree, + leaf, + parallel, + repeat, + sequential, +} from "../../src/bt/index"; import { CommandQueue } from "../../src/commands/index"; import { @@ -64,56 +70,43 @@ registerCommands(world, commands, pieces); pieces.spawnPiece(); // ── Behaviour Tree ─────────────────────────────────── -const runner = buildTree(world, { - kind: "parallel", - children: [ - { - 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 (world.hasSingleton(Piece)) { - const piece = world.getSingleton(Piece); - const board = world.getSingleton(Board); - if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { - piece.y++; - } else { - pieces.lockAndSpawn(); - } +const runner = buildTree( + world, + parallel([ + leaf(function* gravityTick() { + 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 (world.hasSingleton(Piece)) { + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); + if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { + piece.y++; + } else { + pieces.lockAndSpawn(); } } } - }, - }, - { - kind: "repeat", - child: { - kind: "sequential", - children: [ - { - kind: "leaf", - run: () => { - commands.execute(); - }, - }, - { - kind: "leaf", - run: () => { - render(world, ui); - }, - }, - ], - }, - }, - ], -}); + } + }), + repeat( + sequential([ + leaf(() => { + commands.execute(); + }), + leaf(() => { + render(world, ui); + }), + ]), + ), + ]), +); // ── Input → Command mapping ────────────────────────── const keyToCommand: Partial> = { diff --git a/src/bt/index.ts b/src/bt/index.ts index 5402133..f720ea1 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -23,10 +23,4 @@ export { random, repeat, } from "./tree-def"; -export type { - TreeDef, - LegacyTreeDef, - TaskEntityDef, - LeafTaskMeta, - LeafFn, -} from "./tree-def"; +export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def"; diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts index f12e8b6..1b7eef4 100644 --- a/src/bt/tree-def.ts +++ b/src/bt/tree-def.ts @@ -22,15 +22,6 @@ export type LeafFn = | ((world: World, dt: number) => void) | (() => Generator); -/** Legacy declarative behaviour-tree definition. */ -export type LegacyTreeDef = - | { kind: "leaf"; run: LeafFn } - | { kind: "sequential"; children: LegacyTreeDef[] } - | { kind: "parallel"; children: LegacyTreeDef[] } - | { kind: "selector"; children: LegacyTreeDef[] } - | { kind: "random"; children: LegacyTreeDef[] } - | { kind: "repeat"; child: LegacyTreeDef }; - export interface LeafTaskMeta { readonly run: LeafFn; } @@ -40,8 +31,8 @@ export type TaskEntityDef = EntityDef< LeafTaskMeta | undefined >; -/** Behaviour-tree definitions accepted by `buildTree`. */ -export type TreeDef = LegacyTreeDef | TaskEntityDef; +/** Behaviour-tree definition accepted by `buildTree`. */ +export type TreeDef = TaskEntityDef; // ── Entity task factories ────────────────────────────── @@ -118,37 +109,14 @@ export function repeat( // ── Builder ─────────────────────────────────────────── -function isEntityDef(def: TreeDef | EntityDef): def is EntityDef { - return def.kind === "entity"; -} - -function normalizeLegacyTree(def: LegacyTreeDef): TaskEntityDef { - switch (def.kind) { - case "leaf": - return leaf(def.run); - case "repeat": - return repeat(normalizeLegacyTree(def.child)); - case "sequential": - return sequential(def.children.map(normalizeLegacyTree)); - case "parallel": - return parallel(def.children.map(normalizeLegacyTree)); - case "selector": - return selector(def.children.map(normalizeLegacyTree)); - case "random": - return random(def.children.map(normalizeLegacyTree)); - } -} - /** * Materialize a behaviour-tree definition into ECS entities and return a * fully-wired `TaskRunner`. * - * The preferred definition format is an `EntityDef` tree produced by the task - * factories (`leaf`, `sequential`, `parallel`, `selector`, `random`, `repeat`) - * and generic single-component entity factories. Non-task child entities are - * materialized into the ECS tree but ignored by `TaskRunner` execution. - * - * Legacy object definitions are still accepted for compatibility. + * Definitions are `EntityDef` trees produced by the task factories (`leaf`, + * `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic + * single-component entity factories. Non-task child entities are materialized + * into the ECS tree but ignored by `TaskRunner` execution. * * Leaf `run` functions: * - **Plain function** — runs once per tick. `return` = success. `throw` = fail. @@ -158,7 +126,6 @@ function normalizeLegacyTree(def: LegacyTreeDef): TaskEntityDef { * Generator completion = success. `throw` = fail. `throw Cancel` = cancel. */ export function buildTree(world: World, def: TreeDef): TaskRunner { - const rootDef = isEntityDef(def) ? def : normalizeLegacyTree(def); const leafHandlers = new Map(); // Track generator iterators for multi-frame leaves const generators = new Map>(); @@ -189,7 +156,7 @@ export function buildTree(world: World, def: TreeDef): TaskRunner { return entity; } - const root = build(rootDef); + const root = build(def); if (!world.has(root, Task)) { throw new Error("buildTree root must be a task entity");