ecs-observable/src/bt/runner.ts

288 lines
8.0 KiB
TypeScript
Raw Normal View History

import type { World, Entity } from "../index";
import { query } from "../query";
import {
Task,
Scheduled,
Running,
Succeeded,
Failed,
Cancelled,
TERMINAL_TAGS,
ChildOf,
} from "./task";
// ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */
export type LeafHandler = (world: World, entity: Entity) => void;
/** Callback invoked when a task reaches a terminal status. */
export type TerminalHandler = (
world: World,
entity: Entity,
status: "succeeded" | "failed" | "cancelled",
) => void;
// ── Helpers ───────────────────────────────────────────
function terminalStatus(
world: World,
entity: Entity,
): "succeeded" | "failed" | "cancelled" | null {
if (world.has(entity, Succeeded)) return "succeeded";
if (world.has(entity, Failed)) return "failed";
if (world.has(entity, Cancelled)) return "cancelled";
return null;
}
function isTerminal(world: World, entity: Entity): boolean {
return terminalStatus(world, entity) !== null;
}
function clearStatus(world: World, entity: Entity): void {
for (const tag of TERMINAL_TAGS) {
if (world.has(entity, tag)) world.remove(entity, tag);
}
if (world.has(entity, Running)) world.remove(entity, Running);
}
function childrenOf(world: World, parent: Entity): Entity[] {
return world.getRelatedTo(parent, ChildOf);
}
function parentOf(world: World, child: Entity): Entity | null {
return world.getRelated(child, ChildOf) ?? null;
}
// ── TaskRunner ────────────────────────────────────────
/**
* 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.
*
* 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();
* ```
*/
export class TaskRunner {
private _world: World;
/** Called when a leaf task becomes Scheduled. */
onLeaf: LeafHandler = () => {};
/** Called when any task reaches a terminal status. */
onTerminal: TerminalHandler = () => {};
constructor(world: World) {
this._world = world;
}
// ── Public API ────────────────────────────────────
/**
* Process all Scheduled tasks.
*
* Call once per frame. Only entities with `Scheduled` are touched.
*/
tick(): void {
const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) {
this._world.remove(entity, Scheduled);
this._execute(entity);
}
}
/** Mark a leaf task as succeeded and propagate upward. */
succeed(entity: Entity): void {
this._finish(entity, Succeeded);
}
/** Mark a leaf task as failed and propagate upward. */
fail(entity: Entity): void {
this._finish(entity, Failed);
}
/** Cancel a task and all its descendants. */
cancel(entity: Entity): void {
this._cancelTree(entity);
}
/** Schedule a task for execution next tick. */
schedule(entity: Entity): void {
if (this._world.has(entity, Task)) {
this._world.add(entity, Scheduled);
}
}
/** Reset a task to idle (removes all status tags). */
reset(entity: Entity): void {
clearStatus(this._world, entity);
this._world.remove(entity, Scheduled);
}
// ── Internal execution ────────────────────────────
private _execute(entity: Entity): void {
const t = this._world.get(entity, Task);
switch (t.kind) {
case "leaf":
this._executeLeaf(entity);
break;
case "sequential":
this._executeSequential(entity);
break;
case "parallel":
this._executeParallel(entity);
break;
case "random":
this._executeRandom(entity);
break;
}
}
private _executeLeaf(entity: Entity): void {
this._world.add(entity, Running);
this.onLeaf(this._world, entity);
}
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;
}
// 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);
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;
}
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) {
this._finish(entity, Succeeded);
}
}
private _executeRandom(entity: Entity): void {
const children = childrenOf(this._world, entity);
// Check if any child already reached a terminal
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
this._finish(
entity,
status === "succeeded"
? Succeeded
: status === "cancelled"
? Cancelled
: Failed,
);
return;
}
}
// Pick a random child that isn't already running
const eligible = children.filter(
(c) => !this._world.has(c, Running) && !this._world.has(c, Scheduled),
);
if (eligible.length > 0) {
const pick = eligible[Math.floor(Math.random() * eligible.length)];
this._world.add(pick, Scheduled);
}
// If no eligible children (all running), wait for one to finish
}
// ── Completion propagation ────────────────────────
private _finish(
entity: Entity,
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
): void {
if (!this._world.has(entity, Task)) return;
clearStatus(this._world, entity);
this._world.add(entity, tag);
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);
}
}
private _cancelTree(entity: Entity): void {
if (!this._world.has(entity, Task)) return;
// Cancel children first
for (const child of childrenOf(this._world, entity)) {
this._cancelTree(child);
}
// 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);
}
}
}