feat(bt): Rename repeat to cycle and refactor runner

Rework task execution to be synchronous within a single tick.
Scheduling boundaries are now defined by `cycle` nodes, which
replace the previous `repeat` decorator.  All tasks are executed
immediately when scheduled, and parent/child propagation is
handled inline.
This commit is contained in:
hyper
2026-06-28 14:51:52 +08:00
parent 1253bf82d2
commit 204b9100e6
8 changed files with 254 additions and 265 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ export {
parallel,
selector,
random,
repeat,
cycle,
} from "./tree-def";
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
+157 -117
View File
@@ -12,7 +12,7 @@ import {
} from "./task";
// ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */
/** Callback invoked when a leaf task starts executing. */
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
/** Callback invoked when a task reaches a terminal status. */
@@ -68,10 +68,9 @@ function parentOf(world: World, child: Entity): Entity | null {
/**
* Push-based behaviour-tree runner.
*
* Only tasks tagged with `Scheduled` are processed each tick.
* When a task finishes, it notifies its parent, which may schedule
* the next child (sequential), aggregate results (parallel), or
* propagate upward.
* 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.
*
* Leaves are dispatched to a user-provided `onLeaf` callback.
* Terminal results are dispatched to a user-provided `onTerminal` callback.
@@ -90,11 +89,13 @@ function parentOf(world: World, child: Entity): Entity | null {
*/
export class TaskRunner {
private _world: World;
private _executing = new Set<Entity>();
private _currentDt = 0;
/** Root task entity, set by `buildTree` for convenience. */
root?: Entity;
/** Called when a leaf task becomes Scheduled. */
/** Called when a leaf task starts executing. */
onLeaf: LeafHandler = () => {};
/** Called when any task reaches a terminal status. */
@@ -107,17 +108,27 @@ export class TaskRunner {
// ── Public API ────────────────────────────────────
/**
* Process all Scheduled tasks.
* Process tasks scheduled for this tick.
*
* Call once per frame. Only entities with `Scheduled` are touched.
* Call once per frame. Tasks scheduled while a tick is already in progress are
* deferred until the next tick boundary, but parent/child propagation caused
* by terminal status changes is immediate.
*
* @param dt Delta time in milliseconds since last tick.
*/
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, dt);
const previousDt = this._currentDt;
this._currentDt = dt;
try {
const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) {
if (!this._world.has(entity, Scheduled)) continue;
this._world.remove(entity, Scheduled);
this._execute(entity, dt);
}
} finally {
this._currentDt = previousDt;
}
}
@@ -133,10 +144,10 @@ export class TaskRunner {
/** Cancel a task and all its descendants. */
cancel(entity: Entity): void {
this._cancelTree(entity);
this._cancelTree(entity, true);
}
/** Schedule a task for execution next tick. */
/** Schedule a task for execution at the next tick boundary. */
schedule(entity: Entity): void {
if (this._world.has(entity, Task)) {
this._world.add(entity, Scheduled);
@@ -152,83 +163,103 @@ export class TaskRunner {
// ── Internal execution ────────────────────────────
private _execute(entity: Entity, dt: number): void {
const t = this._world.get(entity, Task);
if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
switch (t.kind) {
case "leaf":
this._executeLeaf(entity, dt);
break;
case "sequential":
this._executeSequential(entity);
break;
case "parallel":
this._executeParallel(entity);
break;
case "random":
this._executeRandom(entity);
break;
case "repeat":
this._executeRepeat(entity);
break;
case "selector":
this._executeSelector(entity);
break;
const t = this._world.get(entity, Task);
this._executing.add(entity);
try {
switch (t.kind) {
case "leaf":
this._executeLeaf(entity, dt);
break;
case "sequential":
this._executeSequential(entity, dt);
break;
case "parallel":
this._executeParallel(entity, dt);
break;
case "random":
this._executeRandom(entity, dt);
break;
case "cycle":
this._executeCycle(entity, dt);
break;
case "selector":
this._executeSelector(entity, dt);
break;
}
} finally {
this._executing.delete(entity);
}
}
private _executeChild(entity: Entity, dt: number): void {
if (
!this._world.has(entity, Task) ||
isTerminal(this._world, entity) ||
this._world.has(entity, Running)
) {
return;
}
if (this._world.has(entity, Scheduled)) {
this._world.remove(entity, Scheduled);
}
this._execute(entity, dt);
}
private _executeLeaf(entity: Entity, dt: number): void {
this._world.add(entity, Running);
this.onLeaf(this._world, entity, dt);
}
private _executeSequential(entity: Entity): void {
const children = childrenOf(this._world, entity);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — continue to next child
continue;
private _executeSequential(entity: Entity, dt: number): void {
for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return;
}
// All children succeeded
this._finish(entity, Succeeded);
}
private _executeParallel(entity: Entity): void {
const children = childrenOf(this._world, entity);
private _executeParallel(entity: Entity, dt: number): void {
let allDone = true;
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — this child is done
continue;
for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
allDone = false;
// Schedule if not already running or scheduled
if (
!this._world.has(child, Running) &&
!this._world.has(child, Scheduled)
) {
this._world.add(child, Scheduled);
}
}
if (allDone) {
@@ -236,12 +267,12 @@ export class TaskRunner {
}
}
private _executeRandom(entity: Entity): void {
// Single pass: check terminals and collect eligible children
private _executeRandom(entity: Entity, dt: number): void {
const eligible: Entity[] = [];
for (const child of childrenOf(this._world, entity)) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
const status = terminalStatus(this._world, child);
if (status) {
this._finish(
entity,
status === "succeeded"
@@ -252,68 +283,73 @@ export class TaskRunner {
);
return;
}
if (
!this._world.has(child, Running) &&
!this._world.has(child, Scheduled)
) {
if (!this._world.has(child, Running)) {
eligible.push(child);
}
}
if (eligible.length > 0) {
const pick = eligible[Math.floor(Math.random() * eligible.length)];
this._world.add(pick, Scheduled);
if (eligible.length === 0) return;
const pick = eligible[Math.floor(Math.random() * eligible.length)];
this._executeChild(pick, dt);
const status = terminalStatus(this._world, pick);
if (status) {
this._finish(
entity,
status === "succeeded"
? Succeeded
: status === "cancelled"
? Cancelled
: Failed,
);
}
// If no eligible children (all running), wait for one to finish
}
private _executeRepeat(entity: Entity): void {
private _executeCycle(entity: Entity, dt: number): void {
const children = [...childrenOf(this._world, entity)];
// Repeat expects exactly one child
// Cycle expects exactly one task child. Non-task child entities are ignored.
if (children.length === 0) return;
const child = children[0];
// If child reached a terminal, reset the entire subtree and schedule again
if (isTerminal(this._world, child)) {
clearSubtree(this._world, child);
this._world.add(child, Scheduled);
return;
}
// Schedule child if not already running or scheduled
if (
!this._world.has(child, Running) &&
!this._world.has(child, Scheduled)
) {
this._executeChild(child, dt);
if (isTerminal(this._world, child)) {
clearSubtree(this._world, child);
this._world.add(child, Scheduled);
}
// Repeat itself never terminates — it just keeps the child going
// Cycle itself never terminates — it just creates tick-boundary cycles.
}
private _executeSelector(entity: Entity): void {
const children = childrenOf(this._world, entity);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "succeeded") {
// First success — selector succeeds
this._finish(entity, Succeeded);
return;
}
// failed or cancelled — continue to next child
continue;
private _executeSelector(entity: Entity, dt: number): void {
for (const child of childrenOf(this._world, entity)) {
let status = terminalStatus(this._world, child);
if (status === "succeeded") {
this._finish(entity, Succeeded);
return;
}
if (status === "failed" || status === "cancelled") continue;
this._executeChild(child, dt);
status = terminalStatus(this._world, child);
if (status === "succeeded") {
this._finish(entity, Succeeded);
return;
}
if (status === "failed" || status === "cancelled") continue;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return;
}
// All children failed
this._finish(entity, Failed);
}
@@ -331,30 +367,34 @@ export class TaskRunner {
const status = terminalStatus(this._world, entity)!;
this.onTerminal(this._world, entity, status);
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
if (parent && !this._executing.has(parent)) {
if (this._world.has(parent, Scheduled)) {
this._world.remove(parent, Scheduled);
}
this._execute(parent, this._currentDt);
}
}
private _cancelTree(entity: Entity): void {
private _cancelTree(entity: Entity, notifyParent: boolean): void {
if (!this._world.has(entity, Task)) return;
// Cancel children first
// Cancel children first without repeatedly waking this node while its
// subtree is still being cancelled.
for (const child of childrenOf(this._world, entity)) {
this._cancelTree(child);
this._cancelTree(child, false);
}
// Cancel this node
clearStatus(this._world, entity);
this._world.add(entity, Cancelled);
this.onTerminal(this._world, entity, "cancelled");
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
if (notifyParent && parent && !this._executing.has(parent)) {
if (this._world.has(parent, Scheduled)) {
this._world.remove(parent, Scheduled);
}
this._execute(parent, this._currentDt);
}
}
}
+6 -10
View File
@@ -9,23 +9,19 @@ import { defineRelationship } from "../relationship";
* - `"leaf"` — terminal node; external logic drives it to completion.
* - `"sequential"` — runs children one at a time, left to right.
* Succeeds when all children succeed; fails when any child fails.
* - `"parallel"` — schedules all children at once.
* - `"parallel"` — starts all children at once.
* Succeeds when all children succeed; fails when any child fails.
* - `"random"` — picks one child at random each time it's scheduled.
* - `"random"` — picks one child at random each time it runs.
* Succeeds/fails with that child's result.
* - `"repeat"` — runs its single child. When the child finishes, resets it
* and runs again. Never terminates on its own (only via cancel).
* - `"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).
* - `"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"
| "repeat"
| "selector",
"leaf" | "sequential" | "parallel" | "random" | "cycle" | "selector",
});
export type TaskKind = (typeof Task.type)["kind"];
+7 -7
View File
@@ -91,18 +91,18 @@ export function random(
}
/**
* Create a repeat task entity definition.
* Create a cycle task entity definition.
*
* `repeat(child)` and `repeat([child, metadataEntity])` are both supported.
* `cycle(child)` and `cycle([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(
export function cycle(child: EntityDef): TaskEntityDef;
export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef;
export function cycle(
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
): TaskEntityDef {
return task(
"repeat",
"cycle",
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
);
}
@@ -114,7 +114,7 @@ export function repeat(
* fully-wired `TaskRunner`.
*
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
* `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic
* `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.
*