Compare commits

..

2 Commits

Author SHA1 Message Date
hyper 1253bf82d2 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`.
2026-06-28 10:19:40 +08:00
hyper ddde3f7597 feat(bt): Add entity-based task factories
Add `leaf`, `sequential`, `parallel`, `selector`, `random`, and `repeat`
functions that return `EntityDef` trees. Legacy object definitions are
still
accepted for backward compatibility. Non-task child entities are ignored
by
the runner, and `buildTree` now stores the root entity on
`TaskRunner.root`.
2026-06-28 10:15:38 +08:00
9 changed files with 398 additions and 179 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

@ -13,5 +13,14 @@ 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, Cancel } from "./tree-def"; export {
export type { TreeDef, LeafFn } from "./tree-def"; buildTree,
Cancel,
leaf,
sequential,
parallel,
selector,
random,
repeat,
} from "./tree-def";
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";

View File

@ -53,7 +53,11 @@ function clearSubtree(world: World, entity: Entity): void {
} }
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> { function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
yield* world.getRelatedTo(parent, ChildOf); for (const child of world.getRelatedTo(parent, ChildOf)) {
if (world.has(child, Task)) {
yield child;
}
}
} }
function parentOf(world: World, child: Entity): Entity | null { function parentOf(world: World, child: Entity): Entity | null {
@ -87,6 +91,9 @@ function parentOf(world: World, child: Entity): Entity | null {
export class TaskRunner { export class TaskRunner {
private _world: World; private _world: World;
/** Root task entity, set by `buildTree` for convenience. */
root?: Entity;
/** Called when a leaf task becomes Scheduled. */ /** Called when a leaf task becomes Scheduled. */
onLeaf: LeafHandler = () => {}; onLeaf: LeafHandler = () => {};

View File

@ -1,4 +1,5 @@
import type { World, Entity } from "../index"; import type { World, Entity } from "../index";
import type { EntityDef, EntityDefChild } from "../entity-tree";
import { Task, ChildOf } from "./task"; import { Task, ChildOf } from "./task";
import { TaskRunner } from "./runner"; import { TaskRunner } from "./runner";
@ -9,7 +10,7 @@ import { TaskRunner } from "./runner";
* *
* @example * @example
* ```ts * ```ts
* { kind: "leaf", run: () => { throw Cancel; } } * leaf(() => { throw Cancel; })
* ``` * ```
*/ */
export const Cancel: unique symbol = Symbol("leaf.cancel"); export const Cancel: unique symbol = Symbol("leaf.cancel");
@ -21,67 +22,135 @@ export type LeafFn =
| ((world: World, dt: number) => void) | ((world: World, dt: number) => void)
| (() => Generator<number | void, void, number>); | (() => Generator<number | void, void, number>);
/** Declarative behaviour-tree definition. */ export interface LeafTaskMeta {
export type TreeDef = readonly run: LeafFn;
| { kind: "leaf"; run: LeafFn } }
| { kind: "sequential"; children: TreeDef[] }
| { kind: "parallel"; children: TreeDef[] } export type TaskEntityDef = EntityDef<
| { kind: "selector"; children: TreeDef[] } typeof Task.type,
| { kind: "random"; children: TreeDef[] } LeafTaskMeta | undefined
| { kind: "repeat"; child: TreeDef }; >;
/** Behaviour-tree definition accepted by `buildTree`. */
export type TreeDef = TaskEntityDef;
// ── Entity task factories ──────────────────────────────
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
return children.filter(Boolean) as EntityDef[];
}
function task(
kind: typeof Task.type.kind,
children: readonly EntityDefChild[] = [],
meta?: LeafTaskMeta,
): TaskEntityDef {
return {
kind: "entity",
component: Task,
value: { kind },
children: compactChildren(children),
meta,
};
}
/** Create a leaf task entity definition. */
export function leaf(
run: LeafFn,
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("leaf", children, { run });
}
/** Create a sequential task entity definition. */
export function sequential(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("sequential", children);
}
/** Create a parallel task entity definition. */
export function parallel(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("parallel", children);
}
/** Create a selector task entity definition. */
export function selector(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("selector", children);
}
/** Create a random task entity definition. */
export function random(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("random", children);
}
/**
* Create a repeat task entity definition.
*
* `repeat(child)` and `repeat([child, metadataEntity])` are both supported.
* The runner operates only on child entities that have the `Task` component.
*/
export function repeat(child: EntityDef): TaskEntityDef;
export function repeat(children?: readonly EntityDefChild[]): TaskEntityDef;
export function repeat(
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
): TaskEntityDef {
return task(
"repeat",
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
);
}
// ── Builder ─────────────────────────────────────────── // ── Builder ───────────────────────────────────────────
/** /**
* Recursively materialize a `TreeDef` into ECS entities and return a * Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* 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: * Leaf `run` functions:
* - **Plain function** runs once per tick. `return` = success. `throw` = fail. * - **Plain function** runs once per tick. `return` = success. `throw` = fail.
* `throw Cancel` = cancel. * `throw Cancel` = cancel.
* - **Generator function** each `yield` suspends until next tick. The value * - **Generator function** each `yield` suspends until next tick. The value
* yielded is the desired delay in ms (or `undefined` for next frame). * yielded is the desired delay in ms (or `undefined` for next frame).
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel. * Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
*
* @example
* ```ts
* const runner = buildTree(world, {
* kind: "repeat",
* child: {
* kind: "sequential",
* children: [
* { kind: "leaf", run: () => { doWork(); } },
* { kind: "leaf", *run() { yield 1000; doLater(); } },
* ],
* },
* });
* runner.schedule(runner.root);
* setInterval(() => runner.tick(16), 16);
* ```
*/ */
export function buildTree(world: World, def: TreeDef): TaskRunner { export function buildTree(world: World, def: TreeDef): TaskRunner {
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>>();
function build(def: TreeDef, parent?: Entity): Entity { function build(def: EntityDef, parent?: Entity): Entity {
const entity = world.spawn(); const entity = world.spawn();
world.add(entity, def.component, def.value);
if (def.kind === "leaf") { if (parent !== undefined) {
world.add(entity, Task, { kind: "leaf" }); world.relate(entity, ChildOf, parent);
leafHandlers.set(entity, def.run); }
} else if (def.kind === "repeat") {
world.add(entity, Task, { kind: "repeat" }); if (def.component === Task) {
build(def.child, entity); const taskData = world.get(entity, Task);
} else { if (taskData.kind === "leaf") {
world.add(entity, Task, { kind: def.kind }); const run = (def.meta as LeafTaskMeta | undefined)?.run;
for (const child of def.children) { if (!run) {
build(child, entity); throw new Error("Leaf task entity is missing a run function");
}
leafHandlers.set(entity, run);
} }
} }
if (parent) { for (const child of def.children) {
world.relate(entity, ChildOf, parent); build(child, entity);
} }
return entity; return entity;
@ -89,7 +158,12 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
const root = build(def); const root = build(def);
if (!world.has(root, Task)) {
throw new Error("buildTree root must be a task entity");
}
const runner = new TaskRunner(world); const runner = new TaskRunner(world);
runner.root = root;
runner.onLeaf = (_w, entity, dt) => { runner.onLeaf = (_w, entity, dt) => {
const handler = leafHandlers.get(entity); const handler = leafHandlers.get(entity);
@ -143,8 +217,5 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
generators.delete(entity); generators.delete(entity);
}; };
// Stash the root entity on the runner for convenience
(runner as any).root = root;
return runner; return runner;
} }

80
src/entity-tree.ts Normal file
View File

@ -0,0 +1,80 @@
import type { ComponentDef } from "./component";
import type { Entity } from "./entity";
import type { RelationshipDef } from "./relationship";
import type { World } from "./world";
/**
* Declarative definition for one entity with one primary component.
*
* Children are materialized as entities and related to their parent by the
* relationship passed to `buildEntityTree`.
*/
export interface EntityDef<T extends Record<string, any> = any, M = unknown> {
readonly kind: "entity";
readonly component: ComponentDef<T>;
readonly value?: Partial<T>;
readonly children: readonly EntityDef[];
readonly meta?: M;
}
export type EntityDefChild = EntityDef | null | undefined | false;
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
return children.filter(Boolean) as EntityDef[];
}
/** Create a single-component entity definition. */
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
value?: Partial<T>,
children?: readonly EntityDefChild[],
): EntityDef<T>;
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
children?: readonly EntityDefChild[],
): EntityDef<T>;
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
valueOrChildren?: Partial<T> | readonly EntityDefChild[],
children: readonly EntityDefChild[] = [],
): EntityDef<T> {
const hasChildrenAsSecondArg = Array.isArray(valueOrChildren);
const value = hasChildrenAsSecondArg
? undefined
: (valueOrChildren as Partial<T> | undefined);
const childDefs = hasChildrenAsSecondArg ? valueOrChildren : children;
return {
kind: "entity",
component,
value,
children: compactChildren(childDefs),
};
}
/**
* Materialize an `EntityDef` tree into ECS entities.
*
* The supplied relationship connects child entities to their parent, which lets
* callers choose the semantic meaning of the tree edge.
*/
export function buildEntityTree(
world: World,
def: EntityDef,
childRelationship: RelationshipDef,
parent?: Entity,
): Entity {
const entity = world.spawn();
world.add(entity, def.component, def.value);
if (parent !== undefined) {
world.relate(entity, childRelationship, parent);
}
for (const child of def.children) {
buildEntityTree(world, child, childRelationship, entity);
}
return entity;
}

View File

@ -8,6 +8,8 @@ export { query } from "./query";
export { Query } from "./query"; export { Query } from "./query";
export type { Entity } from "./entity"; export type { Entity } from "./entity";
export { makeEntity, entityIndex, entityGeneration } from "./entity"; export { makeEntity, entityIndex, entityGeneration } from "./entity";
export { entity, buildEntityTree } from "./entity-tree";
export type { EntityDef, EntityDefChild } from "./entity-tree";
export { SparseSet } from "./storage/sparse-set"; export { SparseSet } from "./storage/sparse-set";
export type { export type {
WorldEvent, WorldEvent,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { World, type Entity } from "../src/index"; import { World, defineComponent, entity, type Entity } from "../src/index";
import { import {
Task, Task,
Scheduled, Scheduled,
@ -9,6 +9,10 @@ import {
Cancelled, Cancelled,
ChildOf, ChildOf,
TaskRunner, TaskRunner,
buildTree,
leaf,
repeat,
sequential,
} from "../src/bt/index"; } from "../src/bt/index";
// ── Helpers ───────────────────────────────────────── // ── Helpers ─────────────────────────────────────────
@ -54,6 +58,60 @@ function makeSelector(world: World, parent?: Entity): Entity {
return e; return e;
} }
// ── Entity task factories ───────────────────────────
describe("Entity task factories", () => {
it("materializes non-task child entities and ignores them during execution", () => {
const Label = defineComponent("testLabel", { value: "" });
const world = new World();
const calls: string[] = [];
const runner = buildTree(
world,
sequential([
entity(Label, { value: "sequence metadata" }),
leaf(() => calls.push("a")),
entity(Label, { value: "between leaves" }),
leaf(() => calls.push("b")),
]),
);
const root = runner.root!;
const children = [...world.getRelatedTo(root, ChildOf)];
const labels = children
.filter((child) => world.has(child, Label))
.map((child) => world.get(child, Label).value);
expect(labels).toEqual(["sequence metadata", "between leaves"]);
runner.schedule(root);
for (let i = 0; i < 5; i++) runner.tick();
expect(calls).toEqual(["a", "b"]);
expect(world.has(root, Succeeded)).toBe(true);
});
it("repeat works with component child entities beside its task child", () => {
const Label = defineComponent("repeatLabel", { value: "" });
const world = new World();
let calls = 0;
const runner = buildTree(
world,
repeat([
entity(Label, { value: "repeat metadata" }),
leaf(() => {
calls++;
}),
]),
);
runner.schedule(runner.root!);
for (let i = 0; i < 5; i++) runner.tick();
expect(calls).toBeGreaterThan(1);
});
});
// ── Leaf tasks ────────────────────────────────────── // ── Leaf tasks ──────────────────────────────────────
describe("Leaf tasks", () => { describe("Leaf tasks", () => {
let world: World; let world: World;