feat(pudding-monsters): per-cell blob rendering with jelly animations

Track each monster's original index in cell data [x, y, m] so merged blobs
retain individual colors and faces. Replace uniform blob coloring with a
multi-pass silhouette renderer that blends adjacent monster colors and draws
eyes on every cell. Add squash-and-spring wobble tweens for merge impacts
and slide movements.
This commit is contained in:
Brian Fertig 2026-06-12 00:28:20 -06:00
parent d51d026352
commit affe120ab7
2 changed files with 115 additions and 45 deletions

View File

@ -21,6 +21,18 @@ const TARGET_LN = 0xffec99;
const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d];
// Each cell carries the index of the monster that started there (cells[i][2]),
// so a monster keeps its colour and face after it merges into a bigger blob.
const cellColor = (m) => PALETTE[(m ?? 0) % PALETTE.length];
const shade = (color, f) => {
const ch = (s) => Math.min(255, Math.round(((color >> s) & 0xff) * f));
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
};
const mix = (a, b) => {
const ch = (s) => (((a >> s) & 0xff) + ((b >> s) & 0xff)) >> 1;
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
};
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 {
@ -334,11 +346,6 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.layer.add(deco);
}
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() {
@ -365,46 +372,71 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.state.blobs.forEach((blob, i) => {
if (i === excludeIdx) return;
const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i;
this.drawBlobInto(g, blob.cells, this.colorFor(blob), 0, 0, sel);
this.drawBlobInto(g, blob.cells, 0, 0, sel);
});
}
drawBlobInto(g, cells, color, ox, oy, selected = false) {
drawBlobInto(g, cells, ox, oy, selected = false) {
const c = this.cell;
const inset = c * 0.10;
const r = c * 0.30;
const set = new Set(cells.map(([x, y]) => `${x},${y}`));
const byCell = new Map(cells.map((cell) => [`${cell[0]},${cell[1]}`, cell]));
g.fillStyle(color, 1);
// Silhouette passes, outermost first: selection glow, dark rind, jelly body.
if (selected) this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.035, () => 0xffffff, 0.9);
this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.065, (m) => shade(cellColor(m), 0.55), 1);
this.fillBlobShape(g, cells, byCell, ox, oy, c * 0.115, (m) => cellColor(m), 1);
// sheen along the blob's exposed top edge
g.fillStyle(0xffffff, 0.2);
for (const [x, y] of cells) {
g.fillRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
if (byCell.has(`${x},${y - 1}`)) continue;
g.fillRoundedRect(this.cellLeft(x) + ox + c * 0.20, this.cellTop(y) + oy + c * 0.16, c * 0.32, c * 0.13, c * 0.065);
}
// every monster keeps its face inside the merged blob: eyes on every cell
const er = c * 0.105, sp = c * 0.155;
for (const [x, y] of cells) {
const ex = this.cellCx(x) + ox, ey = this.cellCy(y) + oy - c * 0.04;
g.fillStyle(0xffffff, 1);
g.fillCircle(ex - sp, ey, er); g.fillCircle(ex + sp, ey, er);
g.fillStyle(0x1a1a1a, 1);
g.fillCircle(ex - sp + er * 0.25, ey + er * 0.1, er * 0.5);
g.fillCircle(ex + sp + er * 0.25, ey + er * 0.1, er * 0.5);
}
}
// One filled silhouette of the blob at the given inset. Corners are rounded
// only where the blob is convex, shared edges are bridged and 2x2 interiors
// plugged, so the union reads as a single jelly rather than a pile of tiles.
fillBlobShape(g, cells, byCell, ox, oy, inset, colorOf, alpha) {
const c = this.cell;
const r = Math.min(c * 0.32, (c - 2 * inset) / 2);
for (const cell of cells) {
const [x, y, m] = cell;
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
if (set.has(`${x + 1},${y}`)) g.fillRect(L + c - inset - 1, T + inset, 2 * inset + 2, c - 2 * inset);
if (set.has(`${x},${y + 1}`)) g.fillRect(L + inset, T + c - inset - 1, c - 2 * inset, 2 * inset + 2);
}
// glossy highlight
g.fillStyle(0xffffff, 0.18);
for (const [x, y] of cells) {
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
g.fillRoundedRect(L + inset + c * 0.12, T + inset + c * 0.10, c * 0.30, c * 0.14, c * 0.07);
}
if (selected) {
g.lineStyle(3, 0xffffff, 0.85);
for (const [x, y] of cells) {
g.strokeRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
const up = byCell.get(`${x},${y - 1}`);
const down = byCell.get(`${x},${y + 1}`);
const left = byCell.get(`${x - 1},${y}`);
const right = byCell.get(`${x + 1},${y}`);
g.fillStyle(colorOf(m), alpha);
g.fillRoundedRect(L + inset, T + inset, c - 2 * inset, c - 2 * inset, {
tl: up || left ? 0 : r,
tr: up || right ? 0 : r,
bl: down || left ? 0 : r,
br: down || right ? 0 : r,
});
if (right) {
g.fillStyle(mix(colorOf(m), colorOf(right[2])), alpha);
g.fillRect(L + c - inset - 1, T + inset, 2 * inset + 2, c - 2 * inset);
}
if (down) {
g.fillStyle(mix(colorOf(m), colorOf(down[2])), alpha);
g.fillRect(L + inset, T + c - inset - 1, c - 2 * inset, 2 * inset + 2);
}
if (right && down && byCell.has(`${x + 1},${y + 1}`)) {
g.fillStyle(colorOf(m), alpha);
g.fillRect(L + c - inset - 1, T + c - inset - 1, 2 * inset + 2, 2 * inset + 2);
}
}
// eyes at centroid
let ex = 0, ey = 0;
for (const [x, y] of cells) { ex += this.cellCx(x) + ox; ey += this.cellCy(y) + oy; }
ex /= cells.length; ey /= cells.length;
const er = c * 0.12, sp = c * 0.17;
g.fillStyle(0xffffff, 1);
g.fillCircle(ex - sp, ey - er * 0.6, er); g.fillCircle(ex + sp, ey - er * 0.6, er);
g.fillStyle(0x1a1a1a, 1);
g.fillCircle(ex - sp + er * 0.2, ey - er * 0.45, er * 0.5); g.fillCircle(ex + sp + er * 0.2, ey - er * 0.45, er * 0.5);
}
drawHud() {
@ -495,8 +527,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.moves++;
this.updateHud();
const movingCells = this.state.blobs[idx].cells.map((c) => [c[0], c[1]]);
const color = this.colorFor(this.state.blobs[idx]);
const movingCells = this.state.blobs[idx].cells.map((c) => c.slice());
const [dx, dy] = DIRS[dir];
const steps = deathStep > 0 ? deathStep : maxSteps;
@ -506,7 +537,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const proxy = { v: 0 };
const tgtX = dx * steps * this.cell;
const tgtY = dy * steps * this.cell;
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, color, tgtX * t, tgtY * t); };
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, tgtX * t, tgtY * t); };
drawAt(0);
playSound(this, SFX.PIECE_CLICK);
@ -527,12 +558,48 @@ export default class PuddingMonstersGame extends Phaser.Scene {
if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; }
// 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.drawTargets();
this.updateHud();
if (res.merged) playSound(this, SFX.CARD_PLACE);
this.busy = false;
if (this.state.state === 'won') this.onSolved();
const restIdx = blobAt(this.state, this.selectedCell.x, this.selectedCell.y);
const finish = () => {
if (!this.monsterGfx) return; // scene view changed mid-animation
this.drawAllMonsters();
this.busy = false;
if (this.state.state === 'won') this.onSolved();
};
if (restIdx < 0) { finish(); return; }
if (res.merged) {
playSound(this, SFX.CARD_PLACE);
this.wobbleBlob(restIdx, 1.16, 0.84, finish); // jelly merge jiggle
} else {
const sx = dx !== 0 ? 0.9 : 1.1; // impact squash along the slide axis
this.wobbleBlob(restIdx, sx, 2 - sx, finish, true);
}
}
// Squash-and-spring jelly deformation of one blob, scaled around its centroid.
// quick = brief impact squash; otherwise a full elastic merge wobble.
wobbleBlob(idx, sx, sy, onDone, quick = false) {
const blob = this.state.blobs[idx];
let cx = 0, cy = 0;
for (const [x, y] of blob.cells) { cx += this.cellCx(x); cy += this.cellCy(y); }
cx /= blob.cells.length; cy /= blob.cells.length;
this.drawAllMonsters(idx);
const g = this.add.graphics().setDepth(D.anim).setPosition(cx, cy);
this.layer.add(g);
this.drawBlobInto(g, blob.cells, -cx, -cy);
const done = () => { g.destroy(); onDone?.(); };
this.tweens.add({
targets: g, scaleX: sx, scaleY: sy, duration: quick ? 70 : 90, ease: 'Quad.easeOut',
onComplete: () => this.tweens.add({
targets: g, scaleX: 1, scaleY: 1,
duration: quick ? 110 : 380, ease: quick ? 'Quad.easeIn' : 'Elastic.easeOut',
onComplete: done,
}),
});
}
splatAndFail(idx, dir) {

View File

@ -12,7 +12,10 @@
// fails and the level must be restarted). STARS are bonus floor tiles collected
// when a monster covers them.
//
// A blob: { cells: [[x,y], ...] } (rigid; moves as a unit)
// 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
// along through slides and merges so the renderer can keep each monster's
// colour and face inside a merged blob. All rules ignore it.
export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
export const DIR_LIST = ['up', 'down', 'left', 'right'];
@ -135,7 +138,7 @@ export function slide(state, idx, dir) {
}
const blob = state.blobs[idx];
blob.cells = blob.cells.map(([x, y]) => [x + dx * maxSteps, y + dy * maxSteps]);
blob.cells = blob.cells.map(([x, y, m]) => [x + dx * maxSteps, y + dy * maxSteps, m]);
const before = state.blobs.length;
mergeBlobs(state);
pickUpStars(state);
@ -172,7 +175,7 @@ export function cloneState(state) {
walls: state.walls, // immutable during play — shared
spikes: state.spikes, // immutable during play — shared
stars: state.stars, // immutable during play — shared
blobs: state.blobs.map((b) => ({ cells: b.cells.map(([x, y]) => [x, y]) })),
blobs: state.blobs.map((b) => ({ cells: b.cells.map((cell) => cell.slice()) })),
collected: new Set(state.collected),
state: state.state,
};
@ -185,7 +188,7 @@ export function newState(level) {
walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))),
spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))),
stars: (level.stars ?? []).map((c) => [c[0], c[1]]),
blobs: (level.monsters ?? []).map(([x, y]) => ({ cells: [[x, y]] })),
blobs: (level.monsters ?? []).map(([x, y], m) => ({ cells: [[x, y, m]] })),
collected: new Set(),
state: 'playing',
};