132 lines
4.1 KiB
TypeScript
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;
|
|
}
|