// ── 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> = { 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); });