From 0201b0de7db4a13eb7e8f0b7f7a4edc909cd1752 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 28 Jul 2026 17:27:14 -0600 Subject: [PATCH] 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 --- src/data/assetManifest.js | 1 + src/games/zuma/ZumaGame.js | 67 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 052f948..65270b9 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -327,6 +327,7 @@ export const MANIFEST = { { 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-explode', path: 'assets/fx/zuma-explode.mp3' }, + { type: 'audio', key: 'sfx-zuma-win', path: 'assets/fx/zuma-win.mp3' }, ], '2048': [ // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so diff --git a/src/games/zuma/ZumaGame.js b/src/games/zuma/ZumaGame.js index 390a7d3..0a9402d 100644 --- a/src/games/zuma/ZumaGame.js +++ b/src/games/zuma/ZumaGame.js @@ -7,7 +7,7 @@ import { playSound, playScifiExplode, SFX } from '../../ui/Sounds.js'; import { api } from '../../services/api.js'; import { TUNING, BALL_COLORS, PATH_STYLE, createLevel, step, fireBall, swapBalls, rayHit, - isHidden, visibilityAt, pathSpans, sampleRange, + isHidden, visibilityAt, pathSpans, sampleRange, nearestS, } from './ZumaLogic.js'; import { PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures, @@ -63,6 +63,12 @@ const TUNE = { // 52-marble bank level still tips in inside a beat PIT_SWALLOW: 76, // px of travel over which one marble shrinks to // 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 { @@ -86,6 +92,8 @@ export default class ZumaGame extends Phaser.Scene { this.aimAngle = -Math.PI / 2; this.ballSprites = new Map(); // ball id -> { img, icon } 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() { @@ -938,12 +946,14 @@ export default class ZumaGame extends Phaser.Scene { this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a'); 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); + this.lastClearX = e.x; this.lastClearY = e.y; break; } case 'explosion': playScifiExplode(this); this.burst(e.x, e.y, 0xffa726, 16, 2.2); this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36); + this.lastClearX = e.x; this.lastClearY = e.y; break; case 'powerup': playSound(this, SFX.SCIFI_REVEAL); @@ -1078,7 +1088,6 @@ export default class ZumaGame extends Phaser.Scene { onWon(timeBonus) { this.overlayUp = true; this.laserGfx?.clear(); - playSound(this, SFX.VICTORY_SHORT); const score = this.state.score; const stars = this.medalStars(score, this.levelDef.starScores); @@ -1094,6 +1103,60 @@ export default class ZumaGame extends Phaser.Scene { }).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 banner = this.add.text(cx, cy - 60, 'ZUMA!', { fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex,