feat(puddingmonsters): add eye animations and squash/stretch blob effects

- Introduce animated eye pupils that periodically shift direction per monster
- Replace single monster graphics with per-blob containers for independent squash-and-stretch pulse animations
- Add 4 new sound effects: squish, squash, woosh, jello-monsters
- Update move animations with woosh sound, merge with squash jiggle, and victory with jello-monsters fanfare
- Adjust HUD positioning for moves and stars text
- Refactor nudgeInvalid to work with per-blob container system
This commit is contained in:
Brian Fertig 2026-06-19 15:37:38 -06:00
parent 54a1ba2280
commit 4f23a8470a
7 changed files with 123 additions and 19 deletions

Binary file not shown.

BIN
public/assets/fx/squash.mp3 Normal file

Binary file not shown.

BIN
public/assets/fx/squish.mp3 Normal file

Binary file not shown.

BIN
public/assets/fx/woosh.mp3 Normal file

Binary file not shown.

View File

@ -56,6 +56,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.drag = null;
this.selectedCell = null;
this.animGfx = null;
this.eyeOffsets = new Map();
this.eyeTimers = [];
this.eyeAnimActive = false;
this.blobGfx = [];
}
async create() {
@ -89,8 +93,12 @@ export default class PuddingMonstersGame extends Phaser.Scene {
}
clearLayer() {
this.eyeAnimActive = false;
this.eyeTimers.forEach((t) => t.remove(false));
this.eyeTimers = [];
this.eyeOffsets = new Map();
this.layer.removeAll(true);
this.monsterGfx = null;
this.blobGfx = [];
this.targetGfx = null;
this.animGfx = null;
this.undoBtn = null;
@ -262,12 +270,65 @@ export default class PuddingMonstersGame extends Phaser.Scene {
this.clearLayer();
this.computeLayout();
this.buildBoard();
this.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx);
this.targetGfx = this.add.graphics().setDepth(D.target); this.layer.add(this.targetGfx);
this.drawAllMonsters();
this.drawTargets();
this.drawHud();
this.updateHud();
this.initEyeOffsets();
this.startEyeAnimation();
}
initEyeOffsets() {
const er = this.cell * 0.105;
const maxR = er * 0.42;
this.eyeOffsets = new Map();
for (const blob of this.state.blobs) {
for (const [, , m] of blob.cells) {
if (!this.eyeOffsets.has(m)) {
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * maxR;
this.eyeOffsets.set(m, { dx: Math.cos(angle) * r, dy: Math.sin(angle) * r });
}
}
}
}
startEyeAnimation() {
const er = this.cell * 0.105;
const maxR = er * 0.42;
this.eyeAnimActive = true;
const scheduleNext = (m) => {
if (!this.eyeAnimActive) return;
const delay = 1000 + Math.random() * 2500;
const timer = this.time.delayedCall(delay, () => {
if (!this.eyeAnimActive || !this.eyeOffsets.has(m)) return;
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * maxR;
const cur = this.eyeOffsets.get(m);
const proxy = { dx: cur.dx, dy: cur.dy };
this.tweens.add({
targets: proxy, dx: Math.cos(angle) * r, dy: Math.sin(angle) * r,
duration: 120 + Math.random() * 80,
ease: 'Sine.easeInOut',
onUpdate: () => {
if (!this.eyeAnimActive || !this.blobGfx.length) return;
this.eyeOffsets.set(m, { dx: proxy.dx, dy: proxy.dy });
if (!this.busy && !this.overlayUp) this.drawAllMonsters();
},
onComplete: () => scheduleNext(m),
});
});
this.eyeTimers.push(timer);
};
let stagger = 0;
for (const m of this.eyeOffsets.keys()) {
const t = this.time.delayedCall(stagger + Math.random() * 600, () => scheduleNext(m));
this.eyeTimers.push(t);
stagger += 200;
}
}
computeLayout() {
@ -371,12 +432,42 @@ export default class PuddingMonstersGame extends Phaser.Scene {
}
drawAllMonsters(excludeIdx = -1) {
const g = this.monsterGfx;
g.clear();
// Destroy previous per-blob containers (their tweens are auto-removed on destroy)
this.blobGfx.forEach(({ container }) => container.destroy());
this.blobGfx = [];
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, 0, 0, sel);
// Position the container at the blob centroid so scaling pivots from the centre
let cx = 0, cy = 0;
for (const [bx, by] of blob.cells) { cx += this.cellCx(bx); cy += this.cellCy(by); }
cx /= blob.cells.length; cy /= blob.cells.length;
const container = this.add.container(cx, cy).setDepth(D.monster);
const g = this.add.graphics();
this.drawBlobInto(g, blob.cells, -cx, -cy, sel);
container.add(g);
this.layer.add(container);
// Squash-and-stretch pulse: even/odd blobs start at opposite phases so they
// never all peak simultaneously. Duration varies per blob to drift over time.
const a = 0.015;
const startX = i % 2 === 0 ? 1 + a : 1 - a;
const startY = i % 2 === 0 ? 1 - a : 1 + a;
container.setScale(startX, startY);
this.tweens.add({
targets: container,
scaleX: 1 - a,
scaleY: 1 + a,
duration: 900 + i * 310,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
this.blobGfx.push({ container, blobIdx: i });
});
}
@ -398,13 +489,14 @@ export default class PuddingMonstersGame extends Phaser.Scene {
// 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, m] of cells) {
const eo = this.eyeOffsets?.get(m) ?? { dx: 0, dy: 0 };
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);
g.fillCircle(ex - sp + eo.dx, ey + eo.dy, er * 0.5);
g.fillCircle(ex + sp + eo.dx, ey + eo.dy, er * 0.5);
}
}
@ -449,10 +541,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add(title);
this.movesText = this.add.text(GAME_WIDTH - 50, 80, '', {
this.movesText = this.add.text(GAME_WIDTH - 50, 155, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.starsText = this.add.text(GAME_WIDTH - 50, 124, '', {
this.starsText = this.add.text(GAME_WIDTH - 50, 199, '', {
fontFamily: 'serif', fontSize: '30px', color: '#ffd54a',
}).setOrigin(1, 0.5).setDepth(D.ui);
this.layer.add([this.movesText, this.starsText]);
@ -545,7 +637,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const tgtY = dy * animSteps * this.cell;
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, tgtX * t, tgtY * t); };
drawAt(0);
playSound(this, SFX.PIECE_CLICK);
playSound(this, SFX.WOOSH);
this.tweens.add({
targets: proxy, v: 1,
@ -569,16 +661,17 @@ export default class PuddingMonstersGame extends Phaser.Scene {
const restIdx = blobAt(this.state, this.selectedCell.x, this.selectedCell.y);
const finish = () => {
if (!this.monsterGfx) return; // scene view changed mid-animation
if (this.view !== 'play') 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);
playSound(this, SFX.SQUASH);
this.wobbleBlob(restIdx, 1.16, 0.84, finish); // jelly merge jiggle
} else {
playSound(this, SFX.SQUISH);
const sx = dx !== 0 ? 0.9 : 1.1; // impact squash along the slide axis
this.wobbleBlob(restIdx, sx, 2 - sx, finish, true);
}
@ -626,11 +719,14 @@ export default class PuddingMonstersGame extends Phaser.Scene {
}
nudgeInvalid(idx) {
// brief wobble of the whole monster layer to signal "can't move"
const blob = this.state.blobs[idx];
const cx = this.cellCx(repCell(blob)[0]);
void cx;
this.tweens.add({ targets: this.monsterGfx, x: 6, duration: 50, yoyo: true, repeat: 1, onComplete: () => { this.monsterGfx.x = 0; } });
const entry = this.blobGfx.find((b) => b.blobIdx === idx);
if (!entry) return;
const { container } = entry;
const origX = container.x;
this.tweens.add({
targets: container, x: origX + 6, duration: 50, yoyo: true, repeat: 1,
onComplete: () => { container.x = origX; },
});
}
undo() {
@ -722,7 +818,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
api.post('/history/single-player', {
slug: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win',
}).catch(() => { /* best effort */ });
playSound(this, SFX.VICTORY_SHORT);
playSound(this, SFX.JELLO_MONSTERS);
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;

View File

@ -119,6 +119,10 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-8bit-activate', '/assets/fx/8bit-activate.mp3');
this.load.audio('sfx-8bit-action', '/assets/fx/8bit-action.mp3');
this.load.audio('sfx-8bit-move', '/assets/fx/8bit-move.mp3');
this.load.audio('sfx-squish', '/assets/fx/squish.mp3');
this.load.audio('sfx-squash', '/assets/fx/squash.mp3');
this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3');
this.load.audio('sfx-jello-monsters','/assets/fx/jello-monsters.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -44,6 +44,10 @@ export const SFX = {
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
EIGHTBIT_ACTION: 'sfx-8bit-action',
EIGHTBIT_MOVE: 'sfx-8bit-move',
SQUISH: 'sfx-squish',
SQUASH: 'sfx-squash',
WOOSH: 'sfx-woosh',
JELLO_MONSTERS: 'sfx-jello-monsters',
};
export function playSound(scene, key) {