From c3c24d2350d5255e442e41ee3261dfc039e3f4eb Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 1 Jun 2026 23:18:07 +0800 Subject: [PATCH] feat(examples): add Tetris game example --- examples/tetris/commands.ts | 22 +++ examples/tetris/components.ts | 31 ++++ examples/tetris/game.ts | 131 ++++++++++++++++ examples/tetris/input.ts | 25 ++++ examples/tetris/main.ts | 271 ++++++++++++++++++++++++++++++++++ examples/tetris/render.ts | 179 ++++++++++++++++++++++ package-lock.json | 43 ++++++ package.json | 3 + 8 files changed, 705 insertions(+) create mode 100644 examples/tetris/commands.ts create mode 100644 examples/tetris/components.ts create mode 100644 examples/tetris/game.ts create mode 100644 examples/tetris/input.ts create mode 100644 examples/tetris/main.ts create mode 100644 examples/tetris/render.ts diff --git a/examples/tetris/commands.ts b/examples/tetris/commands.ts new file mode 100644 index 0000000..68e0321 --- /dev/null +++ b/examples/tetris/commands.ts @@ -0,0 +1,22 @@ +import { defineComponent } from "../../src/component"; + +/** Move the active piece left. */ +export const MoveLeft = defineComponent("moveLeft", {}); + +/** Move the active piece right. */ +export const MoveRight = defineComponent("moveRight", {}); + +/** Rotate the active piece clockwise. */ +export const Rotate = defineComponent("rotate", {}); + +/** Soft drop — move piece down one row immediately. */ +export const SoftDrop = defineComponent("softDrop", {}); + +/** Hard drop — slam piece to the bottom instantly. */ +export const HardDrop = defineComponent("hardDrop", {}); + +/** Pause / unpause the game. */ +export const TogglePause = defineComponent("togglePause", {}); + +/** Restart after game over. */ +export const Restart = defineComponent("restart", {}); diff --git a/examples/tetris/components.ts b/examples/tetris/components.ts new file mode 100644 index 0000000..c382203 --- /dev/null +++ b/examples/tetris/components.ts @@ -0,0 +1,31 @@ +import { defineComponent } from "../../src/component"; + +// ── Board ──────────────────────────────────────────── +/** The playfield grid (20 rows × 10 cols). 0 = empty, non-zero = color index. */ +export const Board = defineComponent("board", { + grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[], +}); + +// ── Active piece ───────────────────────────────────── +export const Piece = defineComponent("piece", { + shape: [] as number[][], + color: 1, + x: 3, + y: 0, +}); + +// ── Score / state ──────────────────────────────────── +export const Score = defineComponent("score", { + points: 0, + lines: 0, + level: 1, +}); + +export const GameOver = defineComponent("gameOver", {}); +export const Paused = defineComponent("paused", {}); + +// ── Timing ─────────────────────────────────────────── +export const TickTimer = defineComponent("tickTimer", { + accumulator: 0, + interval: 800, // ms between gravity ticks +}); diff --git a/examples/tetris/game.ts b/examples/tetris/game.ts new file mode 100644 index 0000000..624fdf0 --- /dev/null +++ b/examples/tetris/game.ts @@ -0,0 +1,131 @@ +// ── Tetris game logic (pure functions, no ECS dependency) ── + +export const BOARD_W = 10; +export const BOARD_H = 20; + +// ── Tetrominoes ────────────────────────────────────── +export const PIECES: { shape: number[][]; color: number }[] = [ + { shape: [[1, 1, 1, 1]], color: 1 }, // I + { shape: [[1, 1], [1, 1]], color: 2 }, // O + { shape: [[0, 1, 0], [1, 1, 1]], color: 3 }, // T + { shape: [[1, 0, 0], [1, 1, 1]], color: 4 }, // J + { shape: [[0, 0, 1], [1, 1, 1]], color: 5 }, // L + { shape: [[0, 1, 1], [1, 1, 0]], color: 6 }, // S + { shape: [[1, 1, 0], [0, 1, 1]], color: 7 }, // Z +]; + +export function randomPiece(): { shape: number[][]; color: number } { + const p = PIECES[Math.floor(Math.random() * PIECES.length)]; + return { shape: p.shape.map((r) => [...r]), color: p.color }; +} + +// ── Collision ──────────────────────────────────────── +export function collides( + grid: Uint8Array[], + shape: number[][], + px: number, + py: number, +): boolean { + for (let r = 0; r < shape.length; r++) { + for (let c = 0; c < shape[r].length; c++) { + if (!shape[r][c]) continue; + const bx = px + c; + const by = py + r; + if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true; + if (by < 0) continue; // above the board is ok + if (grid[by][bx]) return true; + } + } + return false; +} + +// ── Lock piece onto the grid ───────────────────────── +export function lockPiece( + grid: Uint8Array[], + shape: number[][], + color: number, + px: number, + py: number, +): void { + for (let r = 0; r < shape.length; r++) { + for (let c = 0; c < shape[r].length; c++) { + if (!shape[r][c]) continue; + const bx = px + c; + const by = py + r; + if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) { + grid[by][bx] = color; + } + } + } +} + +// ── Line clearing ──────────────────────────────────── +export function clearLines(grid: Uint8Array[]): number { + let cleared = 0; + for (let r = BOARD_H - 1; r >= 0; r--) { + if (grid[r].every((v) => v !== 0)) { + // Shift everything above down + for (let rr = r; rr > 0; rr--) { + grid[rr] = grid[rr - 1]; + } + grid[0] = new Uint8Array(BOARD_W); + cleared++; + r++; // re-check this row + } + } + return cleared; +} + +// ── Scoring ────────────────────────────────────────── +const LINE_SCORES = [0, 100, 300, 500, 800]; + +export function scoreForLines(lines: number, level: number): number { + return (LINE_SCORES[lines] ?? lines * 200) * level; +} + +// ── Ghost piece (hard-drop preview) ────────────────── +export function ghostY( + grid: Uint8Array[], + shape: number[][], + px: number, + py: number, +): number { + let gy = py; + while (!collides(grid, shape, px, gy + 1)) { + gy++; + } + return gy; +} + +// ── Rotation (clockwise) ───────────────────────────── +export function rotateCW(shape: number[][]): number[][] { + const rows = shape.length; + const cols = shape[0].length; + const rotated: number[][] = []; + for (let c = 0; c < cols; c++) { + const row: number[] = []; + for (let r = rows - 1; r >= 0; r--) { + row.push(shape[r][c]); + } + rotated.push(row); + } + return rotated; +} + +// ── Wall kick ──────────────────────────────────────── +/** Try to rotate with basic wall kicks. Returns the rotated shape and x offset, or null. */ +export function tryRotate( + grid: Uint8Array[], + shape: number[][], + px: number, + py: number, +): { shape: number[][]; x: number } | null { + const rotated = rotateCW(shape); + // Try offsets: 0, -1, +1, -2, +2 + for (const dx of [0, -1, 1, -2, 2]) { + if (!collides(grid, rotated, px + dx, py)) { + return { shape: rotated, x: px + dx }; + } + } + return null; +} diff --git a/examples/tetris/input.ts b/examples/tetris/input.ts new file mode 100644 index 0000000..1c1f07b --- /dev/null +++ b/examples/tetris/input.ts @@ -0,0 +1,25 @@ +// ── Keyboard input via blessed ──────────────────────── +import type blessed from "blessed"; + +export type Key = "left" | "right" | "up" | "down" | "space" | "p" | "r" | "q"; + +/** Wire blessed screen key events to a callback. */ +export function startInput( + screen: blessed.Widgets.Screen, + onKey: (key: Key) => void, +): void { + screen.key( + ["left", "right", "up", "down", "space", "p", "r", "q", "C-c"], + (_ch, key) => { + if (key.name === "q") { + screen.destroy(); + process.exit(0); + } + if (key.name === "C-c") { + screen.destroy(); + process.exit(0); + } + onKey(key.name as Key); + }, + ); +} diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts new file mode 100644 index 0000000..7c59f23 --- /dev/null +++ b/examples/tetris/main.ts @@ -0,0 +1,271 @@ +// ── 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); +}); diff --git a/examples/tetris/render.ts b/examples/tetris/render.ts new file mode 100644 index 0000000..d52c108 --- /dev/null +++ b/examples/tetris/render.ts @@ -0,0 +1,179 @@ +// ── Terminal rendering via blessed ──────────────────── +import blessed from "blessed"; +import type { World, Entity } from "../../src/index"; +import { Board, Piece, Score, GameOver, Paused } from "./components"; +import { BOARD_W, BOARD_H, ghostY } from "./game"; + +// ANSI color codes for the 7 piece colors +const COLORS: Record = { + 0: "\x1b[40m", // black (empty) + 1: "\x1b[46m", // cyan (I) + 2: "\x1b[43m", // yellow (O) + 3: "\x1b[45m", // magenta (T) + 4: "\x1b[44m", // blue (J) + 5: "\x1b[47m\x1b[30m", // white on black (L) + 6: "\x1b[42m", // green (S) + 7: "\x1b[41m", // red (Z) +}; + +const RESET = "\x1b[0m"; +const GHOST_CHAR = "░"; + +export function createUI(): { + screen: blessed.Widgets.Screen; + boardBox: blessed.Widgets.BoxElement; + scoreText: blessed.Widgets.TextElement; + statusBox: blessed.Widgets.BoxElement; + statusText: blessed.Widgets.TextElement; + controlsText: blessed.Widgets.TextElement; +} { + const screen = blessed.screen({ + smartCSR: true, + title: "Tetris", + }); + + // Board display + const boardBox = blessed.box({ + parent: screen, + top: 2, + left: "center", + width: BOARD_W * 2 + 2, + height: BOARD_H + 2, + border: { type: "line" }, + style: { border: { fg: "white" } }, + }); + + // Score + const scoreText = blessed.text({ + parent: screen, + top: 2, + left: 2, + width: 30, + height: 3, + content: "Score: 0 Lines: 0 Level: 1", + style: { fg: "white" }, + }); + + // Status overlay (game over / paused) + const statusBox = blessed.box({ + parent: screen, + top: "center", + left: "center", + width: 22, + height: 5, + border: { type: "line" }, + style: { border: { fg: "yellow" }, fg: "yellow" }, + hidden: true, + }); + + const statusText = blessed.text({ + parent: statusBox, + top: 1, + left: "center", + width: 20, + align: "center", + style: { fg: "yellow" }, + }); + + // Controls hint + const controlsText = blessed.text({ + parent: screen, + bottom: 0, + left: "center", + width: 60, + height: 1, + content: + "← → : move ↑ : rotate ↓ : soft drop Space : hard drop P : pause Q : quit", + style: { fg: "gray" }, + }); + + return { screen, boardBox, scoreText, statusBox, statusText, controlsText }; +} + +/** Render the full game state into the blessed UI. */ +export function render( + world: World, + gameEntity: Entity, + ui: ReturnType, +): void { + 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 + const display = board.grid.map((row) => [...row]); + + if (piece) { + const gy = ghostY(board.grid, piece.shape, piece.x, piece.y); + + // Ghost + for (let r = 0; r < piece.shape.length; r++) { + for (let c = 0; c < piece.shape[r].length; c++) { + if (!piece.shape[r][c]) continue; + const by = gy + r; + const bx = piece.x + c; + if ( + by >= 0 && + by < BOARD_H && + bx >= 0 && + bx < BOARD_W && + !display[by][bx] + ) { + display[by][bx] = -piece.color; + } + } + } + + // Active piece + for (let r = 0; r < piece.shape.length; r++) { + for (let c = 0; c < piece.shape[r].length; c++) { + if (!piece.shape[r][c]) continue; + const by = piece.y + r; + const bx = piece.x + c; + if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) { + display[by][bx] = piece.color; + } + } + } + } + + // Build board string + let boardStr = ""; + for (let r = 0; r < BOARD_H; r++) { + for (let c = 0; c < BOARD_W; c++) { + const v = display[r][c]; + if (v === 0) { + boardStr += " ·"; + } else if (v < 0) { + boardStr += COLORS[-v] + GHOST_CHAR + GHOST_CHAR + RESET; + } else { + boardStr += COLORS[v] + " " + RESET; + } + } + if (r < BOARD_H - 1) boardStr += "\n"; + } + + ui.boardBox.setContent(boardStr); + + // Score + if (score) { + ui.scoreText.setContent( + `Score: ${score.points} Lines: ${score.lines} Level: ${score.level}`, + ); + } + + // Status + if (isOver) { + ui.statusBox.show(); + ui.statusText.setContent("GAME OVER\nPress R to restart"); + } else if (isPaused) { + ui.statusBox.show(); + ui.statusText.setContent("PAUSED"); + } else { + ui.statusBox.hide(); + } + + ui.screen.render(); +} diff --git a/package-lock.json b/package-lock.json index 3ea3f29..307ff91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@types/blessed": "^0.1.27", + "@types/node": "^25.9.1", + "blessed": "^0.1.81", "tsup": "^8.3.5", "typescript": "^5.6.0", "vitest": "^4.1.7" @@ -1247,6 +1250,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/blessed": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz", + "integrity": "sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1272,6 +1285,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -1415,6 +1438,19 @@ "node": ">=12" } }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "dev": true, + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2489,6 +2525,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", diff --git a/package.json b/package.json index 19b1666..e9e9812 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "prepublishOnly": "npm run build" }, "devDependencies": { + "@types/blessed": "^0.1.27", + "@types/node": "^25.9.1", + "blessed": "^0.1.81", "tsup": "^8.3.5", "typescript": "^5.6.0", "vitest": "^4.1.7"