feat(zuma): add victory fireworks finale with time bonus payout

Add a chain of explosions that runs from the last match location to the
pit on level completion, distributing the time bonus across each blast.
The sequence ducks the soundtrack during the finale and plays the win
jingle as the chain reaches the edge, then resumes music before
revealing the victory banner.

- Register new `sfx-zuma-win` audio asset in the manifest
- Track last clear position (lastClearX/lastClearY) from match/explosion events
- Add `nearestS` to ZumaLogic for sampling the path at a world coordinate
- Implement `playVictoryFireworks()` orchestrating the timed explosion chain
  with configurable step/gap tuning constants
- Add `victoryExplosion()` for a brighter additive flash + particle burst
- Remove premature `SFX.VICTORY_SHORT` play in favor of the win jingle timed
  to the finale
This commit is contained in:
Brian Fertig 2026-07-28 17:27:14 -06:00
parent 10165547ae
commit 0201b0de7d
2 changed files with 66 additions and 2 deletions

View File

@ -327,6 +327,7 @@ export const MANIFEST = {
{ type: 'audio', key: 'sfx-zuma-hit', path: 'assets/fx/zuma-hit.mp3' }, { type: 'audio', key: 'sfx-zuma-hit', path: 'assets/fx/zuma-hit.mp3' },
{ type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' }, { type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' },
{ type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' }, { type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' },
{ type: 'audio', key: 'sfx-zuma-win', path: 'assets/fx/zuma-win.mp3' },
], ],
'2048': [ '2048': [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so

View File

@ -7,7 +7,7 @@ import { playSound, playScifiExplode, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { import {
TUNING, BALL_COLORS, PATH_STYLE, createLevel, step, fireBall, swapBalls, rayHit, TUNING, BALL_COLORS, PATH_STYLE, createLevel, step, fireBall, swapBalls, rayHit,
isHidden, visibilityAt, pathSpans, sampleRange, isHidden, visibilityAt, pathSpans, sampleRange, nearestS,
} from './ZumaLogic.js'; } from './ZumaLogic.js';
import { import {
PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures, PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures,
@ -63,6 +63,12 @@ const TUNE = {
// 52-marble bank level still tips in inside a beat // 52-marble bank level still tips in inside a beat
PIT_SWALLOW: 76, // px of travel over which one marble shrinks to PIT_SWALLOW: 76, // px of travel over which one marble shrinks to
// nothing and its colour drains to black // nothing and its colour drains to black
FIREWORK_STEP_PX: 130, // roughly this many px of path between explosions
FIREWORK_MIN_STEPS: 3, // ...but never fewer than this many
FIREWORK_MAX_STEPS: 8, // ...nor more, however far the last pop landed from the pit
FIREWORK_GAP_MS: 240, // delay between one explosion and the next
FIREWORK_WIN_GAP_MS: 260, // beat between the last explosion and the win jingle
FIREWORK_RESUME_MS: 900, // win jingle plays out before the soundtrack resumes
}; };
export default class ZumaGame extends Phaser.Scene { export default class ZumaGame extends Phaser.Scene {
@ -86,6 +92,8 @@ export default class ZumaGame extends Phaser.Scene {
this.aimAngle = -Math.PI / 2; this.aimAngle = -Math.PI / 2;
this.ballSprites = new Map(); // ball id -> { img, icon } this.ballSprites = new Map(); // ball id -> { img, icon }
this.flightSprites = new Map(); // flight id -> img this.flightSprites = new Map(); // flight id -> img
this.lastClearX = null; // where the last pop/explosion landed —
this.lastClearY = null; // the victory fireworks start from here
} }
async create() { async create() {
@ -938,12 +946,14 @@ export default class ZumaGame extends Phaser.Scene {
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a'); this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a');
if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36); if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36);
else if (e.combo > 1) this.floatText(e.x, e.y - 64, `COMBO x${e.combo}`, '#6fd47e', 34); else if (e.combo > 1) this.floatText(e.x, e.y - 64, `COMBO x${e.combo}`, '#6fd47e', 34);
this.lastClearX = e.x; this.lastClearY = e.y;
break; break;
} }
case 'explosion': case 'explosion':
playScifiExplode(this); playScifiExplode(this);
this.burst(e.x, e.y, 0xffa726, 16, 2.2); this.burst(e.x, e.y, 0xffa726, 16, 2.2);
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36); this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36);
this.lastClearX = e.x; this.lastClearY = e.y;
break; break;
case 'powerup': case 'powerup':
playSound(this, SFX.SCIFI_REVEAL); playSound(this, SFX.SCIFI_REVEAL);
@ -1078,7 +1088,6 @@ export default class ZumaGame extends Phaser.Scene {
onWon(timeBonus) { onWon(timeBonus) {
this.overlayUp = true; this.overlayUp = true;
this.laserGfx?.clear(); this.laserGfx?.clear();
playSound(this, SFX.VICTORY_SHORT);
const score = this.state.score; const score = this.state.score;
const stars = this.medalStars(score, this.levelDef.starScores); const stars = this.medalStars(score, this.levelDef.starScores);
@ -1094,6 +1103,60 @@ export default class ZumaGame extends Phaser.Scene {
}).catch(() => { /* best effort */ }); }).catch(() => { /* best effort */ });
} }
this.playVictoryFireworks(timeBonus, () => this.revealWonBanner(score, stars, timeBonus));
}
// Classic Zuma's clear finale: a chain of explosions runs from wherever the
// last match landed down to the pit, each cashing in a slice of the time
// bonus already folded into `score`, and the win jingle lands right as the
// chain reaches the edge. The soundtrack ducks for the whole sequence —
// same trick as the level-start fanfare (see MusicPlayer.pause/resume).
playVictoryFireworks(timeBonus, onDone) {
this.music?.pause();
const path = this.state.path;
const from = nearestS(path, this.lastClearX ?? this.state.frog.x, this.lastClearY ?? this.state.frog.y).s;
const span = Math.max(0, path.length - from);
const T = TUNE;
const steps = Math.max(T.FIREWORK_MIN_STEPS,
Math.min(T.FIREWORK_MAX_STEPS, Math.round(span / T.FIREWORK_STEP_PX) || T.FIREWORK_MIN_STEPS));
const share = Math.floor(timeBonus / steps);
const extra = timeBonus - share * steps; // remainder goes to the first few blasts
let i = 0;
const nextBlast = () => {
if (i >= steps) {
this.time.delayedCall(T.FIREWORK_WIN_GAP_MS, () => {
playSound(this, 'sfx-zuma-win');
this.time.delayedCall(T.FIREWORK_RESUME_MS, () => { this.music?.resume(); onDone(); });
});
return;
}
const s = from + (span * (i + 1)) / steps;
const p = path.pointAt(s);
this.victoryExplosion(p.x, p.y);
playSound(this, 'sfx-zuma-explode');
const bonus = share + (i < extra ? 1 : 0);
if (bonus > 0) this.floatText(p.x, p.y - 24, `+${bonus}`, '#ffd54a', 34);
i++;
this.time.delayedCall(T.FIREWORK_GAP_MS, nextBlast);
};
nextBlast();
}
// A bigger, brighter cousin of burst() for the victory fireworks: an
// additive flash ring on top of the usual particle debris.
victoryExplosion(x, y) {
const flash = this.add.image(x, y, 'zuma-glow').setTint(0xffe08a)
.setBlendMode(Phaser.BlendModes.ADD).setScale(0.4).setAlpha(0.9).setDepth(D.fx);
this.layer.add(flash);
this.tweens.add({
targets: flash, scale: 6, alpha: 0, duration: 420, ease: 'Quad.easeOut',
onComplete: () => flash.destroy(),
});
this.burst(x, y, 0xffa726, 18, 2.4);
}
revealWonBanner(score, stars, timeBonus) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const banner = this.add.text(cx, cy - 60, 'ZUMA!', { const banner = this.add.text(cx, cy - 60, 'ZUMA!', {
fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex,