diff --git a/data/tetrisattack-artwork.json b/data/tetrisattack-artwork.json new file mode 100644 index 0000000..52f6328 --- /dev/null +++ b/data/tetrisattack-artwork.json @@ -0,0 +1,15 @@ +{ + "_comment": "Drop-in painted art for Tetris Attack (spec: src/games/tetrisattack/sprites.md). Paint a sheet, put it under assets/images/tetrisattack/, set its path below and reload — no code changes needed. Any sheet left path:null renders procedurally (colored panel shapes; opponent-portrait fallback for characters).", + "panelSheet": { + "key": "tetrisattack-panels", + "path": null, + "frameWidth": 48, + "frameHeight": 48 + }, + "characterSheet": { + "key": "tetrisattack-characters", + "path": null, + "frameWidth": 200, + "frameHeight": 280 + } +} diff --git a/data/tetrisattack-puzzles.json b/data/tetrisattack-puzzles.json new file mode 100644 index 0000000..3e7c4f1 --- /dev/null +++ b/data/tetrisattack-puzzles.json @@ -0,0 +1,262 @@ +{ + "puzzles": [ + { + "id": 1, + "moves": 2, + "grid": [ + "...g.c", + "g.gc.c" + ], + "maxMoves": 2 + }, + { + "id": 2, + "moves": 2, + "grid": [ + "..g...", + "..r...", + "grg.r." + ], + "maxMoves": 2 + }, + { + "id": 3, + "moves": 2, + "grid": [ + "..c...", + "..g...", + "..g.g.", + "ggcgc." + ], + "maxMoves": 2 + }, + { + "id": 4, + "moves": 2, + "grid": [ + "....c.", + "....c.", + "..gcgg" + ], + "maxMoves": 2 + }, + { + "id": 5, + "moves": 2, + "grid": [ + "...g..", + "..gc..", + "..gc.c" + ], + "maxMoves": 2 + }, + { + "id": 6, + "moves": 2, + "grid": [ + "...r.c", + "rr.c.c" + ], + "maxMoves": 2 + }, + { + "id": 7, + "moves": 2, + "grid": [ + "y.yc.g", + "yccg.g" + ], + "maxMoves": 2 + }, + { + "id": 8, + "moves": 2, + "grid": [ + ".cc...", + ".cc.cc" + ], + "maxMoves": 2 + }, + { + "id": 9, + "moves": 3, + "grid": [ + "..rg..", + ".ggr..", + "crcc.." + ], + "maxMoves": 3 + }, + { + "id": 10, + "moves": 3, + "grid": [ + "..y...", + "..r...", + ".rr...", + "ggyg.y" + ], + "maxMoves": 3 + }, + { + "id": 11, + "moves": 3, + "grid": [ + "g.....", + "y.....", + "gy.y..", + "yg.y.y" + ], + "maxMoves": 3 + }, + { + "id": 12, + "moves": 3, + "grid": [ + "...yg.", + "..ryr.", + "y.rgg." + ], + "maxMoves": 3 + }, + { + "id": 13, + "moves": 3, + "grid": [ + "...c..", + "...r..", + "...c..", + "...c..", + "crcrc." + ], + "maxMoves": 3 + }, + { + "id": 14, + "moves": 3, + "grid": [ + "...y..", + "...g..", + "...g..", + "...r.g", + "y.yr.r" + ], + "maxMoves": 3 + }, + { + "id": 15, + "moves": 3, + "grid": [ + "...g.g", + "..gc.g", + "..gcgc" + ], + "maxMoves": 3 + }, + { + "id": 16, + "moves": 3, + "grid": [ + "....c.", + ".c.yr.", + "cyyrr." + ], + "maxMoves": 3 + }, + { + "id": 17, + "moves": 4, + "grid": [ + ".c.g..", + ".g.r..", + "gr.rcc", + "ggcgcc" + ], + "maxMoves": 4 + }, + { + "id": 18, + "moves": 4, + "grid": [ + "..g...", + "..g...", + "..y.g.", + "y.y.g.", + "gygyy." + ], + "maxMoves": 4 + }, + { + "id": 19, + "moves": 4, + "grid": [ + "r.....", + "r.....", + "y....c", + "r.y.yc", + "y.c.yy" + ], + "maxMoves": 4 + }, + { + "id": 20, + "moves": 4, + "grid": [ + "c..r..", + "rg.c..", + "gr.gr.", + "rcgrgg" + ], + "maxMoves": 4 + }, + { + "id": 21, + "moves": 4, + "grid": [ + "...r..", + "...r..", + "...yg.", + "...rrg", + "yy.grr" + ], + "maxMoves": 4 + }, + { + "id": 22, + "moves": 4, + "grid": [ + "...g..", + "...g..", + "...r..", + "...g..", + "y..gg.", + "y.yrgr" + ], + "maxMoves": 4 + }, + { + "id": 23, + "moves": 4, + "grid": [ + "....y.", + "....r.", + "...yc.", + ".y.rc.", + ".yryyc" + ], + "maxMoves": 4 + }, + { + "id": 24, + "moves": 4, + "grid": [ + "y.....", + "y.....", + "c.g.g.", + "g.g.c.", + "y.c.gg" + ], + "maxMoves": 4 + } + ] +} diff --git a/data/tetrisattack.json b/data/tetrisattack.json new file mode 100644 index 0000000..00a2bbb --- /dev/null +++ b/data/tetrisattack.json @@ -0,0 +1,76 @@ +{ + "endless": { + "name": "Endless", + "description": "Clear panels for as long as you can. The stack rises faster as you go." + }, + "stageClear": { + "name": "Stage Clear", + "description": "Clear the starting stack before it tops out. Six challengers, one at a time.", + "rounds": [ + { + "characterId": "beth", + "spriteIndex": 18, + "name": "Beth", + "speedLevel": 1, + "startRows": 6, + "intro": "I ain't seen you 'round these parts before. Let's see whatcha got.", + "winLine": "Well shoot, you got the better of me!", + "loseLine": "Told ya you weren't ready, sugar." + }, + { + "characterId": "jerry", + "spriteIndex": 10, + "name": "Jerry", + "speedLevel": 2, + "startRows": 6, + "intro": "Well now, y'all know I came to play some'a them games!", + "winLine": "Hoo-wee, you got me good!", + "loseLine": "Better luck next time, partner!" + }, + { + "characterId": "michael", + "spriteIndex": 15, + "name": "Michael", + "speedLevel": 3, + "startRows": 7, + "intro": "Hey mon! I been waitin' ta play on da Fertig Games — let's go mon!", + "winLine": "Ya beat me fair, mon. Respect!", + "loseLine": "Not today, mon, ya!" + }, + { + "characterId": "steve", + "spriteIndex": 17, + "name": "Steve", + "speedLevel": 4, + "startRows": 7, + "intro": "I traveled WAY too far across the galaxy for this. Don't waste my time.", + "winLine": "Impossible... a lifeform this primitive bested me?", + "loseLine": "Exactly as I calculated. Predictable." + }, + { + "characterId": "victor", + "spriteIndex": 2, + "name": "Victor", + "speedLevel": 5, + "startRows": 8, + "intro": "Patience. Every panel falls exactly where the ancients intended.", + "winLine": "The ancients smile upon you today.", + "loseLine": "Patience was never your virtue." + }, + { + "characterId": "klaxon", + "spriteIndex": 29, + "name": "Klaxon", + "speedLevel": 6, + "startRows": 8, + "intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.", + "winLine": "ERROR. DEFEAT NOT IN PARAMETERS.", + "loseLine": "OBJECTIVE COMPLETE. RESISTANCE FUTILE." + } + ] + }, + "puzzle": { + "name": "Puzzle", + "description": "Clear every panel in the fewest swaps. No time pressure — think it through." + } +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 4fe0b7c..b29c916 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -216,6 +216,16 @@ export const MANIFEST = { { type: 'audio', key: 'sfx-march', path: 'assets/fx/march.mp3' }, (scene) => musicFrom(scene, 'nintendo-music'), ], + // Config + puzzle bank always load; panel/character sheets are optional + // drop-ins declared in data/tetrisattack-artwork.json (spec: src/games/ + // tetrisattack/sprites.md) — path:null stays procedural. Shares the nintendo + // soundtrack (see services/soundtrack.js). + tetrisattack: [ + { type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' }, + { type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' }, + (scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']), + (scene) => musicFrom(scene, 'nintendo-music'), + ], coloradodefense: [ // arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here // so its audio only downloads once Colorado Defense is actually entered. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 6c6f1c8..37d0723 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -114,3 +114,4 @@ registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-con registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 84 }); registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 }); registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 }); +registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 }); diff --git a/src/games/tetrisattack/TetrisAttackGame.js b/src/games/tetrisattack/TetrisAttackGame.js new file mode 100644 index 0000000..ff54450 --- /dev/null +++ b/src/games/tetrisattack/TetrisAttackGame.js @@ -0,0 +1,601 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { + COLS, ROWS, PANEL_COLORS, TUNING, + newGame, step, moveCursor, trySwap, setRaise, +} from './TetrisAttackLogic.js'; +import { + showMenu, showPuzzleSelect, showStageIntro, showResult, createHostPortrait, FONT, +} from './TetrisAttackScreens.js'; + +// ── Layout ────────────────────────────────────────────────────────────────── +const CELL = 72; +const BOARD_W = COLS * CELL; // 432 +const BOARD_H = ROWS * CELL; // 864 +const BOARD_LEFT = 744; +const BOARD_TOP = 116; + +const STEP_MS = 1000 / 60; // logic tick rate + +export const D = { bg: -10, boardBg: 0, panels: 5, incoming: 4, cursor: 12, fx: 16, hud: 24, portrait: 20, overlay: 60, overlayUI: 62 }; + +// Bright 16-bit panel palette: fill, light facet, dark rim. +const PANEL_PAL = { + red: { base: 0xe8394a, hi: 0xffb0b8, lo: 0x8f1522 }, + yellow: { base: 0xf2c81e, hi: 0xfff2a8, lo: 0x8f7406 }, + green: { base: 0x36c94f, hi: 0xb6f7bf, lo: 0x136b22 }, + cyan: { base: 0x30c8d8, hi: 0xb0f2f7, lo: 0x0e6a78 }, + purple: { base: 0xb060e8, hi: 0xe6c2ff, lo: 0x5a1f8a }, + blue: { base: 0x3a7bf0, hi: 0xaec6ff, lo: 0x123f8f }, +}; + +const BEST_KEY = 'tetrisattack-best'; + +export default class TetrisAttackGame extends Phaser.Scene { + constructor() { super('TetrisAttackGame'); } + + init(data) { + this.gameDef = data?.game ?? { slug: 'tetrisattack', name: 'Tetris Attack' }; + this.config = null; + this.state = null; + this.mode = null; + this.playing = false; + this.acc = 0; + this.sprites = new Map(); // panel.id -> Phaser image + this.incomingSprites = []; + this.stageIndex = 0; + this.puzzleIndex = 0; + this.hostPortrait = null; + this.uiLayer = null; + this.overlayObjs = []; + this.best = 0; + } + + create() { + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); + } catch (_) { /* optional */ } + + this.config = this.cache.json.get('tetrisattack') ?? {}; + this.puzzles = (this.cache.json.get('tetrisattack-puzzles')?.puzzles) ?? []; + this.best = Number(localStorage.getItem(BEST_KEY) ?? 0); + this.usePanelSheet = this.textures.exists('tetrisattack-panels'); + if (!this.usePanelSheet) this.createPanelTextures(); + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg); + this.buildBackdrop(); + + this.boardLayer = this.add.container(0, 0).setDepth(D.panels); + const maskShape = this.make.graphics(); + maskShape.fillStyle(0xffffff).fillRect(BOARD_LEFT, BOARD_TOP, BOARD_W, BOARD_H); + this.boardLayer.setMask(maskShape.createGeometryMask()); + + this.registerInput(); + this.showMenu(); + } + + clearOverlay() { + for (const o of this.overlayObjs) o.destroy(); + this.overlayObjs = []; + } + + // ── Menus / flow (delegates to Screens) ─────────────────────────────────── + showMenu() { this.clearOverlay(); this.teardownGameplay(); showMenu(this); } + startEndless() { this.beginGame({ mode: 'endless' }); } + startStageClear() { this.stageIndex = 0; this.showStageIntro(); } + startPuzzleMode() { showPuzzleSelect(this); } + + showStageIntro() { + const rounds = this.config?.stageClear?.rounds ?? []; + const round = rounds[this.stageIndex]; + if (!round) { this.showStageComplete(); return; } + showStageIntro(this, round, this.stageIndex, () => { + this.beginGame({ mode: 'stageclear', round }); + }); + } + + beginStagePuzzleFromSelect(index) { this.puzzleIndex = index; this.beginGame({ mode: 'puzzle', puzzleIndex: index }); } + + // ── Gameplay setup ──────────────────────────────────────────────────────── + beginGame({ mode, round, puzzleIndex }) { + this.clearOverlay(); + this.teardownGameplay(); + this.mode = mode; + const rng = () => Math.random(); + this.rng = rng; + + if (mode === 'puzzle') { + const p = this.puzzles[puzzleIndex] ?? this.puzzles[0]; + this.currentPuzzle = p; + this.state = newGame({ mode: 'puzzle', puzzle: { grid: p.grid, maxMoves: p.maxMoves }, rng }); + } else if (mode === 'stageclear') { + this.currentRound = round; + this.state = newGame({ mode: 'stageclear', stage: { speedLevel: round.speedLevel, startRows: round.startRows }, rng }); + } else { + this.state = newGame({ mode: 'endless', rng }); + } + + this.buildHUD(); + this.playing = true; + this.acc = 0; + this.renderBoard(); + } + + teardownGameplay() { + this.playing = false; + for (const s of this.sprites.values()) s.destroy(); + this.sprites.clear(); + for (const s of this.incomingSprites) s.destroy(); + this.incomingSprites = []; + if (this.cursorGfx) { this.cursorGfx.destroy(); this.cursorGfx = null; } + if (this.hudLayer) { this.hudLayer.destroy(); this.hudLayer = null; } + if (this.boardWell) { this.boardWell.destroy(); this.boardWell = null; } + if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; } + if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; } + this.state = null; + } + + // ── Main loop ───────────────────────────────────────────────────────────── + update(time, delta) { + if (!this.playing || !this.state) return; + this.acc += delta; + let guard = 0; + while (this.acc >= STEP_MS && guard < 8) { + this.acc -= STEP_MS; + guard++; + const events = step(this.state, this.rng); + if (events.length) this.handleEvents(events); + if (!this.playing) break; + } + if (this.state) { this.renderBoard(); this.updateHUD(); } + } + + handleEvents(events) { + for (const e of events) { + if (e.type === 'clear') this.onClear(e); + else if (e.type === 'rowShift') playSound(this, SFX.EIGHTBIT_MOVE); + else if (e.type === 'danger') this.onDanger(e.on); + else if (e.type === 'gameOver') this.onGameOver(); + else if (e.type === 'win') this.onWin(); + } + this.updateHUD(); + } + + onClear(e) { + // flash + pop the cleared sprites (positions carried on the event's cells) + const dur = TUNING.CLEAR_TICKS * STEP_MS; + let cx = 0, cy = 0, n = 0; + for (const cell of (e.cells ?? [])) { + const spr = this.sprites.get(cell.id); + if (!spr) continue; + // Keep it in the map but flag it dying: the panel stays on the board (state + // 'clearing') until it pops, and renderBoard skips dying sprites — so this + // prevents a duplicate sprite being spawned for the same cell. + spr.dying = true; + const pos = this.cellCenter(cell.r, cell.c, false); + cx += pos.x; cy += pos.y; n++; + this.tweens.add({ targets: spr, alpha: 0.35, duration: 90, yoyo: true, repeat: 2 }); + this.tweens.add({ + targets: spr, scale: 0, angle: 40, delay: dur * 0.55, duration: dur * 0.45, + ease: 'Back.easeIn', onComplete: () => { spr.destroy(); this.sprites.delete(cell.id); }, + }); + } + // sfx scales with size + const biggest = e.combo ?? 3; + if (e.chain >= 2) playSound(this, SFX.GEM_CHAIN); + else if (biggest >= 5) playSound(this, SFX.GEM_MATCH_5); + else if (biggest >= 4) playSound(this, SFX.GEM_MATCH_4); + else playSound(this, SFX.GEM_MATCH_1); + + if (n && (e.chain >= 2 || biggest >= 4)) { + const label = e.chain >= 2 ? `CHAIN ×${e.chain}` : `COMBO ×${biggest}`; + const color = e.chain >= 2 ? '#ffd35e' : '#7de6ff'; + const t = this.add.text(cx / n, cy / n - 20, label, { + fontFamily: FONT, fontSize: '34px', color, stroke: '#101018', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.fx); + this.tweens.add({ targets: t, y: t.y - 70, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + // host reacts: player doing well → worried + this.hostPortrait?.setPose('worried'); + } + // floating score + if (n && e.score) { + const s = this.add.text(cx / n, cy / n + 24, `+${e.score}`, { + fontFamily: FONT, fontSize: '24px', color: '#ffffff', stroke: '#101018', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.fx); + this.tweens.add({ targets: s, y: s.y - 46, alpha: 0, duration: 750, onComplete: () => s.destroy() }); + } + } + + onDanger(on) { + if (this.boardFrame) this.boardFrame.setStrokeStyle(6, on ? 0xff4d5e : 0x40506a); + if (on) { + this.hostPortrait?.setPose('happy'); + if (!this._dangerShake) { + this._dangerShake = this.tweens.add({ + targets: this.boardLayer, x: { from: -6, to: 6 }, duration: 70, yoyo: true, repeat: -1, + }); + } + } else if (this._dangerShake) { + this._dangerShake.stop(); this._dangerShake = null; this.boardLayer.x = 0; + this.hostPortrait?.setPose('neutral'); + } + } + + onGameOver() { + this.playing = false; + if (this._dangerShake) { this._dangerShake.stop(); this._dangerShake = null; this.boardLayer.x = 0; } + playSound(this, SFX.EIGHTBIT_EXPLODE); + if (this.mode === 'endless') { + const isBest = this.state.score > this.best; + if (isBest) { this.best = this.state.score; localStorage.setItem(BEST_KEY, String(this.best)); } + showResult(this, { + title: 'GAME OVER', + lines: [`Score ${this.state.score}`, isBest ? 'NEW BEST!' : `Best ${this.best}`], + buttons: [ + { label: 'Play Again', action: () => this.startEndless() }, + { label: 'Menu', action: () => this.showMenu() }, + ], + }); + } else if (this.mode === 'stageclear') { + const round = this.currentRound; + this._resultPose = 'happy'; + showResult(this, { + title: 'TOPPED OUT', + lines: [`${round.name}: "${round.loseLine}"`], + round, + buttons: [ + { label: 'Retry', action: () => this.showStageIntro() }, + { label: 'Menu', action: () => this.showMenu() }, + ], + }); + } else { + // puzzle failed + showResult(this, { + title: 'OUT OF MOVES', + lines: ['Could not clear the board.'], + buttons: [ + { label: 'Retry', action: () => this.beginStagePuzzleFromSelect(this.puzzleIndex) }, + { label: 'Puzzle List', action: () => this.startPuzzleMode() }, + ], + }); + } + } + + onWin() { + this.playing = false; + playSound(this, SFX.VICTORY_SHORT); + if (this.mode === 'stageclear') { + const round = this.currentRound; + this._resultPose = 'worried'; + // record the win so the puzzle/stage progress could be surfaced later + const last = this.stageIndex >= (this.config.stageClear.rounds.length - 1); + showResult(this, { + title: `${round.name.toUpperCase()} DEFEATED`, + lines: [`${round.name}: "${round.winLine}"`, `Score ${this.state.score}`], + round, + buttons: last + ? [{ label: 'Finish', action: () => this.showStageComplete() }] + : [{ label: 'Next Challenger', action: () => { this.stageIndex++; this.showStageIntro(); } }, + { label: 'Menu', action: () => this.showMenu() }], + }); + } else if (this.mode === 'puzzle') { + // persist solved state for the puzzle-select checkmarks + const key = 'tetrisattack-puzzle-solved'; + const solved = new Set((localStorage.getItem(key) ?? '').split(',').filter(Boolean)); + solved.add(String(this.currentPuzzle.id)); + localStorage.setItem(key, [...solved].join(',')); + const next = this.puzzleIndex + 1; + const hasNext = next < this.puzzles.length; + showResult(this, { + title: 'SOLVED!', + lines: [`Cleared in ${this.currentPuzzle.maxMoves - (this.state.movesLeft ?? 0)} / ${this.currentPuzzle.maxMoves} swaps`], + buttons: hasNext + ? [{ label: 'Next Puzzle', action: () => this.beginStagePuzzleFromSelect(next) }, + { label: 'Puzzle List', action: () => this.startPuzzleMode() }] + : [{ label: 'Puzzle List', action: () => this.startPuzzleMode() }], + }); + } + } + + showStageComplete() { + this.teardownGameplay(); + showResult(this, { + title: 'ALL CHALLENGERS CLEARED!', + lines: ['You beat every stage. Nicely done.'], + buttons: [{ label: 'Menu', action: () => this.showMenu() }], + }); + } + + // ── Rendering ───────────────────────────────────────────────────────────── + cellCenter(r, c, withRise = true) { + const rise = withRise ? this.state.riseOffset * CELL : 0; + return { x: BOARD_LEFT + (c + 0.5) * CELL, y: BOARD_TOP + (r + 0.5) * CELL - rise }; + } + + makePanelSprite(color) { + let spr; + if (this.usePanelSheet) { + const idx = PANEL_COLORS.indexOf(color); + spr = this.add.image(0, 0, 'tetrisattack-panels', idx); + const s = (CELL - 4) / Math.max(spr.width, 1); + spr.setScale(s); + } else { + spr = this.add.image(0, 0, `ta-panel-${color}`); + } + this.boardLayer.add(spr); + return spr; + } + + renderBoard() { + const st = this.state; + if (!st) return; + const fp = st.fallTick / TUNING.FALL_INTERVAL; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const p = st.board[r][c]; + if (!p) continue; + let spr = this.sprites.get(p.id); + if (!spr) { spr = this.makePanelSprite(p.color); this.sprites.set(p.id, spr); } + if (spr.dying) continue; + const pos = this.cellCenter(r, c); + spr.x = pos.x; + spr.y = pos.y + (p.state === 'falling' ? fp * CELL : 0); + spr.setDepth(D.panels); + } + } + // incoming preview row (dim), just below the bottom line + for (let c = 0; c < COLS; c++) { + let spr = this.incomingSprites[c]; + const color = st.incoming[c]; + if (!spr || spr._color !== color) { + if (spr) spr.destroy(); + spr = this.makePanelSprite(color); + spr._color = color; + spr.setAlpha(0.45).setDepth(D.incoming); + this.incomingSprites[c] = spr; + } + const pos = this.cellCenter(ROWS, c); + spr.x = pos.x; spr.y = pos.y; + } + this.drawCursor(); + } + + drawCursor() { + if (!this.cursorGfx) this.cursorGfx = this.add.graphics().setDepth(D.cursor); + const g = this.cursorGfx; + g.clear(); + const { row, col } = this.state.cursor; + const pos = this.cellCenter(row, col); + const x = pos.x - CELL / 2; + const y = pos.y - CELL / 2; + // clamp cursor draw to the visible board window + const top = BOARD_TOP - 2, bot = BOARD_TOP + BOARD_H + 2; + if (y + CELL < top || y > bot) return; + g.lineStyle(6, 0xffffff, 0.95); + g.strokeRoundedRect(x - 3, y - 3, CELL + 6, CELL + 6, 8); + g.strokeRoundedRect(x + CELL - 3, y - 3, CELL + 6, CELL + 6, 8); + g.lineStyle(2, 0x101018, 0.6); + g.strokeRoundedRect(x - 3, y - 3, CELL * 2 + 6, CELL + 6, 8); + } + + // ── Backdrop / frame / HUD ──────────────────────────────────────────────── + buildBackdrop() { + const g = this.add.graphics().setDepth(D.bg + 1); + // vertical gradient panel behind the whole scene + for (let i = 0; i <= 40; i++) { + const t = i / 40; + const col = Phaser.Display.Color.Interpolate.ColorWithColor( + Phaser.Display.Color.ValueToColor(0x1a2440), Phaser.Display.Color.ValueToColor(0x0a0e1c), 40, i); + g.fillStyle(Phaser.Display.Color.GetColor(col.r, col.g, col.b), 1); + g.fillRect(0, (GAME_HEIGHT / 41) * i, GAME_WIDTH, GAME_HEIGHT / 41 + 1); + } + } + + buildHUD() { + this.hudLayer = this.add.container(0, 0).setDepth(D.hud); + // board well — must sit BEHIND the panels (own depth, not in hudLayer, or the + // 90%-opaque fill would wash the panels out to gray). + this.boardWell = this.add.rectangle(BOARD_LEFT + BOARD_W / 2, BOARD_TOP + BOARD_H / 2, BOARD_W + 16, BOARD_H + 16, 0x0a1024, 0.92) + .setDepth(D.boardBg); + // frame is a stroke-only outline, fine to draw on top of the panels + this.boardFrame = this.add.rectangle(BOARD_LEFT + BOARD_W / 2, BOARD_TOP + BOARD_H / 2, BOARD_W + 16, BOARD_H + 16) + .setStrokeStyle(6, 0x40506a); + this.hudLayer.add(this.boardFrame); + + const title = this.config?.[this.mode === 'stageclear' ? 'stageClear' : this.mode]?.name ?? 'Tetris Attack'; + this.hudLayer.add(this.add.text(80, 70, 'TETRIS\nATTACK', { + fontFamily: FONT, fontSize: '56px', color: '#ffe66e', lineSpacing: 6, + })); + this.hudLayer.add(this.add.text(80, 210, title.toUpperCase(), { + fontFamily: FONT, fontSize: '30px', color: '#7de6ff', + })); + + // Left HUD stats + this.hudTexts = {}; + const mk = (y, label) => { + this.hudLayer.add(this.add.text(80, y, label, { fontFamily: FONT, fontSize: '24px', color: '#9fb0c8' })); + const v = this.add.text(80, y + 30, '', { fontFamily: FONT, fontSize: '40px', color: '#ffffff' }); + this.hudLayer.add(v); + return v; + }; + this.hudTexts.score = mk(300, 'SCORE'); + if (this.mode === 'endless') { + this.hudTexts.best = mk(410, 'BEST'); + this.hudTexts.level = mk(520, 'SPEED'); + } else if (this.mode === 'stageclear') { + this.hudTexts.targets = mk(410, 'PANELS LEFT'); + this.hudTexts.level = mk(520, 'SPEED'); + } else { + this.hudTexts.moves = mk(410, 'SWAPS LEFT'); + } + + // Right side: host portrait (stage clear) + controls + if (this.mode === 'stageclear') { + this.hostPortrait = createHostPortrait(this, GAME_WIDTH - 260, 360, this.currentRound); + this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 560, this.currentRound.name.toUpperCase(), { + fontFamily: FONT, fontSize: '34px', color: '#ffe66e', + }).setOrigin(0.5)); + } + + const controls = this.mode === 'puzzle' + ? ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', '', 'Clear every panel!'] + : ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', 'SHIFT raise stack']; + this.hudLayer.add(this.add.text(GAME_WIDTH - 420, 700, controls.join('\n'), { + fontFamily: FONT, fontSize: '26px', color: '#9fb0c8', lineSpacing: 10, + })); + + // buttons: restart + leave (built as simple text buttons) + this.makeTextButton(GAME_WIDTH - 300, GAME_HEIGHT - 130, 'RESTART', () => this.restartCurrent()); + this.makeTextButton(GAME_WIDTH - 300, GAME_HEIGHT - 60, 'LEAVE', () => this.scene.start('GameMenu')); + + this.updateHUD(); + } + + makeTextButton(x, y, label, action) { + const w = 240, h = 56; + const bg = this.add.rectangle(x, y, w, h, 0x223052, 1).setStrokeStyle(3, 0x4a6ea8).setDepth(D.hud); + const t = this.add.text(x, y, label, { fontFamily: FONT, fontSize: '28px', color: '#dbe6ff' }).setOrigin(0.5).setDepth(D.hud); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x2e4270)); + bg.on('pointerout', () => bg.setFillStyle(0x223052)); + bg.on('pointerdown', () => { playSound(this, SFX.UI_PICK); action(); }); + this.hudLayer.add([bg, t]); + return bg; + } + + restartCurrent() { + if (this.mode === 'endless') this.startEndless(); + else if (this.mode === 'stageclear') this.beginGame({ mode: 'stageclear', round: this.currentRound }); + else this.beginStagePuzzleFromSelect(this.puzzleIndex); + } + + updateHUD() { + if (!this.hudTexts || !this.state) return; + this.hudTexts.score?.setText(String(this.state.score)); + this.hudTexts.best?.setText(String(this.best)); + this.hudTexts.level?.setText(String(this.state.level)); + if (this.hudTexts.targets) this.hudTexts.targets.setText(String(this.state.targetsTotal ? this.countTargets() : 0)); + if (this.hudTexts.moves) this.hudTexts.moves.setText(String(Math.max(0, this.state.movesLeft ?? 0))); + } + + countTargets() { + let n = 0; + for (const row of this.state.board) for (const p of row) if (p && p.target) n++; + return n; + } + + // ── Input ───────────────────────────────────────────────────────────────── + registerInput() { + const kb = this.input.keyboard; + const dirMap = { + ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down', + KeyA: 'left', KeyD: 'right', KeyW: 'up', KeyS: 'down', + }; + kb.on('keydown', (e) => { + if (!this.playing || !this.state) return; + const dir = dirMap[e.code]; + if (dir) { e.preventDefault?.(); moveCursor(this.state, dir); playSound(this, SFX.EIGHTBIT_MOVE); this.renderBoard(); return; } + if (e.code === 'Space' || e.code === 'KeyZ' || e.code === 'Enter' || e.code === 'KeyX') { + e.preventDefault?.(); + const ev = trySwap(this.state); + if (ev) { playSound(this, SFX.EIGHTBIT_SELECT); this.renderBoard(); } + } + if ((e.code === 'ShiftLeft' || e.code === 'ShiftRight') && this.mode !== 'puzzle') { + e.preventDefault?.(); setRaise(this.state, true); + } + }); + kb.on('keyup', (e) => { + if (!this.state) return; + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') setRaise(this.state, false); + }); + + // Pointer: click a cell to move the cursor there; drag to an adjacent cell to swap. + this.input.on('pointerdown', (p) => { + if (!this.playing || !this.state) return; + this.dragCell = this.cellAt(p.x, p.y); + if (this.dragCell) { + this.state.cursor.row = this.dragCell.r; + this.state.cursor.col = Math.min(COLS - 2, this.dragCell.c); + this.renderBoard(); + } + }); + this.input.on('pointerup', (p) => { + if (!this.playing || !this.state || !this.dragCell) return; + const up = this.cellAt(p.x, p.y); + if (up && up.r === this.dragCell.r && Math.abs(up.c - this.dragCell.c) === 1) { + this.state.cursor.row = up.r; + this.state.cursor.col = Math.min(this.dragCell.c, up.c); + const ev = trySwap(this.state); + if (ev) { playSound(this, SFX.EIGHTBIT_SELECT); this.renderBoard(); } + } + this.dragCell = null; + }); + } + + cellAt(x, y) { + const rise = this.state.riseOffset * CELL; + const c = Math.floor((x - BOARD_LEFT) / CELL); + const r = Math.floor((y - BOARD_TOP + rise) / CELL); + if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return null; + return { r, c }; + } + + // ── Procedural panel textures (fallback when no spritesheet) ─────────────── + createPanelTextures() { + for (const color of PANEL_COLORS) { + const key = `ta-panel-${color}`; + if (this.textures.exists(key)) continue; + const pal = PANEL_PAL[color]; + const g = this.add.graphics(); + const S = CELL; + const m = 3; + // body + g.fillStyle(pal.lo, 1); g.fillRoundedRect(m, m, S - m * 2, S - m * 2, 12); + g.fillStyle(pal.base, 1); g.fillRoundedRect(m + 2, m + 2, S - m * 2 - 4, S - m * 2 - 8, 11); + // top highlight + g.fillStyle(pal.hi, 0.55); g.fillRoundedRect(m + 6, m + 6, S - m * 2 - 12, 12, 6); + // icon + this.drawPanelIcon(g, color, S, pal); + g.generateTexture(key, S, S); + g.destroy(); + } + } + + drawPanelIcon(g, color, S, pal) { + const cx = S / 2, cy = S / 2 + 2, R = S * 0.22; + g.fillStyle(0xffffff, 0.9); + g.lineStyle(0); + switch (color) { + case 'red': { // heart + g.fillCircle(cx - R * 0.5, cy - R * 0.2, R * 0.55); + g.fillCircle(cx + R * 0.5, cy - R * 0.2, R * 0.55); + g.fillTriangle(cx - R, cy, cx + R, cy, cx, cy + R * 1.1); + break; + } + case 'yellow': { // star + const pts = []; + for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (Math.PI / 5) * i; const rad = i % 2 ? R * 0.45 : R; pts.push({ x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad }); } + g.fillPoints(pts, true); + break; + } + case 'green': // up-triangle + g.fillTriangle(cx, cy - R, cx + R, cy + R * 0.8, cx - R, cy + R * 0.8); + break; + case 'cyan': // diamond + g.fillPoints([{ x: cx, y: cy - R }, { x: cx + R, y: cy }, { x: cx, y: cy + R }, { x: cx - R, y: cy }], true); + break; + case 'purple': // circle + g.fillCircle(cx, cy, R * 0.92); + break; + case 'blue': // inverted triangle + g.fillTriangle(cx - R, cy - R * 0.8, cx + R, cy - R * 0.8, cx, cy + R); + break; + default: + g.fillCircle(cx, cy, R * 0.9); + } + } +} diff --git a/src/games/tetrisattack/TetrisAttackLogic.js b/src/games/tetrisattack/TetrisAttackLogic.js new file mode 100644 index 0000000..918f4c6 --- /dev/null +++ b/src/games/tetrisattack/TetrisAttackLogic.js @@ -0,0 +1,606 @@ +// 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; +} diff --git a/src/games/tetrisattack/TetrisAttackScreens.js b/src/games/tetrisattack/TetrisAttackScreens.js new file mode 100644 index 0000000..b7c3a15 --- /dev/null +++ b/src/games/tetrisattack/TetrisAttackScreens.js @@ -0,0 +1,241 @@ +// Tetris Attack — front-end screens: mode menu, puzzle select, the Stage Clear +// intro cutscene, results overlays, and the character host portrait. These build +// into scene.overlayObjs (cleared via scene.clearOverlay()) and delegate all +// actions back to methods on the scene. Kept as a one-way dependency (this file +// never imports the gameplay scene) so there's no import cycle. +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; + +export const FONT = 'm6x11'; +const OVL = 60, OVL_UI = 62; + +// Character↔portrait fallback: opponents.png frame per round (see sprites.md). +const POSE_OFFSET = { neutral: 0, happy: 1, worried: 2 }; + +function dim(scene, alpha = 0.72) { + const r = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05070f, alpha).setDepth(OVL); + scene.overlayObjs.push(r); + return r; +} + +function textButton(scene, x, y, label, action, opts = {}) { + const w = opts.width ?? 300, h = opts.height ?? 66; + const bg = scene.add.rectangle(x, y, w, h, 0x243458, 1).setStrokeStyle(3, 0x5a7cbf).setDepth(OVL_UI); + const t = scene.add.text(x, y, label, { fontFamily: FONT, fontSize: `${opts.fontSize ?? 30}px`, color: '#e6eeff' }).setOrigin(0.5).setDepth(OVL_UI); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x304670)); + bg.on('pointerout', () => bg.setFillStyle(0x243458)); + bg.on('pointerdown', () => { playSound(scene, SFX.UI_PICK); action(); }); + scene.overlayObjs.push(bg, t); + return { bg, t }; +} + +// ── Mode menu ──────────────────────────────────────────────────────────────── +export function showMenu(scene) { + scene.clearOverlay(); + dim(scene, 0.9); + const cx = GAME_WIDTH / 2; + + scene.overlayObjs.push(scene.add.text(cx, 150, 'TETRIS ATTACK', { + fontFamily: FONT, fontSize: '96px', color: '#ffe66e', stroke: '#3a2a00', strokeThickness: 8, + }).setOrigin(0.5).setDepth(OVL_UI)); + scene.overlayObjs.push(scene.add.text(cx, 240, 'Panel de Pon — 1 Player', { + fontFamily: FONT, fontSize: '32px', color: '#7de6ff', + }).setOrigin(0.5).setDepth(OVL_UI)); + + const modes = [ + { key: 'endless', name: scene.config?.endless?.name ?? 'Endless', desc: scene.config?.endless?.description ?? '', action: () => scene.startEndless() }, + { key: 'stageclear', name: scene.config?.stageClear?.name ?? 'Stage Clear', desc: scene.config?.stageClear?.description ?? '', action: () => scene.startStageClear() }, + { key: 'puzzle', name: scene.config?.puzzle?.name ?? 'Puzzle', desc: scene.config?.puzzle?.description ?? '', action: () => scene.startPuzzleMode() }, + ]; + const cardW = 460, cardH = 200, gap = 40; + const totalW = cardW * 3 + gap * 2; + let x = cx - totalW / 2 + cardW / 2; + for (const m of modes) { + const y = 500; + const bg = scene.add.rectangle(x, y, cardW, cardH, 0x1a2440, 1).setStrokeStyle(4, 0x4a6ea8).setDepth(OVL_UI); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setStrokeStyle(4, 0xffe66e)); + bg.on('pointerout', () => bg.setStrokeStyle(4, 0x4a6ea8)); + bg.on('pointerdown', () => { playSound(scene, SFX.UI_ACTIVATE); m.action(); }); + scene.overlayObjs.push(bg); + scene.overlayObjs.push(scene.add.text(x, y - 60, m.name.toUpperCase(), { fontFamily: FONT, fontSize: '40px', color: '#ffe66e' }).setOrigin(0.5).setDepth(OVL_UI)); + scene.overlayObjs.push(scene.add.text(x, y + 20, m.desc, { + fontFamily: FONT, fontSize: '22px', color: '#c3d2ea', align: 'center', wordWrap: { width: cardW - 50 }, + }).setOrigin(0.5).setDepth(OVL_UI)); + x += cardW + gap; + } + + textButton(scene, cx, GAME_HEIGHT - 110, 'LEAVE', () => scene.scene.start('GameMenu'), { width: 260 }); +} + +// ── Puzzle select ──────────────────────────────────────────────────────────── +export function showPuzzleSelect(scene) { + scene.clearOverlay(); + scene.teardownGameplay(); + dim(scene, 0.9); + const cx = GAME_WIDTH / 2; + scene.overlayObjs.push(scene.add.text(cx, 110, 'PUZZLE MODE', { + fontFamily: FONT, fontSize: '72px', color: '#ffe66e', + }).setOrigin(0.5).setDepth(OVL_UI)); + scene.overlayObjs.push(scene.add.text(cx, 180, 'Clear every panel in the fewest swaps', { + fontFamily: FONT, fontSize: '26px', color: '#7de6ff', + }).setOrigin(0.5).setDepth(OVL_UI)); + + const puzzles = scene.puzzles ?? []; + const cols = 8; + const size = 120, gap = 22; + const startX = cx - ((Math.min(cols, puzzles.length) * (size + gap) - gap)) / 2 + size / 2; + const startY = 300; + const solvedKey = 'tetrisattack-puzzle-solved'; + const solved = new Set((localStorage.getItem(solvedKey) ?? '').split(',').filter(Boolean)); + puzzles.forEach((p, i) => { + const r = Math.floor(i / cols), c = i % cols; + const x = startX + c * (size + gap); + const y = startY + r * (size + gap); + const done = solved.has(String(p.id)); + const bg = scene.add.rectangle(x, y, size, size, done ? 0x24503a : 0x243458, 1) + .setStrokeStyle(3, done ? 0x5ecf8e : 0x5a7cbf).setDepth(OVL_UI); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setStrokeStyle(3, 0xffe66e)); + bg.on('pointerout', () => bg.setStrokeStyle(3, done ? 0x5ecf8e : 0x5a7cbf)); + bg.on('pointerdown', () => { playSound(scene, SFX.UI_PICK); scene.beginStagePuzzleFromSelect(i); }); + scene.overlayObjs.push(bg); + scene.overlayObjs.push(scene.add.text(x, y - 16, String(i + 1), { fontFamily: FONT, fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setDepth(OVL_UI)); + scene.overlayObjs.push(scene.add.text(x, y + 30, `${p.maxMoves} swaps`, { fontFamily: FONT, fontSize: '18px', color: '#c3d2ea' }).setOrigin(0.5).setDepth(OVL_UI)); + }); + + textButton(scene, cx, GAME_HEIGHT - 100, 'BACK', () => scene.showMenu(), { width: 240 }); +} + +// ── Stage Clear intro cutscene ──────────────────────────────────────────────── +export function showStageIntro(scene, round, index, onContinue) { + scene.clearOverlay(); + scene.teardownGameplay(); + dim(scene, 0.92); + const cx = GAME_WIDTH / 2; + const total = (scene.config?.stageClear?.rounds ?? []).length; + + scene.overlayObjs.push(scene.add.text(cx, 120, `STAGE ${index + 1} OF ${total}`, { + fontFamily: FONT, fontSize: '40px', color: '#7de6ff', + }).setOrigin(0.5).setDepth(OVL_UI)); + + // portrait slides in from the left + const portrait = buildPortraitImage(scene, round, index, 'neutral', 480, GAME_HEIGHT / 2 + 40, 2.0); + portrait.setDepth(OVL_UI); + portrait.x = -300; + scene.overlayObjs.push(portrait); + scene.tweens.add({ targets: portrait, x: 480, duration: 650, ease: 'Back.easeOut' }); + + scene.overlayObjs.push(scene.add.text(480, GAME_HEIGHT / 2 + 300, round.name.toUpperCase(), { + fontFamily: FONT, fontSize: '64px', color: '#ffe66e', stroke: '#3a2a00', strokeThickness: 6, + }).setOrigin(0.5).setDepth(OVL_UI)); + + // speech bubble + const bx = 1130, by = GAME_HEIGHT / 2 - 20; + const bubble = scene.add.graphics().setDepth(OVL_UI); + bubble.fillStyle(0xffffff, 1); bubble.lineStyle(5, 0x2a3a5a, 1); + bubble.fillRoundedRect(bx - 380, by - 150, 760, 300, 24); + bubble.strokeRoundedRect(bx - 380, by - 150, 760, 300, 24); + bubble.fillTriangle(bx - 380, by - 20, bx - 440, by + 10, bx - 380, by + 40); + bubble.setAlpha(0); + scene.overlayObjs.push(bubble); + const line = scene.add.text(bx, by, `"${round.intro}"`, { + fontFamily: FONT, fontSize: '34px', color: '#1a2036', align: 'center', wordWrap: { width: 680 }, + }).setOrigin(0.5).setDepth(OVL_UI).setAlpha(0); + scene.overlayObjs.push(line); + scene.tweens.add({ targets: [bubble, line], alpha: 1, delay: 500, duration: 400 }); + + const prompt = scene.add.text(cx, GAME_HEIGHT - 90, 'PRESS ANY KEY OR CLICK TO BEGIN', { + fontFamily: FONT, fontSize: '30px', color: '#ffffff', + }).setOrigin(0.5).setDepth(OVL_UI); + scene.overlayObjs.push(prompt); + scene.tweens.add({ targets: prompt, alpha: 0.25, duration: 600, yoyo: true, repeat: -1 }); + + let done = false; + const go = () => { + if (done) return; done = true; + scene.input.keyboard.off('keydown', go); + scene.input.off('pointerdown', go); + playSound(scene, SFX.UI_ACTIVATE); + scene.clearOverlay(); + onContinue(); + }; + // small delay so the cutscene reads before an accidental keypress skips it + scene.time.delayedCall(350, () => { + if (done) return; + scene.input.keyboard.once('keydown', go); + scene.input.once('pointerdown', go); + }); +} + +// ── Result overlay ──────────────────────────────────────────────────────────── +export function showResult(scene, { title, lines = [], buttons = [], round = null }) { + scene.clearOverlay(); + dim(scene, 0.78); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const panel = scene.add.rectangle(cx, cy, 900, 560, 0x141c30, 0.98).setStrokeStyle(5, 0x5a7cbf).setDepth(OVL_UI); + scene.overlayObjs.push(panel); + scene.overlayObjs.push(scene.add.text(cx, cy - 200, title, { + fontFamily: FONT, fontSize: '58px', color: '#ffe66e', align: 'center', wordWrap: { width: 820 }, + }).setOrigin(0.5).setDepth(OVL_UI)); + + if (round) { + const p = buildPortraitImage(scene, round, scene.stageIndex, scene._resultPose ?? 'neutral', cx, cy - 60, 0.85); + p.setDepth(OVL_UI); + scene.overlayObjs.push(p); + } + + const firstLineY = round ? cy + 90 : cy - 70; + lines.forEach((l, i) => { + scene.overlayObjs.push(scene.add.text(cx, firstLineY + i * 48, l, { + fontFamily: FONT, fontSize: '32px', color: '#dbe6ff', align: 'center', wordWrap: { width: 800 }, + }).setOrigin(0.5).setDepth(OVL_UI)); + }); + + const bw = 300, gap = 40; + const totalW = buttons.length * bw + (buttons.length - 1) * gap; + let bx = cx - totalW / 2 + bw / 2; + for (const b of buttons) { + textButton(scene, bx, cy + 210, b.label, () => { scene.clearOverlay(); b.action(); }, { width: bw }); + bx += bw + gap; + } +} + +// ── Host portrait (in-game HUD) ─────────────────────────────────────────────── +export function createHostPortrait(scene, x, y, round, index = scene.stageIndex) { + const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 1).setStrokeStyle(5, 0x4a6ea8).setDepth(20); + const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0); + img.setDepth(21); + return { + sprite: img, + setPose(pose) { + if (scene.textures.exists('tetrisattack-characters')) { + img.setFrame(index * 3 + (POSE_OFFSET[pose] ?? 0)); + } else { + // opponents fallback has no poses — pulse instead + const tint = pose === 'happy' ? 0xffd0d0 : pose === 'worried' ? 0xd0ffe0 : 0xffffff; + img.setTint(tint); + scene.tweens.add({ targets: img, scale: img.scaleX * 1.06, duration: 120, yoyo: true }); + } + }, + destroy() { frameBg.destroy(); img.destroy(); }, + }; +} + +// Build a portrait image: character spritesheet frame if painted, else the +// opponent's round portrait from the shared opponents.png sheet. +function buildPortraitImage(scene, round, index, pose, x, y, scaleBoost = 1) { + let img; + if (scene.textures.exists('tetrisattack-characters')) { + img = scene.add.image(x, y, 'tetrisattack-characters', index * 3 + (POSE_OFFSET[pose] ?? 0)); + const s = Math.min(280 / img.width, 360 / img.height) * scaleBoost; + img.setScale(s); + } else { + // opponents.png is 300×300 frames; crop to a portrait-ish square + img = scene.add.image(x, y, 'opponents', round.spriteIndex ?? 0); + const s = (280 / 300) * scaleBoost; + img.setScale(s); + } + return img; +} diff --git a/src/games/tetrisattack/sprites.md b/src/games/tetrisattack/sprites.md new file mode 100644 index 0000000..c521fbe --- /dev/null +++ b/src/games/tetrisattack/sprites.md @@ -0,0 +1,68 @@ +# Tetris Attack — art spec + +All art is **optional and drop-in**. The game ships fully playable on procedural +fallbacks; paint a sheet, drop it at the path below, set that `path` in +`data/tetrisattack-artwork.json`, and reload — no code changes needed. Any sheet +left `path: null` keeps rendering procedurally. + +Style target: bright, crisp 16-bit **Super NES** look (no CRT/scanline overlay). +UI text uses the `m6x11` pixel font (already loaded). + +--- + +## 1. Panels — `assets/images/tetrisattack/tetrisattack-panels.png` + +`frameWidth: 48, frameHeight: 48` (a grid of 48×48 cells, left→right, top→bottom). + +Six panel colors, in this fixed order (index = color): + +| frame | color | +|-------|--------| +| 0 | red | +| 1 | yellow | +| 2 | green | +| 3 | cyan | +| 4 | purple | +| 5 | blue | + +- **Row 0 (frames 0–5)** — the normal resting panel for each color. Give each a + distinct icon/shape as well as a distinct hue (the original uses heart, circle, + up-triangle, diamond, star, inverted-triangle) so colorblind players can read + the board by shape. The procedural fallback already draws these six shapes. +- **Row 1 (frames 6–11)** *(optional)* — the "face / flashing" state shown while a + matched group is popping, same color order. If absent, the game tints/flashes + the row-0 frame instead. + +No "garbage block" art is needed — garbage panels are a versus-only mechanic and +this is a 1-player build. + +--- + +## 2. Characters — `assets/images/tetrisattack/tetrisattack-characters.png` + +`frameWidth: 200, frameHeight: 280` (portrait cells, left→right, top→bottom). + +Six characters, **3 poses each** = 18 frames. Character order (rows), pose order +(columns within a row): + +| row | character | frames (neutral, happy, worried) | +|-----|-----------|----------------------------------| +| 0 | Beth | 0, 1, 2 | +| 1 | Jerry | 3, 4, 5 | +| 2 | Michael | 6, 7, 8 | +| 3 | Steve | 9, 10, 11 | +| 4 | Victor | 12, 13, 14 | +| 5 | Klaxon | 15, 16, 17 | + +- **neutral** — used in the Stage Clear intro cutscene and as the idle host. +- **happy** — the host is winning / you just topped out (they cheer). +- **worried** — you're clearing well / you just beat them. + +Until this sheet is supplied, characters fall back to their existing round +portraits from the shared `opponents.png` sheet (Beth 18, Jerry 10, Michael 15, +Steve 17, Victor 2, Klaxon 29). + +To re-theme, re-order, or swap which opponents appear, edit +`data/tetrisattack.json` → `stageClear.rounds` (each round's `characterId`, +`spriteIndex`, and lines). The character↔frame mapping above is by round order, +so keep the sheet rows in sync with that array. diff --git a/src/main.js b/src/main.js index c4cc846..d4f08e2 100644 --- a/src/main.js +++ b/src/main.js @@ -96,6 +96,7 @@ import TempestGame from './games/tempest/TempestGame.js'; import SuperKartGame from './games/superkart/SuperKartGame.js'; import SuperKartEditor from './games/superkart/SuperKartEditor.js'; import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js'; +import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js'; const config = { type: Phaser.AUTO, @@ -205,6 +206,7 @@ const config = { SuperKartGame, SuperKartEditor, AdvanceWarsGame, + TetrisAttackGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 4825e9a..fdf171f 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index df59486..1959134 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -48,6 +48,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('hacker-music', 'data/hacker-music.json'); this.load.json('adventure-music', 'data/adventure-music.json'); this.load.json('advancewars-artwork', 'data/advancewars-artwork.json'); + this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); this.load.json('rushhour', 'data/rushhour.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('shift-artwork', 'data/shift-artwork.json'); diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 5c1b92e..7f41226 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -19,6 +19,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { superkart: 'nintendo', advancewars: 'nintendo', + tetrisattack: 'nintendo', coloradodefense: 'arcadedark', tempest: 'arcadedark', mastermind: 'hacker', diff --git a/tools/genTetrisAttackPuzzles.js b/tools/genTetrisAttackPuzzles.js new file mode 100644 index 0000000..0f760c4 --- /dev/null +++ b/tools/genTetrisAttackPuzzles.js @@ -0,0 +1,96 @@ +// Generates data/tetrisattack-puzzles.json — a bank of solvable Puzzle-mode +// boards. Each puzzle is a small bottom-aligned stack of panels that can be +// cleared to nothing within `maxMoves` swaps. Every candidate is verified with +// the engine's BFS solver, so the shipped bank is guaranteed winnable and the +// stored `maxMoves` is the optimal (minimal) swap count. +// +// node tools/genTetrisAttackPuzzles.js +// +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { COLS, mulberry32, solvePuzzle } from '../src/games/tetrisattack/TetrisAttackLogic.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT = join(__dirname, '..', 'data', 'tetrisattack-puzzles.json'); + +const CH = ['r', 'y', 'g', 'c', 'p', 'b']; + +// Build a random bottom-aligned grid: pick a per-color multiset whose counts are +// each a multiple of 3 (necessary to clear fully), scatter into column stacks. +function randomCandidate(rng, panels) { + // choose color counts (multiples of 3) summing to `panels` + const triples = panels / 3; + const counts = {}; + for (let i = 0; i < triples; i++) { + const ch = CH[(rng() * 4) | 0]; // bias to first 4 colors for denser matches + counts[ch] = (counts[ch] ?? 0) + 3; + } + const bag = []; + for (const [ch, n] of Object.entries(counts)) for (let i = 0; i < n; i++) bag.push(ch); + // shuffle + for (let i = bag.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + [bag[i], bag[j]] = [bag[j], bag[i]]; + } + // distribute into column heights + const heights = Array(COLS).fill(0); + for (let i = 0; i < bag.length; i++) heights[(rng() * COLS) | 0]++; + const maxH = Math.max(...heights); + if (maxH === 0) return null; + const grid = Array.from({ length: maxH }, () => Array(COLS).fill('.')); + let idx = 0; + for (let c = 0; c < COLS; c++) { + for (let h = 0; h < heights[c]; h++) { + // fill bottom-up: bottom row is last + const row = maxH - 1 - h; + grid[row][c] = bag[idx++]; + } + } + return grid.map((r) => r.join('')); +} + +function hasImmediateMatch(grid) { + // quick reject: a puzzle that already contains a 3-run needs no swap + const rows = grid.length; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < COLS; c++) { + const ch = grid[r][c]; + if (ch === '.') continue; + if (c + 2 < COLS && grid[r][c + 1] === ch && grid[r][c + 2] === ch) return true; + if (r + 2 < rows && grid[r + 1][c] === ch && grid[r + 2][c] === ch) return true; + } + } + return false; +} + +const rng = mulberry32(20260719); +const puzzles = []; +const targets = [ + { moves: 2, count: 8, panels: [6, 9] }, + { moves: 3, count: 8, panels: [9, 12] }, + { moves: 4, count: 8, panels: [12, 15] }, +]; + +let id = 1; +for (const t of targets) { + let made = 0; + let attempts = 0; + while (made < t.count && attempts < 6000) { + attempts++; + const panels = t.panels[(rng() * t.panels.length) | 0]; + const grid = randomCandidate(rng, panels); + if (!grid) continue; + if (hasImmediateMatch(grid)) continue; + const def = { grid, maxMoves: t.moves }; + const sol = solvePuzzle(def, () => 0.5); + if (sol && sol.length === t.moves) { + puzzles.push({ id: id++, moves: t.moves, grid, maxMoves: t.moves }); + made++; + } + } + console.log(` ${t.moves}-move puzzles: ${made}/${t.count} (in ${attempts} attempts)`); +} + +writeFileSync(OUT, JSON.stringify({ puzzles }, null, 2) + '\n'); +console.log(`Wrote ${puzzles.length} puzzles → ${OUT}`); diff --git a/tools/verifyTetrisAttack.js b/tools/verifyTetrisAttack.js new file mode 100644 index 0000000..80644ed --- /dev/null +++ b/tools/verifyTetrisAttack.js @@ -0,0 +1,271 @@ +// Headless verifier for the Tetris Attack engine. +// node tools/verifyTetrisAttack.js +// Exits non-zero if any check fails. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor, + findMatchGroups, findClearingSwap, isSettled, remainingTargets, + loadPuzzle, solvePuzzle, resolveFully, comboBonus, chainBonus, +} from '../src/games/tetrisattack/TetrisAttackLogic.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let failures = 0; +let checks = 0; +function check(name, cond, detail = '') { + checks++; + if (!cond) { failures++; console.log(` ✗ ${name}${detail ? ' — ' + detail : ''}`); } +} +function section(t) { console.log(`\n${t}`); } + +// Build a settled state from a bottom-first grid (for fixtures). Always places +// the explicit grid (via puzzle load) then retags the mode so endless/stageclear +// fixtures use the hand-authored board rather than a random fill. +function fromGrid(grid, mode = 'puzzle') { + const s = newGame({ mode: 'puzzle', puzzle: { grid, maxMoves: 20 } }); + s.mode = mode; + if (mode !== 'puzzle') s.riseRate = 0; // keep fixtures still unless a test opts in + return s; +} +// Step until settled, recording the peak chain and all emitted event types. +function resolveRecord(state, rng = () => 0.5, cap = 3000) { + let peakChain = 0; + const types = new Set(); + let i = 0; + while (i < cap) { + const events = step(state, rng); + for (const e of events) types.add(e.type); + peakChain = Math.max(peakChain, state.chain); + i++; + if (isSettled(state) && state.chain === 0) break; + } + return { peakChain, types, ticks: i }; +} + +// ── 1. Match detection ────────────────────────────────────────────────────── +section('1. Match detection'); +{ + const s = fromGrid(['rrr...']); + const g = findMatchGroups(s.board); + check('horizontal 3-run found', g.length === 1 && g[0].size === 3); + + const s2 = fromGrid(['g.....', 'g.....', 'g.....']); + const g2 = findMatchGroups(s2.board); + check('vertical 3-run found', g2.length === 1 && g2[0].size === 3); + + // L-shape merges into one group of 5 + const s3 = fromGrid(['b.....', 'b.....', 'bbb...']); + const g3 = findMatchGroups(s3.board); + check('L-shape is one group of 5', g3.length === 1 && g3[0].size === 5, JSON.stringify(g3.map(x => x.size))); + + const s4 = fromGrid(['ryg...']); + check('no false match', findMatchGroups(s4.board).length === 0); +} + +// ── 2. Basic clear + scoring ──────────────────────────────────────────────── +section('2. Clear & scoring'); +{ + const s = fromGrid(['rrr...']); + const before = s.score; + const rec = resolveRecord(s); + check('clear removes matched panels', s.board.every((row) => row.every((p) => p === null || p.color !== 'red'))); + check('clear event fired', rec.types.has('clear')); + check('pop event fired', rec.types.has('pop')); + check('score increased by base*3', s.score - before === 3 * TUNING.BASE_PANEL, `got ${s.score - before}`); + check('comboBonus(3)=0, (4)=20', comboBonus(3) === 0 && comboBonus(4) === 20); + check('chainBonus(1)=0, (2)=50', chainBonus(1) === 0 && chainBonus(2) === 50); +} + +// ── 3. Chain propagation ──────────────────────────────────────────────────── +section('3. Chain propagation'); +{ + // Bottom RRR clears; the g above col2 falls and completes GGG (cols 2,3,4). + const s = fromGrid(['..g...', 'rrrgg.']); + const rec = resolveRecord(s); + check('chain reaches level 2', rec.peakChain >= 2, `peak ${rec.peakChain}`); + check('chainEnd fired', rec.types.has('chainEnd')); + check('board fully cleared by chain', s.board.every((row) => row.every((p) => p === null))); +} + +// ── 4. Gravity: no floating panels ────────────────────────────────────────── +section('4. Gravity'); +{ + // A panel with empty space beneath must fall to the floor. + const s = fromGrid(['r.....', '......', '......']); + // r is at top of a 3-tall region; resolve (no match) → it should rest on floor + resolveRecord(s, () => 0.5, 200); + const bottomHasR = s.board[ROWS - 1][0]?.color === 'red'; + check('lone panel falls to floor', bottomHasR, JSON.stringify(s.board.map(r => r[0]?.color ?? '.'))); + // invariant: no idle panel has an empty cell directly beneath it + let floating = 0; + for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) { + if (s.board[r][c] && !s.board[r + 1][c]) floating++; + } + check('no floating panels after settle', floating === 0, `${floating} floating`); +} + +// ── 5. Cursor & swap ──────────────────────────────────────────────────────── +section('5. Cursor & swap'); +{ + const s = fromGrid(['ryr...', 'yyr...', 'rryyy.'], 'endless'); + // move cursor bounds + s.cursor = { row: 0, col: 0 }; + moveCursor(s, 'left'); + check('cursor col clamps at 0', s.cursor.col === 0); + for (let i = 0; i < 10; i++) moveCursor(s, 'right'); + check('cursor col clamps at COLS-2', s.cursor.col === COLS - 2); + + // a swap between two idle panels succeeds and exchanges them + const s2 = fromGrid(['rg....'], 'endless'); + s2.cursor = { row: ROWS - 1, col: 0 }; + const a = s2.board[ROWS - 1][0].id; + const ev = trySwap(s2); + check('swap returns event', ev && ev.type === 'swap'); + check('swap exchanged cells', s2.board[ROWS - 1][1].id === a); + + // cannot swap a clearing panel + const s3 = fromGrid(['rrr...'], 'endless'); + step(s3, () => 0.5); // starts the clear + s3.cursor = { row: ROWS - 1, col: 0 }; + check('cannot swap mid-clear', trySwap(s3) === null); +} + +// ── 6. Rise & emerge ──────────────────────────────────────────────────────── +section('6. Rise & row emergence'); +{ + const s = newGame({ mode: 'endless', rng: mulberry32(7) }); + s.riseRate = 0.34; // fast for the test + const raised0 = s.rowsRaised; + const bottomBefore = s.incoming.slice(); + let sawShift = false; + for (let i = 0; i < 20; i++) { + const ev = step(s, mulberry32(99 + i)); + if (ev.some((e) => e.type === 'rowShift')) sawShift = true; + } + check('rows emerge as the stack rises', s.rowsRaised > raised0 && sawShift); + check('riseOffset stays in [0,1)', s.riseOffset >= 0 && s.riseOffset < 1, `${s.riseOffset}`); +} + +// ── 7. Top-out / game over ────────────────────────────────────────────────── +section('7. Top-out'); +{ + const s = newGame({ mode: 'endless', rng: mulberry32(3) }); + // fill the entire board so row 0 is occupied and nothing can clear + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + s.board[r][c] = { color: 'red', id: 10000 + r * COLS + c, state: 'idle', chain: false }; + } + // give it distinct colors to avoid instant clears masking the top-out + const cols = ['red', 'yellow', 'green', 'cyan', 'purple', 'blue']; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) s.board[r][c].color = cols[(r + c) % 6]; + let over = false; + for (let i = 0; i < TUNING.PANIC_GRACE + 50 && !over; i++) { + const ev = step(s, mulberry32(1 + i)); + if (ev.some((e) => e.type === 'gameOver')) over = true; + } + check('full board tops out', over && s.over); +} + +// ── 8. Stage Clear win condition ──────────────────────────────────────────── +section('8. Stage Clear'); +{ + // one target row of 3 that clears immediately → win + const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 0 } }); + // hand-place a single clearing target group + for (let c = 0; c < 3; c++) s.board[ROWS - 1][c] = { color: 'green', id: c + 1, state: 'idle', chain: false, target: true }; + s.targetsTotal = 3; + let won = false; + for (let i = 0; i < 200 && !won; i++) { + const ev = step(s, mulberry32(50 + i)); + if (ev.some((e) => e.type === 'win')) won = true; + } + check('clearing all targets wins the stage', won && s.won); + check('no targets remain on win', remainingTargets(s) === 0); +} + +// ── 9. Puzzle bank ────────────────────────────────────────────────────────── +section('9. Puzzle bank'); +{ + const bankPath = join(__dirname, '..', 'data', 'tetrisattack-puzzles.json'); + const bank = JSON.parse(readFileSync(bankPath, 'utf8')); + check('bank has puzzles', bank.puzzles.length >= 12, `${bank.puzzles.length}`); + let solvable = 0; + let optimal = 0; + for (const p of bank.puzzles) { + const sol = solvePuzzle({ grid: p.grid, maxMoves: p.maxMoves }, () => 0.5); + if (sol) { + solvable++; + if (sol.length <= p.maxMoves) optimal++; + // apply the solution and confirm the board empties + const s = newGame({ mode: 'puzzle', puzzle: { grid: p.grid, maxMoves: p.maxMoves } }); + for (const mv of sol) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); resolveFully(s, () => 0.5); } + const empty = s.board.every((row) => row.every((c) => c === null)); + if (!empty) { failures++; checks++; console.log(` ✗ puzzle ${p.id} solution did not empty board`); } + else checks++; + } + } + check('every puzzle is solvable', solvable === bank.puzzles.length, `${solvable}/${bank.puzzles.length}`); + check('every puzzle solvable within maxMoves', optimal === bank.puzzles.length, `${optimal}/${bank.puzzles.length}`); +} + +// ── 10. Determinism ───────────────────────────────────────────────────────── +section('10. Determinism'); +{ + const boardHash = (s) => s.board.map((row) => row.map((p) => (p ? p.color[0] : '.')).join('')).join('|'); + const run = (seed) => { + const rng = mulberry32(seed); + const s = newGame({ mode: 'endless', rng }); + const trace = []; + for (let i = 0; i < 400; i++) { + if (i % 5 === 0) { const mv = findClearingSwap(s); if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); } } + step(s, rng); + trace.push(`${s.score}:${boardHash(s)}`); + } + return trace.join(','); + }; + check('seeded runs are identical', run(12345) === run(12345)); + check('different seeds differ', run(12345) !== run(999)); +} + +// ── 11. Monte-carlo self-play (invariants) ────────────────────────────────── +section('11. Self-play invariants'); +{ + let games = 0, totalTicks = 0, gameOvers = 0, maxScore = 0, invariantBreaks = 0; + for (let g = 0; g < 40; g++) { + const seed = 1000 + g; + const rng = mulberry32(seed); + const s = newGame({ mode: 'endless', rng }); + let lastScore = 0; + for (let t = 0; t < 1500 && !s.over; t++) { + // occasionally act: make a clearing swap when one exists + if (t % 5 === 0) { + const mv = findClearingSwap(s); + if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); } + } + step(s, rng); + totalTicks++; + // invariant: score never decreases + if (s.score < lastScore) invariantBreaks++; + lastScore = s.score; + // invariant: cursor in bounds + if (s.cursor.row < 0 || s.cursor.row >= ROWS || s.cursor.col < 0 || s.cursor.col > COLS - 2) invariantBreaks++; + // invariant: when fully settled, no floating idle panels + if (isSettled(s)) { + for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) { + if (s.board[r][c] && s.board[r][c].state === 'idle' && !s.board[r + 1][c]) invariantBreaks++; + } + } + } + games++; + if (s.over) gameOvers++; + maxScore = Math.max(maxScore, s.score); + } + check('self-play produced no invariant breaks', invariantBreaks === 0, `${invariantBreaks} breaks`); + check('self-play scored points', maxScore > 0, `max ${maxScore}`); + console.log(` (ran ${games} games, ${totalTicks} ticks, ${gameOvers} top-outs, peak score ${maxScore})`); +} + +// ── Summary ───────────────────────────────────────────────────────────────── +console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'} — ${checks - failures}/${checks} checks passed`); +process.exit(failures ? 1 : 0);