Compare commits
No commits in common. "ef9abf03c63a3bd743e8cdad92b7a828a4f4de86" and "efa92be5ab15b5899e72ee9817f0ef1dee5a827b" have entirely different histories.
ef9abf03c6
...
efa92be5ab
152
USAGE.md
152
USAGE.md
|
|
@ -10,15 +10,12 @@ Entity-Component-System with an observable-style API for TypeScript. Built for g
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [World](#world)
|
- [World](#world)
|
||||||
- [Components](#components)
|
- [Components](#components)
|
||||||
- [Singleton Components](#singleton-components)
|
|
||||||
- [Queries](#queries)
|
- [Queries](#queries)
|
||||||
- [Observable Queries](#observable-queries)
|
- [Observable Queries](#observable-queries)
|
||||||
- [Change Tracking](#change-tracking)
|
- [Change Tracking](#change-tracking)
|
||||||
- [Relationships](#relationships)
|
- [Relationships](#relationships)
|
||||||
- [Events](#events)
|
- [Events](#events)
|
||||||
- [Serialization](#serialization)
|
- [Serialization](#serialization)
|
||||||
- [Commands](#commands)
|
|
||||||
- [Behaviour Trees](#behaviour-trees)
|
|
||||||
- [TypeScript Inference](#typescript-inference)
|
- [TypeScript Inference](#typescript-inference)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -135,33 +132,6 @@ Get returns a **live mutable reference** — no defensive copies. Mutations are
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Singleton Components
|
|
||||||
|
|
||||||
For global state (score, board, config) that doesn't need per-entity tracking. A single internal entity is created lazily and reused for all singleton components.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const Score = defineComponent("score", { points: 0, level: 1 });
|
|
||||||
|
|
||||||
// Add (auto-creates the backing entity on first call)
|
|
||||||
world.addSingleton(Score);
|
|
||||||
|
|
||||||
// Get / set / check — no entity argument needed
|
|
||||||
world.getSingleton(Score).points += 100;
|
|
||||||
world.hasSingleton(Score); // true
|
|
||||||
world.setSingleton(Score, { points: 0, level: 2 });
|
|
||||||
|
|
||||||
// Try-get
|
|
||||||
const s = world.tryGetSingleton(Score); // Score | undefined
|
|
||||||
|
|
||||||
// Mark dirty for change tracking
|
|
||||||
world.markDirtySingleton(Score);
|
|
||||||
|
|
||||||
// Remove (destroys the backing entity if it becomes bare)
|
|
||||||
world.removeSingleton(Score);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
Create filters with `query()`. Chain `.without()` to exclude components.
|
Create filters with `query()`. Chain `.without()` to exclude components.
|
||||||
|
|
@ -354,128 +324,6 @@ world2.get(e2, Health); // { current: 75, max: 100 }
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
Decouple input from game logic. Define command components, register handlers, spawn command entities from input — the `CommandQueue` drains and dispatches them each frame.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { CommandQueue } from "ecs-observable/commands";
|
|
||||||
|
|
||||||
const MoveLeft = defineComponent("moveLeft", {});
|
|
||||||
const MoveRight = defineComponent("moveRight", {});
|
|
||||||
|
|
||||||
const queue = new CommandQueue(world);
|
|
||||||
|
|
||||||
// Register handlers
|
|
||||||
queue.handle(MoveLeft, () => {
|
|
||||||
player.x -= 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.handle(MoveRight, () => {
|
|
||||||
player.x += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Input → spawn command entities
|
|
||||||
onKey("ArrowLeft", () => {
|
|
||||||
const cmd = world.spawn();
|
|
||||||
world.add(cmd, MoveLeft);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Each frame — drain and dispatch
|
|
||||||
queue.execute();
|
|
||||||
```
|
|
||||||
|
|
||||||
Command entities are automatically destroyed after processing if they become bare.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Behaviour Trees
|
|
||||||
|
|
||||||
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 declarative definition and materializes it into entities, returning a fully-wired `TaskRunner`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildTree, Cancel } from "ecs-observable/bt";
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Leaf patterns
|
|
||||||
|
|
||||||
**One-shot** — just return. Implicit success.
|
|
||||||
```ts
|
|
||||||
{ kind: "leaf", run: () => { doWork(); } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fail** — throw any error.
|
|
||||||
```ts
|
|
||||||
{ kind: "leaf", run: () => { throw new Error("bad"); } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cancel** — throw the `Cancel` symbol.
|
|
||||||
```ts
|
|
||||||
{ 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.
|
|
||||||
```ts
|
|
||||||
{ kind: "leaf", *run() {
|
|
||||||
while (true) {
|
|
||||||
const dt: number = yield; // delta time from runner.tick(dt)
|
|
||||||
timer.accumulator += dt;
|
|
||||||
if (timer.accumulator >= timer.interval) {
|
|
||||||
// ... act ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Composite nodes
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{ kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed
|
|
||||||
{ kind: "selector", children: [a, b, c] } // left-to-right, first success wins
|
|
||||||
{ kind: "parallel", children: [a, b, c] } // all at once, all must succeed
|
|
||||||
{ kind: "random", children: [a, b, c] } // pick one child each activation
|
|
||||||
{ kind: "repeat", child: a } // decorator — re-run child forever
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Full example
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const runner = buildTree(world, {
|
|
||||||
kind: "parallel",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
kind: "leaf",
|
|
||||||
*run() {
|
|
||||||
while (true) {
|
|
||||||
const dt: number = yield;
|
|
||||||
updatePhysics(dt);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "repeat",
|
|
||||||
child: {
|
|
||||||
kind: "sequential",
|
|
||||||
children: [
|
|
||||||
{ kind: "leaf", run: () => { handleInput(); } },
|
|
||||||
{ kind: "leaf", run: () => { render(); } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kick off
|
|
||||||
runner.schedule((runner as any).root);
|
|
||||||
|
|
||||||
// Game loop
|
|
||||||
setInterval(() => runner.tick(16), 16);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeScript Inference
|
## TypeScript Inference
|
||||||
|
|
||||||
Components infer their type from the defaults object — no separate type declaration needed.
|
Components infer their type from the defaults object — no separate type declaration needed.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// ── Tetris: BT-driven game loop with command-based input ──
|
// ── Tetris: BT-driven game loop with command-based input ──
|
||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (TaskRunner) — controls game flow:
|
||||||
// root (repeat)
|
// root (sequential, repeat)
|
||||||
// └── seq (sequential)
|
|
||||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
// ├── gravityTick (leaf) — auto-drop piece on timer
|
||||||
// └── render (leaf) — draws via blessed
|
// └── render (leaf) — draws via blessed
|
||||||
|
|
@ -12,14 +11,11 @@
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
// → handlers mutate game state
|
// → handlers mutate game state
|
||||||
//
|
//
|
||||||
// Singleton components — global state accessed via world.*Singleton():
|
|
||||||
// Board, Piece, Score, GameOver, Paused, TickTimer
|
|
||||||
//
|
|
||||||
// Usage:
|
// Usage:
|
||||||
// npx tsx examples/tetris/main.ts
|
// npx tsx examples/tetris/main.ts
|
||||||
|
|
||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import { buildTree } from "../../src/bt/index";
|
import { TaskRunner, Task, ChildOf } from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
|
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
|
||||||
|
|
@ -50,10 +46,11 @@ import { startInput, type Key } from "./input";
|
||||||
// ── Setup ────────────────────────────────────────────
|
// ── Setup ────────────────────────────────────────────
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
||||||
// Singleton game state — one entity holds all of these
|
// Create the singleton game entity
|
||||||
world.addSingleton(Board);
|
const game = world.spawn();
|
||||||
world.addSingleton(Score);
|
world.add(game, Board);
|
||||||
world.addSingleton(TickTimer);
|
world.add(game, Score);
|
||||||
|
world.add(game, TickTimer);
|
||||||
|
|
||||||
// Create blessed UI
|
// Create blessed UI
|
||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
|
|
@ -66,7 +63,7 @@ const commands = new CommandQueue(world);
|
||||||
|
|
||||||
function spawnPiece(): void {
|
function spawnPiece(): void {
|
||||||
const p = randomPiece();
|
const p = randomPiece();
|
||||||
world.addSingleton(Piece, {
|
world.add(game, Piece, {
|
||||||
shape: p.shape,
|
shape: p.shape,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
||||||
|
|
@ -75,19 +72,15 @@ function spawnPiece(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasActivePiece(): boolean {
|
function hasActivePiece(): boolean {
|
||||||
return world.hasSingleton(Piece);
|
return world.has(game, Piece);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move left
|
// Move left
|
||||||
commands.handle(MoveLeft, () => {
|
commands.handle(MoveLeft, () => {
|
||||||
if (
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||||
!hasActivePiece() ||
|
|
||||||
world.hasSingleton(GameOver) ||
|
|
||||||
world.hasSingleton(Paused)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
||||||
piece.x--;
|
piece.x--;
|
||||||
}
|
}
|
||||||
|
|
@ -95,14 +88,10 @@ commands.handle(MoveLeft, () => {
|
||||||
|
|
||||||
// Move right
|
// Move right
|
||||||
commands.handle(MoveRight, () => {
|
commands.handle(MoveRight, () => {
|
||||||
if (
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||||
!hasActivePiece() ||
|
|
||||||
world.hasSingleton(GameOver) ||
|
|
||||||
world.hasSingleton(Paused)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
||||||
piece.x++;
|
piece.x++;
|
||||||
}
|
}
|
||||||
|
|
@ -110,14 +99,10 @@ commands.handle(MoveRight, () => {
|
||||||
|
|
||||||
// Rotate
|
// Rotate
|
||||||
commands.handle(Rotate, () => {
|
commands.handle(Rotate, () => {
|
||||||
if (
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||||
!hasActivePiece() ||
|
|
||||||
world.hasSingleton(GameOver) ||
|
|
||||||
world.hasSingleton(Paused)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
||||||
if (result) {
|
if (result) {
|
||||||
piece.shape = result.shape;
|
piece.shape = result.shape;
|
||||||
|
|
@ -127,14 +112,10 @@ commands.handle(Rotate, () => {
|
||||||
|
|
||||||
// Soft drop
|
// Soft drop
|
||||||
commands.handle(SoftDrop, () => {
|
commands.handle(SoftDrop, () => {
|
||||||
if (
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||||
!hasActivePiece() ||
|
|
||||||
world.hasSingleton(GameOver) ||
|
|
||||||
world.hasSingleton(Paused)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
}
|
}
|
||||||
|
|
@ -142,14 +123,10 @@ commands.handle(SoftDrop, () => {
|
||||||
|
|
||||||
// Hard drop
|
// Hard drop
|
||||||
commands.handle(HardDrop, () => {
|
commands.handle(HardDrop, () => {
|
||||||
if (
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
||||||
!hasActivePiece() ||
|
|
||||||
world.hasSingleton(GameOver) ||
|
|
||||||
world.hasSingleton(Paused)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
}
|
}
|
||||||
|
|
@ -158,82 +135,101 @@ commands.handle(HardDrop, () => {
|
||||||
|
|
||||||
// Toggle pause
|
// Toggle pause
|
||||||
commands.handle(TogglePause, () => {
|
commands.handle(TogglePause, () => {
|
||||||
if (world.hasSingleton(GameOver)) return;
|
if (world.has(game, GameOver)) return;
|
||||||
if (world.hasSingleton(Paused)) {
|
if (world.has(game, Paused)) {
|
||||||
world.removeSingleton(Paused);
|
world.remove(game, Paused);
|
||||||
} else {
|
} else {
|
||||||
world.addSingleton(Paused);
|
world.add(game, Paused);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restart
|
// Restart
|
||||||
commands.handle(Restart, () => {
|
commands.handle(Restart, () => {
|
||||||
if (!world.hasSingleton(GameOver)) return;
|
if (!world.has(game, GameOver)) return;
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
for (let r = 0; r < board.grid.length; r++) {
|
for (let r = 0; r < board.grid.length; r++) {
|
||||||
board.grid[r].fill(0);
|
board.grid[r].fill(0);
|
||||||
}
|
}
|
||||||
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
|
world.set(game, Score, { points: 0, lines: 0, level: 1 });
|
||||||
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
|
world.set(game, TickTimer, { accumulator: 0, interval: 800 });
|
||||||
world.removeSingleton(GameOver);
|
world.remove(game, GameOver);
|
||||||
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
|
if (world.has(game, Piece)) world.remove(game, Piece);
|
||||||
spawnPiece();
|
spawnPiece();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Lock piece & spawn next ──────────────────────────
|
// ── Lock piece & spawn next ──────────────────────────
|
||||||
function lockAndSpawn(): void {
|
function lockAndSpawn(): void {
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
|
|
||||||
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
||||||
world.removeSingleton(Piece);
|
world.remove(game, Piece);
|
||||||
|
|
||||||
const cleared = clearLines(board.grid);
|
const cleared = clearLines(board.grid);
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
const score = world.getSingleton(Score);
|
const score = world.get(game, Score);
|
||||||
score.lines += cleared;
|
score.lines += cleared;
|
||||||
score.points += scoreForLines(cleared, score.level);
|
score.points += scoreForLines(cleared, score.level);
|
||||||
score.level = Math.floor(score.lines / 10) + 1;
|
score.level = Math.floor(score.lines / 10) + 1;
|
||||||
const timer = world.getSingleton(TickTimer);
|
const timer = world.get(game, TickTimer);
|
||||||
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnPiece();
|
spawnPiece();
|
||||||
|
|
||||||
const newPiece = world.getSingleton(Piece);
|
const newPiece = world.get(game, Piece);
|
||||||
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
||||||
world.removeSingleton(Piece);
|
world.remove(game, Piece);
|
||||||
world.addSingleton(GameOver);
|
world.add(game, GameOver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Behaviour Tree ───────────────────────────────────
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
const runner = buildTree(world, {
|
const runner = new TaskRunner(world);
|
||||||
kind: "repeat",
|
|
||||||
child: {
|
// Build the BT structure:
|
||||||
kind: "sequential",
|
// root (repeat)
|
||||||
children: [
|
// └── seq (sequential)
|
||||||
{
|
// ├── handleInput (leaf)
|
||||||
kind: "leaf",
|
// ├── gravityTick (leaf)
|
||||||
run: () => {
|
// └── render (leaf)
|
||||||
|
|
||||||
|
const root = world.spawn();
|
||||||
|
world.add(root, Task, { kind: "repeat" });
|
||||||
|
|
||||||
|
const seq = world.spawn();
|
||||||
|
world.add(seq, Task, { kind: "sequential" });
|
||||||
|
world.relate(seq, ChildOf, root);
|
||||||
|
|
||||||
|
const handleInputTask = world.spawn();
|
||||||
|
world.add(handleInputTask, Task, { kind: "leaf" });
|
||||||
|
world.relate(handleInputTask, ChildOf, seq);
|
||||||
|
|
||||||
|
const gravityTask = world.spawn();
|
||||||
|
world.add(gravityTask, Task, { kind: "leaf" });
|
||||||
|
world.relate(gravityTask, ChildOf, seq);
|
||||||
|
|
||||||
|
const renderTask = world.spawn();
|
||||||
|
world.add(renderTask, Task, { kind: "leaf" });
|
||||||
|
world.relate(renderTask, ChildOf, seq);
|
||||||
|
|
||||||
|
// ── Leaf handlers ────────────────────────────────────
|
||||||
|
runner.onLeaf = (_w, entity) => {
|
||||||
|
if (entity === handleInputTask) {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
},
|
runner.succeed(entity);
|
||||||
},
|
} else if (entity === gravityTask) {
|
||||||
{
|
if (world.has(game, GameOver) || world.has(game, Paused)) {
|
||||||
kind: "leaf",
|
runner.succeed(entity);
|
||||||
*run() {
|
return;
|
||||||
while (true) {
|
|
||||||
const dt: number = yield;
|
|
||||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const timer = world.getSingleton(TickTimer);
|
const timer = world.get(game, TickTimer);
|
||||||
timer.accumulator += dt;
|
timer.accumulator += 16;
|
||||||
if (timer.accumulator >= timer.interval) {
|
if (timer.accumulator >= timer.interval) {
|
||||||
timer.accumulator -= timer.interval;
|
timer.accumulator -= timer.interval;
|
||||||
if (hasActivePiece()) {
|
if (hasActivePiece()) {
|
||||||
const piece = world.getSingleton(Piece);
|
const piece = world.get(game, Piece);
|
||||||
const board = world.getSingleton(Board);
|
const board = world.get(game, Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -241,18 +237,12 @@ const runner = buildTree(world, {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
runner.succeed(entity);
|
||||||
|
} else if (entity === renderTask) {
|
||||||
|
render(world, game, ui);
|
||||||
|
runner.succeed(entity);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
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>> = {
|
||||||
|
|
@ -274,11 +264,11 @@ startInput(ui.screen, (key) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Game loop ────────────────────────────────────────
|
// ── Game loop ────────────────────────────────────────
|
||||||
runner.schedule((runner as any).root);
|
runner.schedule(root);
|
||||||
|
|
||||||
const TICK_MS = 16;
|
const TICK_MS = 16;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
runner.tick(TICK_MS);
|
runner.tick();
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
|
|
||||||
// Cleanup on exit
|
// Cleanup on exit
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ── Terminal rendering via blessed ────────────────────
|
// ── Terminal rendering via blessed ────────────────────
|
||||||
import blessed from "blessed";
|
import blessed from "blessed";
|
||||||
import type { World } from "../../src/index";
|
import type { World, Entity } from "../../src/index";
|
||||||
import { Board, Piece, Score, GameOver, Paused } from "./components";
|
import { Board, Piece, Score, GameOver, Paused } from "./components";
|
||||||
import { BOARD_W, BOARD_H, ghostY } from "./game";
|
import { BOARD_W, BOARD_H, ghostY } from "./game";
|
||||||
|
|
||||||
|
|
@ -91,12 +91,16 @@ export function createUI(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the full game state into the blessed UI. */
|
/** Render the full game state into the blessed UI. */
|
||||||
export function render(world: World, ui: ReturnType<typeof createUI>): void {
|
export function render(
|
||||||
const board = world.getSingleton(Board);
|
world: World,
|
||||||
const piece = world.tryGetSingleton(Piece);
|
gameEntity: Entity,
|
||||||
const score = world.tryGetSingleton(Score);
|
ui: ReturnType<typeof createUI>,
|
||||||
const isOver = world.hasSingleton(GameOver);
|
): void {
|
||||||
const isPaused = world.hasSingleton(Paused);
|
const board = world.get(gameEntity, Board);
|
||||||
|
const piece = world.tryGet(gameEntity, Piece);
|
||||||
|
const score = world.tryGet(gameEntity, Score);
|
||||||
|
const isOver = world.has(gameEntity, GameOver);
|
||||||
|
const isPaused = world.has(gameEntity, Paused);
|
||||||
|
|
||||||
// Build display grid
|
// Build display grid
|
||||||
const display = board.grid.map((row) => [...row]);
|
const display = board.grid.map((row) => [...row]);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,3 @@ 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 { buildTree, Cancel } from "./tree-def";
|
|
||||||
export type { TreeDef, LeafFn } from "./tree-def";
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────
|
||||||
/** Callback invoked for each leaf task that becomes Scheduled. */
|
/** Callback invoked for each leaf task that becomes Scheduled. */
|
||||||
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
export type LeafHandler = (world: World, entity: Entity) => void;
|
||||||
|
|
||||||
/** Callback invoked when a task reaches a terminal status. */
|
/** Callback invoked when a task reaches a terminal status. */
|
||||||
export type TerminalHandler = (
|
export type TerminalHandler = (
|
||||||
|
|
@ -103,14 +103,12 @@ export class TaskRunner {
|
||||||
* Process all Scheduled tasks.
|
* Process all Scheduled tasks.
|
||||||
*
|
*
|
||||||
* Call once per frame. Only entities with `Scheduled` are touched.
|
* Call once per frame. Only entities with `Scheduled` are touched.
|
||||||
*
|
|
||||||
* @param dt Delta time in milliseconds since last tick.
|
|
||||||
*/
|
*/
|
||||||
tick(dt: number = 0): void {
|
tick(): void {
|
||||||
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
||||||
for (const entity of scheduled) {
|
for (const entity of scheduled) {
|
||||||
this._world.remove(entity, Scheduled);
|
this._world.remove(entity, Scheduled);
|
||||||
this._execute(entity, dt);
|
this._execute(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,12 +142,12 @@ export class TaskRunner {
|
||||||
|
|
||||||
// ── Internal execution ────────────────────────────
|
// ── Internal execution ────────────────────────────
|
||||||
|
|
||||||
private _execute(entity: Entity, dt: number): void {
|
private _execute(entity: Entity): void {
|
||||||
const t = this._world.get(entity, Task);
|
const t = this._world.get(entity, Task);
|
||||||
|
|
||||||
switch (t.kind) {
|
switch (t.kind) {
|
||||||
case "leaf":
|
case "leaf":
|
||||||
this._executeLeaf(entity, dt);
|
this._executeLeaf(entity);
|
||||||
break;
|
break;
|
||||||
case "sequential":
|
case "sequential":
|
||||||
this._executeSequential(entity);
|
this._executeSequential(entity);
|
||||||
|
|
@ -169,9 +167,9 @@ export class TaskRunner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeLeaf(entity: Entity, dt: number): void {
|
private _executeLeaf(entity: Entity): void {
|
||||||
this._world.add(entity, Running);
|
this._world.add(entity, Running);
|
||||||
this.onLeaf(this._world, entity, dt);
|
this.onLeaf(this._world, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeSequential(entity: Entity): void {
|
private _executeSequential(entity: Entity): void {
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import type { World, Entity } from "../index";
|
|
||||||
import { Task, ChildOf } from "./task";
|
|
||||||
import { TaskRunner } from "./runner";
|
|
||||||
|
|
||||||
// ── Cancel ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* { kind: "leaf", run: () => { throw Cancel; } }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
|
||||||
|
|
||||||
// ── Tree definition ───────────────────────────────────
|
|
||||||
|
|
||||||
/** A leaf function — plain or generator. */
|
|
||||||
export type LeafFn =
|
|
||||||
| ((world: World, dt: number) => void)
|
|
||||||
| (() => Generator<number | void, void, number>);
|
|
||||||
|
|
||||||
/** Declarative behaviour-tree definition. */
|
|
||||||
export type TreeDef =
|
|
||||||
| { kind: "leaf"; run: LeafFn }
|
|
||||||
| { kind: "sequential"; children: TreeDef[] }
|
|
||||||
| { kind: "parallel"; children: TreeDef[] }
|
|
||||||
| { kind: "selector"; children: TreeDef[] }
|
|
||||||
| { kind: "random"; children: TreeDef[] }
|
|
||||||
| { kind: "repeat"; child: TreeDef };
|
|
||||||
|
|
||||||
// ── Builder ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively materialize a `TreeDef` into ECS entities and return a
|
|
||||||
* fully-wired `TaskRunner`.
|
|
||||||
*
|
|
||||||
* 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 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 {
|
|
||||||
const entity = world.spawn();
|
|
||||||
|
|
||||||
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 runner = new TaskRunner(world);
|
|
||||||
|
|
||||||
runner.onLeaf = (_w, entity, dt) => {
|
|
||||||
const handler = leafHandlers.get(entity);
|
|
||||||
if (!handler) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this leaf has an active generator
|
|
||||||
let gen = generators.get(entity);
|
|
||||||
|
|
||||||
if (gen) {
|
|
||||||
// Resume existing generator
|
|
||||||
const result = gen.next(dt);
|
|
||||||
if (result.done) {
|
|
||||||
generators.delete(entity);
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
// If not done, leaf stays Running — nothing to do
|
|
||||||
} else {
|
|
||||||
// First invocation — call the handler
|
|
||||||
const ret = handler(_w, dt);
|
|
||||||
|
|
||||||
// Check if it returned a generator
|
|
||||||
if (ret != null && typeof (ret as any).next === "function") {
|
|
||||||
const gen = ret as Generator<number | void, void, number>;
|
|
||||||
generators.set(entity, gen);
|
|
||||||
const result = gen.next(dt);
|
|
||||||
if (result.done) {
|
|
||||||
generators.delete(entity);
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
// Not done → leaf stays Running
|
|
||||||
} else {
|
|
||||||
// Plain function — returned undefined → success
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Clean up generator if one was active
|
|
||||||
generators.delete(entity);
|
|
||||||
|
|
||||||
if (err === Cancel) {
|
|
||||||
runner.cancel(entity);
|
|
||||||
} else {
|
|
||||||
runner.fail(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
runner.onTerminal = (_w, entity) => {
|
|
||||||
// Clean up generator when a leaf reaches terminal by external means
|
|
||||||
generators.delete(entity);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stash the root entity on the runner for convenience
|
|
||||||
(runner as any).root = root;
|
|
||||||
|
|
||||||
return runner;
|
|
||||||
}
|
|
||||||
66
src/world.ts
66
src/world.ts
|
|
@ -42,9 +42,6 @@ export class World {
|
||||||
// ── Observable layer ──────────────────────────────
|
// ── Observable layer ──────────────────────────────
|
||||||
private _observable = new ObservableLayer();
|
private _observable = new ObservableLayer();
|
||||||
|
|
||||||
// ── Singleton entity ──────────────────────────────
|
|
||||||
private _singletonEntity: Entity | null = null;
|
|
||||||
|
|
||||||
/** Global event stream. */
|
/** Global event stream. */
|
||||||
get events$(): Observable<WorldEvent> {
|
get events$(): Observable<WorldEvent> {
|
||||||
return this._observable.events$.asObservable();
|
return this._observable.events$.asObservable();
|
||||||
|
|
@ -253,69 +250,6 @@ export class World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Singleton component access ────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a singleton component to the world.
|
|
||||||
*
|
|
||||||
* A single shared entity is created lazily and reused for all singleton
|
|
||||||
* components. Returns a mutable reference to the component data.
|
|
||||||
*/
|
|
||||||
addSingleton<T extends Record<string, any>>(
|
|
||||||
def: ComponentDef<T>,
|
|
||||||
init?: Partial<T>,
|
|
||||||
): T {
|
|
||||||
if (this._singletonEntity === null) {
|
|
||||||
this._singletonEntity = this.spawn();
|
|
||||||
}
|
|
||||||
return this.add(this._singletonEntity, def, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a singleton component. Destroys the backing entity if it becomes bare. */
|
|
||||||
removeSingleton(def: ComponentDef<any>): void {
|
|
||||||
const e = this._singletonEntity;
|
|
||||||
if (e === null) return;
|
|
||||||
this.remove(e, def);
|
|
||||||
if (!this.hasAnyComponent(e)) {
|
|
||||||
this.destroy(e);
|
|
||||||
this._singletonEntity = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a mutable reference to a singleton component. Throws if missing. */
|
|
||||||
getSingleton<T extends Record<string, any>>(def: ComponentDef<T>): T {
|
|
||||||
return this.get(this._singletonEntity!, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Try-get a singleton component. Returns undefined if missing. */
|
|
||||||
tryGetSingleton<T extends Record<string, any>>(
|
|
||||||
def: ComponentDef<T>,
|
|
||||||
): T | undefined {
|
|
||||||
const e = this._singletonEntity;
|
|
||||||
if (e === null) return undefined;
|
|
||||||
return this.tryGet(e, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether a singleton component is present. */
|
|
||||||
hasSingleton(def: ComponentDef<any>): boolean {
|
|
||||||
const e = this._singletonEntity;
|
|
||||||
if (e === null) return false;
|
|
||||||
return this.has(e, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bulk-replace a singleton component's data. Marks dirty. */
|
|
||||||
setSingleton<T extends Record<string, any>>(
|
|
||||||
def: ComponentDef<T>,
|
|
||||||
value: T,
|
|
||||||
): void {
|
|
||||||
this.set(this._singletonEntity!, def, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a singleton component as dirty for change tracking. */
|
|
||||||
markDirtySingleton(def: ComponentDef<any>): void {
|
|
||||||
this.markDirty(this._singletonEntity!, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Relationships ─────────────────────────────────
|
// ── Relationships ─────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue