feat(puddingmonsters): implement open-edge sliding mechanic

Make board edges open instead of walled — blobs that slide off the
table fall and die, faithful to the original Pudding Monsters.

- Detect edge deaths in computeSlide (deathCause: 'edge')
- Animate blobs tumbling off the board with overshoot + fade
- Show 'Overboard!' death overlay for edge falls vs 'Splat!' for spikes
- Redesign board as a floating table with open edges (drop shadow, no rim)
- Regenerate all levels with walls added to every tier (walls are the
  only terrain that stops a slide, so they're required from tier 1)
- Update UI copy to explain the new slide-and-fall rules
This commit is contained in:
Brian Fertig 2026-06-12 00:52:36 -06:00
parent affe120ab7
commit c9192cda75
4 changed files with 1257 additions and 873 deletions

File diff suppressed because it is too large Load Diff

View File

@ -126,7 +126,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 to slide & stick. Merge them into one — finish on the yellow squares, in par, for ★★★.', { const sub = this.add.text(cx, 140, 'Flick the jellies — only walls & friends stop a slide, the open edges drop you! Merge into one 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]);
@ -300,9 +300,13 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const boardW = cols * this.cell; const boardW = cols * this.cell;
const boardH = rows * this.cell; const boardH = rows * this.cell;
// Floating table: a thin slab over a drop shadow, not an enclosing rim —
// the edges are open and monsters slide right off them.
const frame = this.add.graphics().setDepth(D.frame); const frame = this.add.graphics().setDepth(D.frame);
frame.fillStyle(0x000000, 0.4);
frame.fillRoundedRect(this.originX - 8, this.originY + 8, boardW + 16, boardH + 18, 18);
frame.fillStyle(FRAME, 1); frame.fillStyle(FRAME, 1);
frame.fillRoundedRect(this.originX - 18, this.originY - 18, boardW + 36, boardH + 36, 22); frame.fillRoundedRect(this.originX - 7, this.originY - 7, boardW + 14, boardH + 14, 14);
this.layer.add(frame); this.layer.add(frame);
const floor = this.add.graphics().setDepth(D.floor); const floor = this.add.graphics().setDepth(D.floor);
@ -470,7 +474,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 slide it\n(or arrow keys). Finish on the\nyellow squares, in par, for ★★★.', { const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag to flick (or arrow keys).\nOnly walls & monsters stop a\nslide — don\'t fall off the edge!\nYellow squares + par = ★★★.', {
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);
@ -519,7 +523,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
doFlick(cell, dir) { doFlick(cell, dir) {
const idx = blobAt(this.state, cell.x, cell.y); const idx = blobAt(this.state, cell.x, cell.y);
if (idx < 0) return; if (idx < 0) return;
const { maxSteps, deathStep } = computeSlide(this.state, idx, dir); const { maxSteps, deathStep, deathCause } = computeSlide(this.state, idx, dir);
if (maxSteps === 0) { this.nudgeInvalid(idx); return; } if (maxSteps === 0) { this.nudgeInvalid(idx); return; }
this.busy = true; this.busy = true;
@ -530,24 +534,26 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const movingCells = this.state.blobs[idx].cells.map((c) => c.slice()); const movingCells = this.state.blobs[idx].cells.map((c) => c.slice());
const [dx, dy] = DIRS[dir]; const [dx, dy] = DIRS[dir];
const steps = deathStep > 0 ? deathStep : maxSteps; const steps = deathStep > 0 ? deathStep : maxSteps;
// edge deaths overshoot the rim a little before the blob tips off
const animSteps = deathCause === 'edge' ? deathStep + 1.25 : steps;
this.drawAllMonsters(idx); this.drawAllMonsters(idx);
this.animGfx = this.add.graphics().setDepth(D.anim); this.animGfx = this.add.graphics().setDepth(D.anim);
this.layer.add(this.animGfx); this.layer.add(this.animGfx);
const proxy = { v: 0 }; const proxy = { v: 0 };
const tgtX = dx * steps * this.cell; const tgtX = dx * animSteps * this.cell;
const tgtY = dy * steps * this.cell; const tgtY = dy * animSteps * this.cell;
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, tgtX * t, tgtY * t); }; const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, tgtX * t, tgtY * t); };
drawAt(0); drawAt(0);
playSound(this, SFX.PIECE_CLICK); playSound(this, SFX.PIECE_CLICK);
this.tweens.add({ this.tweens.add({
targets: proxy, v: 1, targets: proxy, v: 1,
duration: Math.min(420, 110 + steps * 55), duration: Math.min(420, 110 + animSteps * 55),
ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut', ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut',
onUpdate: () => drawAt(proxy.v), onUpdate: () => drawAt(proxy.v),
onComplete: () => { onComplete: () => {
if (deathStep > 0) this.splatAndFail(idx, dir); if (deathStep > 0) this.splatAndFail(idx, dir, deathCause);
else this.commitMove(idx, dir, movingCells, dx, dy, steps); else this.commitMove(idx, dir, movingCells, dx, dy, steps);
}, },
}); });
@ -602,17 +608,20 @@ export default class PuddingMonstersGame extends Phaser.Scene {
}); });
} }
splatAndFail(idx, dir) { splatAndFail(idx, dir, cause) {
slide(this.state, idx, dir); // marks state 'dead' (positions unchanged) slide(this.state, idx, dir); // marks state 'dead' (positions unchanged)
playSound(this, SFX.SCIFI_EXPLODE); playSound(this, SFX.SCIFI_EXPLODE);
const g = this.animGfx; const g = this.animGfx;
if (g) { if (!g) { this.onDead(cause); return; }
const done = () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(cause); };
if (cause === 'edge') {
// tumble off the table: drop and shrink while fading
this.tweens.add({ this.tweens.add({
targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn', targets: g, alpha: 0, y: g.y + this.cell * 0.9, scaleX: 0.85, scaleY: 0.85,
onComplete: () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(); }, duration: 340, ease: 'Quad.easeIn', onComplete: done,
}); });
} else { } else {
this.onDead(); this.tweens.add({ targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn', onComplete: done });
} }
} }
@ -669,7 +678,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
// ── End states ────────────────────────────────────────────────────────────── // ── End states ──────────────────────────────────────────────────────────────
onDead() { onDead(cause) {
this.overlayUp = true; this.overlayUp = true;
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2; const cy = GAME_HEIGHT / 2;
@ -683,12 +692,14 @@ export default class PuddingMonstersGame extends Phaser.Scene {
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20); panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
this.layer.add(panel); this.layer.add(panel);
const title = this.add.text(cx, cy - 90, 'Splat!', { const title = this.add.text(cx, cy - 90, cause === 'edge' ? 'Overboard!' : 'Splat!', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex, fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI); }).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 14, 'A monster slid onto the spikes. Try again!', { const msg = this.add.text(cx, cy - 14,
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, cause === 'edge' ? 'A monster slid off the open edge. Try again!'
}).setOrigin(0.5).setDepth(D.overlayUI); : '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]); this.layer.add([title, msg]);
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level), const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),

