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

View File

@ -315,6 +315,10 @@ export const MANIFEST = {
],
zuma: [
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
// slot cut out; the ready marble is drawn between them.
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-explode', path: 'assets/fx/zuma-explode.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': [
// 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.clearLayer();
this.drawBackground();
this.drawPath();
this.drawPortals();
this.drawPit();
@ -587,6 +588,26 @@ export default class ZumaGame extends Phaser.Scene {
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() {
const path = this.state.path;
const tunnels = this.state.tunnels;
@ -1050,7 +1071,7 @@ export default class ZumaGame extends Phaser.Scene {
onLost() {
this.overlayUp = true;
playSound(this, SFX.CASINO_LOSE);
this.playLoseFanfare();
this.laserGfx?.clear();
if (!this.testLevel) {
@ -1160,6 +1181,7 @@ export default class ZumaGame extends Phaser.Scene {
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' });

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
// 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 = [
// level, name, shape, colors, quota, intro, push, powerUpRate, tunnels
[1, 'Riverbend', 'sCurve', 4, 22, 7, 100, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 26, 8, 128, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 140, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 32, 10, 140, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 148, 0.06, [[0.36, 0.45]]],
[6, 'Crossroads', 'figureEight', 4, 34, 10, 148, 0.06],
[7, 'Rapids', 'sCurve', 4, 26, 8, 180, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 160, 0.055, [[0.34, 0.44]]],
[9, 'Thunder Steps', 'zigzag', 5, 36, 11, 160, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 172, 0.055, [[0.40, 0.50]]],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 172, 0.05, [[0.30, 0.40]]],
[12, 'Tangled Path', 'figureEight', 5, 38, 11, 180, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 34, 10, 192, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 192, 0.05, [[0.26, 0.36], [0.60, 0.70]]],
[15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 204, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 212, 0.05, [[0.38, 0.46]]],
[17, 'Stormsteps', 'zigzag', 6, 34, 10, 200, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 200, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 50, 14, 244, 0.045],
[20, 'The Final Coil', 'spiral', 6, 52, 14, 256, 0.045],
[1, 'Riverbend', 'sCurve', 4, 22, 7, 60, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 26, 8, 76, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 84, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 32, 10, 84, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 88, 0.06, [[0.36, 0.45]]],
[6, 'Crossroads', 'figureEight', 4, 34, 10, 88, 0.06],
[7, 'Rapids', 'sCurve', 4, 26, 8, 108, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 96, 0.055, [[0.34, 0.44]]],
[9, 'Thunder Steps', 'zigzag', 5, 36, 11, 96, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 104, 0.055, [[0.40, 0.50]]],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 104, 0.05, [[0.30, 0.40]]],
[12, 'Tangled Path', 'figureEight', 5, 38, 11, 108, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 34, 10, 116, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 116, 0.05, [[0.26, 0.36], [0.60, 0.70]]],
[15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 124, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 128, 0.05, [[0.38, 0.46]]],
[17, 'Stormsteps', 'zigzag', 6, 34, 10, 120, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 120, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 50, 14, 148, 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
@ -214,6 +217,10 @@ function starScores(quota, pushSpeed) {
// ── 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 = [];
let bad = 0;
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 def = {
level, name, shape, points, frog, tunnels, colors, quota, introBalls, pushSpeed, powerUpRate,
background: BACKGROUNDS[(level - 1) % BACKGROUNDS.length],
seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed),
};