feat: enhance Bejeweled sound effects and Yatzi dice animations

- Replace generic sci-fi sounds with gem-themed audio (match, chain, drop, big drop)
- Add context-aware match sounds based on special gems (hyper, flame) and match size
- Improve Yatzi dice roll with bezier curve trajectories, rotation, and bounce
- Restructure dice rendering to use containers for consistent transforms
- Add new gem sound assets and SFX constants
This commit is contained in:
Brian Fertig 2026-06-20 11:47:34 -06:00
parent d22f6ff902
commit 5632dbae9b
11 changed files with 106 additions and 33 deletions

Binary file not shown.

Binary file not shown.

BIN
public/assets/fx/gem-04.mp3 Normal file

Binary file not shown.

BIN
public/assets/fx/gem-05.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -81,6 +81,7 @@ export default class BejeweledGame extends Phaser.Scene {
this.lastAction = 0;
this.maxCascade = 0;
this.peakMultiplier = 1;
this.gemToggle = false;
}
create() {
@ -387,6 +388,7 @@ export default class BejeweledGame extends Phaser.Scene {
this.gemLayer.setMask(this.boardMask.createGeometryMask());
this.buildGems(true);
this.time.delayedCall(900, () => playSound(this, SFX.GEM_BIG_DROP));
this.time.delayedCall(950, () => {
this.busy = false;
this.lastAction = this.time.now;
@ -735,7 +737,6 @@ export default class BejeweledGame extends Phaser.Scene {
if (!phases) { this.invalidSwap(a, b); return; }
this.busy = true;
playSound(this, SFX.SCIFI_WOOSH);
const sa = this.grid[a.r][a.c];
const sb = this.grid[b.r][b.c];
const pa = this.cellXY(a.c, a.r);
@ -761,7 +762,6 @@ export default class BejeweledGame extends Phaser.Scene {
invalidSwap(a, b) {
this.busy = true;
playSound(this, SFX.MASTERMIND_DENIED);
const sa = this.grid[a.r][a.c];
const sb = this.grid[b.r][b.c];
const pa = this.cellXY(a.c, a.r);
@ -827,8 +827,19 @@ export default class BejeweledGame extends Phaser.Scene {
this.redrawTimer();
}
// Clear matched gems with a burst.
if (phase.cleared.length) playSound(this, SFX.MASTERMIND_MATCH);
// Clear matched gems with a burst — sound depends on match size.
if (phase.cleared.length) {
const hasHyper = phase.spawns.some((s) => s.special === SPECIAL.HYPER);
const hasFlame = phase.spawns.some((s) => s.special === SPECIAL.FLAME);
if (hasHyper) {
playSound(this, SFX.GEM_MATCH_5);
} else if (hasFlame) {
playSound(this, SFX.GEM_MATCH_4);
} else {
playSound(this, this.gemToggle ? SFX.GEM_MATCH_2 : SFX.GEM_MATCH_1);
this.gemToggle = !this.gemToggle;
}
}
const sparse = phase.cleared.length > 14;
phase.cleared.forEach((cell, i) => {
const sprite = this.grid[cell.r][cell.c];
@ -897,6 +908,10 @@ export default class BejeweledGame extends Phaser.Scene {
for (const f of phase.falls) maxFall = Math.max(maxFall, 110 + 58 * (f.toR - f.fromR));
for (const f of phase.refills) maxFall = Math.max(maxFall, 110 + 58 * (f.r - f.fromR));
if (maxFall > 0 && (phase.falls.length || phase.refills.length)) {
this.time.delayedCall(FALL_AT + maxFall, () => playSound(this, SFX.GEM_DROP));
}
this.time.delayedCall(FALL_AT + maxFall + 70, done);
}
@ -910,6 +925,7 @@ export default class BejeweledGame extends Phaser.Scene {
}
const { x, y } = this.cellXY(e.c, e.r);
if (e.type === 'flame') {
playSound(this, SFX.GEM_CHAIN);
playSound(this, SFX.SCIFI_EXPLODE);
this.cameras.main.shake(110, 0.0045);
const flash = this.add.image(x, y, 'bj-glow').setScale(1).setTint(0xff7a1a)
@ -928,7 +944,7 @@ export default class BejeweledGame extends Phaser.Scene {
this.time.delayedCall(800, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(500, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'star') {
playSound(this, SFX.SCIFI_LAUNCH);
playSound(this, SFX.GEM_CHAIN);
this.cameras.main.shake(90, 0.003);
const h = this.add.image(BOARD_X + BOARD_W / 2, y, 'bj-beam')
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110);
@ -945,6 +961,7 @@ export default class BejeweledGame extends Phaser.Scene {
this.time.delayedCall(700, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(400, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'hyper') {
playSound(this, SFX.GEM_CHAIN);
playSound(this, SFX.SCIFI_REVEAL);
this.cameras.main.shake(170, 0.006);
const tint = e.color ? GEMS[e.color].base : 0xffffff;

View File

@ -304,12 +304,14 @@ export default class YatziGame extends Phaser.Scene {
for (let i = 0; i < 5; i++) {
const x = DICE_LEFT + i * (DICE_SIZE + DICE_GAP) + DICE_SIZE / 2;
const y = DICE_Y;
const frame = this.add.graphics().setDepth(DEPTH.dieBox);
const face = this.add.graphics().setDepth(DEPTH.diePip);
const container = this.add.container(x, y).setDepth(DEPTH.dieBox);
const frame = this.add.graphics();
const face = this.add.graphics();
container.add([frame, face]);
const hit = this.add.zone(x, y, DICE_SIZE, DICE_SIZE).setOrigin(0.5).setDepth(DEPTH.dieHit);
hit.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.onDieClick(i));
this.dieEls.push({ frame, face, hit, cx: x, cy: y, currentFace: 1 });
this.dieEls.push({ frame, face, hit, container, cx: x, cy: y, currentFace: 1 });
}
}
@ -322,26 +324,25 @@ export default class YatziGame extends Phaser.Scene {
renderDieFace(idx, face, held) {
const el = this.dieEls[idx];
el.currentFace = face;
const x = el.cx, y = el.cy;
const half = DICE_SIZE / 2;
const r = 14;
// Frame
// Frame (drawn at container-local coords, center = 0,0)
el.frame.clear();
const borderColor = held ? COLORS.accent : COLORS.muted;
const borderWidth = held ? 5 : 2;
el.frame.fillStyle(0xf2ead8, 1); // cream face
el.frame.fillRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r);
el.frame.fillStyle(0xf2ead8, 1);
el.frame.fillRoundedRect(-half, -half, DICE_SIZE, DICE_SIZE, r);
el.frame.lineStyle(borderWidth, borderColor, 1);
el.frame.strokeRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r);
el.frame.strokeRoundedRect(-half, -half, DICE_SIZE, DICE_SIZE, r);
// Pips
el.face.clear();
el.face.fillStyle(0x1a1208, 1);
const pipR = 9;
const off = DICE_SIZE * 0.28;
for (const [cx, cy] of PIP_POS[face]) {
el.face.fillCircle(x + cx * off, y + cy * off, pipR);
for (const [px, py] of PIP_POS[face]) {
el.face.fillCircle(px * off, py * off, pipR);
}
}
@ -377,25 +378,66 @@ export default class YatziGame extends Phaser.Scene {
async animateRoll(targetDice) {
playSound(this, SFX.DICE_ROLL);
this.animating = true;
return new Promise((resolve) => {
this.tweens.addCounter({
from: 0, to: 1, duration: 550, ease: 'Quad.Out',
onUpdate: () => {
for (let i = 0; i < 5; i++) {
if (this.gs.held[i]) continue;
const fake = Math.ceil(Math.random() * 6);
this.renderDieFace(i, fake, false);
}
},
onComplete: () => {
for (let i = 0; i < 5; i++) {
this.renderDieFace(i, targetDice[i], this.gs.held[i]);
}
this.animating = false;
resolve();
},
const promises = [];
for (let i = 0; i < 5; i++) {
if (this.gs.held[i]) continue;
const el = this.dieEls[i];
const finalX = el.cx;
const finalY = el.cy;
const startX = finalX + Phaser.Math.Between(-120, 120);
const startY = 1280;
const landX = finalX + Phaser.Math.Between(-14, 14);
const landY = finalY + Phaser.Math.Between(-14, 14);
const P1x = (startX + landX) / 2 + Phaser.Math.Between(-80, 80);
const P1y = landY - Phaser.Math.Between(380, 520);
const startRot = Phaser.Math.FloatBetween(-Math.PI * 3, Math.PI * 3);
const endRot = Phaser.Math.FloatBetween(-0.12, 0.12);
el.container.x = startX;
el.container.y = startY;
el.container.rotation = startRot;
const progress = { t: 0 };
const p = new Promise((resolve) => {
this.tweens.add({
targets: progress,
t: 1,
duration: 700,
delay: i * 70,
ease: 'Cubic.Out',
onUpdate: () => {
const t = progress.t;
const u = 1 - t;
el.container.x = u * u * startX + 2 * u * t * P1x + t * t * landX;
el.container.y = u * u * startY + 2 * u * t * P1y + t * t * landY;
el.container.rotation = startRot + (endRot - startRot) * t;
this.renderDieFace(i, Math.ceil(Math.random() * 6), false);
},
onComplete: () => {
el.container.x = landX;
el.container.y = landY;
el.container.rotation = endRot;
this.renderDieFace(i, targetDice[i], false);
this.tweens.add({
targets: el.container,
scaleX: { from: 1.12, to: 1 },
scaleY: { from: 1.12, to: 1 },
duration: 110,
ease: 'Quad.Out',
onComplete: resolve,
});
},
});
});
});
promises.push(p);
}
await Promise.all(promises);
this.animating = false;
}
// ─── Portraits ─────────────────────────────────────────────────────────

View File

@ -132,6 +132,13 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-ui-flip', '/assets/fx/ui-flip.mp3');
this.load.audio('sfx-ui-place', '/assets/fx/ui-place.mp3');
this.load.audio('sfx-ui-chime', '/assets/fx/ui-chime.mp3');
this.load.audio('sfx-gem-match-1', '/assets/fx/gem-01.mp3');
this.load.audio('sfx-gem-match-2', '/assets/fx/gem-02.mp3');
this.load.audio('sfx-gem-match-4', '/assets/fx/gem-04.mp3');
this.load.audio('sfx-gem-match-5', '/assets/fx/gem-05.mp3');
this.load.audio('sfx-gem-chain', '/assets/fx/gem-chain.mp3');
this.load.audio('sfx-gem-drop', '/assets/fx/gem-drop.mp3');
this.load.audio('sfx-gem-big-drop','/assets/fx/gem-big-drop.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -55,6 +55,13 @@ export const SFX = {
UI_FLIP: 'sfx-ui-flip',
UI_PLACE: 'sfx-ui-place',
UI_CHIME: 'sfx-ui-chime',
GEM_MATCH_1: 'sfx-gem-match-1',
GEM_MATCH_2: 'sfx-gem-match-2',
GEM_MATCH_4: 'sfx-gem-match-4',
GEM_MATCH_5: 'sfx-gem-match-5',
GEM_CHAIN: 'sfx-gem-chain',
GEM_DROP: 'sfx-gem-drop',
GEM_BIG_DROP: 'sfx-gem-big-drop',
};
export function playSound(scene, key) {