feat: add Jell-o Monsters (Pudding Monsters) slide-and-merge puzzle game

Introduce a new single-player logic puzzle game where players flick jelly
blobs across a grid to slide and merge them into a single monster.

- PuddingMonstersLogic.js: pure slide-and-merge model with BFS solver,
  no Phaser/DOM dependencies; shared by client and generator
- PuddingMonstersGame.js: Phaser scene with drag-to-flick, arrow keys,
  undo/reset/hint, par tracking, star collection, death on spikes
- genPuddingMonsters.js: offline level generator using seeded RNG and
  BFS solver; produces 40 levels across 5 difficulty tiers
- puddingmonsters.json: generated level data (40 levels, 5–7 grid sizes)
- Update main.js, GameRoomScene.js, PreloadScene.js, registry.js to
  wire the new game into the app
- Update game-icons assets
This commit is contained in:
Brian Fertig 2026-06-08 21:58:28 -06:00
parent 147ef4b89b
commit ea44758f7d
10 changed files with 3244 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,662 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, starsCollected,
} from './PuddingMonstersLogic.js';
const BG = 0x161226;
const FRAME = 0x241c3a;
const FLOOR = 0x2b2347;
const FLOOR_LN = 0x372c59;
const WALL = 0x0e0a1a;
const WALL_HI = 0x3a2f56;
const SPIKE_BG = 0x3a1320;
const SPIKE = 0xe0506b;
const STAR_OFF = 0x6b5a8a;
const STAR_ON = 0xffd54a;
const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d];
const D = { frame: -1, floor: 0, deco: 1, star: 2, monster: 10, anim: 12, ui: 30, overlay: 60, overlayUI: 62 };
export default class PuddingMonstersGame extends Phaser.Scene {
constructor() { super('PuddingMonstersGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'puddingmonsters', name: 'Jell-o Monsters' };
this.bank = [];
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'select';
this.level = 0;
this.levelDef = null;
this.state = null;
this.undoStack = [];
this.moves = 0;
this.par = 0;
this.overlayUp = false;
this.busy = false;
this.drag = null;
this.selectedCell = null;
this.animGfx = null;
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2);
const raw = this.cache.json.get('puddingmonsters');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
try {
const res = await api.get('/puzzles/puddingmonsters/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) {
this.canPersist = false;
this.levelsCompleted = 0;
}
this.layer = this.add.container(0, 0);
this.bindInput();
this.showLevelSelect();
}
bindInput() {
this.input.on('pointerdown', (p) => this.onPointerDown(p));
this.input.on('pointerup', (p) => this.onPointerUp(p));
this.input.keyboard.on('keydown', (e) => this.onKey(e));
}
clearLayer() {
this.layer.removeAll(true);
this.monsterGfx = null;
this.starGfx = null;
this.animGfx = null;
this.undoBtn = null;
this.movesText = null;
this.starsText = null;
}
bestStars(level) {
try { return Number(localStorage.getItem(`pm-stars-${level}`)) || 0; } catch (_) { return 0; }
}
saveStars(level, stars) {
try {
if (stars > this.bestStars(level)) localStorage.setItem(`pm-stars-${level}`, String(stars));
} catch (_) { /* ignore */ }
}
// ── Level select ────────────────────────────────────────────────────────────
showLevelSelect() {
this.view = 'select';
this.overlayUp = false;
this.busy = false;
this.drag = null;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'JELL-O MONSTERS', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 140, 'Flick the jellies so they slide and stick — merge them all into one. Grab the 3 stars!', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genPuddingMonsters.js', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
this.layer.add(msg);
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
this.layer.add(back);
return;
}
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const prog = this.add.text(cx, 184, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add(prog);
const COLS = 10;
const SIZE = 120;
const GAP = 16;
const gridW = COLS * SIZE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2;
const top = 280;
this.bank.forEach((p, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = left + col * (SIZE + GAP);
const y = top + row * (SIZE + GAP);
const level = p.level;
const cleared = level <= this.levelsCompleted;
const playable = level <= nextLevel;
const fill = cleared ? 0x35265a : playable ? 0x271f44 : 0x191427;
const stroke = cleared ? 0xc792ff : playable ? COLORS.gold : 0x2a2440;
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
const num = this.add.text(x, y - 18, String(level), {
fontFamily: 'Righteous', fontSize: '40px',
color: playable || cleared ? COLORS.textHex : '#564f6b',
}).setOrigin(0.5);
this.layer.add([tile, num]);
if (playable || cleared) {
const earned = this.bestStars(level);
const stars = this.add.text(x, y + 30, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), {
fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#6b5a8a',
}).setOrigin(0.5);
const par = this.add.text(x, y + 52, `par ${p.par}`, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([stars, par]);
} else {
const lock = this.add.text(x, y + 36, 'locked', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#564f6b',
}).setOrigin(0.5);
this.layer.add(lock);
}
if (playable) {
tile.setInteractive({ useHandCursor: true });
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
tile.on('pointerup', () => this.playLevel(level));
}
});
const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
{ width: 300, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
this.layer.add([resume, back]);
if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add(note);
}
}
// ── Play a level ──────────────────────────────────────────────────────────────
playLevel(level) {
const def = this.bank.find((p) => p.level === level);
if (!def) return;
this.view = 'play';
this.level = level;
this.levelDef = def;
this.par = def.par;
this.state = newState(def);
this.undoStack = [];
this.moves = 0;
this.overlayUp = false;
this.busy = false;
this.drag = null;
this.selectedCell = null;
this.clearLayer();
this.computeLayout();
this.buildBoard();
this.starGfx = this.add.graphics().setDepth(D.star); this.layer.add(this.starGfx);
this.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx);
this.drawStars();
this.drawAllMonsters();
this.drawHud();
this.updateHud();
}
computeLayout() {
const { cols, rows } = this.state;
const LEFT_STRIP = 300;
const TOP = 170;
const BOTTOM = GAME_HEIGHT - 50;
const RIGHT = GAME_WIDTH - 50;
this.cell = Math.min((RIGHT - LEFT_STRIP) / cols, (BOTTOM - TOP) / rows, 132);
const boardW = cols * this.cell;
const boardH = rows * this.cell;
this.originX = LEFT_STRIP + (RIGHT - LEFT_STRIP - boardW) / 2;
this.originY = TOP + (BOTTOM - TOP - boardH) / 2;
}
cellLeft(x) { return this.originX + x * this.cell; }
cellTop(y) { return this.originY + y * this.cell; }
cellCx(x) { return this.originX + (x + 0.5) * this.cell; }
cellCy(y) { return this.originY + (y + 0.5) * this.cell; }
cellFromPx(px, py) {
const x = Math.floor((px - this.originX) / this.cell);
const y = Math.floor((py - this.originY) / this.cell);
if (x < 0 || y < 0 || x >= this.state.cols || y >= this.state.rows) return null;
return { x, y };
}
buildBoard() {
const { cols, rows } = this.state;
const boardW = cols * this.cell;
const boardH = rows * this.cell;
const frame = this.add.graphics().setDepth(D.frame);
frame.fillStyle(FRAME, 1);
frame.fillRoundedRect(this.originX - 18, this.originY - 18, boardW + 36, boardH + 36, 22);
this.layer.add(frame);
const floor = this.add.graphics().setDepth(D.floor);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const k = `${x},${y}`;
if (this.state.walls.has(k)) continue;
floor.fillStyle(FLOOR, 1);
floor.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
floor.lineStyle(1, FLOOR_LN, 1);
floor.strokeRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
}
}
this.layer.add(floor);
const deco = this.add.graphics().setDepth(D.deco);
for (const k of this.state.walls) {
const [x, y] = k.split(',').map(Number);
deco.fillStyle(WALL, 1);
deco.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
deco.fillStyle(WALL_HI, 1);
deco.fillRoundedRect(this.cellLeft(x) + 6, this.cellTop(y) + 6, this.cell - 12, this.cell * 0.22, 6);
}
for (const k of this.state.spikes) {
const [x, y] = k.split(',').map(Number);
const L = this.cellLeft(x), T = this.cellTop(y), c = this.cell;
deco.fillStyle(SPIKE_BG, 1);
deco.fillRoundedRect(L + 2, T + 2, c - 4, c - 4, 8);
deco.fillStyle(SPIKE, 1);
const baseY = T + c - c * 0.18;
const tipY = T + c * 0.24;
for (let i = 0; i < 3; i++) {
const bx = L + c * (0.2 + i * 0.3);
deco.fillTriangle(bx, baseY, bx + c * 0.2, baseY, bx + c * 0.1, tipY);
}
}
this.layer.add(deco);
}
drawStars() {
const g = this.starGfx;
g.clear();
for (const [sx, sy] of this.state.stars) {
const on = this.state.collected.has(`${sx},${sy}`);
const cx = this.cellCx(sx), cy = this.cellCy(sy);
if (on) {
g.fillStyle(STAR_ON, 0.22);
g.fillCircle(cx, cy, this.cell * 0.42);
}
this.drawStarShape(g, cx, cy, this.cell * 0.26, on);
}
}
drawStarShape(g, cx, cy, r, filled) {
const pts = [];
for (let i = 0; i < 10; i++) {
const ang = (Math.PI / 180) * (-90 + i * 36);
const rad = i % 2 === 0 ? r : r * 0.45;
pts.push(cx + rad * Math.cos(ang), cy + rad * Math.sin(ang));
}
if (filled) {
g.fillStyle(STAR_ON, 1);
g.beginPath(); g.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]);
g.closePath(); g.fillPath();
}
g.lineStyle(3, filled ? 0xffec99 : STAR_OFF, 1);
g.beginPath(); g.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]);
g.closePath(); g.strokePath();
}
colorFor(blob) {
const [x, y] = repCell(blob);
return PALETTE[(x * 7 + y * 13) % PALETTE.length];
}
drawAllMonsters(excludeIdx = -1) {
const g = this.monsterGfx;
g.clear();
this.state.blobs.forEach((blob, i) => {
if (i === excludeIdx) return;
const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i;
this.drawBlobInto(g, blob.cells, this.colorFor(blob), 0, 0, sel);
});
}
drawBlobInto(g, cells, color, ox, oy, selected = false) {
const c = this.cell;
const inset = c * 0.10;
const r = c * 0.30;
const set = new Set(cells.map(([x, y]) => `${x},${y}`));
g.fillStyle(color, 1);
for (const [x, y] of cells) {
g.fillRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
}
for (const [x, y] of cells) {
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
if (set.has(`${x + 1},${y}`)) g.fillRect(L + c - inset - 1, T + inset, 2 * inset + 2, c - 2 * inset);
if (set.has(`${x},${y + 1}`)) g.fillRect(L + inset, T + c - inset - 1, c - 2 * inset, 2 * inset + 2);
}
// glossy highlight
g.fillStyle(0xffffff, 0.18);
for (const [x, y] of cells) {
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
g.fillRoundedRect(L + inset + c * 0.12, T + inset + c * 0.10, c * 0.30, c * 0.14, c * 0.07);
}
if (selected) {
g.lineStyle(3, 0xffffff, 0.85);
for (const [x, y] of cells) {
g.strokeRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
}
}
// eyes at centroid
let ex = 0, ey = 0;
for (const [x, y] of cells) { ex += this.cellCx(x) + ox; ey += this.cellCy(y) + oy; }
ex /= cells.length; ey /= cells.length;
const er = c * 0.12, sp = c * 0.17;
g.fillStyle(0xffffff, 1);
g.fillCircle(ex - sp, ey - er * 0.6, er); g.fillCircle(ex + sp, ey - er * 0.6, er);
g.fillStyle(0x1a1a1a, 1);
g.fillCircle(ex - sp + er * 0.2, ey - er * 0.45, er * 0.5); g.fillCircle(ex + sp + er * 0.2, ey - er * 0.45, er * 0.5);
}
drawHud() {
const title = this.add.text(40, 80, 'JELL-O\nMONSTERS', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, lineSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add(title);
this.movesText = this.add.text(GAME_WIDTH - 50, 80, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.starsText = this.add.text(GAME_WIDTH - 50, 124, '', {
fontFamily: 'serif', fontSize: '30px', color: '#ffd54a',
}).setOrigin(1, 0.5).setDepth(D.ui);
this.layer.add([this.movesText, this.starsText]);
const BTN_W = 200;
const BTN_H = 56;
const BTN_GAP = 14;
const BTN_X = 150;
const totalH = 4 * BTN_H + 3 * BTN_GAP;
let y = GAME_HEIGHT / 2 - totalH / 2;
const undo = new Button(this, BTN_X, y, 'Undo', () => this.undo(), { width: BTN_W, height: BTN_H, fontSize: 22 });
y += BTN_H + BTN_GAP;
const reset = new Button(this, BTN_X, y, 'Reset', () => this.resetLevel(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
y += BTN_H + BTN_GAP;
const hint = new Button(this, BTN_X, y, 'Hint', () => this.showHint(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
y += BTN_H + BTN_GAP;
const levels = new Button(this, BTN_X, y, 'Levels', () => this.showLevelSelect(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.undoBtn = undo;
this.layer.add([undo, reset, hint, levels]);
const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag a monster to\nslide it. Arrow keys\nflick the selected one.', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.ui);
this.layer.add(tip);
}
updateHud() {
if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`);
if (this.starsText) this.starsText.setText(`${starsCollected(this.state)}/3`);
if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0);
}
// ── Input handling ──────────────────────────────────────────────────────────
onPointerDown(p) {
if (this.view !== 'play' || this.overlayUp || this.busy) return;
const cell = this.cellFromPx(p.x, p.y);
if (cell && blobAt(this.state, cell.x, cell.y) >= 0) {
this.drag = { cell, sx: p.x, sy: p.y };
this.selectedCell = cell;
this.drawAllMonsters();
} else {
this.drag = null;
}
}
onPointerUp(p) {
if (this.view !== 'play' || this.overlayUp || this.busy || !this.drag) return;
const dx = p.x - this.drag.sx;
const dy = p.y - this.drag.sy;
const cell = this.drag.cell;
this.drag = null;
if (Math.max(Math.abs(dx), Math.abs(dy)) < this.cell * 0.28) return; // tap, not a flick
const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
this.doFlick(cell, dir);
}
onKey(e) {
if (this.view !== 'play' || this.overlayUp || this.busy || !this.selectedCell) return;
const map = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
const dir = map[e.key];
if (dir) { e.preventDefault?.(); this.doFlick(this.selectedCell, dir); }
}
// ── Flick / move ────────────────────────────────────────────────────────────
doFlick(cell, dir) {
const idx = blobAt(this.state, cell.x, cell.y);
if (idx < 0) return;
const { maxSteps, deathStep } = computeSlide(this.state, idx, dir);
if (maxSteps === 0) { this.nudgeInvalid(idx); return; }
this.busy = true;
this.undoStack.push(cloneState(this.state));
this.moves++;
this.updateHud();
const movingCells = this.state.blobs[idx].cells.map((c) => [c[0], c[1]]);
const color = this.colorFor(this.state.blobs[idx]);
const [dx, dy] = DIRS[dir];
const steps = deathStep > 0 ? deathStep : maxSteps;
this.drawAllMonsters(idx);
this.animGfx = this.add.graphics().setDepth(D.anim);
this.layer.add(this.animGfx);
const proxy = { v: 0 };
const tgtX = dx * steps * this.cell;
const tgtY = dy * steps * this.cell;
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, color, tgtX * t, tgtY * t); };
drawAt(0);
playSound(this, SFX.PIECE_CLICK);
this.tweens.add({
targets: proxy, v: 1,
duration: Math.min(420, 110 + steps * 55),
ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut',
onUpdate: () => drawAt(proxy.v),
onComplete: () => {
if (deathStep > 0) this.splatAndFail(idx, dir);
else this.commitMove(idx, dir, movingCells, dx, dy, steps);
},
});
}
commitMove(idx, dir, movingCells, dx, dy, steps) {
const res = slide(this.state, idx, dir);
if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; }
// keep the moved blob selected at its new resting position
this.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps };
this.drawAllMonsters();
this.drawStars();
this.updateHud();
if (res.merged) playSound(this, SFX.CARD_PLACE);
this.busy = false;
if (this.state.state === 'won') this.onSolved();
}
splatAndFail(idx, dir) {
slide(this.state, idx, dir); // marks state 'dead' (positions unchanged)
playSound(this, SFX.SCIFI_EXPLODE);
const g = this.animGfx;
if (g) {
this.tweens.add({
targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn',
onComplete: () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(); },
});
} else {
this.onDead();
}
}
nudgeInvalid(idx) {
// brief wobble of the whole monster layer to signal "can't move"
const blob = this.state.blobs[idx];
const cx = this.cellCx(repCell(blob)[0]);
void cx;
this.tweens.add({ targets: this.monsterGfx, x: 6, duration: 50, yoyo: true, repeat: 1, onComplete: () => { this.monsterGfx.x = 0; } });
}
undo() {
if (!this.undoStack.length || this.busy || this.overlayUp) return;
this.state = this.undoStack.pop();
this.moves++; // an undo still counts as a move (matches Rush Hour)
this.selectedCell = null;
this.drawAllMonsters();
this.drawStars();
this.updateHud();
playSound(this, SFX.PIECE_CLICK);
}
resetLevel() {
if (this.busy || this.overlayUp) return;
this.state = newState(this.levelDef);
this.undoStack = [];
this.moves = 0;
this.selectedCell = null;
this.drawAllMonsters();
this.drawStars();
this.updateHud();
playSound(this, SFX.CARD_SHUFFLE);
}
showHint() {
if (this.busy || this.overlayUp) return;
const { path } = solve(this.state, { maxStates: 200000 });
if (!path || !path.length) return;
const mv = path[0];
const idx = blobAt(this.state, mv.cell[0], mv.cell[1]);
if (idx < 0) return;
this.selectedCell = { x: mv.cell[0], y: mv.cell[1] };
this.drawAllMonsters();
const [dx, dy] = DIRS[mv.dir];
const cx = this.cellCx(mv.cell[0]);
const cy = this.cellCy(mv.cell[1]);
const arrow = this.add.text(cx + dx * this.cell * 0.5, cy + dy * this.cell * 0.5,
({ up: '↑', down: '↓', left: '←', right: '→' })[mv.dir], {
fontFamily: 'Righteous', fontSize: `${Math.round(this.cell * 0.6)}px`, color: '#ffffff',
}).setOrigin(0.5).setDepth(D.anim);
this.layer.add(arrow);
this.tweens.add({ targets: arrow, alpha: 0, scale: 1.4, duration: 900, ease: 'Quad.easeOut', onComplete: () => arrow.destroy() });
}
// ── End states ──────────────────────────────────────────────────────────────
onDead() {
this.overlayUp = true;
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
this.layer.add(panel);
const title = this.add.text(cx, cy - 90, 'Splat!', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 14, 'A monster slid onto the spikes. Try again!', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, msg]);
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),
{ width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(),
{ width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([retry, levels]);
}
onSolved() {
this.overlayUp = true;
const stars = starsCollected(this.state);
this.saveStars(this.level, stars);
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
api.post('/puzzles/puddingmonsters/complete', { level: this.level })
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
.catch(() => { /* best effort */ });
api.post('/history/single-player', {
slug: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win',
}).catch(() => { /* best effort */ });
playSound(this, SFX.VICTORY_SHORT);
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20);
panel.lineStyle(3, 0xc792ff, 1);
panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20);
this.layer.add(panel);
const title = this.add.text(cx, cy - 140, 'Solved!', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), {
fontFamily: 'serif', fontSize: '56px', color: '#ffd54a',
}).setOrigin(0.5).setDepth(D.overlayUI);
const beatPar = this.moves <= this.par;
const stat = this.add.text(cx, cy - 4,
`Level ${this.level} cleared in ${this.moves} moves (par ${this.par})${beatPar ? ' ★ par or better!' : ''}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, starRow, stat]);
const hasNext = this.level < this.bank.length;
const btns = [];
if (hasNext) {
btns.push(new Button(this, cx, cy + 70, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
} else {
btns.push(this.add.text(cx, cy + 62, 'You cleared every level. Sweet!', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
}
const replay = new Button(this, cx - 120, cy + 150, 'Replay', () => this.playLevel(this.level),
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
const levels = new Button(this, cx + 120, cy + 150, 'Levels', () => this.showLevelSelect(),
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
btns.push(replay, levels);
this.layer.add(btns);
}
}

View File

@ -0,0 +1,232 @@
// Pudding Monsters — pure slide-and-merge model + BFS solver. No Phaser, no DOM.
// Shared by the client scene and the offline level generator (both ESM).
//
// A level is a grid of cols x rows. Monsters are jelly blobs (each starts as one
// cell). Flicking a blob slides it in a direction until any of its cells would
// leave the board, hit a WALL, or hit another blob — classic "ice slide". When a
// blob comes to rest orthogonally adjacent to another blob they STICK into one
// rigid blob. The level is solved when every monster has merged into a single
// connected blob.
//
// SPIKES are deadly: if a blob's slide path crosses a spike it dies (the run
// fails and the level must be restarted). STARS are bonus floor tiles collected
// when a monster covers them.
//
// A blob: { cells: [[x,y], ...] } (rigid; moves as a unit)
export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
export const DIR_LIST = ['up', 'down', 'left', 'right'];
const ADJ = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const key = (x, y) => `${x},${y}`;
function occupiedAt(state, x, y) {
for (const b of state.blobs) {
for (const [cx, cy] of b.cells) if (cx === x && cy === y) return true;
}
return false;
}
export function blobAt(state, x, y) {
for (let i = 0; i < state.blobs.length; i++) {
for (const [cx, cy] of state.blobs[i].cells) if (cx === x && cy === y) return i;
}
return -1;
}
// Lowest cell (top-left in row-major order) — a stable representative for a blob.
export function repCell(blob) {
return blob.cells.reduce((best, c) =>
(c[1] < best[1] || (c[1] === best[1] && c[0] < best[0])) ? c : best, blob.cells[0]);
}
// Union any blobs that are orthogonally adjacent, transitively, into single blobs.
function mergeBlobs(state) {
const occ = new Map();
state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i)));
const parent = state.blobs.map((_, i) => i);
const find = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
state.blobs.forEach((b, i) => {
for (const [x, y] of b.cells) {
for (const [dx, dy] of ADJ) {
const j = occ.get(key(x + dx, y + dy));
if (j !== undefined && j !== i) union(i, j);
}
}
});
const groups = new Map();
state.blobs.forEach((b, i) => {
const r = find(i);
if (!groups.has(r)) groups.set(r, []);
groups.get(r).push(...b.cells);
});
state.blobs = [...groups.values()].map((cells) => ({ cells }));
}
function pickUpStars(state) {
for (const [sx, sy] of state.stars) {
if (!state.collected.has(key(sx, sy)) && occupiedAt(state, sx, sy)) {
state.collected.add(key(sx, sy));
}
}
}
export function starsCollected(state) {
return state.collected.size;
}
// How far blob `idx` can slide in `dir`, and whether that path crosses a spike.
// Pure (does not mutate). { maxSteps, deathStep } — deathStep>0 means fatal.
export function computeSlide(state, idx, dir) {
const [dx, dy] = DIRS[dir];
const blob = state.blobs[idx];
const other = new Set();
state.blobs.forEach((b, i) => {
if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y)));
});
let maxSteps = 0;
const limit = state.cols + state.rows;
for (let s = 1; s <= limit; s++) {
let ok = true;
for (const [x, y] of blob.cells) {
const nx = x + dx * s, ny = y + dy * s;
if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { ok = false; break; }
if (state.walls.has(key(nx, ny)) || other.has(key(nx, ny))) { ok = false; break; }
}
if (!ok) break;
maxSteps = s;
}
let deathStep = 0;
for (let s = 1; s <= maxSteps && deathStep === 0; s++) {
for (const [x, y] of blob.cells) {
if (state.spikes.has(key(x + dx * s, y + dy * s))) { deathStep = s; break; }
}
}
return { maxSteps, deathStep };
}
// Flick blob `idx` in `dir`. Mutates state. Returns:
// { moved:false } — couldn't move (no-op)
// { moved:true, dead:true, deathStep } — slid onto a spike (run fails)
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged
export function slide(state, idx, dir) {
const { maxSteps, deathStep } = computeSlide(state, idx, dir);
if (maxSteps === 0) return { moved: false };
const [dx, dy] = DIRS[dir];
if (deathStep > 0) {
state.state = 'dead';
return { moved: true, dead: true, deathStep };
}
const blob = state.blobs[idx];
blob.cells = blob.cells.map(([x, y]) => [x + dx * maxSteps, y + dy * maxSteps]);
const before = state.blobs.length;
mergeBlobs(state);
pickUpStars(state);
state.state = state.blobs.length === 1 ? 'won' : 'playing';
return { moved: true, dead: false, merged: state.blobs.length < before, steps: maxSteps };
}
// Every non-fatal flick available. Each entry carries the blob's representative
// cell so a move stays identifiable after merges renumber the blobs.
export function legalMoves(state) {
const moves = [];
state.blobs.forEach((blob, idx) => {
for (const dir of DIR_LIST) {
const { maxSteps, deathStep } = computeSlide(state, idx, dir);
if (maxSteps > 0 && deathStep === 0) moves.push({ idx, dir, cell: repCell(blob) });
}
});
return moves;
}
// Canonical key: cells sorted within each blob, blobs sorted, joined. Walls and
// spikes are fixed for a level, so this fully identifies a configuration.
export function stateKey(state) {
return state.blobs
.map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';'))
.sort()
.join('|');
}
export function cloneState(state) {
return {
cols: state.cols,
rows: state.rows,
walls: state.walls, // immutable during play — shared
spikes: state.spikes, // immutable during play — shared
stars: state.stars, // immutable during play — shared
blobs: state.blobs.map((b) => ({ cells: b.cells.map(([x, y]) => [x, y]) })),
collected: new Set(state.collected),
state: state.state,
};
}
export function newState(level) {
const state = {
cols: level.cols,
rows: level.rows,
walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))),
spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))),
stars: (level.stars ?? []).map((c) => [c[0], c[1]]),
blobs: (level.monsters ?? []).map(([x, y]) => ({ cells: [[x, y]] })),
collected: new Set(),
state: 'playing',
};
mergeBlobs(state); // merge any monsters that start touching
pickUpStars(state);
state.state = state.blobs.length === 1 ? 'won' : 'playing';
return state;
}
// Breadth-first shortest solution (fewest flicks to merge everything into one
// blob). Returns { moves, path, footprint }:
// moves — minimum flicks (0 if already solved, -1 if none within maxStates)
// path — [{ cell:[x,y], dir }] optimal flicks (null if unsolvable)
// footprint — cells of the final single blob (null if unsolvable); the
// generator marks 3 of these as stars to guarantee a 3-star clear.
export function solve(state, { maxStates = 200000 } = {}) {
const start = cloneState(state);
if (start.blobs.length === 1) return { moves: 0, path: [], footprint: start.blobs[0].cells };
const startKey = stateKey(start);
const meta = new Map([[startKey, null]]);
const stateByKey = new Map([[startKey, start]]);
let frontier = [startKey];
let depth = 0;
while (frontier.length) {
depth++;
const next = [];
for (const k of frontier) {
const cur = stateByKey.get(k);
for (const mv of legalMoves(cur)) {
const ns = cloneState(cur);
slide(ns, mv.idx, mv.dir);
const nk = stateKey(ns);
if (meta.has(nk)) continue;
meta.set(nk, { parentKey: k, move: { cell: mv.cell, dir: mv.dir } });
if (ns.blobs.length === 1) {
const path = [];
let c = nk;
while (meta.get(c)) { const e = meta.get(c); path.unshift(e.move); c = e.parentKey; }
return { moves: depth, path, footprint: ns.blobs[0].cells };
}
stateByKey.set(nk, ns);
next.push(nk);
}
if (meta.size > maxStates) return { moves: -1, path: null, footprint: null };
}
frontier = next;
if (depth > 60) break;
}
return { moves: -1, path: null, footprint: null };
}

