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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue