ecs-observable/examples/tetris/game.ts

132 lines
4.1 KiB
TypeScript

// ── 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;
}