View File

@ -63,6 +63,7 @@ import TriominoesGame from './games/triominoes/TriominoesGame.js';
import FreecellGame from './games/freecell/FreecellGame.js';
import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
const config = {
type: Phaser.AUTO,
@ -139,6 +140,7 @@ const config = {
FreecellGame,
RushHourGame,
HexsweeperGame,
PuddingMonstersGame,
],
};

View File

@ -22,7 +22,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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -60,6 +60,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json');
this.load.json('rushhour', '/data/rushhour.json');
this.load.json('puddingmonsters', '/data/puddingmonsters.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');

View File

@ -78,3 +78,4 @@ registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category:
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });
registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 });

View File

@ -0,0 +1,178 @@
// Offline generator for Pudding Monsters levels.
//
// For each difficulty tier it random-fills a grid with walls, spikes and K
// monsters, runs the BFS solver to (a) reject unsolvable/trivial layouts and
// (b) label each survivor with its minimum flick count (par), then places 3
// guaranteed-collectable stars on cells of that solution's final footprint and
// writes ordered levels to public/data/puddingmonsters.json.
//
// Usage:
// node server/scripts/genPuddingMonsters.js [seed] [outFile]
//
// Deterministic: same seed -> same bank. Re-run after changing the curve.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { newState, solve } from '../../public/src/games/puddingmonsters/PuddingMonstersLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[3]
? path.resolve(process.argv[3])
: path.join(__dirname, '../../public/data/puddingmonsters.json');
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x5eed1234;
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
function makeRng(seed) {
let a = seed >>> 0;
return () => {
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;
};
}
const rng = makeRng(SEED);
const randInt = (n) => Math.floor(rng() * n);
// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each
// keeps `count` levels whose par falls in [minPar, maxPar]. Spikes appear only
// in later tiers (yield + difficulty). Levels are numbered tier-by-tier.
const TIERS = [
{ count: 8, cols: 5, rows: 5, monsters: 3, walls: 0, spikes: 0, minPar: 2, maxPar: 3 },
{ count: 8, cols: 6, rows: 6, monsters: 3, walls: 2, spikes: 0, minPar: 3, maxPar: 5 },
{ count: 8, cols: 6, rows: 6, monsters: 4, walls: 3, spikes: 0, minPar: 4, maxPar: 7 },
{ count: 8, cols: 7, rows: 7, monsters: 4, walls: 4, spikes: 1, minPar: 5, maxPar: 9 },
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 5, spikes: 2, minPar: 7, maxPar: 14 },
];
const MAX_ATTEMPTS = 6000000;
const MAX_SECONDS = 200;
const SOLVE_MAX_STATES = 80000;
const keyOf = (x, y) => `${x},${y}`;
// Place `n` distinct random cells avoiding `taken`; returns null if it can't.
function placeCells(n, cols, rows, taken) {
const out = [];
let tries = 0;
while (out.length < n && tries < 400) {
tries++;
const x = randInt(cols), y = randInt(rows);
const k = keyOf(x, y);
if (taken.has(k)) continue;
taken.add(k);
out.push([x, y]);
}
return out.length === n ? out : null;
}
// Build one random candidate level for a tier (or null on failure).
function randomLevel(tier) {
const taken = new Set();
const walls = placeCells(tier.walls, tier.cols, tier.rows, taken);
if (!walls) return null;
const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken);
if (!spikes) return null;
const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken);
if (!monsters) return null;
return {
cols: tier.cols, rows: tier.rows, walls, spikes, monsters, stars: [],
};
}
function canonKey(lvl) {
const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' ');
return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`;
}
// Pick 3 spread-out, ideally non-starting footprint cells -> guaranteed stars.
function chooseStars(footprint, monsters) {
const startSet = new Set(monsters.map(([x, y]) => keyOf(x, y)));
const nonStart = footprint.filter(([x, y]) => !startSet.has(keyOf(x, y)));
const pool = nonStart.length >= 3 ? nonStart : footprint;
const sorted = pool.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0]));
const idx = [0, Math.floor(sorted.length / 2), sorted.length - 1];
return [...new Set(idx)].map((i) => [sorted[i][0], sorted[i][1]]);
}
// ── Generate pool ────────────────────────────────────────────────────────────
console.log(`[pudding] generating with seed ${SEED}`);
const target = TIERS.reduce((t, x) => t + x.count, 0);
const buckets = TIERS.map(() => []);
const seen = new Set();
let attempts = 0;
let solved = 0;
const startedAt = Date.now();
const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
while (attempts < MAX_ATTEMPTS && !tiersFull()) {
// Round-robin the tiers that still need levels so every tier gets airtime.
for (let ti = 0; ti < TIERS.length; ti++) {
if (buckets[ti].length >= TIERS[ti].count) continue;
attempts++;
if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) {
console.log('\n[pudding] time budget reached, stopping early');
break;
}
const tier = TIERS[ti];
const lvl = randomLevel(tier);
if (!lvl) continue;
const ck = canonKey(lvl);
if (seen.has(ck)) continue;
seen.add(ck);
const state = newState(lvl);
if (state.blobs.length !== tier.monsters) continue; // started adjacent -> skip
const res = solve(state, { maxStates: SOLVE_MAX_STATES });
if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue;
solved++;
lvl.stars = chooseStars(res.footprint, lvl.monsters);
if (lvl.stars.length !== 3) continue;
lvl.par = res.moves;
buckets[ti].push(lvl);
if (solved % 200 === 0) {
const kept = buckets.reduce((t, b) => t + b.length, 0);
process.stdout.write(`\r[pudding] attempts=${attempts} kept=${kept}/${target} `);
}
}
if ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break;
}
process.stdout.write('\n');
// ── Assemble ordered levels (tier order, then par ascending within tier) ──────
const chosen = [];
buckets.forEach((b) => {
b.sort((p, q) => p.par - q.par);
chosen.push(...b);
});
const levels = chosen.map((lvl, i) => ({
level: i + 1,
cols: lvl.cols,
rows: lvl.rows,
walls: lvl.walls,
spikes: lvl.spikes,
stars: lvl.stars,
monsters: lvl.monsters,
par: lvl.par,
}));
const payload = {
generatedAt: new Date().toISOString(),
seed: SEED,
count: levels.length,
levels,
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' ');
console.log(`[pudding] attempts=${attempts} solvable=${solved}`);
console.log(`[pudding] tiers filled: ${perTier}`);
console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`);