From ddde3f75979168d50ea01789dfea6eafe3639300 Mon Sep 17 00:00:00 2001 From: hyper Date: Sun, 28 Jun 2026 10:15:38 +0800 Subject: [PATCH] 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`. --- src/bt/index.ts | 19 ++++- src/bt/runner.ts | 9 ++- src/bt/tree-def.ts | 188 +++++++++++++++++++++++++++++++++++---------- src/entity-tree.ts | 80 +++++++++++++++++++ src/index.ts | 2 + test/bt.test.ts | 60 ++++++++++++++- 6 files changed, 312 insertions(+), 46 deletions(-) create mode 100644 src/entity-tree.ts diff --git a/src/bt/index.ts b/src/bt/index.ts index 759d36f..5402133 100644 --- a/src/bt/index.ts +++ b/src/bt/index.ts @@ -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"; diff --git a/src/bt/runner.ts b/src/bt/runner.ts index 4735574..a1883a2 100644 --- a/src/bt/runner.ts +++ b/src/bt/runner.ts @@ -53,7 +53,11 @@ function clearSubtree(world: World, entity: Entity): void { } function* childrenOf(world: World, parent: Entity): IterableIterator { - 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 = () => {}; diff --git a/src/bt/tree-def.ts b/src/bt/tree-def.ts index 7f3ade4..f12e8b6 100644 --- a/src/bt/tree-def.ts +++ b/src/bt/tree-def.ts @@ -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); -/** 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(); // Track generator iterators for multi-frame leaves const generators = new Map>(); - 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 (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 !== 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 (parent) { - world.relate(entity, ChildOf, parent); + for (const child of def.children) { + build(child, entity); } 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; } diff --git a/src/entity-tree.ts b/src/entity-tree.ts new file mode 100644 index 0000000..8f12574 --- /dev/null +++ b/src/entity-tree.ts @@ -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 = any, M = unknown> { + readonly kind: "entity"; + readonly component: ComponentDef; + readonly value?: Partial; + 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>( + component: ComponentDef, + value?: Partial, + children?: readonly EntityDefChild[], +): EntityDef; +export function entity>( + component: ComponentDef, + children?: readonly EntityDefChild[], +): EntityDef; +export function entity>( + component: ComponentDef, + valueOrChildren?: Partial | readonly EntityDefChild[], + children: readonly EntityDefChild[] = [], +): EntityDef { + const hasChildrenAsSecondArg = Array.isArray(valueOrChildren); + + const value = hasChildrenAsSecondArg + ? undefined + : (valueOrChildren as Partial | 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; +} diff --git a/src/index.ts b/src/index.ts index 9bb2d6a..8ca98a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/test/bt.test.ts b/test/bt.test.ts index bbf3d8b..1b27642 100644 --- a/test/bt.test.ts +++ b/test/bt.test.ts @@ -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;