From 4182bc1578c585489620d698391847ee8fd788c2 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 2 Jun 2026 00:10:18 +0800 Subject: [PATCH] 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. --- examples/tetris/main.ts | 35 ++++++++------- src/bt/index.ts | 4 +- src/bt/runner.ts | 16 ++++--- src/bt/tree-def.ts | 95 +++++++++++++++++++++++++++++++++-------- 4 files changed, 107 insertions(+), 43 deletions(-) diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 8c2e63b..0c3dddf 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -221,21 +221,24 @@ const runner = buildTree(world, { }, { kind: "leaf", - run: () => { - if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { - return; - } - 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(); + *run() { + while (true) { + const dt: number = yield; + if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { + continue; + } + const timer = world.getSingleton(TickTimer); + timer.accumulator += dt; + 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(); + } } } } @@ -275,7 +278,7 @@ runner.schedule((runner as any).root); const TICK_MS = 16; const interval = setInterval(() => { - runner.tick(); + runner.tick(TICK_MS); }, TICK_MS); // Cleanup on exit diff --git a/src/bt/index.ts b/src/bt/index.ts index 910ef3e..759d36f 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -13,5 +13,5 @@ export type { TaskKind } from "./task"; export { TaskRunner } from "./runner"; export type { LeafHandler, TerminalHandler } from "./runner"; -export { buildTree } from "./tree-def"; -export type { TreeDef, LeafResult } from "./tree-def"; +export { buildTree, Cancel } from "./tree-def"; +export type { TreeDef, LeafFn } from "./tree-def"; diff --git a/src/bt/runner.ts b/src/bt/runner.ts index 2d9d27e..f2386e7 100644 --- a/src/bt/runner.ts +++ b/src/bt/runner.ts @@ -13,7 +13,7 @@ import { // ── Types ───────────────────────────────────────────── /** 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. */ export type TerminalHandler = ( @@ -103,12 +103,14 @@ export class TaskRunner { * Process all Scheduled tasks. * * 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))]; for (const entity of scheduled) { this._world.remove(entity, Scheduled); - this._execute(entity); + this._execute(entity, dt); } } @@ -142,12 +144,12 @@ export class TaskRunner { // ── Internal execution ──────────────────────────── - private _execute(entity: Entity): void { + private _execute(entity: Entity, dt: number): void { const t = this._world.get(entity, Task); switch (t.kind) { case "leaf": - this._executeLeaf(entity); + this._executeLeaf(entity, dt); break; case "sequential": 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.onLeaf(this._world, entity); + this.onLeaf(this._world, entity, dt); } private _executeSequential(entity: Entity): void { diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts index c48ddc8..7f3ade4 100644 --- a/src/bt/tree-def.ts +++ b/src/bt/tree-def.ts @@ -2,14 +2,28 @@ import type { World, Entity } from "../index"; import { Task, ChildOf } from "./task"; 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); /** Declarative behaviour-tree definition. */ export type TreeDef = - | { kind: "leaf"; run: (world: World) => LeafResult } + | { kind: "leaf"; run: LeafFn } | { kind: "sequential"; children: TreeDef[] } | { kind: "parallel"; children: TreeDef[] } | { kind: "selector"; children: TreeDef[] } @@ -22,11 +36,12 @@ export type TreeDef = * 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 + * Leaf `run` functions: + * - **Plain function** — runs once per tick. `return` = success. `throw` = fail. + * `throw Cancel` = cancel. + * - **Generator function** — each `yield` suspends until next tick. The value + * yielded is the desired delay in ms (or `undefined` for next frame). + * Generator completion = success. `throw` = fail. `throw Cancel` = cancel. * * @example * ```ts @@ -36,16 +51,18 @@ export type TreeDef = * kind: "sequential", * children: [ * { kind: "leaf", run: () => { doWork(); } }, - * { kind: "leaf", run: () => { render(); } }, + * { kind: "leaf", *run() { yield 1000; doLater(); } }, * ], * }, * }); * runner.schedule(runner.root); - * setInterval(() => runner.tick(), 16); + * setInterval(() => runner.tick(16), 16); * ``` */ export function buildTree(world: World, def: TreeDef): TaskRunner { - const leafHandlers = new Map LeafResult>(); + const leafHandlers = new Map(); + // Track generator iterators for multi-frame leaves + const generators = new Map>(); function build(def: TreeDef, parent?: Entity): Entity { const entity = world.spawn(); @@ -74,14 +91,56 @@ export function buildTree(world: World, def: TreeDef): TaskRunner { const runner = new TaskRunner(world); - runner.onLeaf = (_w, entity) => { + runner.onLeaf = (_w, entity, dt) => { 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 + + try { + // Check if this leaf has an active generator + let gen = generators.get(entity); + + 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; + 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