678 lines
27 KiB
JavaScript
678 lines
27 KiB
JavaScript
// 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
|
||
CLEAR_LINE_ROWS: 3, // Stage Clear: rows that must rise before the line appears
|
||
STAGE_SPEED_STEP: 0.2, // Stage Clear: speed-level bump per stage within a round
|
||
// 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));
|
||
}
|
||
|
||
// Panels still sitting above the Stage Clear boundary line (the objective).
|
||
// The line is drawn along the TOP edge of row `state.clearLine`, so rows
|
||
// 0..clearLine-1 are above it. Zero while the line has not appeared yet.
|
||
function countAboveLine(state) {
|
||
if (state.clearLine == null) return 0;
|
||
let n = 0;
|
||
for (let r = 0; r < state.clearLine; r++) {
|
||
for (const p of state.board[r]) if (p) 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,
|
||
// Stage Clear: index of the row whose top edge carries the boundary line
|
||
// (null until it appears). Rides the stack — decremented on every row shift.
|
||
clearLine: null,
|
||
clearLineRows: TUNING.CLEAR_LINE_ROWS,
|
||
clearLineAt: TUNING.CLEAR_LINE_ROWS, // rowsRaised value that spawns the line
|
||
stage: 1, // stage within the round (1..stagesPerRound)
|
||
speed: opts.level ?? 1, // fractional speed level (level + per-stage steps)
|
||
};
|
||
|
||
if (mode === 'puzzle') {
|
||
loadPuzzle(state, opts.puzzle);
|
||
state.riseRate = 0;
|
||
} else if (mode === 'stageclear') {
|
||
// A round is `stagesPerRound` stages hosted by one character. `level` is the
|
||
// round's integer difficulty (what the HUD and the select screen show);
|
||
// `speed` adds a fraction of a level per stage for a slow continuous ramp.
|
||
const speedLevel = opts.stage?.speedLevel ?? 1;
|
||
const speedStep = opts.stage?.speedStep ?? TUNING.STAGE_SPEED_STEP;
|
||
state.level = speedLevel;
|
||
state.stage = opts.stage?.stageNumber ?? 1;
|
||
state.speed = speedLevel + (state.stage - 1) * speedStep;
|
||
state.riseRate = TUNING.BASE_RISE + (state.speed - 1) * TUNING.RISE_PER_LEVEL;
|
||
state.clearLineRows = opts.stage?.clearLineRows ?? TUNING.CLEAR_LINE_ROWS;
|
||
state.clearLineAt = state.clearLineRows;
|
||
fillInitial(state, rng, opts.stage?.startRows ?? 7);
|
||
} else {
|
||
// endless
|
||
state.level = opts.level ?? 1;
|
||
state.speed = state.level;
|
||
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).
|
||
function fillInitial(state, rng, nRows) {
|
||
const { board } = state;
|
||
for (let r = ROWS - nRows; r < ROWS; r++) {
|
||
for (let c = 0; c < COLS; c++) {
|
||
board[r][c] = newPanel(state, safeColor(board, r, c, rng));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// ── Stage Clear: next stage, same board ─────────────────────────────────────
|
||
// Clearing a stage does not end the match — the stack that was below the line
|
||
// keeps rising exactly where it is, a fresh line is scheduled a few rows out,
|
||
// and the rise speeds up a notch. Board / riseOffset / score / cursor are all
|
||
// deliberately left alone; only the objective and the pace change.
|
||
export function advanceStage(state, opts = {}) {
|
||
state.won = false;
|
||
state.clearLine = null;
|
||
state.clearLineRows = opts.clearLineRows ?? state.clearLineRows;
|
||
state.clearLineAt = state.rowsRaised + state.clearLineRows;
|
||
state.stage++;
|
||
state.speed += opts.speedStep ?? TUNING.STAGE_SPEED_STEP;
|
||
state.riseRate = TUNING.BASE_RISE + (state.speed - 1) * TUNING.RISE_PER_LEVEL;
|
||
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';
|
||
delete p._prevRow; // landed — clear animation origin
|
||
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) {
|
||
p._prevRow = r; // remember old row for fall animation
|
||
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++;
|
||
// Stage Clear boundary line: once placed it is glued to the two rows it sits
|
||
// between, so it moves up one index with them. It appears between the row
|
||
// that just emerged and the one above it, making the whole current stack the
|
||
// objective — everything fed in afterwards arrives below the line.
|
||
if (state.clearLine != null) {
|
||
state.clearLine--;
|
||
} else if (state.mode === 'stageclear' && state.rowsRaised >= state.clearLineAt) {
|
||
state.clearLine = ROWS - 1;
|
||
state._lineJustSpawned = true;
|
||
}
|
||
if (state.mode === 'endless') {
|
||
const lvl = 1 + Math.floor(state.rowsRaised / TUNING.ROWS_PER_LEVEL);
|
||
if (lvl !== state.level) {
|
||
state.level = lvl;
|
||
state.speed = lvl;
|
||
state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL;
|
||
return true; // signal level-up to caller
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── 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 });
|
||
if (landed2.length) state._justLanded = true;
|
||
}
|
||
} else if (state._justLanded) {
|
||
// one settling frame after last panels land: show final animation frame
|
||
// before match detection fires, so the player sees panels arrive in place.
|
||
state.fallTick = TUNING.FALL_INTERVAL;
|
||
state._justLanded = false;
|
||
} else {
|
||
state.fallTick = 0;
|
||
}
|
||
|
||
// 3. Match detection over settled idle panels (skip during settling frame).
|
||
if (!state._justLanded) {
|
||
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;
|
||
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 });
|
||
}
|
||
} // end: if (!state._justLanded)
|
||
|
||
// 4. Wave end: board settled with no clears / falls / new matches → reset chain.
|
||
// (skip during settling frame — chain may still continue)
|
||
if (!state._justLanded) {
|
||
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;
|
||
const leveledUp = emergeRow(state, rng);
|
||
events.push({ type: 'rowShift' });
|
||
if (leveledUp) events.push({ type: 'levelUp', level: state.level });
|
||
if (state._lineJustSpawned) {
|
||
state._lineJustSpawned = false;
|
||
events.push({ type: 'clearLineAppear', row: state.clearLine });
|
||
}
|
||
}
|
||
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) && !state._justLanded;
|
||
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') {
|
||
// Win once every panel above the boundary line is gone. Clearing panels
|
||
// stay on the board until they pop, so this waits for the pop naturally.
|
||
if (state.clearLine != null && countAboveLine(state) === 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) && !state._justLanded;
|
||
}
|
||
|
||
export function panelsAboveClearLine(state) {
|
||
return countAboveLine(state);
|
||
}
|
||
|
||
// 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;
|
||
}
|