fertig-classic-games/src/games/zuma/ZumaGame.js

1279 lines
52 KiB
JavaScript

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]);
});
}
}