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:
parent
54a1ba2280
commit
4f23a8470a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -56,6 +56,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
this.selectedCell = null;
|
this.selectedCell = null;
|
||||||
this.animGfx = null;
|
this.animGfx = null;
|
||||||
|
this.eyeOffsets = new Map();
|
||||||
|
this.eyeTimers = [];
|
||||||
|
this.eyeAnimActive = false;
|
||||||
|
this.blobGfx = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
|
|
@ -89,8 +93,12 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearLayer() {
|
clearLayer() {
|
||||||
|
this.eyeAnimActive = false;
|
||||||
|
this.eyeTimers.forEach((t) => t.remove(false));
|
||||||
|
this.eyeTimers = [];
|
||||||
|
this.eyeOffsets = new Map();
|
||||||
this.layer.removeAll(true);
|
this.layer.removeAll(true);
|
||||||
this.monsterGfx = null;
|
this.blobGfx = [];
|
||||||
this.targetGfx = null;
|
this.targetGfx = null;
|
||||||
this.animGfx = null;
|
this.animGfx = null;
|
||||||
this.undoBtn = null;
|
this.undoBtn = null;
|
||||||
|
|
@ -262,12 +270,65 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
this.clearLayer();
|
this.clearLayer();
|
||||||
this.computeLayout();
|
this.computeLayout();
|
||||||
this.buildBoard();
|
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.targetGfx = this.add.graphics().setDepth(D.target); this.layer.add(this.targetGfx);
|
||||||
this.drawAllMonsters();
|
this.drawAllMonsters();
|
||||||
this.drawTargets();
|
this.drawTargets();
|
||||||
this.drawHud();
|
this.drawHud();
|
||||||
this.updateHud();
|
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() {
|
computeLayout() {
|
||||||
|
|
@ -371,12 +432,42 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
drawAllMonsters(excludeIdx = -1) {
|
drawAllMonsters(excludeIdx = -1) {
|
||||||
const g = this.monsterGfx;
|
// Destroy previous per-blob containers (their tweens are auto-removed on destroy)
|
||||||
g.clear();
|
this.blobGfx.forEach(({ container }) => container.destroy());
|
||||||
|
this.blobGfx = [];
|
||||||
|
|
||||||
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, 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
|
// every monster keeps its face inside the merged blob: eyes on every cell
|
||||||
const er = c * 0.105, sp = c * 0.155;
|
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;
|
const ex = this.cellCx(x) + ox, ey = this.cellCy(y) + oy - c * 0.04;
|
||||||
g.fillStyle(0xffffff, 1);
|
g.fillStyle(0xffffff, 1);
|
||||||
g.fillCircle(ex - sp, ey, er); g.fillCircle(ex + sp, ey, 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.25, ey + er * 0.1, er * 0.5);
|
g.fillCircle(ex - sp + eo.dx, ey + eo.dy, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,10 +541,10 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||||
this.layer.add(title);
|
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,
|
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
}).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',
|
fontFamily: 'serif', fontSize: '30px', color: '#ffd54a',
|
||||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||||
this.layer.add([this.movesText, this.starsText]);
|
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 tgtY = dy * animSteps * this.cell;
|
||||||
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, 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.WOOSH);
|
||||||
|
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: proxy, v: 1,
|
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 restIdx = blobAt(this.state, this.selectedCell.x, this.selectedCell.y);
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (!this.monsterGfx) return; // scene view changed mid-animation
|
if (this.view !== 'play') return; // scene view changed mid-animation
|
||||||
this.drawAllMonsters();
|
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 (restIdx < 0) { finish(); return; }
|
||||||
if (res.merged) {
|
if (res.merged) {
|
||||||
playSound(this, SFX.CARD_PLACE);
|
playSound(this, SFX.SQUASH);
|
||||||
this.wobbleBlob(restIdx, 1.16, 0.84, finish); // jelly merge jiggle
|
this.wobbleBlob(restIdx, 1.16, 0.84, finish); // jelly merge jiggle
|
||||||
} else {
|
} else {
|
||||||
|
playSound(this, SFX.SQUISH);
|
||||||
const sx = dx !== 0 ? 0.9 : 1.1; // impact squash along the slide axis
|
const sx = dx !== 0 ? 0.9 : 1.1; // impact squash along the slide axis
|
||||||
this.wobbleBlob(restIdx, sx, 2 - sx, finish, true);
|
this.wobbleBlob(restIdx, sx, 2 - sx, finish, true);
|
||||||
}
|
}
|
||||||
|
|
@ -626,11 +719,14 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
nudgeInvalid(idx) {
|
nudgeInvalid(idx) {
|
||||||
// brief wobble of the whole monster layer to signal "can't move"
|
const entry = this.blobGfx.find((b) => b.blobIdx === idx);
|
||||||
const blob = this.state.blobs[idx];
|
if (!entry) return;
|
||||||
const cx = this.cellCx(repCell(blob)[0]);
|
const { container } = entry;
|
||||||
void cx;
|
const origX = container.x;
|
||||||
this.tweens.add({ targets: this.monsterGfx, x: 6, duration: 50, yoyo: true, repeat: 1, onComplete: () => { this.monsterGfx.x = 0; } });
|
this.tweens.add({
|
||||||
|
targets: container, x: origX + 6, duration: 50, yoyo: true, repeat: 1,
|
||||||
|
onComplete: () => { container.x = origX; },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
undo() {
|
undo() {
|
||||||
|
|
@ -722,7 +818,7 @@ export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
api.post('/history/single-player', {
|
api.post('/history/single-player', {
|
||||||
slug: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win',
|
slug: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win',
|
||||||
}).catch(() => { /* best effort */ });
|
}).catch(() => { /* best effort */ });
|
||||||
playSound(this, SFX.VICTORY_SHORT);
|
playSound(this, SFX.JELLO_MONSTERS);
|
||||||
|
|
||||||
const cx = GAME_WIDTH / 2;
|
const cx = GAME_WIDTH / 2;
|
||||||
const cy = GAME_HEIGHT / 2;
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
|
|
||||||
|
|
@ -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-activate', '/assets/fx/8bit-activate.mp3');
|
||||||
this.load.audio('sfx-8bit-action', '/assets/fx/8bit-action.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-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 });
|
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ export const SFX = {
|
||||||
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
|
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
|
||||||
EIGHTBIT_ACTION: 'sfx-8bit-action',
|
EIGHTBIT_ACTION: 'sfx-8bit-action',
|
||||||
EIGHTBIT_MOVE: 'sfx-8bit-move',
|
EIGHTBIT_MOVE: 'sfx-8bit-move',
|
||||||
|
SQUISH: 'sfx-squish',
|
||||||
|
SQUASH: 'sfx-squash',
|
||||||
|
WOOSH: 'sfx-woosh',
|
||||||
|
JELLO_MONSTERS: 'sfx-jello-monsters',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function playSound(scene, key) {
|
export function playSound(scene, key) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue