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.
This commit is contained in:
parent
ccd0e3afb4
commit
ec8f668392
|
|
@ -1,7 +1,7 @@
|
||||||
// ── 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 (repeat)
|
// root (repeat)
|
||||||
// └── seq (sequential)
|
// └── seq (sequential)
|
||||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
// 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";
|
||||||
|
|
@ -208,64 +208,48 @@ function lockAndSpawn(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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: () => {
|
||||||
|
commands.execute();
|
||||||
const root = world.spawn();
|
},
|
||||||
world.add(root, Task, { kind: "repeat" });
|
},
|
||||||
|
{
|
||||||
const seq = world.spawn();
|
kind: "leaf",
|
||||||
world.add(seq, Task, { kind: "sequential" });
|
run: () => {
|
||||||
world.relate(seq, ChildOf, root);
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
|
return;
|
||||||
const handleInputTask = world.spawn();
|
}
|
||||||
world.add(handleInputTask, Task, { kind: "leaf" });
|
const timer = world.getSingleton(TickTimer);
|
||||||
world.relate(handleInputTask, ChildOf, seq);
|
timer.accumulator += 16;
|
||||||
|
if (timer.accumulator >= timer.interval) {
|
||||||
const gravityTask = world.spawn();
|
timer.accumulator -= timer.interval;
|
||||||
world.add(gravityTask, Task, { kind: "leaf" });
|
if (hasActivePiece()) {
|
||||||
world.relate(gravityTask, ChildOf, seq);
|
const piece = world.getSingleton(Piece);
|
||||||
|
const board = world.getSingleton(Board);
|
||||||
const renderTask = world.spawn();
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
world.add(renderTask, Task, { kind: "leaf" });
|
piece.y++;
|
||||||
world.relate(renderTask, ChildOf, seq);
|
} else {
|
||||||
|
lockAndSpawn();
|
||||||
// ── 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)) {
|
kind: "leaf",
|
||||||
runner.succeed(entity);
|
run: () => {
|
||||||
return;
|
render(world, ui);
|
||||||
}
|
},
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Input → Command mapping ──────────────────────────
|
// ── Input → Command mapping ──────────────────────────
|
||||||
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
||||||
|
|
@ -287,7 +271,7 @@ 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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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 } from "./tree-def";
|
||||||
|
export type { TreeDef, LeafResult } from "./tree-def";
|
||||||
|
|
|
||||||
|
|
@ -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<Entity, (world: World) => 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue