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:
parent
ddde3f7597
commit
1253bf82d2
92
USAGE.md
92
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.
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>> = {
|
||||||
|
|
|
||||||
|
|
@ -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>> = {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue