feat(bt)!: Remove legacy tree definition format

The `LegacyTreeDef` type and its normalization path have been removed.
All tree definitions must now use the factory functions (`leaf`,
`sequential`, `parallel`, `selector`, `random`, `repeat`). The
`TreeDef` type is now simply `TaskEntityDef`.
This commit is contained in:
hyper 2026-06-28 10:19:40 +08:00
parent ddde3f7597
commit 1253bf82d2
5 changed files with 133 additions and 180 deletions

View File

@ -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. 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 ```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 #### Leaf patterns
**One-shot** — just return. Implicit success. **One-shot** — just return. Implicit success.
```ts ```ts
{ kind: "leaf", run: () => { doWork(); } } leaf(() => { doWork(); })
``` ```
**Fail** — throw any error. **Fail** — throw any error.
```ts ```ts
{ kind: "leaf", run: () => { throw new Error("bad"); } } leaf(() => { throw new Error("bad"); })
``` ```
**Cancel** — throw the `Cancel` symbol. **Cancel** — throw the `Cancel` symbol.
```ts ```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. **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 ```ts
{ kind: "leaf", *run() { leaf(function* tickTimer() {
while (true) { while (true) {
const dt: number = yield; // delta time from runner.tick(dt) const dt: number = yield; // delta time from runner.tick(dt)
timer.accumulator += dt; timer.accumulator += dt;
if (timer.accumulator >= timer.interval) { if (timer.accumulator >= timer.interval) {
// ... act ... // ... act ...
}
} }
} } }
})
``` ```
#### Composite nodes #### Composite nodes
```ts ```ts
{ kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed sequential([a, b, c]) // left-to-right, all must succeed
{ kind: "selector", children: [a, b, c] } // left-to-right, first success wins selector([a, b, c]) // left-to-right, first success wins
{ kind: "parallel", children: [a, b, c] } // all at once, all must succeed parallel([a, b, c]) // all at once, all must succeed
{ kind: "random", children: [a, b, c] } // pick one child each activation random([a, b, c]) // pick one child each activation
{ kind: "repeat", child: a } // decorator — re-run child forever 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 #### Full example
```ts ```ts
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ leaf(function* physicsLoop() {
kind: "leaf", while (true) {
*run() { const dt: number = yield;
while (true) { updatePhysics(dt);
const dt: number = yield; }
updatePhysics(dt); }),
} repeat(
}, sequential([
}, leaf(() => { handleInput(); }),
{ leaf(() => { render(); }),
kind: "repeat", ]),
child: { ),
kind: "sequential", ]),
children: [ );
{ kind: "leaf", run: () => { handleInput(); } },
{ kind: "leaf", run: () => { render(); } },
],
},
},
],
});
// Kick off // Kick off
runner.schedule((runner as any).root); runner.schedule(runner.root!);
// Game loop // Game loop
setInterval(() => runner.tick(16), 16); setInterval(() => runner.tick(16), 16);

View File

