diff --git a/public/assets/images/opponents.png b/public/assets/images/opponents.png index 78c58fd..6d1b2a7 100644 Binary files a/public/assets/images/opponents.png and b/public/assets/images/opponents.png differ diff --git a/public/assets/images/opponents.psd b/public/assets/images/opponents.psd index 1796917..99ee6cb 100644 Binary files a/public/assets/images/opponents.psd and b/public/assets/images/opponents.psd differ diff --git a/public/assets/videos/beth-happy.mp4 b/public/assets/videos/beth-happy.mp4 new file mode 100644 index 0000000..ef0ab05 Binary files /dev/null and b/public/assets/videos/beth-happy.mp4 differ diff --git a/public/assets/videos/beth-idle.mp4 b/public/assets/videos/beth-idle.mp4 new file mode 100644 index 0000000..2e3f5d4 Binary files /dev/null and b/public/assets/videos/beth-idle.mp4 differ diff --git a/public/assets/videos/beth-upset.mp4 b/public/assets/videos/beth-upset.mp4 new file mode 100644 index 0000000..4a479a8 Binary files /dev/null and b/public/assets/videos/beth-upset.mp4 differ diff --git a/public/assets/videos/blackwind-happy.mp4 b/public/assets/videos/blackwind-happy.mp4 new file mode 100644 index 0000000..b00330e Binary files /dev/null and b/public/assets/videos/blackwind-happy.mp4 differ diff --git a/public/assets/videos/blackwind-idle.mp4 b/public/assets/videos/blackwind-idle.mp4 new file mode 100644 index 0000000..1a3a9f1 Binary files /dev/null and b/public/assets/videos/blackwind-idle.mp4 differ diff --git a/public/assets/videos/blackwind-upset.mp4 b/public/assets/videos/blackwind-upset.mp4 new file mode 100644 index 0000000..bb76435 Binary files /dev/null and b/public/assets/videos/blackwind-upset.mp4 differ diff --git a/public/data/opponents.json b/public/data/opponents.json index 9dc951a..356dc9c 100644 --- a/public/data/opponents.json +++ b/public/data/opponents.json @@ -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!" } ] } \ No newline at end of file diff --git a/public/data/puddingmonsters.json b/public/data/puddingmonsters.json index 34733c9..3325b9c 100644 --- a/public/data/puddingmonsters.json +++ b/public/data/puddingmonsters.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-09T03:30:42.804Z", + "generatedAt": "2026-06-09T04:18:02.258Z", "seed": 1592594996, "count": 40, "levels": [ @@ -9,7 +9,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 0, 0 @@ -45,7 +45,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 4, 2 @@ -81,7 +81,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 0, 0 @@ -117,7 +117,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 0, 0 @@ -153,7 +153,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 1, 0 @@ -189,7 +189,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 0, 3 @@ -225,7 +225,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 1, 3 @@ -261,7 +261,7 @@ "rows": 5, "walls": [], "spikes": [], - "stars": [ + "targets": [ [ 3, 0 @@ -306,7 +306,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 1, 0 @@ -351,7 +351,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 5, 4 @@ -396,7 +396,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 2, 0 @@ -441,7 +441,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 2, 4 @@ -486,7 +486,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 4, 4 @@ -531,7 +531,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 2, 0 @@ -576,7 +576,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 3, 1 @@ -621,7 +621,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 0, 0 @@ -670,17 +670,17 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 1, 3 ], [ 1, - 4 + 5 ], [ - 1, + 2, 5 ] ], @@ -723,7 +723,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 2, 0 @@ -776,7 +776,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 0, 0 @@ -829,7 +829,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 3, 4 @@ -882,7 +882,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 3, 0 @@ -935,7 +935,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 2, 3 @@ -988,7 +988,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 3, 0 @@ -1041,7 +1041,7 @@ ] ], "spikes": [], - "stars": [ + "targets": [ [ 5, 0 @@ -1103,7 +1103,7 @@ 3 ] ], - "stars": [ + "targets": [ [ 4, 0 @@ -1165,7 +1165,7 @@ 4 ] ], - "stars": [ + "targets": [ [ 1, 4 @@ -1227,7 +1227,7 @@ 1 ] ], - "stars": [ + "targets": [ [ 3, 0 @@ -1289,7 +1289,7 @@ 6 ] ], - "stars": [ + "targets": [ [ 5, 3 @@ -1351,7 +1351,7 @@ 0 ] ], - "stars": [ + "targets": [ [ 1, 3 @@ -1413,17 +1413,17 @@ 3 ] ], - "stars": [ + "targets": [ [ 6, 1 ], [ - 6, - 2 + 5, + 3 ], [ - 5, + 6, 3 ] ], @@ -1475,7 +1475,7 @@ 5 ] ], - "stars": [ + "targets": [ [ 3, 0 @@ -1537,9 +1537,9 @@ 3 ] ], - "stars": [ + "targets": [ [ - 3, + 2, 0 ], [ @@ -1607,7 +1607,7 @@ 6 ] ], - "stars": [ + "targets": [ [ 3, 2 @@ -1618,7 +1618,7 @@ ], [ 4, - 4 + 5 ] ], "monsters": [ @@ -1681,13 +1681,13 @@ 4 ] ], - "stars": [ - [ - 4, - 3 - ], + "targets": [ [ 5, + 2 + ], + [ + 4, 3 ], [ @@ -1755,7 +1755,7 @@ 5 ] ], - "stars": [ + "targets": [ [ 2, 0 @@ -1765,7 +1765,7 @@ 2 ], [ - 2, + 3, 2 ] ], @@ -1829,13 +1829,13 @@ 4 ] ], - "stars": [ + "targets": [ [ 1, 0 ], [ - 4, + 3, 0 ], [ @@ -1903,13 +1903,13 @@ 3 ] ], - "stars": [ + "targets": [ [ - 2, + 0, 4 ], [ - 1, + 0, 5 ], [ @@ -1977,13 +1977,13 @@ 1 ] ], - "stars": [ + "targets": [ [ 0, - 4 + 3 ], [ - 2, + 1, 4 ], [ @@ -2051,7 +2051,7 @@ 2 ] ], - "stars": [ + "targets": [ [ 0, 0 @@ -2061,8 +2061,8 @@ 0 ], [ - 3, - 0 + 0, + 1 ] ], "monsters": [ @@ -2125,7 +2125,7 @@ 3 ] ], - "stars": [ + "targets": [ [ 3, 0 diff --git a/public/src/games/puddingmonsters/PuddingMonstersGame.js b/public/src/games/puddingmonsters/PuddingMonstersGame.js index 7527326..0cf9877 100644 --- a/public/src/games/puddingmonsters/PuddingMonstersGame.js +++ b/public/src/games/puddingmonsters/PuddingMonstersGame.js @@ -5,7 +5,7 @@ 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, + DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, targetsCovered, } from './PuddingMonstersLogic.js'; const BG = 0x161226; @@ -16,12 +16,12 @@ const WALL = 0x0e0a1a; const WALL_HI = 0x3a2f56; const SPIKE_BG = 0x3a1320; const SPIKE = 0xe0506b; -const STAR_OFF = 0x6b5a8a; -const STAR_ON = 0xffd54a; +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, 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 { constructor() { super('PuddingMonstersGame'); } @@ -79,7 +79,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { clearLayer() { this.layer.removeAll(true); this.monsterGfx = null; - this.starGfx = null; + this.targetGfx = null; this.animGfx = null; this.undoBtn = null; this.movesText = null; @@ -96,6 +96,11 @@ export default class PuddingMonstersGame extends Phaser.Scene { } 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() { @@ -109,7 +114,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { 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!', { + 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]); @@ -183,7 +188,9 @@ export default class PuddingMonstersGame extends Phaser.Scene { { 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]); + 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.', { @@ -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 ────────────────────────────────────────────────────────────── playLevel(level) { @@ -213,10 +250,10 @@ export default class PuddingMonstersGame extends Phaser.Scene { 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.targetGfx = this.add.graphics().setDepth(D.target); this.layer.add(this.targetGfx); this.drawAllMonsters(); + this.drawTargets(); this.drawHud(); 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); } } + 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); @@ -293,44 +334,31 @@ export default class PuddingMonstersGame extends Phaser.Scene { 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]; } + // 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(); @@ -410,7 +438,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { 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.', { + 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); @@ -418,7 +446,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { 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.starsText) this.starsText.setText(`★ ${this.liveStars()}/3`); 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 this.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps }; this.drawAllMonsters(); - this.drawStars(); + this.drawTargets(); this.updateHud(); if (res.merged) playSound(this, SFX.CARD_PLACE); 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.selectedCell = null; this.drawAllMonsters(); - this.drawStars(); + this.drawTargets(); this.updateHud(); playSound(this, SFX.PIECE_CLICK); } @@ -547,7 +575,7 @@ export default class PuddingMonstersGame extends Phaser.Scene { this.moves = 0; this.selectedCell = null; this.drawAllMonsters(); - this.drawStars(); + this.drawTargets(); this.updateHud(); playSound(this, SFX.CARD_SHUFFLE); } @@ -605,7 +633,8 @@ export default class PuddingMonstersGame extends Phaser.Scene { onSolved() { 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); 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), { 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!' : ''}`, { + `${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) { diff --git a/public/src/games/puddingmonsters/PuddingMonstersLogic.js b/public/src/games/puddingmonsters/PuddingMonstersLogic.js index 39965c7..24f78aa 100644 --- a/public/src/games/puddingmonsters/PuddingMonstersLogic.js +++ b/public/src/games/puddingmonsters/PuddingMonstersLogic.js @@ -79,6 +79,14 @@ 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) { diff --git a/public/src/games/rushhour/RushHourGame.js b/public/src/games/rushhour/RushHourGame.js index fbb10d3..6e6c0c8 100644 --- a/public/src/games/rushhour/RushHourGame.js +++ b/public/src/games/rushhour/RushHourGame.js @@ -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) { diff --git a/server/puzzles/routes.js b/server/puzzles/routes.js index c2ebf62..398427e 100644 --- a/server/puzzles/routes.js +++ b/server/puzzles/routes.js @@ -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; diff --git a/server/scripts/genPuddingMonsters.js b/server/scripts/genPuddingMonsters.js index 5ac6541..4a01f19 100644 --- a/server/scripts/genPuddingMonsters.js +++ b/server/scripts/genPuddingMonsters.js @@ -2,9 +2,11 @@ // // 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. +// (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] @@ -77,7 +79,7 @@ function randomLevel(tier) { 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: [], + 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)}`; } -// 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]]); +// 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 ──────────────────────────────────────────────────────────── @@ -130,8 +130,8 @@ while (attempts < MAX_ATTEMPTS && !tiersFull()) { 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.targets = chooseTargets(res.footprint); + if (lvl.targets.length !== 3) continue; lvl.par = res.moves; buckets[ti].push(lvl); @@ -157,7 +157,7 @@ const levels = chosen.map((lvl, i) => ({ rows: lvl.rows, walls: lvl.walls, spikes: lvl.spikes, - stars: lvl.stars, + targets: lvl.targets, monsters: lvl.monsters, par: lvl.par, }));