feat: add Tetris Attack (Panel de Pon) with endless, stage clear, and puzzle modes
Implement the full Tetris Attack clone with three game modes: - Endless: clear panels as the stack rises, speed increases with levels - Stage Clear: defeat 6 character opponents (Beth, Jerry, Michael, Steve, Victor, Klaxon) in sequence - Puzzle: solve 24 pre-vetted puzzles within move limits Core engine (src/games/tetrisattack/TetrisAttackLogic.js) is a pure, deterministic state machine with match detection, chain propagation, gravity, and scoring tables matching the SNES original. Includes a headless verifier (verifyTetrisAttack.js) testing match detection, chains, gravity invariants, top-out, puzzle solvability, and seeded determinism. Art is fully optional via drop-in spritesheets (spec: sprites.md); procedural fallbacks draw 16-bit-style panel icons with distinct shapes for colorblind access. Puzzle bank generated by genTetrisAttackPuzzles.js and verified as solvable within optimal move counts. Shared nintendo soundtrack reused via soundtrack overrides.
This commit is contained in:
parent
8e59c3b1bf
commit
85996ad02c
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
export const GAME_SOUNDTRACK_OVERRIDES = {
|
||||
superkart: 'nintendo',
|
||||
advancewars: 'nintendo',
|
||||
tetrisattack: 'nintendo',
|
||||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
mastermind: 'hacker',
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue