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:
parent
d51d026352
commit
affe120ab7
|
|
@ -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 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);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
const ex = this.cellCx(x) + ox, ey = this.cellCy(y) + oy - c * 0.04;
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(ex - sp, ey - er * 0.6, er); g.fillCircle(ex + sp, ey - er * 0.6, er);
|
||||
g.fillCircle(ex - sp, ey, er); g.fillCircle(ex + sp, ey, 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);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue