// Tetris Attack — pure game engine (no Phaser, no DOM, no timers). // // A faithful Panel de Pon / Puzzle League model. The board is ROWS×COLS of // colored panels that continuously rise from the bottom; a 2-wide cursor swaps // the two panels beneath it. Matching 3+ of a color (horizontally or // vertically) clears them; panels above fall to fill the gap, and if those // falling panels land into a new match that is a CHAIN — the scoring heart of // the game. Clearing 4+ panels at once is a COMBO. // // The scene (or the headless verifier) drives all timing by calling `step()` // once per logic tick (60/sec); each call mutates the state and returns an // ordered event list the renderer replays as FX. RNG is always injected so the // verifier can seed it. Nothing here touches Phaser, the DOM, or real time. export const COLS = 6; export const ROWS = 12; // Panel colors (6, like the SNES original). Order is stable — the renderer maps // these to procedural shapes / spritesheet frames by index. export const PANEL_COLORS = ['red', 'yellow', 'green', 'cyan', 'purple', 'blue']; export const TUNING = { CLEAR_TICKS: 46, // flash + pop duration of a matched group FALL_INTERVAL: 3, // ticks between one-row drops of unsupported panels RAISE_RATE: 0.06, // rise per tick while the raise button is held PANIC_GRACE: 150, // ticks a panel may sit in the top row before top-out START_ROWS: 6, // rows pre-filled at the bottom on a new Endless board // Endless rise speed by level (rise fraction per tick). Ramps as rows emerge. BASE_RISE: 0.0016, RISE_PER_LEVEL: 0.0009, ROWS_PER_LEVEL: 8, // emerged rows between speed-ups // Scoring BASE_PANEL: 10, }; // ── Seeded RNG (mulberry32) ───────────────────────────────────────────────── export function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ── Scoring tables ────────────────────────────────────────────────────────── // Combo bonus by group size (4+ panels cleared together). export function comboBonus(size) { if (size < 4) return 0; const table = [0, 0, 0, 0, 20, 30, 50, 70, 90, 110, 140, 170, 210]; return table[size] ?? 210 + (size - 12) * 40; } // Chain bonus by chain level (2 = first continuation). export function chainBonus(chain) { if (chain < 2) return 0; const table = [0, 0, 50, 80, 150, 300, 400, 500, 700, 900, 1100, 1300, 1500]; return table[chain] ?? 1500 + (chain - 12) * 300; } // ── Small helpers ─────────────────────────────────────────────────────────── const ckey = (r, c) => `${r},${c}`; function makeBoard(rows = ROWS, cols = COLS) { return Array.from({ length: rows }, () => Array.from({ length: cols }, () => null)); } function rowHasPanel(board, r) { const row = board[r]; if (!row) return false; return row.some((p) => p !== null); } function isBoardEmpty(board) { return board.every((row) => row.every((p) => p === null)); } function countTargets(board) { let n = 0; for (const row of board) for (const p of row) if (p && p.target) n++; return n; } // A color for a new panel that won't immediately complete a 3-run at (r,c) // given the panels already placed to its left / below. function safeColor(board, r, c, rng) { for (let attempt = 0; attempt < 20; attempt++) { const color = PANEL_COLORS[(rng() * PANEL_COLORS.length) | 0]; const h = c >= 2 && board[r][c - 1]?.color === color && board[r][c - 2]?.color === color; const v = r >= 2 && board[r - 1][c]?.color === color && board[r - 2][c]?.color === color; if (!h && !v) return color; } return PANEL_COLORS[(rng() * PANEL_COLORS.length) | 0]; } function newPanel(state, color, extra = {}) { return { color, id: state.nextId++, state: 'idle', chain: false, ...extra }; } // A fresh incoming row of colors (no horizontal 3-run within the row). function generateRowColors(rng) { const colors = []; for (let c = 0; c < COLS; c++) { let color; let attempt = 0; do { color = PANEL_COLORS[(rng() * PANEL_COLORS.length) | 0]; attempt++; } while (attempt < 20 && c >= 2 && colors[c - 1] === color && colors[c - 2] === color); colors.push(color); } return colors; } // ── State construction ────────────────────────────────────────────────────── // // opts: { mode: 'endless'|'stageclear'|'puzzle', rng, level, puzzle, stage } // puzzle: { grid: [string rows bottom-last], maxMoves } // stage: { speedLevel, ... } — Stage Clear round tuning export function newGame(opts = {}) { const mode = opts.mode ?? 'endless'; const rng = opts.rng ?? Math.random; const state = { mode, board: makeBoard(), incoming: generateRowColors(rng), riseOffset: 0, riseRate: TUNING.BASE_RISE, raiseHeld: false, cursor: { row: ROWS - 3, col: 2 }, clears: [], // active clearing groups: { cells:[{r,c,color}], timer } fallTick: 0, // shared drop timer for unsupported panels chain: 0, // current chain level (0 = idle) combo: 0, // largest combo this wave (for HUD) score: 0, rowsRaised: 0, level: opts.level ?? 1, over: false, won: false, danger: false, dangerTicks: 0, nextId: 1, // mode-specific movesLeft: null, targetsTotal: 0, }; if (mode === 'puzzle') { loadPuzzle(state, opts.puzzle); state.riseRate = 0; } else if (mode === 'stageclear') { const speedLevel = opts.stage?.speedLevel ?? 1; state.level = speedLevel; state.riseRate = TUNING.BASE_RISE + (speedLevel - 1) * TUNING.RISE_PER_LEVEL; fillInitial(state, rng, opts.stage?.startRows ?? 7, true); state.targetsTotal = countTargets(state.board); } else { // endless state.level = opts.level ?? 1; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; fillInitial(state, rng, TUNING.START_ROWS, false); } return state; } // Fill the bottom `nRows` rows with panels (no starting matches). When // `asTargets` the placed panels are flagged as Stage-Clear objectives. function fillInitial(state, rng, nRows, asTargets) { const { board } = state; for (let r = ROWS - nRows; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { const color = safeColor(board, r, c, rng); board[r][c] = newPanel(state, color, asTargets ? { target: true } : {}); } } } // Build a puzzle board from a compact definition. `grid` is an array of strings // (top row first) using color initials r/y/g/c/p/b and '.' for empty. export function loadPuzzle(state, puzzle) { const COLOR_BY_CH = { r: 'red', y: 'yellow', g: 'green', c: 'cyan', p: 'purple', b: 'blue' }; state.board = makeBoard(); const grid = puzzle?.grid ?? []; const offset = ROWS - grid.length; // bottom-align the puzzle for (let gr = 0; gr < grid.length; gr++) { const row = grid[gr]; for (let c = 0; c < COLS; c++) { const ch = row[c]; const color = COLOR_BY_CH[ch]; if (color) state.board[offset + gr][c] = newPanel(state, color); } } state.movesLeft = puzzle?.maxMoves ?? 5; state.incoming = generateRowColors(state._puzzleRng ?? (() => 0.5)); return state; } // ── Cursor & swap ─────────────────────────────────────────────────────────── export function moveCursor(state, dir) { const { cursor } = state; if (dir === 'left') cursor.col = Math.max(0, cursor.col - 1); else if (dir === 'right') cursor.col = Math.min(COLS - 2, cursor.col + 1); else if (dir === 'up') cursor.row = Math.max(0, cursor.row - 1); else if (dir === 'down') cursor.row = Math.min(ROWS - 1, cursor.row + 1); return cursor; } // Swap the two panels under the cursor. Legal when both cells are empty or hold // an idle panel (never mid-clear / mid-fall). Returns a swap event or null. export function trySwap(state) { if (state.over || state.won) return null; const { board, cursor } = state; const r = cursor.row; const c1 = cursor.col; const c2 = cursor.col + 1; const a = board[r][c1]; const b = board[r][c2]; if ((a && a.state !== 'idle') || (b && b.state !== 'idle')) return null; if (!a && !b) return null; // nothing to do board[r][c1] = b; board[r][c2] = a; // A manual swap clears chain eligibility of the moved panels. if (a) a.chain = false; if (b) b.chain = false; if (state.mode === 'puzzle' && state.movesLeft != null) state.movesLeft--; return { type: 'swap', row: r, col: c1 }; } export function setRaise(state, held) { state.raiseHeld = !!held; } // ── Match detection ───────────────────────────────────────────────────────── // Returns match groups over idle panels, merging runs that share a cell (so an // L/T shape counts as one group). Each group: { cells:Set<"r,c">, color, size }. export function findMatchGroups(board) { const rows = board.length; const cols = board[0].length; const idleColor = (r, c) => { const p = board[r]?.[c]; return p && p.state === 'idle' ? p.color : null; }; const runs = []; // horizontal for (let r = 0; r < rows; r++) { let c = 0; while (c < cols) { const color = idleColor(r, c); if (!color) { c++; continue; } let end = c + 1; while (end < cols && idleColor(r, end) === color) end++; if (end - c >= 3) { const cells = []; for (let i = c; i < end; i++) cells.push([r, i]); runs.push({ color, cells }); } c = end; } } // vertical for (let c = 0; c < cols; c++) { let r = 0; while (r < rows) { const color = idleColor(r, c); if (!color) { r++; continue; } let end = r + 1; while (end < rows && idleColor(end, c) === color) end++; if (end - r >= 3) { const cells = []; for (let i = r; i < end; i++) cells.push([i, c]); runs.push({ color, cells }); } r = end; } } if (!runs.length) return []; // union runs that share a cell const parent = runs.map((_, i) => i); const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i]))); const byCell = new Map(); runs.forEach((run, i) => run.cells.forEach(([r, c]) => { const k = ckey(r, c); if (byCell.has(k)) parent[find(i)] = find(byCell.get(k)); else byCell.set(k, i); })); const groups = new Map(); runs.forEach((run, i) => { const root = find(i); if (!groups.has(root)) groups.set(root, { color: run.color, cells: new Set() }); const g = groups.get(root); run.cells.forEach(([r, c]) => g.cells.add(ckey(r, c))); }); return [...groups.values()].map((g) => ({ ...g, size: g.cells.size })); } // ── Gravity ───────────────────────────────────────────────────────────────── // A cell "supports" the panel above it when it is out of bounds (floor) or // holds a panel that is not itself falling. function supported(board, r, c) { if (r >= board.length - 1) return true; const below = board[r + 1][c]; if (!below) return false; return below.state !== 'falling'; } // Mark unsupported idle panels as falling and land falling panels that have // come to rest. Returns the list of panels that landed this tick. function updateFallStates(board) { const landed = []; // top-down: mark idle panels that have nothing solid beneath as falling for (let c = 0; c < board[0].length; c++) { for (let r = board.length - 2; r >= 0; r--) { const p = board[r][c]; if (p && p.state === 'idle' && !supported(board, r, c)) p.state = 'falling'; } } // now land any faller that is supported for (let c = 0; c < board[0].length; c++) { for (let r = board.length - 1; r >= 0; r--) { const p = board[r][c]; if (p && p.state === 'falling' && supported(board, r, c)) { p.state = 'idle'; landed.push({ r, c, id: p.id }); } } } return landed; } // Move every falling panel down one row (bottom-up so a column drops as a unit). function applyFallStep(board) { const moves = []; for (let c = 0; c < board[0].length; c++) { for (let r = board.length - 2; r >= 0; r--) { const p = board[r][c]; if (p && p.state === 'falling' && board[r + 1][c] === null) { board[r + 1][c] = p; board[r][c] = null; moves.push({ col: c, fromR: r, toR: r + 1, id: p.id }); } } } return moves; } function anyFalling(board) { for (const row of board) for (const p of row) if (p && p.state === 'falling') return true; return false; } // ── Row emergence ─────────────────────────────────────────────────────────── function emergeRow(state, rng) { const { board } = state; for (let r = 0; r < ROWS - 1; r++) board[r] = board[r + 1]; board[ROWS - 1] = state.incoming.map((color) => newPanel(state, color)); state.incoming = generateRowColors(rng); state.cursor.row = Math.max(0, state.cursor.row - 1); state.rowsRaised++; if (state.mode === 'endless') { const lvl = 1 + Math.floor(state.rowsRaised / TUNING.ROWS_PER_LEVEL); if (lvl !== state.level) { state.level = lvl; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; } } } // ── The step ──────────────────────────────────────────────────────────────── // Advance one logic tick. Returns an ordered event list. export function step(state, rng = Math.random) { const events = []; if (state.over || state.won) return events; const { board } = state; // 1. Resolve active clears (flash → pop → remove; flag fallers as chainable). for (let i = state.clears.length - 1; i >= 0; i--) { const clr = state.clears[i]; clr.timer--; if (clr.timer <= 0) { // remove cells const colsHit = new Map(); // col -> topmost removed row for (const cell of clr.cells) { board[cell.r][cell.c] = null; const cur = colsHit.get(cell.c); if (cur == null || cell.r < cur) colsHit.set(cell.c, cell.r); } // panels above each opened gap become chainable fallers-to-be for (const [c, minR] of colsHit) { for (let r = minR - 1; r >= 0; r--) { if (board[r][c]) board[r][c].chain = true; } } events.push({ type: 'pop', cells: clr.cells }); state.clears.splice(i, 1); } } // 2. Gravity: mark falling / land, then move fallers on the drop timer. const landed = updateFallStates(board); for (const l of landed) events.push({ type: 'land', ...l }); if (anyFalling(board)) { state.fallTick++; if (state.fallTick >= TUNING.FALL_INTERVAL) { state.fallTick = 0; const moves = applyFallStep(board); if (moves.length) events.push({ type: 'fall', moves }); // re-evaluate landings after the move const landed2 = updateFallStates(board); for (const l of landed2) events.push({ type: 'land', ...l }); } } else { state.fallTick = 0; } // 3. Match detection over settled idle panels. const groups = findMatchGroups(board); if (groups.length) { let hadChainMember = false; for (const g of groups) { for (const k of g.cells) { const [r, c] = k.split(',').map(Number); if (board[r][c]?.chain) { hadChainMember = true; break; } } if (hadChainMember) break; } if (state.chain === 0) state.chain = 1; else if (hadChainMember) state.chain += 1; state.combo = Math.max(state.combo, ...groups.map((g) => g.size)); let tickScore = 0; let biggestCombo = 0; const allCells = []; for (const g of groups) { const cells = []; for (const k of g.cells) { const [r, c] = k.split(',').map(Number); const p = board[r][c]; p.state = 'clearing'; p.chain = false; p.target = false; // Stage Clear: cleared objectives count as done const cell = { r, c, color: g.color, id: p.id }; cells.push(cell); allCells.push(cell); } state.clears.push({ cells, timer: TUNING.CLEAR_TICKS }); tickScore += g.size * TUNING.BASE_PANEL + comboBonus(g.size); biggestCombo = Math.max(biggestCombo, g.size); } tickScore += chainBonus(state.chain); state.score += tickScore; events.push({ type: 'clear', cells: allCells, groups: groups.map((g) => g.size), combo: biggestCombo, chain: state.chain, score: tickScore }); } // 4. Wave end: board settled with no clears / falls / new matches → reset chain. const settled = state.clears.length === 0 && !anyFalling(board); if (settled) { if (state.chain > 0) { if (state.chain > 1) events.push({ type: 'chainEnd', chain: state.chain }); state.chain = 0; state.combo = 0; // clear lingering chain flags for (const row of board) for (const p of row) if (p) p.chain = false; } } // 5. Rise (paused while anything is clearing; disabled in puzzle mode). if (state.mode !== 'puzzle' && state.clears.length === 0) { const topBlocked = rowHasPanel(board, 0); if (topBlocked) { state.dangerTicks++; if (state.dangerTicks > TUNING.PANIC_GRACE) { state.over = true; events.push({ type: 'gameOver' }); return finishStep(state, events); } } else { const rate = state.raiseHeld ? TUNING.RAISE_RATE : state.riseRate; state.riseOffset += rate; if (state.riseOffset >= 1) { state.riseOffset -= 1; emergeRow(state, rng); events.push({ type: 'rowShift' }); } state.dangerTicks = 0; } } return finishStep(state, events); } // Danger-flag transitions and win checks, applied at the end of every step. function finishStep(state, events) { const { board } = state; const inDanger = !state.over && (rowHasPanel(board, 0) || rowHasPanel(board, 1)); if (inDanger && !state.danger) { state.danger = true; events.push({ type: 'danger', on: true }); } if (!inDanger && state.danger) { state.danger = false; events.push({ type: 'danger', on: false }); } if (!state.over && !state.won) { const idleSettled = state.clears.length === 0 && !anyFalling(board); if (state.mode === 'puzzle') { if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); } else if (idleSettled && state.movesLeft <= 0) { state.over = true; events.push({ type: 'gameOver' }); } } else if (state.mode === 'stageclear') { if (countTargets(board) === 0) { state.won = true; events.push({ type: 'win' }); } } } return events; } // ── Query helpers (renderer / verifier) ───────────────────────────────────── export function isSettled(state) { return state.clears.length === 0 && !anyFalling(state.board); } export function remainingTargets(state) { return countTargets(state.board); } // Find a cursor position whose swap immediately creates a match (greedy helper // used by the verifier's self-play and as an optional hint). Returns {row,col} // or null. export function findClearingSwap(state) { const { board } = state; for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS - 1; c++) { const a = board[r][c]; const b = board[r][c + 1]; if ((a && a.state !== 'idle') || (b && b.state !== 'idle')) continue; if (!a && !b) continue; if (a && b && a.color === b.color) continue; // simulate board[r][c] = b; board[r][c + 1] = a; // a swapped panel with an empty cell beneath won't match (it'd fall) — but // this greedy check ignores that nuance; it's only a hint. const hit = findMatchGroups(board).length > 0; board[r][c] = a; board[r][c + 1] = b; if (hit) return { row: r, col: c }; } } return null; } // Fully resolve a state with rise disabled: keep stepping until it settles. // Used by the puzzle solver and to fast-forward a puzzle to its outcome. export function resolveFully(state, rng = Math.random, cap = 4000) { let i = 0; while (!isSettled(state) && i < cap) { step(state, rng); i++; } // one extra pass to fire win/settle checks step(state, rng); return state; } // ── Puzzle solver (BFS) — verifier / generator only ───────────────────────── // Serialize just the settled board colors (for visited-set dedupe). function boardSignature(board) { return board.map((row) => row.map((p) => (p ? p.color[0] : '.')).join('')).join('|'); } function cloneForSolve(state) { const s = newGame({ mode: 'puzzle', puzzle: { grid: [], maxMoves: state.movesLeft } }); s.board = state.board.map((row) => row.map((p) => (p ? { ...p } : null))); s.movesLeft = state.movesLeft; s.nextId = state.nextId; s.riseRate = 0; return s; } // Solve a puzzle grid within maxMoves. Returns an array of {row,col} swaps or // null. Small puzzles only — the branching factor is ROWS*(COLS-1). export function solvePuzzle(puzzleDef, rng = () => 0.5) { const start = newGame({ mode: 'puzzle', puzzle: puzzleDef, rng }); resolveFully(start, rng); if (isBoardEmpty(start.board)) return []; // already solved const maxMoves = puzzleDef.maxMoves ?? 5; const visited = new Set([boardSignature(start.board)]); let frontier = [{ board: start.board, moves: [] }]; for (let depth = 0; depth < maxMoves; depth++) { const next = []; for (const node of frontier) { for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS - 1; c++) { const a = node.board[r][c]; const b = node.board[r][c + 1]; if (!a && !b) continue; if (a && b && a.color === b.color) continue; // build a scratch state, perform the swap + resolve const s = newGame({ mode: 'puzzle', puzzle: { grid: [], maxMoves } }); s.board = node.board.map((row) => row.map((p) => (p ? { ...p } : null))); s.cursor = { row: r, col: c }; s.movesLeft = maxMoves; const swapped = trySwap(s); if (!swapped) continue; resolveFully(s, rng); if (isBoardEmpty(s.board)) return [...node.moves, { row: r, col: c }]; const sig = boardSignature(s.board); if (visited.has(sig)) continue; visited.add(sig); next.push({ board: s.board, moves: [...node.moves, { row: r, col: c }] }); } } } frontier = next; if (!frontier.length) break; } return null; }