feat(bt): Add entity-based task factories
Add `leaf`, `sequential`, `parallel`, `selector`, `random`, and `repeat` functions that return `EntityDef` trees. Legacy object definitions are still accepted for backward compatibility. Non-task child entities are ignored by the runner, and `buildTree` now stores the root entity on `TaskRunner.root`.
This commit is contained in:
parent
968672da06
commit
ddde3f7597
|
|
@ -13,5 +13,20 @@ export type { TaskKind } from "./task";
|
|||
export { TaskRunner } from "./runner";
|
||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
||||
|
||||
export { buildTree, Cancel } from "./tree-def";
|
||||
export type { TreeDef, LeafFn } from "./tree-def";
|
||||
export {
|
||||
buildTree,
|
||||
Cancel,
|
||||
leaf,
|
||||
sequential,
|
||||
parallel,
|
||||
selector,
|
||||
random,
|
||||
repeat,
|
||||
} from "./tree-def";
|
||||
export type {
|
||||
TreeDef,
|
||||
LegacyTreeDef,
|
||||
TaskEntityDef,
|
||||
LeafTaskMeta,
|
||||
LeafFn,
|
||||
} from "./tree-def";
|
||||
|
|
|
|||
|
|
@ -53,7 +53,11 @@ function clearSubtree(world: World, entity: Entity): void {
|
|||
}
|
||||
|
||||
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
|
||||
yield* world.getRelatedTo(parent, ChildOf);
|
||||
for (const child of world.getRelatedTo(parent, ChildOf)) {
|
||||
if (world.has(child, Task)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parentOf(world: World, child: Entity): Entity | null {
|
||||
|
|
@ -87,6 +91,9 @@ function parentOf(world: World, child: Entity): Entity | null {
|
|||
export class TaskRunner {
|
||||
private _world: World;
|
||||
|
||||
/** Root task entity, set by `buildTree` for convenience. */
|
||||
root?: Entity;
|
||||
|
||||
/** Called when a leaf task becomes Scheduled. */
|
||||
onLeaf: LeafHandler = () => {};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { World, Entity } from "../index";
|
||||
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
||||
import { Task, ChildOf } from "./task";
|
||||
import { TaskRunner } from "./runner";
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ import { TaskRunner } from "./runner";
|
|||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* { kind: "leaf", run: () => { throw Cancel; } }
|
||||
* leaf(() => { throw Cancel; })
|
||||
* ```
|
||||
*/
|
||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
||||
|
|
@ -21,75 +22,181 @@ export type LeafFn =
|
|||
| ((world: World, dt: number) => void)
|
||||
| (() => Generator<number | void, void, number>);
|
||||
|
||||
/** Declarative behaviour-tree definition. */
|
||||
export type TreeDef =
|
||||
/** Legacy declarative behaviour-tree definition. */
|
||||
export type LegacyTreeDef =
|
||||
| { kind: "leaf"; run: LeafFn }
|
||||
| { kind: "sequential"; children: TreeDef[] }
|
||||
| { kind: "parallel"; children: TreeDef[] }
|
||||
| { kind: "selector"; children: TreeDef[] }
|
||||
| { kind: "random"; children: TreeDef[] }
|
||||
| { kind: "repeat"; child: TreeDef };
|
||||
| { kind: "sequential"; children: LegacyTreeDef[] }
|
||||
| { kind: "parallel"; children: LegacyTreeDef[] }
|
||||
| { kind: "selector"; children: LegacyTreeDef[] }
|
||||
| { kind: "random"; children: LegacyTreeDef[] }
|
||||
| { kind: "repeat"; child: LegacyTreeDef };
|
||||
|
||||
export interface LeafTaskMeta {
|
||||
readonly run: LeafFn;
|
||||
}
|
||||
|
||||
export type TaskEntityDef = EntityDef<
|
||||
typeof Task.type,
|
||||
LeafTaskMeta | undefined
|
||||
>;
|
||||
|
||||
/** Behaviour-tree definitions accepted by `buildTree`. */
|
||||
export type TreeDef = LegacyTreeDef | TaskEntityDef;
|
||||
|
||||
// ── Entity task factories ──────────────────────────────
|
||||
|
||||
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
|
||||
return children.filter(Boolean) as EntityDef[];
|
||||
}
|
||||
|
||||
function task(
|
||||
kind: typeof Task.type.kind,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
meta?: LeafTaskMeta,
|
||||
): TaskEntityDef {
|
||||
return {
|
||||
kind: "entity",
|
||||
component: Task,
|
||||
value: { kind },
|
||||
children: compactChildren(children),
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a leaf task entity definition. */
|
||||
export function leaf(
|
||||
run: LeafFn,
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("leaf", children, { run });
|
||||
}
|
||||
|
||||
/** Create a sequential task entity definition. */
|
||||
export function sequential(
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("sequential", children);
|
||||
}
|
||||
|
||||
/** Create a parallel task entity definition. */
|
||||
export function parallel(
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("parallel", children);
|
||||
}
|
||||
|
||||
/** Create a selector task entity definition. */
|
||||
export function selector(
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("selector", children);
|
||||
}
|
||||
|
||||
/** Create a random task entity definition. */
|
||||
export function random(
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task("random", children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repeat task entity definition.
|
||||
*
|
||||
* `repeat(child)` and `repeat([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(
|
||||
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||
): TaskEntityDef {
|
||||
return task(
|
||||
"repeat",
|
||||
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────
|
||||
|
||||
function isEntityDef(def: TreeDef | EntityDef): def is EntityDef {
|
||||
return def.kind === "entity";
|
||||
}
|
||||
|
||||
function normalizeLegacyTree(def: LegacyTreeDef): TaskEntityDef {
|
||||
switch (def.kind) {
|
||||
case "leaf":
|
||||
return leaf(def.run);
|
||||
case "repeat":
|
||||
return repeat(normalizeLegacyTree(def.child));
|
||||
case "sequential":
|
||||
return sequential(def.children.map(normalizeLegacyTree));
|
||||
case "parallel":
|
||||
return parallel(def.children.map(normalizeLegacyTree));
|
||||
case "selector":
|
||||
return selector(def.children.map(normalizeLegacyTree));
|
||||
case "random":
|
||||
return random(def.children.map(normalizeLegacyTree));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively materialize a `TreeDef` into ECS entities and return a
|
||||
* Materialize a behaviour-tree definition into ECS entities and return a
|
||||
* fully-wired `TaskRunner`.
|
||||
*
|
||||
* The preferred definition format is an `EntityDef` tree produced by the task
|
||||
* factories (`leaf`, `sequential`, `parallel`, `selector`, `random`, `repeat`)
|
||||
* and generic single-component entity factories. Non-task child entities are
|
||||
* materialized into the ECS tree but ignored by `TaskRunner` execution.
|
||||
*
|
||||
* Legacy object definitions are still accepted for compatibility.
|
||||
*
|
||||
* 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
|
||||
* const runner = buildTree(world, {
|
||||
* kind: "repeat",
|
||||
* child: {
|
||||
* kind: "sequential",
|
||||
* children: [
|
||||
* { kind: "leaf", run: () => { doWork(); } },
|
||||
* { kind: "leaf", *run() { yield 1000; doLater(); } },
|
||||
* ],
|
||||
* },
|
||||
* });
|
||||
* runner.schedule(runner.root);
|
||||
* setInterval(() => runner.tick(16), 16);
|
||||
* ```
|
||||
*/
|
||||
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||
const rootDef = isEntityDef(def) ? def : normalizeLegacyTree(def);
|
||||
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: EntityDef, parent?: Entity): Entity {
|
||||
const entity = world.spawn();
|
||||
world.add(entity, def.component, def.value);
|
||||
|
||||
if (parent !== undefined) {
|
||||
world.relate(entity, ChildOf, parent);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
leafHandlers.set(entity, run);
|
||||
}
|
||||
}
|
||||
|
||||
if (def.kind === "leaf") {
|
||||
world.add(entity, Task, { kind: "leaf" });
|
||||
leafHandlers.set(entity, def.run);
|
||||
} else if (def.kind === "repeat") {
|
||||
world.add(entity, Task, { kind: "repeat" });
|
||||
build(def.child, entity);
|
||||
} else {
|
||||
world.add(entity, Task, { kind: def.kind });
|
||||
for (const child of def.children) {
|
||||
build(child, entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
world.relate(entity, ChildOf, parent);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
const root = build(def);
|
||||
const root = build(rootDef);
|
||||
|
||||
if (!world.has(root, Task)) {
|
||||
throw new Error("buildTree root must be a task entity");
|
||||
}
|
||||
|
||||
const runner = new TaskRunner(world);
|
||||
runner.root = root;
|
||||
|
||||
runner.onLeaf = (_w, entity, dt) => {
|
||||
const handler = leafHandlers.get(entity);
|
||||
|
|
@ -143,8 +250,5 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
|||
generators.delete(entity);
|
||||
};
|
||||
|
||||
// Stash the root entity on the runner for convenience
|
||||
(runner as any).root = root;
|
||||
|
||||
return runner;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ComponentDef } from "./component";
|
||||
import type { Entity } from "./entity";
|
||||
import type { RelationshipDef } from "./relationship";
|
||||
import type { World } from "./world";
|
||||
|
||||
/**
|
||||
* Declarative definition for one entity with one primary component.
|
||||
*
|
||||
* Children are materialized as entities and related to their parent by the
|
||||
* relationship passed to `buildEntityTree`.
|
||||
*/
|
||||
export interface EntityDef<T extends Record<string, any> = any, M = unknown> {
|
||||
readonly kind: "entity";
|
||||
readonly component: ComponentDef<T>;
|
||||
readonly value?: Partial<T>;
|
||||
readonly children: readonly EntityDef[];
|
||||
readonly meta?: M;
|
||||
}
|
||||
|
||||
export type EntityDefChild = EntityDef | null | undefined | false;
|
||||
|
||||
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
|
||||
return children.filter(Boolean) as EntityDef[];
|
||||
}
|
||||
|
||||
/** Create a single-component entity definition. */
|
||||
export function entity<T extends Record<string, any>>(
|
||||
component: ComponentDef<T>,
|
||||
value?: Partial<T>,
|
||||
children?: readonly EntityDefChild[],
|
||||
): EntityDef<T>;
|
||||
export function entity<T extends Record<string, any>>(
|
||||
component: ComponentDef<T>,
|
||||
children?: readonly EntityDefChild[],
|
||||
): EntityDef<T>;
|
||||
export function entity<T extends Record<string, any>>(
|
||||
component: ComponentDef<T>,
|
||||
valueOrChildren?: Partial<T> | readonly EntityDefChild[],
|
||||
children: readonly EntityDefChild[] = [],
|
||||
): EntityDef<T> {
|
||||
const hasChildrenAsSecondArg = Array.isArray(valueOrChildren);
|
||||
|
||||
const value = hasChildrenAsSecondArg
|
||||
? undefined
|
||||
: (valueOrChildren as Partial<T> | undefined);
|
||||
const childDefs = hasChildrenAsSecondArg ? valueOrChildren : children;
|
||||
|
||||
return {
|
||||
kind: "entity",
|
||||
component,
|
||||
value,
|
||||
children: compactChildren(childDefs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize an `EntityDef` tree into ECS entities.
|
||||
*
|
||||
* The supplied relationship connects child entities to their parent, which lets
|
||||
* callers choose the semantic meaning of the tree edge.
|
||||
*/
|
||||
export function buildEntityTree(
|
||||
world: World,
|
||||
def: EntityDef,
|
||||
childRelationship: RelationshipDef,
|
||||
parent?: Entity,
|
||||
): Entity {
|
||||
const entity = world.spawn();
|
||||
world.add(entity, def.component, def.value);
|
||||
|
||||
if (parent !== undefined) {
|
||||
world.relate(entity, childRelationship, parent);
|
||||
}
|
||||
|
||||
for (const child of def.children) {
|
||||
buildEntityTree(world, child, childRelationship, entity);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ export { query } from "./query";
|
|||
export { Query } from "./query";
|
||||
export type { Entity } from "./entity";
|
||||
export { makeEntity, entityIndex, entityGeneration } from "./entity";
|
||||
export { entity, buildEntityTree } from "./entity-tree";
|
||||
export type { EntityDef, EntityDefChild } from "./entity-tree";
|
||||
export { SparseSet } from "./storage/sparse-set";
|
||||
export type {
|
||||
WorldEvent,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { World, type Entity } from "../src/index";
|
||||
import { World, defineComponent, entity, type Entity } from "../src/index";
|
||||
import {
|
||||
Task,
|
||||
Scheduled,
|
||||
|
|
@ -9,6 +9,10 @@ import {
|
|||
Cancelled,
|
||||
ChildOf,
|
||||
TaskRunner,
|
||||
buildTree,
|
||||
leaf,
|
||||
repeat,
|
||||
sequential,
|
||||
} from "../src/bt/index";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────
|
||||
|
|
@ -54,6 +58,60 @@ function makeSelector(world: World, parent?: Entity): Entity {
|
|||
return e;
|
||||
}
|
||||
|
||||
// ── Entity task factories ───────────────────────────
|
||||
describe("Entity task factories", () => {
|
||||
it("materializes non-task child entities and ignores them during execution", () => {
|
||||
const Label = defineComponent("testLabel", { value: "" });
|
||||
const world = new World();
|
||||
const calls: string[] = [];
|
||||
|
||||
const runner = buildTree(
|
||||
world,
|
||||
sequential([
|
||||
entity(Label, { value: "sequence metadata" }),
|
||||
leaf(() => calls.push("a")),
|
||||
entity(Label, { value: "between leaves" }),
|
||||
leaf(() => calls.push("b")),
|
||||
]),
|
||||
);
|
||||
|
||||
const root = runner.root!;
|
||||
const children = [...world.getRelatedTo(root, ChildOf)];
|
||||
const labels = children
|
||||
.filter((child) => world.has(child, Label))
|
||||
.map((child) => world.get(child, Label).value);
|
||||
|
||||
expect(labels).toEqual(["sequence metadata", "between leaves"]);
|
||||
|
||||
runner.schedule(root);
|
||||
for (let i = 0; i < 5; i++) runner.tick();
|
||||
|
||||
expect(calls).toEqual(["a", "b"]);
|
||||
expect(world.has(root, Succeeded)).toBe(true);
|
||||
});
|
||||
|
||||
it("repeat works with component child entities beside its task child", () => {
|
||||
const Label = defineComponent("repeatLabel", { value: "" });
|
||||
const world = new World();
|
||||
let calls = 0;
|
||||
|
||||
const runner = buildTree(
|
||||
world,
|
||||
repeat([
|
||||
entity(Label, { value: "repeat metadata" }),
|
||||
leaf(() => {
|
||||
calls++;
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
runner.schedule(runner.root!);
|
||||
for (let i = 0; i < 5; i++) runner.tick();
|
||||
|
||||
expect(calls).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Leaf tasks ──────────────────────────────────────
|
||||
describe("Leaf tasks", () => {
|
||||
let world: World;
|
||||
|
|
|
|||
Loading…
Reference in New Issue