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', {
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 ★★★.', {
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,
}).setOrigin(0.5);
this.layer.add([title, sub]);
@ -300,9 +300,13 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const boardW = cols * 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);
frame.fillStyle(0x000000, 0.4);
frame.fillRoundedRect(this.originX - 8, this.originY + 8, boardW + 16, boardH + 18, 18);
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);
const floor = this.add.graphics().setDepth(D.floor);
@ -470,7 +474,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 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',
}).setOrigin(0.5, 0).setDepth(D.ui);
this.layer.add(tip);
@ -519,7 +523,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
doFlick(cell, dir) {
const idx = blobAt(this.state, cell.x, cell.y);
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; }
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 [dx, dy] = DIRS[dir];
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.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 tgtX = dx * animSteps * this.cell;
const tgtY = dy * animSteps * this.cell;
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, 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),
duration: Math.min(420, 110 + animSteps * 55),
ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut',
onUpdate: () => drawAt(proxy.v),
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);
},
});
@ -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)
playSound(this, SFX.SCIFI_EXPLODE);
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({
targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn',
onComplete: () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(); },
targets: g, alpha: 0, y: g.y + this.cell * 0.9, scaleX: 0.85, scaleY: 0.85,
duration: 340, ease: 'Quad.easeIn', onComplete: done,
});
} 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 ──────────────────────────────────────────────────────────────
onDead() {
onDead(cause) {
this.overlayUp = true;
const cx = GAME_WIDTH / 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);
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,
}).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);
const msg = this.add.text(cx, cy - 14,
cause === 'edge' ? 'A monster slid off the open edge. Try again!'
: '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),

View File

@ -3,14 +3,15 @@
//
// 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
// hit a WALL or another blob — classic "ice slide". The board edge does NOT
// 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
// 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.
// SPIKES are equally deadly: if a blob's slide path crosses a spike it dies
// (the run fails and the level must be restarted).
//
// 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
@ -90,8 +91,11 @@ export function targetsCovered(state, targets) {
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.
// How far blob `idx` can slide in `dir`, and whether the slide is 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) {
const [dx, dy] = DIRS[dir];
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)));
});
// 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;
const limit = state.cols + state.rows;
for (let s = 1; s <= limit; s++) {
let ok = true;
let blocked = false;
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; }
const k = key(x + dx * s, y + dy * s);
if (state.walls.has(k) || other.has(k)) { blocked = true; break; }
}
if (!ok) break;
if (blocked) break;
maxSteps = s;
}
let deathStep = 0;
let deathCause = null;
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; }
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:
// { 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
// { moved:false } — couldn't move (no-op)
// { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged
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 };
const [dx, dy] = DIRS[dir];
if (deathStep > 0) {
state.state = 'dead';
return { moved: true, dead: true, deathStep };
return { moved: true, dead: true, deathStep, deathCause };
}
const blob = state.blobs[idx];

View File

@ -1,7 +1,9 @@
// 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
// 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
// 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:
@ -39,14 +41,16 @@ 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.
// keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is
// 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 = [
{ 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 },
{ 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: 4, spikes: 0, minPar: 3, maxPar: 5 },
{ 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: 6, spikes: 1, minPar: 5, maxPar: 9 },
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 },
];
const MAX_ATTEMPTS = 6000000;
const MAX_SECONDS = 200;