import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { getGameSoundtrack } from '../../services/soundtrack.js'; 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, nearestS, } from './ZumaLogic.js'; import { PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures, } from './ZumaPortal.js'; import { PIT_KEY, PIT_SWIRL, buildPitTextures } from './ZumaPit.js'; const BG = 0x0d1a10; // deep jungle green // The launcher is layered around the marble it holds: frog frame 0 sits under // the ready marble, frame 1 (the disc with the mouth slot cut out) sits over // it — and over flights too, so a fired marble slides out from under the lip. // // That only works because `this.layer` is a Phaser Layer, not a Container: a // Container renders its children in insertion order and ignores their depth // entirely, which would put the frog overlay under every marble (the frog is // built before any marble exists). Layer depth-sorts, so these numbers mean // what they say. Do not turn `this.layer` back into a Container. // // Tunnels use the same trick vertically, and the ordering below IS the rule // that a live path always wins an overlap with a tunnel. Bottom to top: // the buried run of path, then the portal's stone head (scenery — a crossing // lane and its marbles ride over it), then the path, then the chain, then the // portal's mouth (the hole itself, narrow enough to cover only its own path, // which is what swallows a marble as it rolls in). Flights sit above even the // mouth: a shot arcs over a gateway, it does not vanish into one. const D = { bg: -6, tunnel: -4, portalStone: -2, path: 0, pit: 2, pitSwirl: 3, frogBase: 4, frogBall: 5, frogBallGloss: 6, ball: 10, ballGloss: 11, icon: 12, portalMouth: 13, portalGlow: 14, flight: 15, flightGloss: 16, laser: 17, frogOver: 18, frogNext: 19, frogNextGloss: 20, fx: 24, ui: 30, overlay: 60, overlayUI: 62, }; // Marbles roll: `zuma-roll` is a greyscale sphere baked once at this many // rotations about the screen-vertical axis, i.e. a roll cycle toward +x. // Rolling in any other direction is that same cycle rotated — exact for a // sphere — so the frame comes from arc-length and the rotation from the path // tangent. Power of two so the frame index can wrap with a mask. const ROLL_FRAMES = 32; const ROLL_COLS = 8; // Render-side feel constants (logic tuning lives in ZumaLogic.TUNING) const TUNE = { INSERT_MS: 120, // squeeze-in tween for a landed shot POP_FX_MS: 320, // particle burst lifetime LASER_ALPHA: 0.55, NEXT_SCALE: 0.55, // the on-deck marble shown in the frog's belly PIT_FALL_SPEED: 1500, // px/s the lost chain rolls the last stretch of path PIT_FALL_MAX_S: 1.45, // ...raised above that if the chain is long, so a // 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 { constructor() { super('ZumaGame'); } init(data) { this.gameDef = data.game ?? { slug: 'zuma', name: 'Zuma' }; // Editor test play: a one-off level to run instead of the bank, and no // progress or history writes for it (see ZumaEditor.testPlay). this.testLevel = data.testLevel ?? null; this.returnToEditor = !!data.returnToEditor; this.manifest = []; // lightweight { level, name, file } entries this.levelCache = new Map(); // level number -> fetched full def this.levelsCompleted = 0; this.canPersist = true; this.view = 'select'; this.level = 0; this.levelDef = null; this.state = null; this.overlayUp = false; 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, this.lastClearPathIdx = null; // and which path's pit they travel to } async create() { const { tracks, volume } = getGameSoundtrack(this); if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2); const raw = this.cache.json.get('zuma-levels'); this.manifest = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); if (this.testLevel) { this.manifest = [{ level: this.testLevel.level, name: this.testLevel.name }]; this.canPersist = false; this.layer = this.add.layer(); this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS; this.buildTextures(); this.bindInput(); this.playLevel(this.testLevel.level, this.testLevel); return; } try { const res = await api.get('/puzzles/zuma/progress'); this.levelsCompleted = res?.levelsCompleted ?? 0; } catch (_) { this.canPersist = false; this.levelsCompleted = 0; } this.layer = this.add.layer(); this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS; // px of travel per full roll this.buildTextures(); this.bindInput(); this.showLevelSelect(); } // ── Generated textures ────────────────────────────────────────────────────── buildTextures() { const R = TUNING.BALL_RADIUS; const size = R * 2; this.buildRollSheet(); this.buildFrogFallback(); buildPortalTextures(this); buildPitTextures(this); // Fixed specular, drawn over the rolling base so the light stays put while // the marble surface turns under it. Untinted — the base carries the color. if (!this.textures.exists('zuma-gloss')) { const g = this.add.graphics(); g.fillStyle(0xffffff, 0.8); g.fillEllipse(R - R * 0.35, R - R * 0.45, R * 0.5, R * 0.32); g.fillStyle(0xffffff, 0.22); g.fillEllipse(R + R * 0.3, R + R * 0.55, R * 0.5, R * 0.2); g.generateTexture('zuma-gloss', size, size); g.destroy(); } if (!this.textures.exists('zuma-glow')) { const g = this.add.graphics(); g.fillStyle(0xffffff, 0.35); g.fillCircle(11, 11, 11); g.fillStyle(0xffffff, 0.8); g.fillCircle(11, 11, 7); g.fillStyle(0xffffff, 1); g.fillCircle(11, 11, 3.5); g.generateTexture('zuma-glow', 22, 22); g.destroy(); } const icon = (key, draw) => { if (this.textures.exists(key)) return; const g = this.add.graphics(); draw(g); g.generateTexture(key, 40, 40); g.destroy(); }; const GOLD = 0xffd54a; icon('zuma-pw-slow', (g) => { // clock g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 15); g.lineBetween(20, 20, 20, 9); g.lineBetween(20, 20, 28, 23); }); icon('zuma-pw-reverse', (g) => { // back arrows g.fillStyle(GOLD, 1); g.fillTriangle(17, 11, 17, 29, 4, 20); g.fillTriangle(36, 11, 36, 29, 23, 20); }); icon('zuma-pw-accuracy', (g) => { // crosshair g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 12); g.lineBetween(20, 1, 20, 12); g.lineBetween(20, 28, 20, 39); g.lineBetween(1, 20, 12, 20); g.lineBetween(28, 20, 39, 20); }); icon('zuma-pw-explosion', (g) => { // starburst g.fillStyle(GOLD, 1); for (let k = 0; k < 8; k++) { const a = (k * Math.PI) / 4; g.fillTriangle( 20 + Math.cos(a) * 19, 20 + Math.sin(a) * 19, 20 + Math.cos(a + 1.2) * 7, 20 + Math.sin(a + 1.2) * 7, 20 + Math.cos(a - 1.2) * 7, 20 + Math.sin(a - 1.2) * 7 ); } g.fillCircle(20, 20, 7); }); } // Bake the roll cycle: ROLL_FRAMES orthographic views of one greyscale // sphere, each rotated a further 2PI/ROLL_FRAMES about the screen-vertical // axis. That axis is exactly a roll toward +x — a surface point at (0,0,R) // maps to (R sin0, 0, R cos0). Shading and markings are baked in greyscale so // setTint() can carry the six marble colors off one sheet. buildRollSheet() { if (this.textures.exists('zuma-roll')) return; const R = TUNING.BALL_RADIUS; const S = R * 2; const rows = ROLL_FRAMES / ROLL_COLS; const W = ROLL_COLS * S; const tex = this.textures.createCanvas('zuma-roll', W, rows * S); if (!tex) return; const ctx = tex.getContext(); const img = ctx.createImageData(W, rows * S); const data = img.data; // Surface markings: spots on a Fibonacci spiral, so every view of the // sphere carries a few and the roll is legible from any angle. Without them // a shaded sphere is rotationally symmetric and rolling is invisible. const NB = 14; const SPOT_COS = 0.94; // angular size: cos of the cap radius const SPOT_SPAN = 1 - SPOT_COS; const SPOT_DEPTH = 0.55; // how much darker the spot centre is const GA = Math.PI * (3 - Math.sqrt(5)); const blobs = []; for (let i = 0; i < NB; i++) { const by = 1 - (2 * i + 1) / NB; const br = Math.sqrt(Math.max(0, 1 - by * by)); blobs.push([Math.cos(GA * i) * br, by, Math.sin(GA * i) * br]); } const LX = -0.42, LY = -0.52, LZ = 0.74; // fixed screen-space light for (let f = 0; f < ROLL_FRAMES; f++) { const th = (f / ROLL_FRAMES) * Math.PI * 2; const ct = Math.cos(th), stn = Math.sin(th); const ox = (f % ROLL_COLS) * S; const oy = Math.floor(f / ROLL_COLS) * S; for (let py = 0; py < S; py++) { for (let px = 0; px < S; px++) { const idx = ((oy + py) * W + ox + px) * 4; const nx = (px + 0.5 - R) / R; const ny = (py + 0.5 - R) / R; const r2 = nx * nx + ny * ny; if (r2 >= 1) { data[idx + 3] = 0; continue; } const nz = Math.sqrt(1 - r2); // object-space point = R_y(-theta) applied to the screen normal const ox3 = nx * ct - nz * stn; const oz3 = nx * stn + nz * ct; let mark = 0; for (let k = 0; k < NB; k++) { const b = blobs[k]; const d = ox3 * b[0] + ny * b[1] + oz3 * b[2]; if (d > SPOT_COS) { const u = (d - SPOT_COS) / SPOT_SPAN; const sm = u * u * (3 - 2 * u); if (sm > mark) mark = sm; } } const lam = Math.max(0, nx * LX + ny * LY + nz * LZ); let v = 0.32 + 0.68 * lam; // diffuse v *= 0.55 + 0.45 * (nz ** 0.45); // rim falloff v *= 1 - SPOT_DEPTH * mark; // markings const c = Math.max(0, Math.min(255, Math.round(v * 255))); data[idx] = c; data[idx + 1] = c; data[idx + 2] = c; data[idx + 3] = r2 > 0.94 ? Math.round((255 * (1 - r2)) / 0.06) : 255; } } } ctx.putImageData(img, 0, 0); tex.refresh(); for (let f = 0; f < ROLL_FRAMES; f++) { tex.add(f, 0, (f % ROLL_COLS) * S, Math.floor(f / ROLL_COLS) * S, S, S); } } // Stand-in for assets/images/zuma/frog.png with the same 200x200 frame // geometry and the same 43px mouth slot, so every offset still lines up if // the art fails to load. buildFrogFallback() { if (this.textures.exists('zuma-frog')) return; const S = 200; const tex = this.textures.createCanvas('zuma-frog', S * 2, S); if (!tex) return; const ctx = tex.getContext(); for (let f = 0; f < 2; f++) { ctx.save(); ctx.translate(f * S, 0); const grad = ctx.createRadialGradient(S * 0.38, S * 0.34, 12, S / 2, S / 2, S / 2); grad.addColorStop(0, '#8a9487'); grad.addColorStop(1, '#39423a'); ctx.beginPath(); ctx.arc(S / 2, S / 2, S / 2 - 3, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.fill(); ctx.lineWidth = 6; ctx.strokeStyle = '#252c26'; ctx.stroke(); ctx.fillStyle = '#3f7d3a'; ctx.beginPath(); ctx.ellipse(S / 2, S * 0.56, S * 0.25, S * 0.31, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1d3d1f'; for (const ex of [0.38, 0.62]) { ctx.beginPath(); ctx.arc(S * ex, S * 0.34, 15, 0, Math.PI * 2); ctx.fill(); } if (f === 1) { // Cut the mouth slot out of frame 1: a 43px channel from the top edge // ending in a round seat, matching frog.png. Two separate paths — one // compound path would connect the rect and the arc with a stray edge. ctx.globalCompositeOperation = 'destination-out'; ctx.fillRect(78, 0, 43, 28); ctx.beginPath(); ctx.arc(99.5, 28, 21.5, 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = 'source-over'; } ctx.restore(); } tex.refresh(); tex.add(0, 0, 0, 0, S, S); tex.add(1, 0, S, 0, S, S); } // ── Marbles ──────────────────────────────────────────────────────────────── // A marble is two sprites: the rolling, tinted base and the fixed highlight. addMarble(x, y, color, dBase, dGloss, scale = 1) { const base = this.add.image(x, y, 'zuma-roll', 0) .setDepth(dBase).setTint(BALL_COLORS[color]).setScale(scale); const gloss = this.add.image(x, y, 'zuma-gloss').setDepth(dGloss).setScale(scale); this.layer.add([base, gloss]); return { base, gloss }; } // Place a marble and spin it to match how far it has travelled in direction // (tx, ty). The frame is the roll phase; the rotation aims the roll cycle. rollMarble(m, x, y, travelled, tx, ty) { m.base.setPosition(x, y); m.base.setFrame(Math.floor((travelled / this.rollCirc) * ROLL_FRAMES) & (ROLL_FRAMES - 1)); m.base.rotation = Math.atan2(ty, tx); m.gloss.setPosition(x, y); } bindInput() { this.input.mouse?.disableContextMenu(); this.input.on('pointermove', (p) => { if (this.view !== 'play' || !this.state) return; this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x); }); this.input.on('pointerdown', (p) => { if (this.view !== 'play' || this.overlayUp || !this.state) return; this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x); if (p.rightButtonDown()) { this.doSwap(); return; } const flight = fireBall(this.state, this.aimAngle); if (flight) playSound(this, 'sfx-zuma-shoot'); }); this.input.keyboard.on('keydown-SPACE', (e) => { e.preventDefault?.(); if (this.view === 'play' && !this.overlayUp && this.state) this.doSwap(); }); } doSwap() { swapBalls(this.state); playSound(this, SFX.CARD_PLACE); } // "Levels" during a normal run, "back to the editor" during a test play. leaveLevel() { if (this.returnToEditor) this.scene.start('ZumaEditor', { resume: true }); else this.showLevelSelect(); } clearLayer() { // Layer extends List, whose removeAll(true) means "skip the remove // callback" — NOT Container's "destroy the children". Destroy explicitly; // each destroy() pulls itself out of the layer, so iterate a copy. // Kill tweens first: Retry during the pit outro would otherwise leave // tweens running against destroyed sprites. for (const obj of [...this.layer.list]) { this.tweens.killTweensOf(obj); obj.destroy(); } this.ballSprites = new Map(); this.flightSprites = new Map(); this.frogBase = null; this.frogOver = null; this.frogCurrent = null; this.frogNext = null; this.laserGfx = null; this.pitFalls = null; this.quotaGfx = null; this.scoreText = null; this.effectsText = null; this.readyText = null; } bestStars(level) { try { return Number(localStorage.getItem(`zuma-stars-${level}`)) || 0; } catch (_) { return 0; } } saveStars(level, stars) { try { if (stars > this.bestStars(level)) localStorage.setItem(`zuma-stars-${level}`, String(stars)); } catch (_) { /* ignore */ } } // Clearing the level is 1 star; the bigger score thresholds add the rest. medalStars(score, starScores) { let stars = 0; for (const t of starScores) if (score >= t) stars++; return Math.max(1, stars); } // ── Level select ──────────────────────────────────────────────────────────── showLevelSelect() { this.view = 'select'; this.overlayUp = false; this.state = null; this.clearLayer(); // Background image (falls back to solid color if not loaded) if (this.textures.exists('zuma-menu-bg')) { this.layer.add(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'zuma-menu-bg') .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg)); } else { this.layer.add(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(D.bg)); } const cx = GAME_WIDTH / 2; if (!this.manifest.length) { const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genZuma.js', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', }).setOrigin(0.5); this.layer.add(msg); const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); this.layer.add(back); return; } const nextLevel = Math.min(this.levelsCompleted + 1, this.manifest.length); const bg = this.add.rectangle(cx, 442, 320, 56, 0x000000, 0.55) .setStrokeStyle(2, 0x6b5638, 0.8) .setOrigin(0.5); const prog = this.add.text(cx, 442, `Completed ${this.levelsCompleted} / ${this.manifest.length}`, { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5); this.layer.add([bg, prog]); const COLS = 10; const SIZE = 120; const GAP = 16; const gridW = COLS * SIZE + (COLS - 1) * GAP; const left = cx - gridW / 2 + SIZE / 2; const top = 550; this.manifest.forEach((l, i) => { const col = i % COLS; const row = Math.floor(i / COLS); const x = left + col * (SIZE + GAP); const y = top + row * (SIZE + GAP + 26); const level = l.level; const cleared = level <= this.levelsCompleted; const playable = level <= nextLevel; const fill = cleared ? 0x1d4023 : playable ? 0x17301c : 0x101a12; const stroke = cleared ? 0x6fd47e : playable ? COLORS.gold : 0x22351f; const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); const num = this.add.text(x, y - 22, String(level), { fontFamily: 'Righteous', fontSize: '40px', color: playable || cleared ? COLORS.textHex : '#4a5e4a', }).setOrigin(0.5); this.layer.add([tile, num]); if (playable || cleared) { const earned = this.bestStars(level); const stars = this.add.text(x, y + 22, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), { fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#5d7a5d', }).setOrigin(0.5); const name = this.add.text(x, y + 48, l.name, { fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, }).setOrigin(0.5); this.layer.add([stars, name]); } else { const lock = this.add.text(x, y + 30, 'locked', { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#4a5e4a', }).setOrigin(0.5); this.layer.add(lock); } if (playable) { tile.setInteractive({ useHandCursor: true }); tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); tile.on('pointerup', () => this.playLevel(level)); } }); const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel), { width: 300, height: 58, fontSize: 24 }); const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); const reset = new Button(this, 210, GAME_HEIGHT - 76, 'Reset Progress', () => this.confirmResetProgress(), { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); this.layer.add([resume, back, reset]); if (!this.canPersist) { const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, }).setOrigin(0.5); this.layer.add(note); } } confirmResetProgress() { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); const panel = this.add.graphics(); panel.fillStyle(COLORS.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20); panel.lineStyle(3, COLORS.danger, 1); panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20); const title = this.add.text(cx, cy - 92, 'Reset Progress?', { fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, }).setOrigin(0.5); const msg = this.add.text(cx, cy - 14, 'This clears every cleared level and your star\nmedals, back to Level 1. This cannot be undone.', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, }).setOrigin(0.5); const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(), { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }); const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), { variant: 'ghost', width: 250, height: 58, fontSize: 24 }); this.layer.add([dim, panel, title, msg, yes, no]); } doResetProgress() { api.post('/puzzles/zuma/reset').catch(() => { /* best effort */ }); this.levelsCompleted = 0; try { this.manifest.forEach((l) => localStorage.removeItem(`zuma-stars-${l.level}`)); } catch (_) { /* ignore */ } this.showLevelSelect(); } // ── Play a level ──────────────────────────────────────────────────────────── // Level payloads are fetched on demand (assets/gamedata/zuma/level-NNN.json, // per the manifest's `file` field) rather than loaded up front — memoized // so re-entering an already-played level is free. async fetchLevel(level) { if (this.levelCache.has(level)) return this.levelCache.get(level); const entry = this.manifest.find((m) => m.level === level); if (!entry) return null; const res = await fetch(`assets/gamedata/zuma/${entry.file}`); const json = await res.json(); this.levelCache.set(level, json); return json; } // `preloadedDef` is set for editor Test Play, which already has the whole // draft level in hand and skips the fetch entirely. async playLevel(level, preloadedDef = null) { const def = preloadedDef ?? await this.fetchLevel(level); if (!def) return; this.view = 'play'; this.level = level; this.levelDef = def; this.state = createLevel(def, def.seed); this.overlayUp = false; this.aimAngle = -Math.PI / 2; this.clearLayer(); this.drawBackground(); this.drawPath(); this.drawPortals(); this.drawPit(); this.buildFrog(); this.laserGfx = this.add.graphics().setDepth(D.laser); this.layer.add(this.laserGfx); this.drawHud(); this.readyText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'GET READY…', { fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.fx); this.layer.add(this.readyText); this.playStartFanfare(); } // Ducks the soundtrack for the level-start jingle, then hands playback back // to MusicPlayer once the jingle finishes — not a fixed delay, since the // fanfare's own length is what determines when the soundtrack should return. playStartFanfare() { this.music?.pause(); const sfx = this.sound.add('sfx-zuma-start', { volume: 0.9 }); sfx.once(Phaser.Sound.Events.COMPLETE, () => this.music?.resume()); sfx.play(); } // Same duck/play/resume trick as playStartFanfare(), for the chain going // over the edge instead of clearing the level. playLoseFanfare() { this.music?.pause(); const sfx = this.sound.add('sfx-zuma-lose'); sfx.once(Phaser.Sound.Events.COMPLETE, () => this.music?.resume()); sfx.play(); } // Per-level art (def.background, see tools/genZuma.js) over the solid BG // rectangle laid down once in create() — that rectangle is the fallback for // any level whose background key isn't painted yet. drawBackground() { const key = this.levelDef.background; if (!key || !this.textures.exists(key)) return; const img = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key) .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); this.layer.add(img); } drawPath() { for (const p of this.state.paths) this.drawOnePath(p); } drawOnePath(p) { const path = p.path; const tunnels = p.tunnels; const { visible, hidden } = pathSpans(path.length, tunnels); // Buried runs first and BELOW the path layer: a dashed scar showing where // the chain goes while it is out of play. Sitting under D.path is what // makes the overlap rule hold — wherever a live section of path crosses a // tunnel, the live section is the one you see. if (hidden.length) { const t = this.add.graphics().setDepth(D.tunnel); for (const [s0, s1] of hidden) { for (let s = s0; s < s1; s += 34) { const a = path.pointAt(s); const b = path.pointAt(Math.min(s1, s + 17)); t.lineStyle(12, 0x000000, 0.34); t.lineBetween(a.x, a.y, b.x, b.y); t.lineStyle(4, 0x6c5735, 0.22); t.lineBetween(a.x, a.y, b.x, b.y); } } this.layer.add(t); } const g = this.add.graphics().setDepth(D.path); for (const { w, color } of PATH_STYLE.bands) { g.lineStyle(w, color, 1); for (const [s0, s1] of visible) { const pts = sampleRange(path, s0, s1); g.beginPath(); g.moveTo(pts[0].x, pts[0].y); for (const p of pts) g.lineTo(p.x, p.y); g.strokePath(); } } // dotted center groove — the seam the ball rolls along, sunk into the // bright core band above g.fillStyle(PATH_STYLE.grooveColor, PATH_STYLE.grooveAlpha); let nextDot = 0; for (const s of path.samples) { if (s.s < nextDot) continue; nextDot += PATH_STYLE.grooveStep; if (tunnels.length && isHidden(tunnels, s.s)) continue; g.fillCircle(s.x, s.y, PATH_STYLE.grooveRadius); } this.layer.add(g); } // A maw at each mouth, above the chain so marbles dive under the jaws. The // exit gets a brighter ember so the two ends read differently at a glance: // one is swallowing, the other is spitting the chain back out. drawPortals() { for (const p of this.state.paths) { for (const t of p.tunnels) { this.addPortal(p, t.enter, 1); this.addPortal(p, t.exit, -1); } } } addPortal(p, s, dir) { const pt = p.path.pointAt(s); const ang = Math.atan2(pt.ty, pt.tx); const rot = dir > 0 ? ang : ang + Math.PI; for (const [key, depth] of [[PORTAL_STONE, D.portalStone], [PORTAL_MOUTH, D.portalMouth]]) { const img = this.add.image(pt.x, pt.y, key) .setOrigin(PORTAL_ORIGIN_X, 0.5) .setRotation(rot) .setDepth(depth); this.layer.add(img); } const glow = this.add.image(pt.x, pt.y, 'zuma-glow') .setTint(dir > 0 ? 0xff8a2b : 0xffc45a) .setBlendMode(Phaser.BlendModes.ADD) .setScale(dir > 0 ? 2.6 : 3.4) .setAlpha(dir > 0 ? 0.22 : 0.34) .setDepth(D.portalGlow); this.layer.add(glow); this.tweens.add({ targets: glow, alpha: glow.alpha * 0.45, scale: glow.scale * 1.25, duration: 1300 + dir * 180, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); } // The end of the path: broken ground around a bottomless shaft. The only // motion is vapour turning slowly in the mouth — the old skull throbbed on a // scale tween, which a hole cannot do without looking like it is breathing. drawPit() { for (const p of this.state.paths) { const end = p.path.pointAt(p.path.length); const pit = this.add.image(end.x, end.y, PIT_KEY).setDepth(D.pit); const swirl = this.add.image(end.x, end.y, PIT_SWIRL) .setBlendMode(Phaser.BlendModes.ADD) .setAlpha(0.5) .setDepth(D.pitSwirl); this.layer.add([pit, swirl]); this.tweens.add({ targets: swirl, angle: 360, duration: 17000, repeat: -1, ease: 'Linear', }); this.tweens.add({ targets: swirl, alpha: 0.22, duration: 2600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); } } // The chain going over the edge. It does NOT cut across to the pit: every // marble keeps its place on the path and the whole formation rolls the last // stretch in arc-length, still spinning off the tangent, so the chain pours // over the lip in the order and along the line it was already travelling. // Driven from update() rather than tweened, because the thing being animated // is one shared `advance` along the path, not per-sprite x/y. Multi-path // levels run every path's fall in parallel, each into its own pit — losing // on one path does not spare the others. startPitFall() { // Any leftover tween (the insert squeeze, the power-up icon pulse) would // fight the per-frame writes below. for (const [, spr] of this.ballSprites) { this.tweens.killTweensOf([spr.base, spr.gloss, ...(spr.icon ? [spr.icon] : [])]); } // A shot frozen in mid-air through the whole outro looks like a stall. for (const [, spr] of this.flightSprites) { this.tweens.add({ targets: [spr.base, spr.gloss], alpha: 0, duration: 200, onComplete: () => this.destroyMarble(spr), }); } this.flightSprites = new Map(); this.pitFalls = []; let maxMs = 0; for (const p of this.state.paths) { const L = p.path.length; const balls = p.balls; const rear = balls[balls.length - 1]; const span = rear ? L - rear.s : 0; if (!span) continue; // Long chains are drained faster rather than made to take longer. const speed = Math.max(TUNE.PIT_FALL_SPEED, span / TUNE.PIT_FALL_MAX_S); this.pitFalls.push({ path: p.path, balls, tunnels: p.tunnels, advance: 0, span, speed, // Each marble shrinks over the last stretch of ITS OWN run. The front // one is already sitting on the pit when the level ends, so a ramp // measured from the pit centre would pop it to a dot on the very first // frame; measured from where it starts, it drops away from full size // like the rest of them, just sooner. ramp: new Map(balls.map((b) => [b.id, Math.max(1, Math.min(TUNE.PIT_SWALLOW, L - b.s))])), }); maxMs = Math.max(maxMs, (span / speed) * 1000); } return maxMs; } stepPitFall(dtMs) { for (const pf of this.pitFalls) this.stepOneFall(pf, dtMs); // Once every path's fall has covered its span everything is in, so the // outro ends unconditionally — leaving pitFalls set would keep update() // short-circuited for the rest of the scene's life. if (this.pitFalls.every((pf) => pf.advance >= pf.span)) { for (const [id, spr] of this.ballSprites) { this.destroyMarble(spr); this.ballSprites.delete(id); } this.pitFalls = null; } } stepOneFall(pf, dtMs) { const path = pf.path; const L = path.length; const tun = pf.tunnels; pf.advance = Math.min(pf.span, pf.advance + pf.speed * (dtMs / 1000)); for (const b of pf.balls) { const spr = this.ballSprites.get(b.id); if (!spr) continue; const s = Math.min(L, b.s + pf.advance); const t = Phaser.Math.Clamp(1 - (L - s) / (pf.ramp.get(b.id) ?? TUNE.PIT_SWALLOW), 0, 1); if (t >= 1) { this.destroyMarble(spr); this.ballSprites.delete(b.id); continue; } const p = path.pointAt(s); this.rollMarble(spr, p.x, p.y, s, p.tx, p.ty); const scale = 1 - 0.94 * t * t; // holds its size, then drops away const k = (1 - t) ** 1.6; // colour drains faster than size const vis = tun.length ? visibilityAt(tun, s) : 1; const dim = Math.max(0, 1 - t * 2.2); // nothing down there is lit const c = BALL_COLORS[b.color]; spr.base.setScale(scale).setAlpha(vis).setTint(Phaser.Display.Color.GetColor( Math.round(((c >> 16) & 0xff) * k), Math.round(((c >> 8) & 0xff) * k), Math.round((c & 0xff) * k) )); spr.gloss.setScale(scale).setAlpha(vis * dim); spr.icon?.setPosition(p.x, p.y).setScale(scale).setAlpha(vis * dim); } } // Four independently depth-sorted pieces (see D above): base disc, the marble // seated in the mouth, the slotted overlay, then the on-deck marble on the // frog's back. Positions and rotation are driven in update(). buildFrog() { const { x, y } = this.state.frog; const SC = TUNING.FROG_SCALE; this.frogBase = this.add.image(x, y, 'zuma-frog', 0).setScale(SC).setDepth(D.frogBase); this.frogCurrent = this.addMarble(x, y - TUNING.FROG_MUZZLE, this.state.current, D.frogBall, D.frogBallGloss); this.frogOver = this.add.image(x, y, 'zuma-frog', 1).setScale(SC).setDepth(D.frogOver); this.frogNext = this.addMarble(x, y, this.state.next, D.frogNext, D.frogNextGloss, TUNE.NEXT_SCALE); this.layer.add([this.frogBase, this.frogOver]); } drawHud() { const title = this.add.text(40, 50, `ZUMA — Level ${this.level}: ${this.levelDef.name}`, { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, }).setOrigin(0, 0.5).setDepth(D.ui); this.scoreText = this.add.text(GAME_WIDTH - 50, 125, '', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex, }).setOrigin(1, 0.5).setDepth(D.ui); this.effectsText = this.add.text(GAME_WIDTH - 50, 92, '', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffd54a', }).setOrigin(1, 0.5).setDepth(D.ui); this.quotaGfx = this.add.graphics().setDepth(D.ui); this.layer.add([title, this.scoreText, this.effectsText, this.quotaGfx]); const levels = new Button(this, 130, GAME_HEIGHT - 60, this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); const restart = new Button(this, 330, GAME_HEIGHT - 60, 'Restart', () => this.playLevel(this.level), { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); this.layer.add([levels, restart]); const tip = this.add.text(GAME_WIDTH - 50, GAME_HEIGHT - 56, 'Click to shoot • Right-click / SPACE to swap', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(1, 0.5).setDepth(D.ui); this.layer.add(tip); } updateHud() { const st = this.state; if (this.scoreText) this.scoreText.setText(`Score: ${st.score}`); if (this.effectsText) { const now = st.elapsedMs; const parts = []; if (now < st.effects.slowUntil) parts.push(`SLOW ${Math.ceil((st.effects.slowUntil - now) / 1000)}s`); if (now < st.effects.reverseUntil) parts.push('REVERSE'); if (now < st.effects.accuracyUntil) parts.push(`LASER ${Math.ceil((st.effects.accuracyUntil - now) / 1000)}s`); this.effectsText.setText(parts.join(' ')); } // quota bar: gold drains as the spawner empties, green = balls left on path const g = this.quotaGfx; if (!g) return; const W = 460, H = 18; const x = GAME_WIDTH / 2 - W / 2, y = 42; g.clear(); g.fillStyle(0x0a120b, 0.9); g.fillRoundedRect(x - 3, y - 3, W + 6, H + 6, 8); const totalQuota = st.paths.reduce((a, p) => a + p.quota, 0); const totalSpawned = st.paths.reduce((a, p) => a + p.spawned, 0); const toCome = (totalQuota - totalSpawned) / totalQuota; g.fillStyle(COLORS.gold, 1); g.fillRoundedRect(x, y, Math.max(2, W * toCome), H, 6); g.lineStyle(2, 0x6b5638, 1); g.strokeRoundedRect(x - 3, y - 3, W + 6, H + 6, 8); } // ── FX helpers ────────────────────────────────────────────────────────────── floatText(x, y, str, color, size = 30) { const t = this.add.text(x, y, str, { fontFamily: 'Righteous', fontSize: `${size}px`, color, }).setOrigin(0.5).setDepth(D.fx); this.layer.add(t); this.tweens.add({ targets: t, y: y - 70, alpha: 0, duration: 900, ease: 'Quad.easeOut', onComplete: () => t.destroy(), }); } burst(x, y, tint, n = 8, scale = 1) { for (let k = 0; k < n; k++) { const a = (k / n) * Math.PI * 2 + Math.random() * 0.6; const dist = (54 + Math.random() * 66) * scale; const p = this.add.image(x, y, 'zuma-glow').setTint(tint).setDepth(D.fx).setScale(1.2 * scale); this.layer.add(p); this.tweens.add({ targets: p, x: x + Math.cos(a) * dist, y: y + Math.sin(a) * dist, alpha: 0, scale: 0.2, duration: TUNE.POP_FX_MS, ease: 'Quad.easeOut', onComplete: () => p.destroy(), }); } } // ── Frame loop ────────────────────────────────────────────────────────────── update(time, delta) { // The lost-chain outro keeps running after overlayUp is set: the engine is // frozen ('lost' makes step() a no-op) but the marbles are still falling. if (this.pitFalls) { this.stepPitFall(delta); return; } if (this.view !== 'play' || this.overlayUp || !this.state) return; const st = this.state; const events = step(st, delta); for (const e of events) this.handleEvent(e); this.syncChain(); this.syncFlights(); // frog aim + shooter marbles. The art faces up, the aim angle is measured // from +x, hence the quarter turn. if (this.frogBase) { const rot = this.aimAngle + Math.PI / 2; this.frogBase.rotation = rot; this.frogOver.rotation = rot; const mx = st.frog.x + Math.cos(this.aimAngle) * TUNING.FROG_MUZZLE; const my = st.frog.y + Math.sin(this.aimAngle) * TUNING.FROG_MUZZLE; this.frogCurrent.base.setPosition(mx, my).setTint(BALL_COLORS[st.current]); this.frogCurrent.gloss.setPosition(mx, my); this.frogNext.base.setTint(BALL_COLORS[st.next]); } // laser sight while accuracy is active if (this.laserGfx) { this.laserGfx.clear(); if (st.elapsedMs < st.effects.accuracyUntil && st.status === 'playing') { const hit = rayHit(st, this.aimAngle); this.laserGfx.lineStyle(3, 0xff4d4d, TUNE.LASER_ALPHA); this.laserGfx.lineBetween(st.frog.x, st.frog.y, hit.x, hit.y); this.laserGfx.fillStyle(0xff4d4d, TUNE.LASER_ALPHA); this.laserGfx.fillCircle(hit.x, hit.y, 8); } } this.updateHud(); } handleEvent(e) { switch (e.type) { case 'ready': if (this.readyText) { this.readyText.setText('FIRE!'); playSound(this, SFX.SCIFI_REVEAL); this.tweens.add({ targets: this.readyText, alpha: 0, scale: 1.6, duration: 600, ease: 'Quad.easeOut', onComplete: () => { this.readyText?.destroy(); this.readyText = null; }, }); } break; case 'inserted': { playSound(this, 'sfx-zuma-hit'); // squeeze-in: the sprite appears via syncChain, then pops to size const ball = this.state.paths[e.pathIdx].balls.find((b) => b.id === e.id); if (ball) this.makeBallSprite(ball, true); break; } case 'clank': playSound(this, SFX.PIECE_CLICK); break; case 'pop': { playSound(this, 'sfx-zuma-explode'); this.burst(e.x, e.y, BALL_COLORS[e.color], 10); 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; this.lastClearPathIdx = e.pathIdx; 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; this.lastClearPathIdx = e.pathIdx; break; case 'powerup': playSound(this, SFX.SCIFI_REVEAL); this.floatText(e.x, e.y - 44, e.kind.toUpperCase(), '#ffd54a', 32); break; case 'lost': this.onLost(); break; case 'won': this.onWon(e.timeBonus); break; default: break; } } makeBallSprite(ball, squeeze = false) { if (this.ballSprites.has(ball.id)) return this.ballSprites.get(ball.id); const marble = this.addMarble(ball.x, ball.y, ball.color, D.ball, D.ballGloss); let icon = null; if (ball.power) { icon = this.add.image(ball.x, ball.y, `zuma-pw-${ball.power}`).setDepth(D.icon); this.layer.add(icon); this.tweens.add({ targets: icon, alpha: 0.45, duration: 450, yoyo: true, repeat: -1 }); } if (squeeze) { marble.base.setScale(0.3); marble.gloss.setScale(0.3); this.tweens.add({ targets: [marble.base, marble.gloss], scale: 1, duration: TUNE.INSERT_MS, ease: 'Back.easeOut', }); } const entry = { ...marble, icon }; this.ballSprites.set(ball.id, entry); return entry; } syncChain() { const seen = new Set(); for (const p of this.state.paths) { for (const b of p.balls) { seen.add(b.id); const spr = this.makeBallSprite(b); // roll phase from arc-length, roll direction from the path tangent const t = p.path.pointAt(b.s); this.rollMarble(spr, b.x, b.y, b.s, t.tx, t.ty); if (spr.icon) spr.icon.setPosition(b.x, b.y); // Tunnels: b.vis ramps 1 → 0 just inside a mouth so the marble sinks // into the maw instead of blinking out. The power-up icon runs its own // pulse tween, so it is toggled rather than alpha-driven. const vis = b.vis ?? 1; if (vis < 1 || spr.base.alpha < 1) { spr.base.setAlpha(vis); spr.gloss.setAlpha(vis); spr.icon?.setVisible(vis > 0.02); } } } for (const [id, spr] of this.ballSprites) { if (!seen.has(id)) { this.destroyMarble(spr); this.ballSprites.delete(id); } } } syncFlights() { const seen = new Set(); for (const f of this.state.flights) { seen.add(f.id); let spr = this.flightSprites.get(f.id); if (!spr) { spr = this.addMarble(f.x, f.y, f.color, D.flight, D.flightGloss); spr.ox = f.x; spr.oy = f.y; this.flightSprites.set(f.id, spr); } // flights travel in a straight line, so distance from the muzzle is the // roll phase and (dx, dy) is the roll direction this.rollMarble(spr, f.x, f.y, Math.hypot(f.x - spr.ox, f.y - spr.oy), f.dx, f.dy); } for (const [id, spr] of this.flightSprites) { if (!seen.has(id)) { this.destroyMarble(spr); this.flightSprites.delete(id); } } } destroyMarble(spr) { spr.base.destroy(); spr.gloss.destroy(); spr.icon?.destroy(); } // ── End states ────────────────────────────────────────────────────────────── onLost() { this.overlayUp = true; this.playLoseFanfare(); this.laserGfx?.clear(); if (!this.testLevel) { api.post('/history/single-player', { slug: 'zuma', score: this.state.score, opponentScores: [], result: 'loss', }).catch(() => { /* best effort */ }); } // the rest of the chain rolls on down the path and over the edge const fallMs = this.startPitFall(); this.time.delayedCall(Math.min(1800, fallMs + 260), () => { const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); const panel = this.add.graphics().setDepth(D.overlay); panel.fillStyle(COLORS.panel, 0.98); panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20); panel.lineStyle(3, COLORS.danger, 1); panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20); const title = this.add.text(cx, cy - 90, 'Into the Abyss!', { fontFamily: 'Righteous', fontSize: '58px', color: COLORS.dangerHex, }).setOrigin(0.5).setDepth(D.overlayUI); const msg = this.add.text(cx, cy - 14, `The chain went over the edge. Score: ${this.state.score}`, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.overlayUI); const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level), { width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); const levels = new Button(this, cx + 150, cy + 90, this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), { width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI); this.layer.add([dim, panel, title, msg, retry, levels]); }); } onWon(timeBonus) { this.overlayUp = true; this.laserGfx?.clear(); const score = this.state.score; const stars = this.medalStars(score, this.levelDef.starScores); if (!this.testLevel) { this.saveStars(this.level, stars); if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; api.post('/puzzles/zuma/complete', { level: this.level }) .then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); }) .catch(() => { /* best effort */ }); api.post('/history/single-player', { slug: 'zuma', score, opponentScores: [], result: 'win', }).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 that path's own 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.paths[this.lastClearPathIdx ?? 0].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, stroke: '#000000', strokeThickness: 8, }).setOrigin(0.5).setScale(0.2).setDepth(D.fx); this.layer.add(banner); this.tweens.add({ targets: banner, scale: 1, duration: 450, ease: 'Back.easeOut' }); this.time.delayedCall(1100, () => { banner.destroy(); const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); const panel = this.add.graphics().setDepth(D.overlay); panel.fillStyle(COLORS.panel, 0.98); panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20); panel.lineStyle(3, COLORS.gold, 1); panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20); const title = this.add.text(cx, cy - 140, 'Path Cleared!', { fontFamily: 'Righteous', fontSize: '60px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.overlayUI); const starRow = this.add.text(cx, cy - 66, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), { fontFamily: 'serif', fontSize: '56px', color: '#ffd54a', }).setOrigin(0.5).setDepth(D.overlayUI); const stat = this.add.text(cx, cy - 6, `Score: ${score} • Time bonus: +${timeBonus}`, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.layer.add([dim, panel, title, starRow, stat]); if (stars < 3) { const [, s2, s3] = this.levelDef.starScores; const hint = this.add.text(cx, cy + 32, `Bigger combos and chains earn more stars (★★ at ${s2}, ★★★ at ${s3}).`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.layer.add(hint); } const hasNext = this.level < this.manifest.length; if (hasNext) { const next = new Button(this, cx, cy + 80, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1), { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI); this.layer.add(next); } else { const done = this.add.text(cx, cy + 72, 'You cleared every path. The pit goes hungry!', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.layer.add(done); } const replay = new Button(this, cx - 120, cy + 152, 'Replay', () => this.playLevel(this.level), { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); const levels = new Button(this, cx + 120, cy + 152, this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(), { width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI); this.layer.add([replay, levels]); }); } }