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:
parent
10165547ae
commit
0201b0de7d
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue