From affe120ab76e560b92501b2c6c4552f089132831 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 12 Jun 2026 00:28:20 -0600 Subject: [PATCH] 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. --- .../puddingmonsters/PuddingMonstersGame.js | 149 +++++++++++++----- .../puddingmonsters/PuddingMonstersLogic.js | 11 +- 2 files changed, 115 insertions(+), 45 deletions(-) diff --git a/public/src/games/puddingmonsters/PuddingMonstersGame.js b/public/src/games/puddingmonsters/PuddingMonstersGame.js index 0cf9877..aff97b5 100644 --- a/public/src/games/puddingmonsters/PuddingMonstersGame.js +++ b/public/src/games/puddingmonsters/PuddingMonstersGame.js @@ -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) { diff --git a/public/src/games/puddingmonsters/PuddingMonstersLogic.js b/public/src/games/puddingmonsters/PuddingMonstersLogic.js index 24f78aa..81c68b3 100644 --- a/public/src/games/puddingmonsters/PuddingMonstersLogic.js +++ b/public/src/games/puddingmonsters/PuddingMonstersLogic.js @@ -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', };