feat(bt): Add wait and whilst, rename leaf to action

This commit is contained in:
hyper
2026-06-28 15:07:34 +08:00
parent 204b9100e6
commit 9bf0e5e049
8 changed files with 571 additions and 319 deletions
+18 -3
View File
@@ -11,16 +11,31 @@ export {
export type { TaskKind } from "./task";
export { TaskRunner } from "./runner";
export type { LeafHandler, TerminalHandler } from "./runner";
export type {
ActionHandler,
WaitHandler,
ConditionHandler,
TaskControl,
TerminalHandler,
} from "./runner";
export {
buildTree,
Cancel,
leaf,
action,
wait,
whilst,
sequential,
parallel,
selector,
random,
cycle,
} from "./tree-def";
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
export type {
TreeDef,
TaskEntityDef,
TaskMeta,
ActionFn,
WaitFn,
ConditionFn,
} from "./tree-def";
+122 -39
View File
@@ -9,11 +9,34 @@ import {
Cancelled,
TERMINAL_TAGS,
ChildOf,
Cancel,
} from "./task";
// ── Types ─────────────────────────────────────────────
/** Callback invoked when a leaf task starts executing. */
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
/** Control object passed to wait tasks so they can complete themselves. */
export interface TaskControl {
succeed(): void;
fail(): void;
cancel(): void;
}
/** Callback invoked when an action task starts executing. */
export type ActionHandler = (world: World, entity: Entity, dt: number) => void;
/** Callback invoked when a wait task starts executing. */
export type WaitHandler = (
world: World,
entity: Entity,
control: TaskControl,
dt: number,
) => void;
/** Callback invoked by whilst tasks before each iteration. */
export type ConditionHandler = (
world: World,
entity: Entity,
dt: number,
) => boolean;
/** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = (
@@ -69,23 +92,11 @@ function parentOf(world: World, child: Entity): Entity | null {
* Push-based behaviour-tree runner.
*
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
* task starts, child/parent propagation is immediate until a running leaf or
* an explicit scheduling boundary (such as `cycle`) yields to a future tick.
* task starts, child/parent propagation is immediate until a running wait task
* or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
*
* Leaves are dispatched to a user-provided `onLeaf` callback.
* Terminal results are dispatched to a user-provided `onTerminal` callback.
*
* @example
* ```ts
* const runner = new TaskRunner(world);
* runner.onLeaf = (world, entity) => {
* // do the leaf's work, then call:
* runner.succeed(entity);
* };
*
* // Each frame:
* runner.tick();
* ```
* Action, wait, and whilst condition callbacks are supplied by `buildTree` or
* assigned directly when using `TaskRunner` manually.
*/
export class TaskRunner {
private _world: World;
@@ -95,8 +106,14 @@ export class TaskRunner {
/** Root task entity, set by `buildTree` for convenience. */
root?: Entity;
/** Called when a leaf task starts executing. */
onLeaf: LeafHandler = () => {};
/** Called when an action task starts executing. */
onAction: ActionHandler = () => {};
/** Called when a wait task starts executing. */
onWait: WaitHandler = () => {};
/** Called by whilst tasks before each iteration. */
onCondition: ConditionHandler = () => false;
/** Called when any task reaches a terminal status. */
onTerminal: TerminalHandler = () => {};
@@ -132,12 +149,12 @@ export class TaskRunner {
}
}
/** Mark a leaf task as succeeded and propagate upward. */
/** Mark a task as succeeded and propagate upward. */
succeed(entity: Entity): void {
this._finish(entity, Succeeded);
}
/** Mark a leaf task as failed and propagate upward. */
/** Mark a task as failed and propagate upward. */
fail(entity: Entity): void {
this._finish(entity, Failed);
}
@@ -170,8 +187,11 @@ export class TaskRunner {
try {
switch (t.kind) {
case "leaf":
this._executeLeaf(entity, dt);
case "action":
this._executeAction(entity, dt);
break;
case "wait":
this._executeWait(entity, dt);
break;
case "sequential":
this._executeSequential(entity, dt);
@@ -185,6 +205,9 @@ export class TaskRunner {
case "cycle":
this._executeCycle(entity, dt);
break;
case "whilst":
this._executeWhilst(entity, dt);
break;
case "selector":
this._executeSelector(entity, dt);
break;
@@ -210,9 +233,31 @@ export class TaskRunner {
this._execute(entity, dt);
}
private _executeLeaf(entity: Entity, dt: number): void {
private _executeAction(entity: Entity, dt: number): void {
this._world.add(entity, Running);
this.onLeaf(this._world, entity, dt);
try {
this.onAction(this._world, entity, dt);
if (!isTerminal(this._world, entity)) {
this.succeed(entity);
}
} catch (err) {
this._finishFromThrown(entity, err);
}
}
private _executeWait(entity: Entity, dt: number): void {
this._world.add(entity, Running);
const control: TaskControl = {
succeed: () => this.succeed(entity),
fail: () => this.fail(entity),
cancel: () => this.cancel(entity),
};
try {
this.onWait(this._world, entity, control, dt);
} catch (err) {
this._finishFromThrown(entity, err);
}
}
private _executeSequential(entity: Entity, dt: number): void {
@@ -307,28 +352,51 @@ export class TaskRunner {
}
private _executeCycle(entity: Entity, dt: number): void {
const children = [...childrenOf(this._world, entity)];
// Cycle expects exactly one task child. Non-task child entities are ignored.
if (children.length === 0) return;
const child = children[0];
if (isTerminal(this._world, child)) {
clearSubtree(this._world, child);
this._world.add(child, Scheduled);
return;
}
const child = this._firstTaskChild(entity);
if (!child) return;
this._executeChild(child, dt);
if (isTerminal(this._world, child)) {
clearSubtree(this._world, child);
this._world.add(child, Scheduled);
this._world.add(entity, Scheduled);
}
// Cycle itself never terminates — it just creates tick-boundary cycles.
}
private _executeWhilst(entity: Entity, dt: number): void {
let condition: boolean;
try {
condition = this.onCondition(this._world, entity, dt);
} catch (err) {
this._finishFromThrown(entity, err);
return;
}
if (!condition) {
this._finish(entity, Succeeded);
return;
}
const child = this._firstTaskChild(entity);
if (!child) {
this._finish(entity, Succeeded);
return;
}
this._executeChild(child, dt);
const status = terminalStatus(this._world, child);
if (status === "succeeded") {
clearSubtree(this._world, child);
this._world.add(entity, Scheduled);
} else if (status === "failed") {
this._finish(entity, Failed);
} else if (status === "cancelled") {
this._finish(entity, Cancelled);
}
}
private _executeSelector(entity: Entity, dt: number): void {
for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
@@ -353,8 +421,23 @@ export class TaskRunner {
this._finish(entity, Failed);
}
private _firstTaskChild(entity: Entity): Entity | null {
for (const child of childrenOf(this._world, entity)) {
return child;
}
return null;
}
// ── Completion propagation ────────────────────────
private _finishFromThrown(entity: Entity, err: unknown): void {
if (err === Cancel) {
this.cancel(entity);
} else {
this.fail(entity);
}
}
private _finish(
entity: Entity,
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
+20 -5
View File
@@ -1,12 +1,18 @@
import { defineComponent } from "../component";
import { defineRelationship } from "../relationship";
// ── Cancel ────────────────────────────────────────────
/** Throw from an action or wait starter to cancel that task and its subtree. */
export const Cancel: unique symbol = Symbol("task.cancel");
// ── Task component ────────────────────────────────────
/**
* Core component for behaviour-tree tasks.
*
* `kind` determines how the task evaluates its children:
* - `"leaf"` — terminal node; external logic drives it to completion.
* `kind` determines how the task evaluates:
* - `"action"` — runs immediately and succeeds when its function returns.
* - `"wait"` — starts once and remains running until external code completes it.
* - `"sequential"` — runs children one at a time, left to right.
* Succeeds when all children succeed; fails when any child fails.
* - `"parallel"` — starts all children at once.
@@ -16,12 +22,21 @@ import { defineRelationship } from "../relationship";
* - `"cycle"` — runs its single child. When the child finishes, resets it
* and schedules the next run for a future tick boundary. Never terminates
* on its own (only via cancel).
* - `"whilst"` — runs its single child while its condition is true.
* Succeeds when the condition becomes false; fails/cancels with its child.
* - `"selector"` — runs children left to right. Succeeds on the first
* child that succeeds; fails only if all children fail.
*/
export const Task = defineComponent("task", {
kind: "leaf" as
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
kind: "action" as
| "action"
| "wait"
| "sequential"
| "parallel"
| "random"
| "cycle"
| "whilst"
| "selector",
});
export type TaskKind = (typeof Task.type)["kind"];
@@ -30,7 +45,7 @@ export type TaskKind = (typeof Task.type)["kind"];
/** A task that should be executed this tick. */
export const Scheduled = defineComponent("scheduled", {});
/** A task that is currently executing (multi-frame leaves). */
/** A task that is currently executing or waiting for completion. */
export const Running = defineComponent("running", {});
/** The task completed successfully. */
+106 -96
View File
@@ -1,35 +1,32 @@
import type { World, Entity } from "../index";
import type { EntityDef, EntityDefChild } from "../entity-tree";
import { Task, ChildOf } from "./task";
import { TaskRunner } from "./runner";
import { Task, ChildOf, Cancel } from "./task";
import { TaskRunner, type TaskControl } from "./runner";
// ── Cancel ────────────────────────────────────────────
export { Cancel };
/**
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
*
* @example
* ```ts
* leaf(() => { throw Cancel; })
* ```
*/
export const Cancel: unique symbol = Symbol("leaf.cancel");
// ── Task callback types ───────────────────────────────
// ── Tree definition ───────────────────────────────────
/** Runs immediately. Return = success, throw = failure, throw Cancel = cancel. */
export type ActionFn = (world: World, entity: Entity, dt: number) => void;
/** A leaf function — plain or generator. */
export type LeafFn =
| ((world: World, dt: number) => void)
| (() => Generator<number | void, void, number>);
/** Starts a task that remains Running until completed by the supplied control. */
export type WaitFn = (
world: World,
entity: Entity,
control: TaskControl,
dt: number,
) => void;
export interface LeafTaskMeta {
readonly run: LeafFn;
}
/** Controls a `whilst` task. False means the loop has completed successfully. */
export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
export type TaskEntityDef = EntityDef<
typeof Task.type,
LeafTaskMeta | undefined
>;
export type TaskMeta =
| { readonly mode: "action"; readonly run: ActionFn }
| { readonly mode: "wait"; readonly start?: WaitFn }
| { readonly mode: "whilst"; readonly condition: ConditionFn };
export type TaskEntityDef = EntityDef<typeof Task.type, TaskMeta | undefined>;
/** Behaviour-tree definition accepted by `buildTree`. */
export type TreeDef = TaskEntityDef;
@@ -43,7 +40,7 @@ function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
function task(
kind: typeof Task.type.kind,
children: readonly EntityDefChild[] = [],
meta?: LeafTaskMeta,
meta?: TaskMeta,
): TaskEntityDef {
return {
kind: "entity",
@@ -54,12 +51,38 @@ function task(
};
}
/** Create a leaf task entity definition. */
export function leaf(
run: LeafFn,
/** Create an action task entity definition. */
export function action(
run: ActionFn,
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("leaf", children, { run });
return task("action", children, { mode: "action", run });
}
/**
* Create a wait task entity definition.
*
* `wait()` creates a task that simply becomes Running. External systems can
* complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
* `runner.cancel(entity)`.
*/
export function wait(children?: readonly EntityDefChild[]): TaskEntityDef;
export function wait(
start: WaitFn,
children?: readonly EntityDefChild[],
): TaskEntityDef;
export function wait(
startOrChildren?: WaitFn | readonly EntityDefChild[],
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
const hasChildrenAsFirstArg = Array.isArray(startOrChildren);
const start = hasChildrenAsFirstArg
? undefined
: (startOrChildren as WaitFn | undefined);
return task("wait", hasChildrenAsFirstArg ? startOrChildren : children, {
mode: "wait",
start,
});
}
/** Create a sequential task entity definition. */
@@ -107,28 +130,44 @@ export function cycle(
);
}
/**
* Create a conditional loop task entity definition.
*
* Runs its child while `condition` returns true. When the child succeeds, the
* child subtree is reset and `whilst` schedules itself for the next tick
* boundary. When `condition` returns false, `whilst` succeeds.
*/
export function whilst(condition: ConditionFn, child: EntityDef): TaskEntityDef;
export function whilst(
condition: ConditionFn,
children?: readonly EntityDefChild[],
): TaskEntityDef;
export function whilst(
condition: ConditionFn,
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
): TaskEntityDef {
return task(
"whilst",
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
{ mode: "whilst", condition },
);
}
// ── Builder ───────────────────────────────────────────
/**
* Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`.
*
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
* `sequential`, `parallel`, `selector`, `random`, `cycle`) and generic
* single-component entity factories. Non-task child entities are materialized
* into the ECS tree but ignored by `TaskRunner` execution.
*
* 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.
* Definitions are `EntityDef` trees produced by the task factories (`action`,
* `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
* and generic single-component entity factories. Non-task child entities are
* materialized into the ECS tree but ignored by `TaskRunner` execution.
*/
export function buildTree(world: World, def: TreeDef): TaskRunner {
const leafHandlers = new Map<Entity, LeafFn>();
// Track generator iterators for multi-frame leaves
const generators = new Map<Entity, Generator<number | void, void, number>>();
const actions = new Map<Entity, ActionFn>();
const waits = new Map<Entity, WaitFn>();
const conditions = new Map<Entity, ConditionFn>();
function build(def: EntityDef, parent?: Entity): Entity {
const entity = world.spawn();
@@ -140,12 +179,22 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
if (def.component === Task) {
const taskData = world.get(entity, Task);
if (taskData.kind === "leaf") {
const run = (def.meta as LeafTaskMeta | undefined)?.run;
if (!run) {
throw new Error("Leaf task entity is missing a run function");
const meta = def.meta as TaskMeta | undefined;
if (taskData.kind === "action") {
if (meta?.mode !== "action") {
throw new Error("Action task entity is missing an action function");
}
leafHandlers.set(entity, run);
actions.set(entity, meta.run);
} else if (taskData.kind === "wait") {
if (meta?.mode === "wait" && meta.start) {
waits.set(entity, meta.start);
}
} else if (taskData.kind === "whilst") {
if (meta?.mode !== "whilst") {
throw new Error("Whilst task entity is missing a condition function");
}
conditions.set(entity, meta.condition);
}
}
@@ -165,56 +214,17 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
const runner = new TaskRunner(world);
runner.root = root;
runner.onLeaf = (_w, entity, dt) => {
const handler = leafHandlers.get(entity);
if (!handler) return;
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<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.onAction = (world, entity, dt) => {
actions.get(entity)?.(world, entity, dt);
};
runner.onTerminal = (_w, entity) => {
// Clean up generator when a leaf reaches terminal by external means
generators.delete(entity);
runner.onWait = (world, entity, control, dt) => {
waits.get(entity)?.(world, entity, control, dt);
};
runner.onCondition = (world, entity, dt) => {
const condition = conditions.get(entity);
return condition ? condition(world, entity, dt) : false;
};
return runner;