// ── Tetris: BT-driven game loop with command-based input ── // // Architecture: // Behaviour Tree (buildTree) — controls game flow: // root (repeat) // └── seq (sequential) // ├── 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 // // Singleton components — global state accessed via world.*Singleton(): // Board, Piece, Score, GameOver, Paused, TickTimer // // Usage: // npx tsx examples/tetris/main.ts import { World } from "../../src/index"; import { buildTree } 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(); // Singleton game state — one entity holds all of these world.addSingleton(Board); world.addSingleton(Score); world.addSingleton(TickTimer); // Create blessed UI const ui = createUI(); // Spawn the first piece spawnPiece(); // ── Command handlers ───────────────────────────────── const commands = new CommandQueue(world); function spawnPiece(): void { const p = randomPiece(); world.addSingleton(Piece, { shape: p.shape, color: p.color, x: Math.floor((BOARD_W - p.shape[0].length) / 2), y: 0, }); } function hasActivePiece(): boolean { return world.hasSingleton(Piece); } // Move left commands.handle(MoveLeft, () => { if ( !hasActivePiece() || world.hasSingleton(GameOver) || world.hasSingleton(Paused) ) return; const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) { piece.x--; } }); // Move right commands.handle(MoveRight, () => { if ( !hasActivePiece() || world.hasSingleton(GameOver) || world.hasSingleton(Paused) ) return; const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) { piece.x++; } }); // Rotate commands.handle(Rotate, () => { if ( !hasActivePiece() || world.hasSingleton(GameOver) || world.hasSingleton(Paused) ) return; const piece = world.getSingleton(Piece); const board = world.getSingleton(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.hasSingleton(GameOver) || world.hasSingleton(Paused) ) return; const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } }); // Hard drop commands.handle(HardDrop, () => { if ( !hasActivePiece() || world.hasSingleton(GameOver) || world.hasSingleton(Paused) ) return; const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } lockAndSpawn(); }); // Toggle pause commands.handle(TogglePause, () => { if (world.hasSingleton(GameOver)) return; if (world.hasSingleton(Paused)) { world.removeSingleton(Paused); } else { world.addSingleton(Paused); } }); // Restart commands.handle(Restart, () => { if (!world.hasSingleton(GameOver)) return; const board = world.getSingleton(Board); for (let r = 0; r < board.grid.length; r++) { board.grid[r].fill(0); } world.setSingleton(Score, { points: 0, lines: 0, level: 1 }); world.setSingleton(TickTimer, { accumulator: 0, interval: 800 }); world.removeSingleton(GameOver); if (world.hasSingleton(Piece)) world.removeSingleton(Piece); spawnPiece(); }); // ── Lock piece & spawn next ────────────────────────── function lockAndSpawn(): void { const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y); world.removeSingleton(Piece); const cleared = clearLines(board.grid); if (cleared > 0) { const score = world.getSingleton(Score); score.lines += cleared; score.points += scoreForLines(cleared, score.level); score.level = Math.floor(score.lines / 10) + 1; const timer = world.getSingleton(TickTimer); timer.interval = Math.max(100, 800 - (score.level - 1) * 70); } spawnPiece(); const newPiece = world.getSingleton(Piece); if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) { world.removeSingleton(Piece); world.addSingleton(GameOver); } } // ── Behaviour Tree ─────────────────────────────────── const runner = buildTree(world, { kind: "repeat", child: { kind: "sequential", children: [ { kind: "leaf", run: () => { commands.execute(); }, }, { kind: "leaf", run: () => { if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { return; } const timer = world.getSingleton(TickTimer); timer.accumulator += 16; if (timer.accumulator >= timer.interval) { timer.accumulator -= timer.interval; if (hasActivePiece()) { const piece = world.getSingleton(Piece); const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } else { lockAndSpawn(); } } } }, }, { kind: "leaf", run: () => { render(world, ui); }, }, ], }, }); // ── 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((runner as any).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); });