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
This commit is contained in:
Brian Fertig 2026-06-08 22:24:12 -06:00
parent ea44758f7d
commit 85f0079b2c
15 changed files with 224 additions and 126 deletions

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" "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!"
} }
] ]
} }

View File

@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-09T03:30:42.804Z", "generatedAt": "2026-06-09T04:18:02.258Z",
"seed": 1592594996, "seed": 1592594996,
"count": 40, "count": 40,
"levels": [ "levels": [
@ -9,7 +9,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -45,7 +45,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
4, 4,
2 2
@ -81,7 +81,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -117,7 +117,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -153,7 +153,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
1, 1,
0 0
@ -189,7 +189,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
3 3
@ -225,7 +225,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
1, 1,
3 3
@ -261,7 +261,7 @@
"rows": 5, "rows": 5,
"walls": [], "walls": [],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
3, 3,
0 0
@ -306,7 +306,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
1, 1,
0 0
@ -351,7 +351,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
5, 5,
4 4
@ -396,7 +396,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
2, 2,
0 0
@ -441,7 +441,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
2, 2,
4 4
@ -486,7 +486,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
4, 4,
4 4
@ -531,7 +531,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
2, 2,
0 0
@ -576,7 +576,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
3, 3,
1 1
@ -621,7 +621,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -670,17 +670,17 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
1, 1,
3 3
], ],
[ [
1, 1,
4 5
], ],
[ [
1, 2,
5 5
] ]
], ],
@ -723,7 +723,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
2, 2,
0 0
@ -776,7 +776,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -829,7 +829,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
3, 3,
4 4
@ -882,7 +882,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
3, 3,
0 0
@ -935,7 +935,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
2, 2,
3 3
@ -988,7 +988,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
3, 3,
0 0
@ -1041,7 +1041,7 @@
] ]
], ],
"spikes": [], "spikes": [],
"stars": [ "targets": [
[ [
5, 5,
0 0
@ -1103,7 +1103,7 @@
3 3
] ]
], ],
"stars": [ "targets": [
[ [
4, 4,
0 0
@ -1165,7 +1165,7 @@
4 4
] ]
], ],
"stars": [ "targets": [
[ [
1, 1,
4 4
@ -1227,7 +1227,7 @@
1 1
] ]
], ],
"stars": [ "targets": [
[ [
3, 3,
0 0
@ -1289,7 +1289,7 @@
6 6
] ]
], ],
"stars": [ "targets": [
[ [
5, 5,
3 3
@ -1351,7 +1351,7 @@
0 0
] ]
], ],
"stars": [ "targets": [
[ [
1, 1,
3 3
@ -1413,17 +1413,17 @@
3 3
] ]
], ],
"stars": [ "targets": [
[ [
6, 6,
1 1
], ],
[ [
6, 5,
2 3
], ],
[ [
5, 6,
3 3
] ]
], ],
@ -1475,7 +1475,7 @@
5 5
] ]
], ],
"stars": [ "targets": [
[ [
3, 3,
0 0
@ -1537,9 +1537,9 @@
3 3
] ]
], ],
"stars": [ "targets": [
[ [
3, 2,
0 0
], ],
[ [
@ -1607,7 +1607,7 @@
6 6
] ]
], ],
"stars": [ "targets": [
[ [
3, 3,
2 2
@ -1618,7 +1618,7 @@
], ],
[ [
4, 4,
4 5
] ]
], ],
"monsters": [ "monsters": [
@ -1681,13 +1681,13 @@
4 4
] ]
], ],
"stars": [ "targets": [
[
4,
3
],
[ [
5, 5,
2
],
[
4,
3 3
], ],
[ [
@ -1755,7 +1755,7 @@
5 5
] ]
], ],
"stars": [ "targets": [
[ [
2, 2,
0 0
@ -1765,7 +1765,7 @@
2 2
], ],
[ [
2, 3,
2 2
] ]
], ],
@ -1829,13 +1829,13 @@
4 4
] ]
], ],
"stars": [ "targets": [
[ [
1, 1,
0 0
], ],
[ [
4, 3,
0 0
], ],
[ [
@ -1903,13 +1903,13 @@
3 3
] ]
], ],
"stars": [ "targets": [
[ [
2, 0,
4 4
], ],
[ [
1, 0,
5 5
], ],
[ [
@ -1977,13 +1977,13 @@
1 1
] ]
], ],
"stars": [ "targets": [
[ [
0, 0,
4 3
], ],
[ [
2, 1,
4 4
], ],
[ [
@ -2051,7 +2051,7 @@
2 2
] ]
], ],
"stars": [ "targets": [
[ [
0, 0,
0 0
@ -2061,8 +2061,8 @@
0 0
], ],
[ [
3, 0,
0 1
] ]
], ],
"monsters": [ "monsters": [
@ -2125,7 +2125,7 @@
3 3
] ]
], ],
"stars": [ "targets": [
[ [
3, 3,
0 0

View File

@ -5,7 +5,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { import {
DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, starsCollected, DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, targetsCovered,
} from './PuddingMonstersLogic.js'; } from './PuddingMonstersLogic.js';
const BG = 0x161226; const BG = 0x161226;
@ -16,12 +16,12 @@ const WALL = 0x0e0a1a;
const WALL_HI = 0x3a2f56; const WALL_HI = 0x3a2f56;
const SPIKE_BG = 0x3a1320; const SPIKE_BG = 0x3a1320;
const SPIKE = 0xe0506b; const SPIKE = 0xe0506b;
const STAR_OFF = 0x6b5a8a; const TARGET = 0xffd54a;
const STAR_ON = 0xffd54a; const TARGET_LN = 0xffec99;
const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d]; 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 }; 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 { export default class PuddingMonstersGame extends Phaser.Scene {
constructor() { super('PuddingMonstersGame'); } constructor() { super('PuddingMonstersGame'); }
@ -79,7 +79,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
clearLayer() { clearLayer() {
this.layer.removeAll(true); this.layer.removeAll(true);
this.monsterGfx = null; this.monsterGfx = null;
this.starGfx = null; this.targetGfx = null;
this.animGfx = null; this.animGfx = null;
this.undoBtn = null; this.undoBtn = null;
this.movesText = null; this.movesText = null;
@ -96,6 +96,11 @@ export default class PuddingMonstersGame extends Phaser.Scene {
} catch (_) { /* ignore */ } } 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 ──────────────────────────────────────────────────────────── // ── Level select ────────────────────────────────────────────────────────────
showLevelSelect() { showLevelSelect() {
@ -109,7 +114,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const title = this.add.text(cx, 84, 'JELL-O MONSTERS', { const title = this.add.text(cx, 84, 'JELL-O MONSTERS', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5); }).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!', { 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, fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
this.layer.add([title, sub]); this.layer.add([title, sub]);
@ -183,7 +188,9 @@ export default class PuddingMonstersGame extends Phaser.Scene {
{ width: 300, height: 58, fontSize: 24 }); { width: 300, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'), const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 }); { variant: 'ghost', width: 180, height: 58, fontSize: 24 });
this.layer.add([resume, back]); 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) { if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', { const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', {
@ -193,6 +200,36 @@ export default class PuddingMonstersGame 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 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 ────────────────────────────────────────────────────────────── // ── Play a level ──────────────────────────────────────────────────────────────
playLevel(level) { playLevel(level) {
@ -213,10 +250,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
this.computeLayout(); this.computeLayout();
this.buildBoard(); 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.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx);
this.drawStars(); this.targetGfx = this.add.graphics().setDepth(D.target); this.layer.add(this.targetGfx);
this.drawAllMonsters(); this.drawAllMonsters();
this.drawTargets();
this.drawHud(); this.drawHud();
this.updateHud(); this.updateHud();
} }
@ -267,6 +304,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
floor.strokeRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8); 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); this.layer.add(floor);
const deco = this.add.graphics().setDepth(D.deco); const deco = this.add.graphics().setDepth(D.deco);
@ -293,44 +334,31 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.layer.add(deco); 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) { colorFor(blob) {
const [x, y] = repCell(blob); const [x, y] = repCell(blob);
return PALETTE[(x * 7 + y * 13) % PALETTE.length]; 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) { drawAllMonsters(excludeIdx = -1) {
const g = this.monsterGfx; const g = this.monsterGfx;
g.clear(); g.clear();
@ -410,7 +438,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.undoBtn = undo; this.undoBtn = undo;
this.layer.add([undo, reset, hint, levels]); 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.', { 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', fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.ui); }).setOrigin(0.5, 0).setDepth(D.ui);
this.layer.add(tip); this.layer.add(tip);
@ -418,7 +446,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
updateHud() { updateHud() {
if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`); if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`);
if (this.starsText) this.starsText.setText(`${starsCollected(this.state)}/3`); if (this.starsText) this.starsText.setText(`${this.liveStars()}/3`);
if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0); if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0);
} }
@ -500,7 +528,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
// keep the moved blob selected at its new resting position // 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.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps };
this.drawAllMonsters(); this.drawAllMonsters();
this.drawStars(); this.drawTargets();
this.updateHud(); this.updateHud();
if (res.merged) playSound(this, SFX.CARD_PLACE); if (res.merged) playSound(this, SFX.CARD_PLACE);
this.busy = false; this.busy = false;
@ -535,7 +563,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.moves++; // an undo still counts as a move (matches Rush Hour) this.moves++; // an undo still counts as a move (matches Rush Hour)
this.selectedCell = null; this.selectedCell = null;
this.drawAllMonsters(); this.drawAllMonsters();
this.drawStars(); this.drawTargets();
this.updateHud(); this.updateHud();
playSound(this, SFX.PIECE_CLICK); playSound(this, SFX.PIECE_CLICK);
} }
@ -547,7 +575,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.moves = 0; this.moves = 0;
this.selectedCell = null; this.selectedCell = null;
this.drawAllMonsters(); this.drawAllMonsters();
this.drawStars(); this.drawTargets();
this.updateHud(); this.updateHud();
playSound(this, SFX.CARD_SHUFFLE); playSound(this, SFX.CARD_SHUFFLE);
} }
@ -605,7 +633,8 @@ export default class PuddingMonstersGame extends Phaser.Scene {
onSolved() { onSolved() {
this.overlayUp = true; this.overlayUp = true;
const stars = starsCollected(this.state); const covered = targetsCovered(this.state, this.levelDef.targets);
const stars = Math.min(covered, this.medalStars(this.moves, this.par));
this.saveStars(this.level, stars); this.saveStars(this.level, stars);
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
@ -635,13 +664,21 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), {
fontFamily: 'serif', fontSize: '56px', color: '#ffd54a', fontFamily: 'serif', fontSize: '56px', color: '#ffd54a',
}).setOrigin(0.5).setDepth(D.overlayUI); }).setOrigin(0.5).setDepth(D.overlayUI);
const beatPar = this.moves <= this.par;
const stat = this.add.text(cx, cy - 4, const stat = this.add.text(cx, cy - 4,
`Level ${this.level} cleared in ${this.moves} moves (par ${this.par})${beatPar ? ' ★ par or better!' : ''}`, { `${this.moves} moves (par ${this.par})${covered} / 3 yellow squares covered`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI); }).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, starRow, stat]); 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 hasNext = this.level < this.bank.length;
const btns = []; const btns = [];
if (hasNext) { if (hasNext) {

View File

@ -79,6 +79,14 @@ export function starsCollected(state) {
return state.collected.size; 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. // 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. // Pure (does not mutate). { maxSteps, deathStep } — deathStep>0 means fatal.
export function computeSlide(state, idx, dir) { export function computeSlide(state, idx, dir) {

View File

@ -156,7 +156,9 @@ export default class RushHourGame extends Phaser.Scene {
{ width: 280, height: 58, fontSize: 24 }); { width: 280, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 }); { 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) { if (!this.canPersist) {
const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', { 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 ──────────────────────────────────────────────────────────── // ── Play a level ────────────────────────────────────────────────────────────
playLevel(level) { playLevel(level) {

View File

@ -51,4 +51,14 @@ router.post('/:slug/complete', requireAuth, (req, res) => {
res.json({ levelsCompleted: level }); 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; export default router;

View File

@ -2,9 +2,11 @@
// //
// For each difficulty tier it random-fills a grid with walls, spikes and K // 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 // 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 // (b) label each survivor with its minimum flick count (par), then marks 3 cells
// guaranteed-collectable stars on cells of that solution's final footprint and // of that solution's final footprint as yellow target squares and writes ordered
// writes ordered levels to public/data/puddingmonsters.json. // 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: // Usage:
// node server/scripts/genPuddingMonsters.js [seed] [outFile] // node server/scripts/genPuddingMonsters.js [seed] [outFile]
@ -77,7 +79,7 @@ function randomLevel(tier) {
const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken); const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken);
if (!monsters) return null; if (!monsters) return null;
return { return {
cols: tier.cols, rows: tier.rows, walls, spikes, monsters, stars: [], cols: tier.cols, rows: tier.rows, walls, spikes, monsters,
}; };
} }
@ -86,14 +88,12 @@ function canonKey(lvl) {
return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`; 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. // 3 spread-out cells of the solution's final footprint -> yellow target squares.
function chooseStars(footprint, monsters) { // The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct.
const startSet = new Set(monsters.map(([x, y]) => keyOf(x, y))); function chooseTargets(footprint) {
const nonStart = footprint.filter(([x, y]) => !startSet.has(keyOf(x, y))); const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0]));
const pool = nonStart.length >= 3 ? nonStart : footprint; const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])];
const sorted = pool.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); return idx.map((i) => [sorted[i][0], sorted[i][1]]);
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 ──────────────────────────────────────────────────────────── // ── Generate pool ────────────────────────────────────────────────────────────
@ -130,8 +130,8 @@ while (attempts < MAX_ATTEMPTS && !tiersFull()) {
if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue; if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue;
solved++; solved++;
lvl.stars = chooseStars(res.footprint, lvl.monsters); lvl.targets = chooseTargets(res.footprint);
if (lvl.stars.length !== 3) continue; if (lvl.targets.length !== 3) continue;
lvl.par = res.moves; lvl.par = res.moves;
buckets[ti].push(lvl); buckets[ti].push(lvl);
@ -157,7 +157,7 @@ const levels = chosen.map((lvl, i) => ({
rows: lvl.rows, rows: lvl.rows,
walls: lvl.walls, walls: lvl.walls,
spikes: lvl.spikes, spikes: lvl.spikes,
stars: lvl.stars, targets: lvl.targets,
monsters: lvl.monsters, monsters: lvl.monsters,
par: lvl.par, par: lvl.par,
})); }));