@ -23,7 +23,13 @@
// npx tsx examples/blackjack/main.ts // npx tsx examples/blackjack/main.ts
import { World } from "../../src/index"; 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 { CommandQueue } from "../../src/commands/index";
import { import {
@ -63,50 +69,37 @@ const commands = new CommandQueue(world);
registerCommands(world, commands, cards); registerCommands(world, commands, cards);
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ leaf(function* dealerPlay() {
kind: "leaf", while (true) {
*run() { yield;
while (true) { const phase = world.getSingleton(GamePhase);
const dt: number = yield; if (phase.phase !== "dealerTurn") continue;
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") continue;
if (dealerShouldHit(cards.getHand(InDealerHand))) { if (dealerShouldHit(cards.getHand(InDealerHand))) {
const cardEntity = cards.drawCard(); const cardEntity = cards.drawCard();
if (cardEntity) { if (cardEntity) {
cards.dealTo(cardEntity, InDealerHand); cards.dealTo(cardEntity, InDealerHand);
}
} else {
resolveRound(world, cards);
} }
} else {
resolveRound(world, cards);
} }
}, }
}, }),
{ repeat(
kind: "repeat", sequential([
child: { leaf(() => {
kind: "sequential", commands.execute();
children: [ }),
{ leaf(() => {
kind: "leaf", render(world, ui);
run: () => { }),
commands.execute(); ]),
}, ),
}, ]),
{ );
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
},
],
});
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof Hit>> = { const keyToCommand: Partial<Record<Key, typeof Hit>> = {

View File

@ -20,7 +20,13 @@
// 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 {
buildTree,
leaf,
parallel,
repeat,
sequential,
} from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
import { import {
@ -64,56 +70,43 @@ registerCommands(world, commands, pieces);
pieces.spawnPiece(); pieces.spawnPiece();
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ leaf(function* gravityTick() {
kind: "leaf", while (true) {
*run() { const dt: number = yield;
while (true) { if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
const dt: number = yield; continue;
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { }
continue; const timer = world.getSingleton(TickTimer);
} timer.accumulator += dt;
const timer = world.getSingleton(TickTimer); if (timer.accumulator >= timer.interval) {
timer.accumulator += dt; timer.accumulator -= timer.interval;
if (timer.accumulator >= timer.interval) { if (world.hasSingleton(Piece)) {
timer.accumulator -= timer.interval; const piece = world.getSingleton(Piece);
if (world.hasSingleton(Piece)) { const board = world.getSingleton(Board);
const piece = world.getSingleton(Piece); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
const board = world.getSingleton(Board); piece.y++;
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { } else {
piece.y++; pieces.lockAndSpawn();
} else {
pieces.lockAndSpawn();
}
} }
} }
} }
}, }
}, }),
{ repeat(
kind: "repeat", sequential([
child: { leaf(() => {
kind: "sequential", commands.execute();
children: [ }),
{ leaf(() => {
kind: "leaf", render(world, ui);
run: () => { }),
commands.execute(); ]),
}, ),
}, ]),
{ );
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
},
],
});
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = { const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {

View File

@ -23,10 +23,4 @@ export {
random, random,
repeat, repeat,
} from "./tree-def"; } from "./tree-def";
export type { export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
TreeDef,
LegacyTreeDef,
TaskEntityDef,
LeafTaskMeta,
LeafFn,
} from "./tree-def";

View File

@ -22,15 +22,6 @@ export type LeafFn =
| ((world: World, dt: number) => void) | ((world: World, dt: number) => void)
| (() => Generator<number | void, void, number>); | (() => Generator<number | void, void, number>);
/** 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 { export interface LeafTaskMeta {
readonly run: LeafFn; readonly run: LeafFn;
} }
@ -40,8 +31,8 @@ export type TaskEntityDef = EntityDef<
LeafTaskMeta | undefined LeafTaskMeta | undefined
>; >;
/** Behaviour-tree definitions accepted by `buildTree`. */ /** Behaviour-tree definition accepted by `buildTree`. */
export type TreeDef = LegacyTreeDef | TaskEntityDef; export type TreeDef = TaskEntityDef;
// ── Entity task factories ────────────────────────────── // ── Entity task factories ──────────────────────────────
@ -118,37 +109,14 @@ export function repeat(
// ── Builder ─────────────────────────────────────────── // ── 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 * Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* The preferred definition format is an `EntityDef` tree produced by the task * Definitions are `EntityDef` trees produced by the task factories (`leaf`,
* factories (`leaf`, `sequential`, `parallel`, `selector`, `random`, `repeat`) * `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic
* and generic single-component entity factories. Non-task child entities are * single-component entity factories. Non-task child entities are materialized
* materialized into the ECS tree but ignored by `TaskRunner` execution. * into the ECS tree but ignored by `TaskRunner` execution.
*
* Legacy object definitions are still accepted for compatibility.
* *
* Leaf `run` functions: * Leaf `run` functions:
* - **Plain function** runs once per tick. `return` = success. `throw` = fail. * - **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. * Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
*/ */
export function buildTree(world: World, def: TreeDef): TaskRunner { export function buildTree(world: World, def: TreeDef): TaskRunner {
const rootDef = isEntityDef(def) ? def : normalizeLegacyTree(def);
const leafHandlers = new Map<Entity, LeafFn>(); const leafHandlers = new Map<Entity, LeafFn>();
// Track generator iterators for multi-frame leaves // Track generator iterators for multi-frame leaves
const generators = new Map<Entity, Generator<number | void, void, number>>(); const generators = new Map<Entity, Generator<number | void, void, number>>();
@ -189,7 +156,7 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
return entity; return entity;
} }
const root = build(rootDef); const root = build(def);
if (!world.has(root, Task)) { if (!world.has(root, Task)) {
throw new Error("buildTree root must be a task entity"); throw new Error("buildTree root must be a task entity");