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];
|
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 };
|
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 {
|
export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
|
|
@ -334,11 +346,6 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
this.layer.add(deco);
|
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
|
// Yellow target-square outlines, drawn above the monsters so coverage stays
|
||||||
// visible (white + thick when a monster is currently sitting on the square).
|
// visible (white + thick when a monster is currently sitting on the square).
|
||||||
drawTargets() {
|
drawTargets() {
|
||||||
|
|
@ -365,46 +372,71 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
this.state.blobs.forEach((blob, i) => {
|
this.state.blobs.forEach((blob, i) => {
|
||||||
if (i === excludeIdx) return;
|
if (i === excludeIdx) return;
|
||||||
const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i;
|
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 c = this.cell;
|
||||||
const inset = c * 0.10;
|
const byCell = new Map(cells.map((cell) => [`${cell[0]},${cell[1]}`, cell]));
|
||||||
const r = c * 0.30;
|
|
||||||
const set = new Set(cells.map(([x, y]) => `${x},${y}`));
|
|
||||||
|
|
||||||
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) {
|
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) {
|
for (const [x, y] of cells) {
|
||||||
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
|
const ex = this.cellCx(x) + ox, ey = this.cellCy(y) + oy - c * 0.04;
|
||||||
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;
|
|
||||||
g.fillStyle(0xffffff, 1);
|
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.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() {
|
drawHud() {
|
||||||
|
|
@ -495,8 +527,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
this.moves++;
|
this.moves++;
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
|
|
||||||
const movingCells = this.state.blobs[idx].cells.map((c) => [c[0], c[1]]);
|
const movingCells = this.state.blobs[idx].cells.map((c) => c.slice());
|
||||||
const color = this.colorFor(this.state.blobs[idx]);
|
|
||||||
const [dx, dy] = DIRS[dir];
|
const [dx, dy] = DIRS[dir];
|
||||||
const steps = deathStep > 0 ? deathStep : maxSteps;
|
const steps = deathStep > 0 ? deathStep : maxSteps;
|
||||||
|
|
||||||
|
|
@ -506,7 +537,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
const proxy = { v: 0 };
|
const proxy = { v: 0 };
|
||||||
const tgtX = dx * steps * this.cell;
|
const tgtX = dx * steps * this.cell;
|
||||||
const tgtY = dy * 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);
|
drawAt(0);
|
||||||
playSound(this, SFX.PIECE_CLICK);
|
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; }
|
if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; }
|
||||||
// keep the moved blob selected at its new resting position
|
// 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.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps };
|
||||||
this.drawAllMonsters();
|
|
||||||
this.drawTargets();
|
this.drawTargets();
|
||||||
this.updateHud();
|
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;
|
this.busy = false;
|
||||||
if (this.state.state === 'won') this.onSolved();
|
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) {
|
splatAndFail(idx, dir) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@
|
||||||
// fails and the level must be restarted). STARS are bonus floor tiles collected
|
// fails and the level must be restarted). STARS are bonus floor tiles collected
|
||||||
// when a monster covers them.
|
// 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 DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
||||||
export const DIR_LIST = ['up', 'down', 'left', 'right'];
|
export const DIR_LIST = ['up', 'down', 'left', 'right'];
|
||||||
|
|
@ -135,7 +138,7 @@ export function slide(state, idx, dir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = state.blobs[idx];
|
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;
|
const before = state.blobs.length;
|
||||||
mergeBlobs(state);
|
mergeBlobs(state);
|
||||||
pickUpStars(state);
|
pickUpStars(state);
|
||||||
|
|
@ -172,7 +175,7 @@ export function cloneState(state) {
|
||||||
walls: state.walls, // immutable during play — shared
|
walls: state.walls, // immutable during play — shared
|
||||||
spikes: state.spikes, // immutable during play — shared
|
spikes: state.spikes, // immutable during play — shared
|
||||||
stars: state.stars, // 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),
|
collected: new Set(state.collected),
|
||||||
state: state.state,
|
state: state.state,
|
||||||
};
|
};
|
||||||
|
|
@ -185,7 +188,7 @@ export function newState(level) {
|
||||||
walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))),
|
walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))),
|
||||||
spikes: new Set((level.spikes ?? []).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]]),
|
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(),
|
collected: new Set(),
|
||||||
state: 'playing',
|
state: 'playing',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue