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:
parent
affe120ab7
commit
c9192cda75
File diff suppressed because it is too large
Load Diff
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue