fertig-classic-games/src/games/tetrisattack/TetrisAttackLogic.js

607 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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