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', {
|
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,10 +692,12 @@ 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,
|
||||||
|
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,
|
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, msg]);
|
this.layer.add([title, msg]);
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue