ecs-observable/examples/tetris/main.ts

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);
});