180 lines
4.5 KiB
TypeScript
180 lines
4.5 KiB
TypeScript
// ── 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();
|
|
}
|