feat(bt): support generator leaves and delta time

Introduce support for generator functions in leaf tasks, allowing
them to yield for multi-frame execution. The `TaskRunner` now accepts
a delta time (`dt`) parameter, which is passed through to leaf
handlers.

Additionally, a `Cancel` symbol is introduced to allow leaf tasks to
explicitly cancel their subtree via a thrown error.
This commit is contained in:
hypercross 2026-06-02 00:10:18 +08:00
parent ec8f668392
commit 4182bc1578
4 changed files with 107 additions and 43 deletions

View File

@ -221,21 +221,24 @@ const runner = buildTree(world, {
}, },
{ {
kind: "leaf", kind: "leaf",
run: () => { *run() {
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { while (true) {
return; const dt: number = yield;
} if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
const timer = world.getSingleton(TickTimer); continue;
timer.accumulator += 16; }
if (timer.accumulator >= timer.interval) { const timer = world.getSingleton(TickTimer);
timer.accumulator -= timer.interval; timer.accumulator += dt;
if (hasActivePiece()) { if (timer.accumulator >= timer.interval) {
const piece = world.getSingleton(Piece); timer.accumulator -= timer.interval;
const board = world.getSingleton(Board); if (hasActivePiece()) {
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { const piece = world.getSingleton(Piece);
piece.y++; const board = world.getSingleton(Board);
} else { if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
lockAndSpawn(); piece.y++;
} else {
lockAndSpawn();
}
} }
} }
} }
@ -275,7 +278,7 @@ runner.schedule((runner as any).root);
const TICK_MS = 16; const TICK_MS = 16;
const interval = setInterval(() => { const interval = setInterval(() => {
runner.tick(); runner.tick(TICK_MS);
}, TICK_MS); }, TICK_MS);
// Cleanup on exit // Cleanup on exit

View File

@ -13,5 +13,5 @@ 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 { buildTree, Cancel } from "./tree-def";
export type { TreeDef, LeafResult } from "./tree-def"; export type { TreeDef, LeafFn } from "./tree-def";

View File

@ -13,7 +13,7 @@ import {
// ── Types ───────────────────────────────────────────── // ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */ /** Callback invoked for each leaf task that becomes Scheduled. */
export type LeafHandler = (world: World, entity: Entity) => void; export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
/** Callback invoked when a task reaches a terminal status. */ /** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = ( export type TerminalHandler = (
@ -103,12 +103,14 @@ export class TaskRunner {
* Process all Scheduled tasks. * Process all Scheduled tasks.
* *
* Call once per frame. Only entities with `Scheduled` are touched. * Call once per frame. Only entities with `Scheduled` are touched.
*
* @param dt Delta time in milliseconds since last tick.
*/ */
tick(): void { tick(dt: number = 0): void {
const scheduled = [...this._world.query(query(Task, Scheduled))]; const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) { for (const entity of scheduled) {
this._world.remove(entity, Scheduled); this._world.remove(entity, Scheduled);
this._execute(entity); this._execute(entity, dt);
} }
} }
@ -142,12 +144,12 @@ export class TaskRunner {
// ── Internal execution ──────────────────────────── // ── Internal execution ────────────────────────────
private _execute(entity: Entity): void { private _execute(entity: Entity, dt: number): void {
const t = this._world.get(entity, Task); const t = this._world.get(entity, Task);
switch (t.kind) { switch (t.kind) {
case "leaf": case "leaf":
this._executeLeaf(entity); this._executeLeaf(entity, dt);
break; break;
case "sequential": case "sequential":
this._executeSequential(entity); this._executeSequential(entity);
@ -167,9 +169,9 @@ export class TaskRunner {
} }
} }
private _executeLeaf(entity: Entity): void { private _executeLeaf(entity: Entity, dt: number): void {
this._world.add(entity, Running); this._world.add(entity, Running);
this.onLeaf(this._world, entity); this.onLeaf(this._world, entity, dt);
} }
private _executeSequential(entity: Entity): void { private _executeSequential(entity: Entity): void {

View File

@ -2,14 +2,28 @@ import type { World, Entity } from "../index";
import { Task, ChildOf } from "./task"; import { Task, ChildOf } from "./task";
import { TaskRunner } from "./runner"; import { TaskRunner } from "./runner";
// ── Types ───────────────────────────────────────────── // ── Cancel ────────────────────────────────────────────
/** Return value from a leaf's `run` function. */ /**
export type LeafResult = "success" | "fail" | "cancel" | void; * Throw this inside a leaf `run` function to cancel the leaf and its subtree.
*
* @example
* ```ts
* { kind: "leaf", run: () => { throw Cancel; } }
* ```
*/
export const Cancel: unique symbol = Symbol("leaf.cancel");
// ── Tree definition ───────────────────────────────────
/** A leaf function — plain or generator. */
export type LeafFn =
| ((world: World, dt: number) => void)
| (() => Generator<number | void, void, number>);
/** Declarative behaviour-tree definition. */ /** Declarative behaviour-tree definition. */
export type TreeDef = export type TreeDef =
| { kind: "leaf"; run: (world: World) => LeafResult } | { kind: "leaf"; run: LeafFn }
| { kind: "sequential"; children: TreeDef[] } | { kind: "sequential"; children: TreeDef[] }
| { kind: "parallel"; children: TreeDef[] } | { kind: "parallel"; children: TreeDef[] }
| { kind: "selector"; children: TreeDef[] } | { kind: "selector"; children: TreeDef[] }
@ -22,11 +36,12 @@ export type TreeDef =
* Recursively materialize a `TreeDef` into ECS entities and return a * Recursively materialize a `TreeDef` into ECS entities and return a
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* Leaf `run` functions are called each tick the leaf is active: * Leaf `run` functions:
* - `"success"` leaf succeeds * - **Plain function** runs once per tick. `return` = success. `throw` = fail.
* - `"fail"` leaf fails * `throw Cancel` = cancel.
* - `"cancel"` leaf (and subtree) is cancelled * - **Generator function** each `yield` suspends until next tick. The value
* - `undefined` leaf stays Running, re-invoked next tick * yielded is the desired delay in ms (or `undefined` for next frame).
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
* *
* @example * @example
* ```ts * ```ts
@ -36,16 +51,18 @@ export type TreeDef =
* kind: "sequential", * kind: "sequential",
* children: [ * children: [
* { kind: "leaf", run: () => { doWork(); } }, * { kind: "leaf", run: () => { doWork(); } },
* { kind: "leaf", run: () => { render(); } }, * { kind: "leaf", *run() { yield 1000; doLater(); } },
* ], * ],
* }, * },
* }); * });
* runner.schedule(runner.root); * runner.schedule(runner.root);
* setInterval(() => runner.tick(), 16); * 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, (world: World) => LeafResult>(); const leafHandlers = new Map<Entity, LeafFn>();
// Track generator iterators for multi-frame leaves
const generators = new Map<Entity, Generator<number | void, void, number>>();
function build(def: TreeDef, parent?: Entity): Entity { function build(def: TreeDef, parent?: Entity): Entity {
const entity = world.spawn(); const entity = world.spawn();
@ -74,14 +91,56 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
const runner = new TaskRunner(world); const runner = new TaskRunner(world);
runner.onLeaf = (_w, entity) => { runner.onLeaf = (_w, entity, dt) => {
const handler = leafHandlers.get(entity); const handler = leafHandlers.get(entity);
if (!handler) return; if (!handler) return;
const result = handler(_w);
if (result === "success") runner.succeed(entity); try {
else if (result === "fail") runner.fail(entity); // Check if this leaf has an active generator
else if (result === "cancel") runner.cancel(entity); let gen = generators.get(entity);
// undefined → leaf stays Running, re-invoked next tick
if (gen) {
// Resume existing generator
const result = gen.next(dt);
if (result.done) {
generators.delete(entity);
runner.succeed(entity);
}
// If not done, leaf stays Running — nothing to do
} else {
// First invocation — call the handler
const ret = handler(_w, dt);
// Check if it returned a generator
if (ret != null && typeof (ret as any).next === "function") {
const gen = ret as Generator<number | void, void, number>;
generators.set(entity, gen);
const result = gen.next(dt);
if (result.done) {
generators.delete(entity);
runner.succeed(entity);
}
// Not done → leaf stays Running
} else {
// Plain function — returned undefined → success
runner.succeed(entity);
}
}
} catch (err) {
// Clean up generator if one was active
generators.delete(entity);
if (err === Cancel) {
runner.cancel(entity);
} else {
runner.fail(entity);
}
}
};
runner.onTerminal = (_w, entity) => {
// Clean up generator when a leaf reaches terminal by external means
generators.delete(entity);
}; };
// Stash the root entity on the runner for convenience // Stash the root entity on the runner for convenience