ecs-observable/examples/tetris/render.ts

176 lines
4.4 KiB
TypeScript

// ── Terminal rendering via blessed ────────────────────
import blessed from "blessed";
import type { World } 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, ui: ReturnType<typeof createUI>): void {
const board = world.getSingleton(Board);
const piece = world.tryGetSingleton(Piece);
const score = world.tryGetSingleton(Score);
const isOver = world.hasSingleton(GameOver);
const isPaused = world.hasSingleton(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();
}