272 lines
7.6 KiB
TypeScript
272 lines
7.6 KiB
TypeScript
// ── Tetris: BT-driven game loop with command-based input ──
|
|
//
|
|
// Architecture:
|
|
// Behaviour Tree (TaskRunner) — controls game flow:
|
|
// root (sequential, repeat)
|
|
// ├── handleInput (leaf) — reads queued commands, mutates state
|
|
// ├── gravityTick (leaf) — auto-drop piece on timer
|
|
// └── render (leaf) — draws via blessed
|
|
//
|
|
// CommandQueue — processes input:
|
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
|
// → handlers mutate game state
|
|
//
|
|
// Usage:
|
|
// npx tsx examples/tetris/main.ts
|
|
|
|
import { World } from "../../src/index";
|
|
import { TaskRunner, Task, ChildOf } from "../../src/bt/index";
|
|
import { CommandQueue } from "../../src/commands/index";
|
|
|
|
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
|
|
|
|
import {
|
|
MoveLeft,
|
|
MoveRight,
|
|
Rotate,
|
|
SoftDrop,
|
|
HardDrop,
|
|
TogglePause,
|
|
Restart,
|
|
} from "./commands";
|
|
|
|
import {
|
|
randomPiece,
|
|
collides,
|
|
lockPiece,
|
|
clearLines,
|
|
scoreForLines,
|
|
tryRotate,
|
|
BOARD_W,
|
|
} from "./game";
|
|
|
|
import { createUI, render } from "./render";
|
|
import { startInput, type Key } from "./input";
|
|
|
|
// ── Setup ────────────────────────────────────────────
|
|
const world = new World();
|
|
|
|
// Create the singleton game entity
|
|
const game = world.spawn();
|
|
world.add(game, Board);
|
|
world.add(game, Score);
|
|
world.add(game, TickTimer);
|
|
|
|
// Create blessed UI
|
|
const ui = createUI();
|
|
|
|
// ── Command handlers ─────────────────────────────────
|
|
const commands = new CommandQueue(world);
|
|
|
|
function spawnPiece(): void {
|
|
const p = randomPiece();
|
|
world.add(game, Piece, {
|
|
shape: p.shape,
|
|
color: p.color,
|
|
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
|
y: 0,
|
|
});
|
|
}
|
|
|
|
function hasActivePiece(): boolean {
|
|
return world.has(game, Piece);
|
|
}
|
|
|
|
// Move left
|
|
commands.handle(MoveLeft, () => {
|
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
|
return;
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
|
piece.x--;
|
|
}
|
|
});
|
|
|
|
// Move right
|
|
commands.handle(MoveRight, () => {
|
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
|
return;
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
|
piece.x++;
|
|
}
|
|
});
|
|
|
|
// Rotate
|
|
commands.handle(Rotate, () => {
|
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
|
return;
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
|
if (result) {
|
|
piece.shape = result.shape;
|
|
piece.x = result.x;
|
|
}
|
|
});
|
|
|
|
// Soft drop
|
|
commands.handle(SoftDrop, () => {
|
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
|
return;
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
|
piece.y++;
|
|
}
|
|
});
|
|
|
|
// Hard drop
|
|
commands.handle(HardDrop, () => {
|
|
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
|
return;
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
|
piece.y++;
|
|
}
|
|
lockAndSpawn();
|
|
});
|
|
|
|
// Toggle pause
|
|
commands.handle(TogglePause, () => {
|
|
if (world.has(game, GameOver)) return;
|
|
if (world.has(game, Paused)) {
|
|
world.remove(game, Paused);
|
|
} else {
|
|
world.add(game, Paused);
|
|
}
|
|
});
|
|
|
|
// Restart
|
|
commands.handle(Restart, () => {
|
|
if (!world.has(game, GameOver)) return;
|
|
const board = world.get(game, Board);
|
|
for (let r = 0; r < board.grid.length; r++) {
|
|
board.grid[r].fill(0);
|
|
}
|
|
world.set(game, Score, { points: 0, lines: 0, level: 1 });
|
|
world.set(game, TickTimer, { accumulator: 0, interval: 800 });
|
|
world.remove(game, GameOver);
|
|
if (world.has(game, Piece)) world.remove(game, Piece);
|
|
spawnPiece();
|
|
});
|
|
|
|
// ── Lock piece & spawn next ──────────────────────────
|
|
function lockAndSpawn(): void {
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
|
|
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
|
world.remove(game, Piece);
|
|
|
|
const cleared = clearLines(board.grid);
|
|
if (cleared > 0) {
|
|
const score = world.get(game, Score);
|
|
score.lines += cleared;
|
|
score.points += scoreForLines(cleared, score.level);
|
|
score.level = Math.floor(score.lines / 10) + 1;
|
|
const timer = world.get(game, TickTimer);
|
|
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
|
}
|
|
|
|
spawnPiece();
|
|
|
|
const newPiece = world.get(game, Piece);
|
|
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
|
world.remove(game, Piece);
|
|
world.add(game, GameOver);
|
|
}
|
|
}
|
|
|
|
// ── Behaviour Tree ───────────────────────────────────
|
|
const runner = new TaskRunner(world);
|
|
|
|
// Build the BT structure:
|
|
// root (repeat)
|
|
// ├── handleInput (leaf)
|
|
// ├── gravityTick (leaf)
|
|
// └── render (leaf)
|
|
|
|
const root = world.spawn();
|
|
world.add(root, Task, { kind: "repeat" });
|
|
|
|
const handleInputTask = world.spawn();
|
|
world.add(handleInputTask, Task, { kind: "leaf" });
|
|
world.relate(handleInputTask, ChildOf, root);
|
|
|
|
const gravityTask = world.spawn();
|
|
world.add(gravityTask, Task, { kind: "leaf" });
|
|
world.relate(gravityTask, ChildOf, root);
|
|
|
|
const renderTask = world.spawn();
|
|
world.add(renderTask, Task, { kind: "leaf" });
|
|
world.relate(renderTask, ChildOf, root);
|
|
|
|
// ── Leaf handlers ────────────────────────────────────
|
|
runner.onLeaf = (_w, entity) => {
|
|
if (entity === handleInputTask) {
|
|
commands.execute();
|
|
runner.succeed(entity);
|
|
} else if (entity === gravityTask) {
|
|
if (world.has(game, GameOver) || world.has(game, Paused)) {
|
|
runner.succeed(entity);
|
|
return;
|
|
}
|
|
const timer = world.get(game, TickTimer);
|
|
timer.accumulator += 16;
|
|
if (timer.accumulator >= timer.interval) {
|
|
timer.accumulator -= timer.interval;
|
|
if (hasActivePiece()) {
|
|
const piece = world.get(game, Piece);
|
|
const board = world.get(game, Board);
|
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
|
piece.y++;
|
|
} else {
|
|
lockAndSpawn();
|
|
}
|
|
}
|
|
}
|
|
runner.succeed(entity);
|
|
} else if (entity === renderTask) {
|
|
render(world, game, ui);
|
|
runner.succeed(entity);
|
|
}
|
|
};
|
|
|
|
// ── Input → Command mapping ──────────────────────────
|
|
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
|
|
left: MoveLeft,
|
|
right: MoveRight,
|
|
up: Rotate,
|
|
down: SoftDrop,
|
|
space: HardDrop,
|
|
p: TogglePause,
|
|
r: Restart,
|
|
};
|
|
|
|
startInput(ui.screen, (key) => {
|
|
const cmd = keyToCommand[key];
|
|
if (cmd) {
|
|
const cmdEntity = world.spawn();
|
|
world.add(cmdEntity, cmd);
|
|
}
|
|
});
|
|
|
|
// ── Game loop ────────────────────────────────────────
|
|
runner.schedule(root);
|
|
|
|
const TICK_MS = 16;
|
|
const interval = setInterval(() => {
|
|
runner.tick();
|
|
}, TICK_MS);
|
|
|
|
// Cleanup on exit
|
|
process.on("SIGINT", () => {
|
|
clearInterval(interval);
|
|
ui.screen.destroy();
|
|
process.exit(0);
|
|
});
|