View File

@ -3,14 +3,15 @@
// //
// A level is a grid of cols x rows. Monsters are jelly blobs (each starts as one // 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 // 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 // hit a WALL or another blob — classic "ice slide". The board edge does NOT
// blob comes to rest orthogonally adjacent to another blob they STICK into one // stop a slide: the table edges are open, and a blob that reaches one slides
// off and falls (the run fails), faithful to the original game. 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 // rigid blob. The level is solved when every monster has merged into a single
// connected blob. // connected blob.
// //
// SPIKES are deadly: if a blob's slide path crosses a spike it dies (the run // SPIKES are equally deadly: if a blob's slide path crosses a spike it dies
// fails and the level must be restarted). STARS are bonus floor tiles collected // (the run fails and the level must be restarted).
// when a monster covers them.
// //
// A blob: { cells: [[x,y,m], ...] } (rigid; moves as a unit). The third // A blob: { cells: [[x,y,m], ...] } (rigid; moves as a unit). The third
// element m is the index of the original monster occupying that cell; it rides // element m is the index of the original monster occupying that cell; it rides
@ -90,8 +91,11 @@ export function targetsCovered(state, targets) {
return 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 the slide is fatal.
// Pure (does not mutate). { maxSteps, deathStep } — deathStep>0 means fatal. // Only walls and other monsters stop a slide — the board edge is open, so a
// blob whose path crosses a spike or leaves the board dies. Pure (does not
// mutate). { maxSteps, deathStep, deathCause } — deathStep>0 means fatal,
// deathCause is 'spike' | 'edge' | null.
export function computeSlide(state, idx, dir) { export function computeSlide(state, idx, dir) {
const [dx, dy] = DIRS[dir]; const [dx, dy] = DIRS[dir];
const blob = state.blobs[idx]; const blob = state.blobs[idx];
@ -101,40 +105,45 @@ export function computeSlide(state, idx, dir) {
if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y))); if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y)));
}); });
// Slide until a wall or another blob blocks. Out-of-board cells block
// nothing (walls and blobs only exist on the board), so an unblocked blob
// runs clean off the table — the death scan below catches that.
let maxSteps = 0; let maxSteps = 0;
const limit = state.cols + state.rows; const limit = state.cols + state.rows;
for (let s = 1; s <= limit; s++) { for (let s = 1; s <= limit; s++) {
let ok = true; let blocked = false;
for (const [x, y] of blob.cells) { for (const [x, y] of blob.cells) {
const nx = x + dx * s, ny = y + dy * s; const k = key(x + dx * s, y + dy * s);
if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { ok = false; break; } if (state.walls.has(k) || other.has(k)) { blocked = true; break; }
if (state.walls.has(key(nx, ny)) || other.has(key(nx, ny))) { ok = false; break; }
} }
if (!ok) break; if (blocked) break;
maxSteps = s; maxSteps = s;
} }
let deathStep = 0; let deathStep = 0;
let deathCause = null;
for (let s = 1; s <= maxSteps && deathStep === 0; s++) { for (let s = 1; s <= maxSteps && deathStep === 0; s++) {
for (const [x, y] of blob.cells) { for (const [x, y] of blob.cells) {
if (state.spikes.has(key(x + dx * s, y + dy * s))) { deathStep = s; break; } const nx = x + dx * s, ny = y + dy * s;
if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { deathStep = s; deathCause = 'edge'; break; }
if (state.spikes.has(key(nx, ny))) { deathStep = s; deathCause = 'spike'; break; }
} }
} }
return { maxSteps, deathStep }; return { maxSteps, deathStep, deathCause };
} }
// Flick blob `idx` in `dir`. Mutates state. Returns: // Flick blob `idx` in `dir`. Mutates state. Returns:
// { moved:false } — couldn't move (no-op) // { moved:false } — couldn't move (no-op)
// { moved:true, dead:true, deathStep } — slid onto a spike (run fails) // { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged // { moved:true, dead:false, merged, steps } — slid and (maybe) merged
export function slide(state, idx, dir) { export function slide(state, idx, dir) {
const { maxSteps, deathStep } = computeSlide(state, idx, dir); const { maxSteps, deathStep, deathCause } = computeSlide(state, idx, dir);
if (maxSteps === 0) return { moved: false }; if (maxSteps === 0) return { moved: false };
const [dx, dy] = DIRS[dir]; const [dx, dy] = DIRS[dir];
if (deathStep > 0) { if (deathStep > 0) {
state.state = 'dead'; state.state = 'dead';
return { moved: true, dead: true, deathStep }; return { moved: true, dead: true, deathStep, deathCause };
} }
const blob = state.blobs[idx]; const blob = state.blobs[idx];

View File

@ -1,7 +1,9 @@
// Offline generator for Pudding Monsters levels. // Offline generator for Pudding Monsters levels.
// //
// 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 (the board edge is open — only walls and monsters stop a slide, so
// every tier carries walls), 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 // (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 // 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: // levels to public/data/puddingmonsters.json. Stars at play time require BOTH:
@ -39,14 +41,16 @@ const rng = makeRng(SEED);
const randInt = (n) => Math.floor(rng() * n); const randInt = (n) => Math.floor(rng() * n);
// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each // Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each
// keeps `count` levels whose par falls in [minPar, maxPar]. Spikes appear only // keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is
// in later tiers (yield + difficulty). Levels are numbered tier-by-tier. // OPEN (sliding off is fatal), so walls — the only terrain that stops a slide —
// appear from tier 1, like the original game. Spikes arrive in later tiers.
// Levels are numbered tier-by-tier.
const TIERS = [ const TIERS = [
{ count: 8, cols: 5, rows: 5, monsters: 3, walls: 0, spikes: 0, minPar: 2, maxPar: 3 }, { count: 8, cols: 5, rows: 5, monsters: 3, walls: 3, 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: 3, walls: 4, 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: 6, rows: 6, monsters: 4, walls: 5, 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: 4, walls: 6, spikes: 1, minPar: 5, maxPar: 9 },
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 5, spikes: 2, minPar: 7, maxPar: 14 }, { count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 },
]; ];
const MAX_ATTEMPTS = 6000000; const MAX_ATTEMPTS = 6000000;
const MAX_SECONDS = 200; const MAX_SECONDS = 200;