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:
parent
ec8f668392
commit
4182bc1578
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue