feat(examples): add Tetris game example
This commit is contained in:
parent
3620e80807
commit
c3c24d2350
|
|
@ -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", {});
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<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);
|
||||||
|
});
|
||||||
|
|
@ -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<number, string> = {
|
||||||
|
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<typeof createUI>,
|
||||||
|
): 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();
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/blessed": "^0.1.27",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"blessed": "^0.1.81",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^4.1.7"
|
"vitest": "^4.1.7"
|
||||||
|
|
@ -1247,6 +1250,16 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
|
@ -1272,6 +1285,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||||
|
|
@ -1415,6 +1438,19 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/bundle-require": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
||||||
|
|
@ -2489,6 +2525,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.14",
|
"version": "8.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/blessed": "^0.1.27",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"blessed": "^0.1.81",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^4.1.7"
|
"vitest": "^4.1.7"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue