tune zuma: slow down ball push speed, add per-level backgrounds & lose fanfare

- Reduce pushSpeed across all 20 levels to ~60% of original values in
  tools/genZuma.js, making the chain advance more slowly and giving the
  player more reaction time. starScores() thresholds auto-adjust downward
  since they are derived from pushSpeed.
- Add per-level background art (zuma-background-1 / -2) alternated per
  level via a new `background` field in each level definition; draw it in
  ZumaGame.drawBackground() over the solid fallback rectangle.
- Register the two new background textures and sfx-zuma-lose in the asset
  manifest.
- Add playLoseFanfare() mirroring playStartFanfare() to duck/pause music
  and play the lose SFX when the chain goes over the edge (onLost).
- Add a black stroke to the "ZUMA!" win banner for readability.
- Bump music volume to 0.5 in zuma-music.json.
This commit is contained in:
Brian Fertig 2026-07-28 17:54:23 -06:00
parent 0201b0de7d
commit eabeef3165
6 changed files with 158 additions and 103 deletions

BIN
assets/fx/zuma-lose.mp3 Normal file

Binary file not shown.

View File

@ -16,5 +16,5 @@
"title": "Deep in the Jungle" "title": "Deep in the Jungle"
} }
], ],
"volume": 0.3 "volume": 0.5
} }

View File

@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-07-28T21:55:21.106Z", "generatedAt": "2026-07-28T23:39:54.596Z",
"count": 20, "count": 20,
"levels": [ "levels": [
{ {
@ -372,13 +372,14 @@
"colors": 4, "colors": 4,
"quota": 22, "quota": 22,
"introBalls": 7, "introBalls": 7,
"pushSpeed": 100, "pushSpeed": 60,
"powerUpRate": 0.07, "powerUpRate": 0.07,
"background": "zuma-background-1",
"seed": 8919, "seed": 8919,
"starScores": [ "starScores": [
750, 660,
1130, 990,
1500 1320
] ]
}, },
{ {
@ -527,13 +528,14 @@
"colors": 4, "colors": 4,
"quota": 26, "quota": 26,
"introBalls": 8, "introBalls": 8,
"pushSpeed": 128, "pushSpeed": 76,
"powerUpRate": 0.07, "powerUpRate": 0.07,
"background": "zuma-background-2",
"seed": 16838, "seed": 16838,
"starScores": [ "starScores": [
960, 820,
1430, 1230,
1910 1640
] ]
}, },
{ {
@ -922,13 +924,14 @@
"colors": 4, "colors": 4,
"quota": 30, "quota": 30,
"introBalls": 9, "introBalls": 9,
"pushSpeed": 140, "pushSpeed": 84,
"powerUpRate": 0.065, "powerUpRate": 0.065,
"background": "zuma-background-1",
"seed": 24757, "seed": 24757,
"starScores": [ "starScores": [
1140, 970,
1710, 1460,
2280 1940
] ]
}, },
{ {
@ -1281,13 +1284,14 @@
"colors": 4, "colors": 4,
"quota": 32, "quota": 32,
"introBalls": 10, "introBalls": 10,
"pushSpeed": 140, "pushSpeed": 84,
"powerUpRate": 0.065, "powerUpRate": 0.065,
"background": "zuma-background-2",
"seed": 32676, "seed": 32676,
"starScores": [ "starScores": [
1220, 1040,
1820, 1550,
2430 2070
] ]
}, },
{ {
@ -1497,13 +1501,14 @@
"colors": 4, "colors": 4,
"quota": 38, "quota": 38,
"introBalls": 11, "introBalls": 11,
"pushSpeed": 148, "pushSpeed": 88,
"powerUpRate": 0.06, "powerUpRate": 0.06,
"background": "zuma-background-1",
"seed": 40595, "seed": 40595,
"starScores": [ "starScores": [
1480, 1250,
2210, 1870,
2950 2490
] ]
}, },
{ {
@ -1724,13 +1729,14 @@
"colors": 4, "colors": 4,
"quota": 34, "quota": 34,
"introBalls": 10, "introBalls": 10,
"pushSpeed": 148, "pushSpeed": 88,
"powerUpRate": 0.06, "powerUpRate": 0.06,
"background": "zuma-background-2",
"seed": 48514, "seed": 48514,
"starScores": [ "starScores": [
1320, 1120,
1980, 1670,
2640 2230
] ]
}, },
{ {
@ -2103,13 +2109,14 @@
"colors": 4, "colors": 4,
"quota": 26, "quota": 26,
"introBalls": 8, "introBalls": 8,
"pushSpeed": 180, "pushSpeed": 108,
"powerUpRate": 0.06, "powerUpRate": 0.06,
"background": "zuma-background-1",
"seed": 56433, "seed": 56433,
"starScores": [ "starScores": [
1090, 910,
1640, 1360,
2180 1810
] ]
}, },
{ {
@ -2263,13 +2270,14 @@
"colors": 5, "colors": 5,
"quota": 30, "quota": 30,
"introBalls": 9, "introBalls": 9,
"pushSpeed": 160, "pushSpeed": 96,
"powerUpRate": 0.055, "powerUpRate": 0.055,
"background": "zuma-background-2",
"seed": 64352, "seed": 64352,
"starScores": [ "starScores": [
1200, 1010,
1800, 1520,
2400 2020
] ]
}, },
{ {
@ -2622,13 +2630,14 @@
"colors": 5, "colors": 5,
"quota": 36, "quota": 36,
"introBalls": 11, "introBalls": 11,
"pushSpeed": 160, "pushSpeed": 96,
"powerUpRate": 0.055, "powerUpRate": 0.055,
"background": "zuma-background-1",
"seed": 72271, "seed": 72271,
"starScores": [ "starScores": [
1440, 1210,
2160, 1820,
2880 2420
] ]
}, },
{ {
@ -3022,13 +3031,14 @@
"colors": 5, "colors": 5,
"quota": 34, "quota": 34,
"introBalls": 10, "introBalls": 10,
"pushSpeed": 172, "pushSpeed": 104,
"powerUpRate": 0.055, "powerUpRate": 0.055,
"background": "zuma-background-2",
"seed": 80190, "seed": 80190,
"starScores": [ "starScores": [
1400, 1170,
2100, 1760,
2800 2340
] ]
}, },
{ {
@ -3238,13 +3248,14 @@
"colors": 5, "colors": 5,
"quota": 42, "quota": 42,
"introBalls": 12, "introBalls": 12,
"pushSpeed": 172, "pushSpeed": 104,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-1",
"seed": 88109, "seed": 88109,
"starScores": [ "starScores": [
1730, 1450,
2600, 2170,
3460 2890
] ]
}, },
{ {
@ -3465,13 +3476,14 @@
"colors": 5, "colors": 5,
"quota": 38, "quota": 38,
"introBalls": 11, "introBalls": 11,
"pushSpeed": 180, "pushSpeed": 108,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-2",
"seed": 96028, "seed": 96028,
"starScores": [ "starScores": [
1600, 1320,
2390, 1980,
3190 2640
] ]
}, },
{ {
@ -3824,13 +3836,14 @@
"colors": 5, "colors": 5,
"quota": 34, "quota": 34,
"introBalls": 10, "introBalls": 10,
"pushSpeed": 192, "pushSpeed": 116,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-1",
"seed": 103947, "seed": 103947,
"starScores": [ "starScores": [
1470, 1210,
2210, 1820,
2940 2420
] ]
}, },
{ {
@ -4044,13 +4057,14 @@
"colors": 5, "colors": 5,
"quota": 46, "quota": 46,
"introBalls": 13, "introBalls": 13,
"pushSpeed": 192, "pushSpeed": 116,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-2",
"seed": 111866, "seed": 111866,
"starScores": [ "starScores": [
1990, 1640,
2980, 2460,
3970 3280
] ]
}, },
{ {
@ -4199,13 +4213,14 @@
"colors": 6, "colors": 6,
"quota": 32, "quota": 32,
"introBalls": 9, "introBalls": 9,
"pushSpeed": 204, "pushSpeed": 124,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-1",
"seed": 119785, "seed": 119785,
"starScores": [ "starScores": [
1420, 1170,
2130, 1750,
2840 2330
] ]
}, },
{ {
@ -4599,13 +4614,14 @@
"colors": 6, "colors": 6,
"quota": 38, "quota": 38,
"introBalls": 11, "introBalls": 11,
"pushSpeed": 212, "pushSpeed": 128,
"powerUpRate": 0.05, "powerUpRate": 0.05,
"background": "zuma-background-2",
"seed": 127704, "seed": 127704,
"starScores": [ "starScores": [
1720, 1400,
2580, 2100,
3440 2800
] ]
}, },
{ {
@ -4958,13 +4974,14 @@
"colors": 6, "colors": 6,
"quota": 34, "quota": 34,
"introBalls": 10, "introBalls": 10,
"pushSpeed": 200, "pushSpeed": 120,
"powerUpRate": 0.045, "powerUpRate": 0.045,
"background": "zuma-background-1",
"seed": 135623, "seed": 135623,
"starScores": [ "starScores": [
1500, 1230,
2240, 1840,
2990 2450
] ]
}, },
{ {
@ -5185,13 +5202,14 @@
"colors": 6, "colors": 6,
"quota": 40, "quota": 40,
"introBalls": 11, "introBalls": 11,
"pushSpeed": 200, "pushSpeed": 120,
"powerUpRate": 0.045, "powerUpRate": 0.045,
"background": "zuma-background-2",
"seed": 143542, "seed": 143542,
"starScores": [ "starScores": [
1760, 1440,
2640, 2160,
3520 2880
] ]
}, },
{ {
@ -5396,13 +5414,14 @@
"colors": 6, "colors": 6,
"quota": 50, "quota": 50,
"introBalls": 14, "introBalls": 14,
"pushSpeed": 244, "pushSpeed": 148,
"powerUpRate": 0.045, "powerUpRate": 0.045,
"background": "zuma-background-1",
"seed": 151461, "seed": 151461,
"starScores": [ "starScores": [
2420, 1940,
3630, 2910,
4840 3880
] ]
}, },
{ {
@ -5607,13 +5626,14 @@
"colors": 6, "colors": 6,
"quota": 52, "quota": 52,
"introBalls": 14, "introBalls": 14,
"pushSpeed": 256, "pushSpeed": 152,
"powerUpRate": 0.045, "powerUpRate": 0.045,
"background": "zuma-background-2",
"seed": 159380, "seed": 159380,
"starScores": [ "starScores": [
2580, 2040,
3870, 3060,
5160 4080
] ]
} }
] ]

View File

@ -315,6 +315,10 @@ export const MANIFEST = {
], ],
zuma: [ zuma: [
image('zuma-menu-bg', 'assets/images/zuma/background-menu.png'), image('zuma-menu-bg', 'assets/images/zuma/background-menu.png'),
// In-level backgrounds, alternated per level by ZumaLogic's `background`
// field (see tools/genZuma.js) — more can be dropped in later.
image('zuma-background-1', 'assets/images/zuma/background-01.png'),
image('zuma-background-2', 'assets/images/zuma/background-02.png'),
// frame 0 = the whole stone frog, frame 1 = the same disc with the mouth // frame 0 = the whole stone frog, frame 1 = the same disc with the mouth
// slot cut out; the ready marble is drawn between them. // slot cut out; the ready marble is drawn between them.
sheet('zuma-frog', 'assets/images/zuma/frog.png', 200, 200), sheet('zuma-frog', 'assets/images/zuma/frog.png', 200, 200),
@ -328,6 +332,7 @@ export const MANIFEST = {
{ type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' }, { type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' },
{ type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' }, { type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' },
{ type: 'audio', key: 'sfx-zuma-win', path: 'assets/fx/zuma-win.mp3' }, { type: 'audio', key: 'sfx-zuma-win', path: 'assets/fx/zuma-win.mp3' },
{ type: 'audio', key: 'sfx-zuma-lose', path: 'assets/fx/zuma-lose.mp3' },
], ],
'2048': [ '2048': [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so

View File

@ -561,6 +561,7 @@ export default class ZumaGame extends Phaser.Scene {
this.aimAngle = -Math.PI / 2; this.aimAngle = -Math.PI / 2;
this.clearLayer(); this.clearLayer();
this.drawBackground();
this.drawPath(); this.drawPath();
this.drawPortals(); this.drawPortals();
this.drawPit(); this.drawPit();
@ -587,6 +588,26 @@ export default class ZumaGame extends Phaser.Scene {
sfx.play(); 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() { drawPath() {
const path = this.state.path; const path = this.state.path;
const tunnels = this.state.tunnels; const tunnels = this.state.tunnels;
@ -1050,7 +1071,7 @@ export default class ZumaGame extends Phaser.Scene {
onLost() { onLost() {
this.overlayUp = true; this.overlayUp = true;
playSound(this, SFX.CASINO_LOSE); this.playLoseFanfare();
this.laserGfx?.clear(); this.laserGfx?.clear();
if (!this.testLevel) { if (!this.testLevel) {
@ -1160,6 +1181,7 @@ export default class ZumaGame extends Phaser.Scene {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const banner = this.add.text(cx, cy - 60, 'ZUMA!', { const banner = this.add.text(cx, cy - 60, 'ZUMA!', {
fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex,
stroke: '#000000', strokeThickness: 8,
}).setOrigin(0.5).setScale(0.2).setDepth(D.fx); }).setOrigin(0.5).setScale(0.2).setDepth(D.fx);
this.layer.add(banner); this.layer.add(banner);
this.tweens.add({ targets: banner, scale: 1, duration: 450, ease: 'Back.easeOut' }); this.tweens.add({ targets: banner, scale: 1, duration: 450, ease: 'Back.easeOut' });

View File

@ -178,28 +178,31 @@ const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight };
// never on zigzag (already only ~57% shootable), and never long enough that the // never on zigzag (already only ~57% shootable), and never long enough that the
// frog runs out of targets while it waits for the chain to surface. // frog runs out of targets while it waits for the chain to surface.
// pushSpeed values are ~60% of their original tuning (a 2026-07-28 across-the-
// board slowdown) — starScores() derives its thresholds from pushSpeed, so
// star bands shifted down with it automatically on regen.
const TABLE = [ const TABLE = [
// level, name, shape, colors, quota, intro, push, powerUpRate, tunnels // level, name, shape, colors, quota, intro, push, powerUpRate, tunnels
[1, 'Riverbend', 'sCurve', 4, 22, 7, 100, 0.07], [1, 'Riverbend', 'sCurve', 4, 22, 7, 60, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 26, 8, 128, 0.07], [2, 'Temple Gate', 'horseshoe', 4, 26, 8, 76, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 140, 0.065], [3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 84, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 32, 10, 140, 0.065], [4, 'Switchbacks', 'zigzag', 4, 32, 10, 84, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 148, 0.06, [[0.36, 0.45]]], [5, 'Serpent Coil', 'spiral', 4, 38, 11, 88, 0.06, [[0.36, 0.45]]],
[6, 'Crossroads', 'figureEight', 4, 34, 10, 148, 0.06], [6, 'Crossroads', 'figureEight', 4, 34, 10, 88, 0.06],
[7, 'Rapids', 'sCurve', 4, 26, 8, 180, 0.06], [7, 'Rapids', 'sCurve', 4, 26, 8, 108, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 160, 0.055, [[0.34, 0.44]]], [8, 'Sun Court', 'horseshoe', 5, 30, 9, 96, 0.055, [[0.34, 0.44]]],
[9, 'Thunder Steps', 'zigzag', 5, 36, 11, 160, 0.055], [9, 'Thunder Steps', 'zigzag', 5, 36, 11, 96, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 172, 0.055, [[0.40, 0.50]]], [10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 104, 0.055, [[0.40, 0.50]]],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 172, 0.05, [[0.30, 0.40]]], [11, 'Deep Coil', 'spiral', 5, 42, 12, 104, 0.05, [[0.30, 0.40]]],
[12, 'Tangled Path', 'figureEight', 5, 38, 11, 180, 0.05], [12, 'Tangled Path', 'figureEight', 5, 38, 11, 108, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 34, 10, 192, 0.05], [13, 'Lightning Run', 'zigzag', 5, 34, 10, 116, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 192, 0.05, [[0.26, 0.36], [0.60, 0.70]]], [14, 'Whirlpool', 'spiral', 5, 46, 13, 116, 0.05, [[0.26, 0.36], [0.60, 0.70]]],
[15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 204, 0.05], [15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 124, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 212, 0.05, [[0.38, 0.46]]], [16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 128, 0.05, [[0.38, 0.46]]],
[17, 'Stormsteps', 'zigzag', 6, 34, 10, 200, 0.045], [17, 'Stormsteps', 'zigzag', 6, 34, 10, 120, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 200, 0.045], [18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 120, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 50, 14, 244, 0.045], [19, 'Abyss Coil', 'spiral', 6, 50, 14, 148, 0.045],
[20, 'The Final Coil', 'spiral', 6, 52, 14, 256, 0.045], [20, 'The Final Coil', 'spiral', 6, 52, 14, 152, 0.045],
]; ];
// Calibrated against the headless aimbot in verifyZuma.js, which takes the best // Calibrated against the headless aimbot in verifyZuma.js, which takes the best
@ -214,6 +217,10 @@ function starScores(quota, pushSpeed) {
// ── Build & write ──────────────────────────────────────────────────────────── // ── Build & write ────────────────────────────────────────────────────────────
// In-level background art (see data/assetManifest.js), alternated one per
// level while there are only two — drop more keys in here as they're painted.
const BACKGROUNDS = ['zuma-background-1', 'zuma-background-2'];
const levels = []; const levels = [];
let bad = 0; let bad = 0;
for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate, tunFrac] of TABLE) { for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate, tunFrac] of TABLE) {
@ -222,6 +229,7 @@ for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRat
const tunnels = (tunFrac ?? []).map(([a, b]) => [Math.round(len * a), Math.round(len * b)]); const tunnels = (tunFrac ?? []).map(([a, b]) => [Math.round(len * a), Math.round(len * b)]);
const def = { const def = {
level, name, shape, points, frog, tunnels, colors, quota, introBalls, pushSpeed, powerUpRate, level, name, shape, points, frog, tunnels, colors, quota, introBalls, pushSpeed, powerUpRate,
background: BACKGROUNDS[(level - 1) % BACKGROUNDS.length],
seed: 1000 + level * 7919, seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed), starScores: starScores(quota, pushSpeed),
}; };