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,