Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 85f0079b2c feat: overhaul Pudding Monsters scoring and add opponent videos
- Replace star collection with target-square coverage for Pudding Monsters
- New 3-star medal system: min(targets covered, par-based efficiency)
- Add "Reset Progress" buttons to Pudding Monsters and Rush Hour level select
- Add reset API endpoint for puzzle progress
- Add Beth and Blackwind opponents with idle/happy/upset animations
- Update puddingmonsters.json levels with target squares instead of stars
2026-06-08 22:24:12 -06:00
Brian Fertig ea44758f7d 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
2026-06-08 21:58:28 -06:00
21 changed files with 3343 additions and 2 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -500,6 +500,18 @@
"steve-pick"
]
}
},
{
"id": "beth",
"spriteIndex": 18,
"name": "Beth",
"bio": "I ain't seen you 'round these parts before."
},
{
"id": "blackwind",
"spriteIndex": 19,
"name": "Blackwind",
"bio": "Aaaaaaargh! Me be playin' these games matey!"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,699 @@
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, targetsCovered,
} 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 TARGET = 0xffd54a;
const TARGET_LN = 0xffec99;
const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d];
const D = { frame: -1, floor: 0, deco: 1, monster: 10, target: 11, 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.targetGfx = 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 */ }
}
// 3-star medal by efficiency: full marks at par, one star off per move over.
medalStars(moves, par) {
return Math.max(0, 3 - Math.max(0, moves - par));
}
// ── 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 to slide & stick. Merge them into one — finish on the yellow squares, in par, for ★★★.', {
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 });
const reset = new Button(this, 210, GAME_HEIGHT - 76, 'Reset Progress', () => this.confirmResetProgress(),
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
this.layer.add([resume, back, reset]);
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);
}
}
confirmResetProgress() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5);
const msg = this.add.text(cx, cy - 14,
'This clears every cleared level and your star\nmedals, back to Level 1. This cannot be undone.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
}).setOrigin(0.5);
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(),
{ width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex });
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 });
this.layer.add([dim, panel, title, msg, yes, no]);
}
doResetProgress() {
api.post('/puzzles/puddingmonsters/reset').catch(() => { /* best effort */ });
this.levelsCompleted = 0;
try { this.bank.forEach((p) => localStorage.removeItem(`pm-stars-${p.level}`)); } catch (_) { /* ignore */ }
this.showLevelSelect();
}
// ── 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.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx);
this.targetGfx = this.add.graphics().setDepth(D.target); this.layer.add(this.targetGfx);
this.drawAllMonsters();
this.drawTargets();
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);
}
}
for (const [tx, ty] of (this.levelDef.targets ?? [])) {
floor.fillStyle(TARGET, 0.30);
floor.fillRoundedRect(this.cellLeft(tx) + 2, this.cellTop(ty) + 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);
}
colorFor(blob) {
const [x, y] = repCell(blob);
return PALETTE[(x * 7 + y * 13) % PALETTE.length];
}
// Yellow target-square outlines, drawn above the monsters so coverage stays
// visible (white + thick when a monster is currently sitting on the square).
drawTargets() {
const g = this.targetGfx;
if (!g) return;
g.clear();
for (const [tx, ty] of (this.levelDef.targets ?? [])) {
const covered = blobAt(this.state, tx, ty) >= 0;
const L = this.cellLeft(tx), T = this.cellTop(ty), c = this.cell;
g.lineStyle(covered ? 5 : 3, covered ? 0xffffff : TARGET_LN, covered ? 1 : 0.9);
g.strokeRoundedRect(L + 5, T + 5, c - 10, c - 10, 8);
}
}
// Live star projection: you need the yellow squares covered AND par moves.
liveStars() {
const covered = targetsCovered(this.state, this.levelDef.targets);
return Math.min(covered, this.medalStars(this.moves, this.par));
}
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 slide it\n(or arrow keys). Finish on the\nyellow squares, in par, for ★★★.', {
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(`★ ${this.liveStars()}/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.drawTargets();
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.drawTargets();
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.drawTargets();
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 covered = targetsCovered(this.state, this.levelDef.targets);
const stars = Math.min(covered, this.medalStars(this.moves, this.par));
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 stat = this.add.text(cx, cy - 4,
`${this.moves} moves (par ${this.par}) • ${covered} / 3 yellow squares covered`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, starRow, stat]);
if (stars < 3) {
const hint = this.add.text(cx, cy + 34,
covered < 3 ? 'Finish with your blob on all 3 yellow squares for more stars.'
: 'Solve in par or fewer moves to keep all your stars.', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(hint);
}
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,240 @@
// 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 many of the given target cells are currently covered by a monster.
// Stars judge this at the final (won) configuration.
export function targetsCovered(state, targets) {
let n = 0;
for (const [tx, ty] of targets ?? []) if (occupiedAt(state, tx, ty)) n++;
return n;
}
// 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

@ -156,7 +156,9 @@ export default class RushHourGame extends Phaser.Scene {
{ width: 280, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
this.layer.add([resume, back]);
const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(),
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
this.layer.add([resume, back, reset]);
if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', {
@ -166,6 +168,35 @@ export default class RushHourGame extends Phaser.Scene {
}
}
confirmResetProgress() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5);
const msg = this.add.text(cx, cy - 14,
'This clears every level you have cleared and\nstarts you back at Level 1. This cannot be undone.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
}).setOrigin(0.5);
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(),
{ width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex });
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 });
this.layer.add([dim, panel, title, msg, yes, no]);
}
doResetProgress() {
api.post('/puzzles/rushhour/reset').catch(() => { /* best effort */ });
this.levelsCompleted = 0;
this.showLevelSelect();
}
// ── Play a level ────────────────────────────────────────────────────────────
playLevel(level) {

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

@ -51,4 +51,14 @@ router.post('/:slug/complete', requireAuth, (req, res) => {
res.json({ levelsCompleted: level });
});
// POST /api/puzzles/:slug/reset
// Clears the signed-in user's progress for this puzzle (back to level 0).
router.post('/:slug/reset', requireAuth, (req, res) => {
const { slug } = req.params;
if (!getGame(slug)) return res.status(400).json({ error: 'Unknown game slug.' });
db.prepare('DELETE FROM puzzle_progress WHERE user_id = ? AND slug = ?').run(req.user.id, slug);
res.json({ levelsCompleted: 0 });
});
export default router;

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 marks 3 cells
// of that solution's final footprint as yellow target squares and writes ordered
// levels to public/data/puddingmonsters.json. Stars at play time require BOTH:
// the final merged blob covering the targets AND solving in par (min of the two
// medals) — so the on-par solution lands on all 3 targets and scores 3 stars.
//
// 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,
};
}
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)}`;
}
// 3 spread-out cells of the solution's final footprint -> yellow target squares.
// The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct.
function chooseTargets(footprint) {
const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0]));
const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])];
return 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.targets = chooseTargets(res.footprint);
if (lvl.targets.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,
targets: lvl.targets,
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}`);