Compare commits
No commits in common. "1253bf82d27f96189bf068416744217afda4e918" and "968672da06146ed9957630eec905c4ba95a98136" have entirely different histories.
1253bf82d2
...
968672da06
92
USAGE.md
92
USAGE.md
|
|
@ -393,88 +393,82 @@ Command entities are automatically destroyed after processing if they become bar
|
||||||
|
|
||||||
Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data.
|
Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data.
|
||||||
|
|
||||||
`buildTree()` takes a task entity definition and materializes it into entities, returning a fully-wired `TaskRunner`. Task definitions are created with factories, and non-task child entities can be mixed in as metadata; the runner ignores those non-task children during execution.
|
`buildTree()` takes a declarative definition and materializes it into entities, returning a fully-wired `TaskRunner`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineComponent, entity } from "ecs-observable";
|
import { buildTree, Cancel } from "ecs-observable/bt";
|
||||||
import { buildTree, Cancel, leaf, parallel, repeat, sequential } from "ecs-observable/bt";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Leaf patterns
|
#### Leaf patterns
|
||||||
|
|
||||||
**One-shot** — just return. Implicit success.
|
**One-shot** — just return. Implicit success.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { doWork(); })
|
{ kind: "leaf", run: () => { doWork(); } }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fail** — throw any error.
|
**Fail** — throw any error.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { throw new Error("bad"); })
|
{ kind: "leaf", run: () => { throw new Error("bad"); } }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cancel** — throw the `Cancel` symbol.
|
**Cancel** — throw the `Cancel` symbol.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { throw Cancel; })
|
{ kind: "leaf", run: () => { throw Cancel; } }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success.
|
**Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success.
|
||||||
```ts
|
```ts
|
||||||
leaf(function* tickTimer() {
|
{ kind: "leaf", *run() {
|
||||||
while (true) {
|
while (true) {
|
||||||
const dt: number = yield; // delta time from runner.tick(dt)
|
const dt: number = yield; // delta time from runner.tick(dt)
|
||||||
timer.accumulator += dt;
|
timer.accumulator += dt;
|
||||||
if (timer.accumulator >= timer.interval) {
|
if (timer.accumulator >= timer.interval) {
|
||||||
// ... act ...
|
// ... act ...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} }
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Composite nodes
|
#### Composite nodes
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
sequential([a, b, c]) // left-to-right, all must succeed
|
{ kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed
|
||||||
selector([a, b, c]) // left-to-right, first success wins
|
{ kind: "selector", children: [a, b, c] } // left-to-right, first success wins
|
||||||
parallel([a, b, c]) // all at once, all must succeed
|
{ kind: "parallel", children: [a, b, c] } // all at once, all must succeed
|
||||||
random([a, b, c]) // pick one child each activation
|
{ kind: "random", children: [a, b, c] } // pick one child each activation
|
||||||
repeat(a) // decorator — re-run child forever
|
{ kind: "repeat", child: a } // decorator — re-run child forever
|
||||||
```
|
|
||||||
|
|
||||||
Non-task entity children are materialized but ignored by the runner:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const Label = defineComponent("label", { value: "" });
|
|
||||||
|
|
||||||
sequential([
|
|
||||||
entity(Label, { value: "main sequence" }),
|
|
||||||
leaf(handleInput),
|
|
||||||
leaf(render),
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Full example
|
#### Full example
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const runner = buildTree(
|
const runner = buildTree(world, {
|
||||||
world,
|
kind: "parallel",
|
||||||
parallel([
|
children: [
|
||||||
leaf(function* physicsLoop() {
|
{
|
||||||
while (true) {
|
kind: "leaf",
|
||||||
const dt: number = yield;
|
*run() {
|
||||||
updatePhysics(dt);
|
while (true) {
|
||||||
}
|
const dt: number = yield;
|
||||||
}),
|
updatePhysics(dt);
|
||||||
repeat(
|
}
|
||||||
sequential([
|
},
|
||||||
leaf(() => { handleInput(); }),
|
},
|
||||||
leaf(() => { render(); }),
|
{
|
||||||
]),
|
kind: "repeat",
|
||||||
),
|
child: {
|
||||||
]),
|
kind: "sequential",
|
||||||
);
|
children: [
|
||||||
|
{ kind: "leaf", run: () => { handleInput(); } },
|
||||||
|
{ kind: "leaf", run: () => { render(); } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Kick off
|
// Kick off
|
||||||
runner.schedule(runner.root!);
|
runner.schedule((runner as any).root);
|
||||||
|
|
||||||
// Game loop
|
// Game loop
|
||||||
setInterval(() => runner.tick(16), 16);
|
setInterval(() => runner.tick(16), 16);
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,7 @@
|
||||||
// npx tsx examples/blackjack/main.ts
|
// npx tsx examples/blackjack/main.ts
|
||||||
|
|
||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import {
|
import { buildTree } from "../../src/bt/index";
|
||||||
buildTree,
|
|
||||||
leaf,
|
|
||||||
parallel,
|
|
||||||
repeat,
|
|
||||||
sequential,
|
|
||||||
} from "../../src/bt/index";
|
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -69,37 +63,50 @@ const commands = new CommandQueue(world);
|
||||||
registerCommands(world, commands, cards);
|
registerCommands(world, commands, cards);
|
||||||
|
|
||||||
// ── Behaviour Tree ───────────────────────────────────
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
const runner = buildTree(
|
const runner = buildTree(world, {
|
||||||
world,
|
kind: "parallel",
|
||||||
parallel([
|
children: [
|
||||||
leaf(function* dealerPlay() {
|
{
|
||||||
while (true) {
|
kind: "leaf",
|
||||||
yield;
|
*run() {
|
||||||
const phase = world.getSingleton(GamePhase);
|
while (true) {
|
||||||
if (phase.phase !== "dealerTurn") continue;
|
const dt: number = yield;
|
||||||
|
const phase = world.getSingleton(GamePhase);
|
||||||
|
if (phase.phase !== "dealerTurn") continue;
|
||||||
|
|
||||||
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
||||||
const cardEntity = cards.drawCard();
|
const cardEntity = cards.drawCard();
|
||||||
if (cardEntity) {
|
if (cardEntity) {
|
||||||
cards.dealTo(cardEntity, InDealerHand);
|
cards.dealTo(cardEntity, InDealerHand);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolveRound(world, cards);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
resolveRound(world, cards);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}),
|
},
|
||||||
repeat(
|
{
|
||||||
sequential([
|
kind: "repeat",
|
||||||
leaf(() => {
|
child: {
|
||||||
commands.execute();
|
kind: "sequential",
|
||||||
}),
|
children: [
|
||||||
leaf(() => {
|
{
|
||||||
render(world, ui);
|
kind: "leaf",
|
||||||
}),
|
run: () => {
|
||||||
]),
|
commands.execute();
|
||||||
),
|
},
|
||||||
]),
|
},
|
||||||
);
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
render(world, ui);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// ── Input → Command mapping ──────────────────────────
|
// ── Input → Command mapping ──────────────────────────
|
||||||
const keyToCommand: Partial<Record<Key, typeof Hit>> = {
|
const keyToCommand: Partial<Record<Key, typeof Hit>> = {
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,7 @@
|
||||||
// npx tsx examples/tetris/main.ts
|
// npx tsx examples/tetris/main.ts
|
||||||
|
|
||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import {
|
import { buildTree } from "../../src/bt/index";
|
||||||
buildTree,
|
|
||||||
leaf,
|
|
||||||
parallel,
|
|
||||||
repeat,
|
|
||||||
sequential,
|
|
||||||
} from "../../src/bt/index";
|
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -70,43 +64,56 @@ registerCommands(world, commands, pieces);
|
||||||
pieces.spawnPiece();
|
pieces.spawnPiece();
|
||||||
|
|
||||||
// ── Behaviour Tree ───────────────────────────────────
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
const runner = buildTree(
|
const runner = buildTree(world, {
|
||||||
world,
|
kind: "parallel",
|
||||||
parallel([
|
children: [
|
||||||
leaf(function* gravityTick() {
|
{
|
||||||
while (true) {
|
kind: "leaf",
|
||||||
const dt: number = yield;
|
*run() {
|
||||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
while (true) {
|
||||||
continue;
|
const dt: number = yield;
|
||||||
}
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
const timer = world.getSingleton(TickTimer);
|
continue;
|
||||||
timer.accumulator += dt;
|
}
|
||||||
if (timer.accumulator >= timer.interval) {
|
const timer = world.getSingleton(TickTimer);
|
||||||
timer.accumulator -= timer.interval;
|
timer.accumulator += dt;
|
||||||
if (world.hasSingleton(Piece)) {
|
if (timer.accumulator >= timer.interval) {
|
||||||
const piece = world.getSingleton(Piece);
|
timer.accumulator -= timer.interval;
|
||||||
const board = world.getSingleton(Board);
|
if (world.hasSingleton(Piece)) {
|
||||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
const piece = world.getSingleton(Piece);
|
||||||
piece.y++;
|
const board = world.getSingleton(Board);
|
||||||
} else {
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
pieces.lockAndSpawn();
|
piece.y++;
|
||||||
|
} else {
|
||||||
|
pieces.lockAndSpawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}),
|
},
|
||||||
repeat(
|
{
|
||||||
sequential([
|
kind: "repeat",
|
||||||
leaf(() => {
|
child: {
|
||||||
commands.execute();
|
kind: "sequential",
|
||||||
}),
|
children: [
|
||||||
leaf(() => {
|
{
|
||||||
render(world, ui);
|
kind: "leaf",
|
||||||
}),
|
run: () => {
|
||||||
]),
|
commands.execute();
|
||||||
),
|
},
|
||||||
]),
|
},
|
||||||
);
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
render(world, ui);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// ── Input → Command mapping ──────────────────────────
|
// ── Input → Command mapping ──────────────────────────
|
||||||
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,5 @@ export type { TaskKind } from "./task";
|
||||||
export { TaskRunner } from "./runner";
|
export { TaskRunner } from "./runner";
|
||||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
export type { LeafHandler, TerminalHandler } from "./runner";
|
||||||
|
|
||||||
export {
|
export { buildTree, Cancel } from "./tree-def";
|
||||||
buildTree,
|
export type { TreeDef, LeafFn } from "./tree-def";
|
||||||
Cancel,
|
|
||||||
leaf,
|
|
||||||
sequential,
|
|
||||||
parallel,
|
|
||||||
selector,
|
|
||||||
random,
|
|
||||||
repeat,
|
|
||||||
} from "./tree-def";
|
|
||||||
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,7 @@ function clearSubtree(world: World, entity: Entity): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
|
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
|
||||||
for (const child of world.getRelatedTo(parent, ChildOf)) {
|
yield* world.getRelatedTo(parent, ChildOf);
|
||||||
if (world.has(child, Task)) {
|
|
||||||
yield child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentOf(world: World, child: Entity): Entity | null {
|
function parentOf(world: World, child: Entity): Entity | null {
|
||||||
|
|
@ -91,9 +87,6 @@ function parentOf(world: World, child: Entity): Entity | null {
|
||||||
export class TaskRunner {
|
export class TaskRunner {
|
||||||
private _world: World;
|
private _world: World;
|
||||||
|
|
||||||
/** Root task entity, set by `buildTree` for convenience. */
|
|
||||||
root?: Entity;
|
|
||||||
|
|
||||||
/** Called when a leaf task becomes Scheduled. */
|
/** Called when a leaf task becomes Scheduled. */
|
||||||
onLeaf: LeafHandler = () => {};
|
onLeaf: LeafHandler = () => {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { World, Entity } from "../index";
|
import type { World, Entity } from "../index";
|
||||||
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
|
||||||
import { Task, ChildOf } from "./task";
|
import { Task, ChildOf } from "./task";
|
||||||
import { TaskRunner } from "./runner";
|
import { TaskRunner } from "./runner";
|
||||||
|
|
||||||
|
|
@ -10,7 +9,7 @@ import { TaskRunner } from "./runner";
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* leaf(() => { throw Cancel; })
|
* { kind: "leaf", run: () => { throw Cancel; } }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
||||||
|
|
@ -22,135 +21,67 @@ export type LeafFn =
|
||||||
| ((world: World, dt: number) => void)
|
| ((world: World, dt: number) => void)
|
||||||
| (() => Generator<number | void, void, number>);
|
| (() => Generator<number | void, void, number>);
|
||||||
|
|
||||||
export interface LeafTaskMeta {
|
/** Declarative behaviour-tree definition. */
|
||||||
readonly run: LeafFn;
|
export type TreeDef =
|
||||||
}
|
| { kind: "leaf"; run: LeafFn }
|
||||||
|
| { kind: "sequential"; children: TreeDef[] }
|
||||||
export type TaskEntityDef = EntityDef<
|
| { kind: "parallel"; children: TreeDef[] }
|
||||||
typeof Task.type,
|
| { kind: "selector"; children: TreeDef[] }
|
||||||
LeafTaskMeta | undefined
|
| { kind: "random"; children: TreeDef[] }
|
||||||
>;
|
| { kind: "repeat"; child: TreeDef };
|
||||||
|
|
||||||
/** Behaviour-tree definition accepted by `buildTree`. */
|
|
||||||
export type TreeDef = 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 ───────────────────────────────────────────
|
// ── Builder ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Materialize a behaviour-tree definition into ECS entities and return a
|
* Recursively materialize a `TreeDef` into ECS entities and return a
|
||||||
* fully-wired `TaskRunner`.
|
* fully-wired `TaskRunner`.
|
||||||
*
|
*
|
||||||
* Definitions are `EntityDef` trees 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.
|
|
||||||
*
|
|
||||||
* Leaf `run` functions:
|
* Leaf `run` functions:
|
||||||
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail.
|
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail.
|
||||||
* `throw Cancel` = cancel.
|
* `throw Cancel` = cancel.
|
||||||
* - **Generator function** — each `yield` suspends until next tick. The value
|
* - **Generator function** — each `yield` suspends until next tick. The value
|
||||||
* yielded is the desired delay in ms (or `undefined` for next frame).
|
* yielded is the desired delay in ms (or `undefined` for next frame).
|
||||||
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
|
* 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 {
|
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||||
const leafHandlers = new Map<Entity, LeafFn>();
|
const leafHandlers = new Map<Entity, LeafFn>();
|
||||||
// Track generator iterators for multi-frame leaves
|
// Track generator iterators for multi-frame leaves
|
||||||
const generators = new Map<Entity, Generator<number | void, void, number>>();
|
const generators = new Map<Entity, Generator<number | void, void, number>>();
|
||||||
|
|
||||||
function build(def: EntityDef, parent?: Entity): Entity {
|
function build(def: TreeDef, parent?: Entity): Entity {
|
||||||
const entity = world.spawn();
|
const entity = world.spawn();
|
||||||
world.add(entity, def.component, def.value);
|
|
||||||
|
|
||||||
if (parent !== undefined) {
|
if (def.kind === "leaf") {
|
||||||
world.relate(entity, ChildOf, parent);
|
world.add(entity, Task, { kind: "leaf" });
|
||||||
}
|
leafHandlers.set(entity, def.run);
|
||||||
|
} else if (def.kind === "repeat") {
|
||||||
if (def.component === Task) {
|
world.add(entity, Task, { kind: "repeat" });
|
||||||
const taskData = world.get(entity, Task);
|
build(def.child, entity);
|
||||||
if (taskData.kind === "leaf") {
|
} else {
|
||||||
const run = (def.meta as LeafTaskMeta | undefined)?.run;
|
world.add(entity, Task, { kind: def.kind });
|
||||||
if (!run) {
|
for (const child of def.children) {
|
||||||
throw new Error("Leaf task entity is missing a run function");
|
build(child, entity);
|
||||||
}
|
|
||||||
leafHandlers.set(entity, run);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of def.children) {
|
if (parent) {
|
||||||
build(child, entity);
|
world.relate(entity, ChildOf, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
|
|
@ -158,12 +89,7 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||||
|
|
||||||
const root = build(def);
|
const root = build(def);
|
||||||
|
|
||||||
if (!world.has(root, Task)) {
|
|
||||||
throw new Error("buildTree root must be a task entity");
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = new TaskRunner(world);
|
const runner = new TaskRunner(world);
|
||||||
runner.root = root;
|
|
||||||
|
|
||||||
runner.onLeaf = (_w, entity, dt) => {
|
runner.onLeaf = (_w, entity, dt) => {
|
||||||
const handler = leafHandlers.get(entity);
|
const handler = leafHandlers.get(entity);
|
||||||
|
|
@ -217,5 +143,8 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||||
generators.delete(entity);
|
generators.delete(entity);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stash the root entity on the runner for convenience
|
||||||
|
(runner as any).root = root;
|
||||||
|
|
||||||
return runner;
|
return runner;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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,8 +8,6 @@ export { query } from "./query";
|
||||||
export { Query } from "./query";
|
export { Query } from "./query";
|
||||||
export type { Entity } from "./entity";
|
export type { Entity } from "./entity";
|
||||||
export { makeEntity, entityIndex, entityGeneration } 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 { SparseSet } from "./storage/sparse-set";
|
||||||
export type {
|
export type {
|
||||||
WorldEvent,
|
WorldEvent,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { World, defineComponent, entity, type Entity } from "../src/index";
|
import { World, type Entity } from "../src/index";
|
||||||
import {
|
import {
|
||||||
Task,
|
Task,
|
||||||
Scheduled,
|
Scheduled,
|
||||||
|
|
@ -9,10 +9,6 @@ import {
|
||||||
Cancelled,
|
Cancelled,
|
||||||
ChildOf,
|
ChildOf,
|
||||||
TaskRunner,
|
TaskRunner,
|
||||||
buildTree,
|
|
||||||
leaf,
|
|
||||||
repeat,
|
|
||||||
sequential,
|
|
||||||
} from "../src/bt/index";
|
} from "../src/bt/index";
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────
|
||||||
|
|
@ -58,60 +54,6 @@ function makeSelector(world: World, parent?: Entity): Entity {
|
||||||
return e;
|
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 ──────────────────────────────────────
|
// ── Leaf tasks ──────────────────────────────────────
|
||||||
describe("Leaf tasks", () => {
|
describe("Leaf tasks", () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue