// Offline generator for Zuma levels. // // Six path shapes in 1920x1080 canvas space crossed with a hand-written 20-row // difficulty table. Every level is validated against ZumaLogic.validateLevel — // the same lint verifyZuma.js and the in-game editor run — so this script // refuses to write a bank the game would consider unplayable. // // Usage: // node tools/genZuma.js [outDir] // // Writes one file per level (assets/gamedata/zuma/level-NNN.json) plus a // lightweight levels.json manifest, matching Peggle/Super Kart/Goo Tower's // layout — the level select screen only ever needs level/name/file, so the // manifest stays cheap to eager-load while the full per-level payload (path // geometry, tunnels, etc.) is fetched on demand when that level is played. // // Geometry note: marbles are BALL_SPACING apart and turns must stay wider than // BALL_RADIUS * 1.7, so shapes are emitted as exact straights and circular arcs // at near-uniform point spacing (see Pen below). Hand-placed control points at // irregular spacing make Catmull-Rom overshoot into cusps the lint rejects. // // Deterministic: shapes and the table are static. Re-run after changing either. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateLevel, buildPath, TUNING } from '../src/games/zuma/ZumaLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_DIR = process.argv[2] ? path.resolve(process.argv[2]) : path.join(__dirname, '../assets/gamedata/zuma'); const rad = (deg) => (deg * Math.PI) / 180; const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]); const STEP = 70; // px between emitted control points // ── Pen: exact straights and circular arcs at uniform spacing ──────────────── class Pen { constructor(x, y, headingDeg) { this.x = x; this.y = y; this.h = rad(headingDeg); this.pts = [[x, y]]; } straight(len) { const n = Math.max(1, Math.round(len / STEP)); const dx = Math.cos(this.h), dy = Math.sin(this.h); for (let i = 1; i <= n; i++) { this.pts.push([this.x + dx * len * (i / n), this.y + dy * len * (i / n)]); } this.x += dx * len; this.y += dy * len; return this; } // deg > 0 curves clockwise on screen (toward +y), deg < 0 counter-clockwise turn(radius, deg) { const sgn = Math.sign(deg); const cx = this.x + Math.cos(this.h + (sgn * Math.PI) / 2) * radius; const cy = this.y + Math.sin(this.h + (sgn * Math.PI) / 2) * radius; const a0 = Math.atan2(this.y - cy, this.x - cx); const sweep = rad(deg); const n = Math.max(2, Math.round((Math.abs(sweep) * radius) / STEP)); for (let i = 1; i <= n; i++) { const a = a0 + sweep * (i / n); this.pts.push([cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]); } const a1 = a0 + sweep; this.x = cx + Math.cos(a1) * radius; this.y = cy + Math.sin(a1) * radius; this.h += sweep; return this; } done() { return rp(this.pts); } } // Offset-ellipse coil: `turns` revolutions shrinking from the full radius to // fEnd of it. It starts at the top of the ellipse, where the tangent is // horizontal, so the off-screen lead-in joins without a kink. fEnd also sets // how close the innermost pass comes to the hub, where the frog sits. function coil(cx, cy, rx, ry, turns, fEnd) { const points = [[-80, cy - ry], [(cx - rx) / 2, cy - ry]]; const steps = Math.round(turns * 22); for (let i = 0; i <= steps; i++) { const u = i / steps; const th = -Math.PI / 2 + u * turns * 2 * Math.PI; const f = 1 - u * (1 - fEnd); points.push([cx + rx * f * Math.cos(th), cy + ry * f * Math.sin(th)]); } return rp(points); } // ── Shapes: { points, frog } — first point is the off-screen spawn lead-in, // the last is the pit ────────────────────────────────────────────────────── // Three-lane serpentine. Lane spacing 420 leaves a 210px mid-lane corridor for // the frog, and the U-turn caps are true half-circles of that same radius. function sCurve() { const p = new Pen(-80, 140, 0); p.straight(1580); // lane 1 p.turn(210, 180); p.straight(1240); // lane 2 p.turn(210, -180); p.straight(1240); // lane 3 p.turn(190, -90).straight(220).turn(160, -90); // hook back inward // Lower corridor, not the upper one: from here the frog has a clear line to // every lane (measured 100% of a full chain reachable, vs 93% from above). return { points: p.done(), frog: [880, 770] }; } // Four tight switchbacks packed into the left two-thirds; the frog watches from // the right margin, where no lane reaches. Stacked parallel lanes shield each // other, so only ~57% of a full chain is reachable from anywhere legal — that // is intrinsic to the shape, and why its levels carry smaller quotas than // their neighbours rather than larger ones. function zigzag() { const p = new Pen(-80, 130, 0); p.straight(1375); p.turn(140, 180); p.straight(1045); p.turn(140, -180); p.straight(1045); p.turn(140, 180); p.straight(1045); return { points: p.done(), frog: [1620, 550] }; } // Wide, shallow coil — a turn and a third, hole well clear of the hub. function horseshoe() { return { points: coil(960, 580, 840, 450, 1.35, 0.6), frog: [960, 580] }; } // Deep coil. fEnd is what keeps the innermost pass off the frog at the hub. function spiral() { return { points: coil(960, 575, 845, 455, 2.0, 0.5), frog: [960, 575] }; } // Two full loops hanging off one lane, frog in the hub of the first. A 360 // turn can only rejoin its straight tangentially, so the lane grazes each loop // at exactly one point — that touch is the shape, not a defect. function doubleLoop() { const p = new Pen(-80, 300, 0); p.straight(700).turn(300, 360); p.straight(780).turn(300, 360); p.straight(220).turn(230, 180).straight(260); return { points: p.done(), frog: [620, 600] }; } // 1:2 Lissajous traced once: enters mid-left, crosses itself at the centre, // ends in the lower-left lobe. The crossing means the frog has to sit below it. function figureEight() { const cx = 960, cy = 505, ax = 830, ay = 375; const t0 = 1.5 * Math.PI; const t1 = t0 + 2 * Math.PI - 0.55; const points = [[-80, 830], [60, 690]]; const n = 48; for (let i = 0; i <= n; i++) { const t = t0 + ((t1 - t0) * i) / n; points.push([cx + ax * Math.sin(t), cy + ay * Math.sin(2 * t)]); } return { points: rp(points), frog: [960, 990] }; } const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight }; // ── Difficulty table ───────────────────────────────────────────────────────── // Quotas are in marbles, and a marble is BALL_SPACING of path, so they are // bounded by each shape's length (the lint reports the ceiling). // // Difficulty is NOT just quota x pushSpeed: how much of the chain the frog can // actually shoot varies hugely by shape (spiral/horseshoe ~100%, zigzag ~57%), // so the zigzag rows carry deliberately small quotas. The aimbot soak in // verifyZuma.js is the arbiter — every row here is tuned against its clear // rate over several seeds, not against how the number looks in the column. // The last column is tunnels, as [start, end] FRACTIONS of the finished path // length — the shapes are emitted in px and their lengths are not round // numbers, so fractions are the only way to say "bury the middle third" and // have it survive a shape edit. They are converted to arc-length below. // // Tunnels cost reachability, which is the thing that actually decides whether // a level is clearable, so they are spent where there is reachability to spare: // 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, 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 // immediately available shot every 450ms and averages 51 points per quota // marble. This curve puts two stars comfortably in its reach (18 of 20 levels) // and three stars just past it (3 of 20) — the gap is the chain and combo play // the bot never attempts, which is worth SCORE_CHAIN_BONUS a pop. function starScores(quota, pushSpeed) { const top = Math.round((quota * (48 + pushSpeed * 0.2)) / 10) * 10; return [Math.round((top * 0.5) / 10) * 10, Math.round((top * 0.75) / 10) * 10, top]; } // ── 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) { const { points, frog } = SHAPES[shape](); const len = buildPath(points).length; const tunnels = (tunFrac ?? []).map(([a, b]) => [Math.round(len * a), Math.round(len * b)]); const def = { level, name, frog, colors, background: BACKGROUNDS[(level - 1) % BACKGROUNDS.length], seed: 1000 + level * 7919, starScores: starScores(quota, pushSpeed), paths: [{ shape, points, tunnels, quota, introBalls, pushSpeed, powerUpRate }], }; // Every table row is still exactly one path — validateLevel/genZuma's own // lint reports below read that one entry's stats (see ZumaLogic.validateLevel). const { errs, length, minFrog, minRadius, hiddenFrac } = validateLevel(def); const cap = Math.floor(length / (TUNING.BALL_SPACING * 1.6)); if (errs.length) { bad++; console.error(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} INVALID: ${errs.join('; ')}`); } else { const tun = tunnels.length ? ` tunnels=${tunnels.length}/${(hiddenFrac * 100).toFixed(0)}%` : ''; console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${String(quota).padStart(2)}/${cap} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}${tun}`); } levels.push(def); } if (bad) { console.error(`\n${bad} invalid level(s) — not writing ${OUT_DIR}`); process.exit(1); } fs.mkdirSync(OUT_DIR, { recursive: true }); const manifest = { version: 1, levels: [] }; for (const def of levels) { const file = `level-${String(def.level).padStart(3, '0')}.json`; fs.writeFileSync(path.join(OUT_DIR, file), `${JSON.stringify(def, null, 1)}\n`); manifest.levels.push({ level: def.level, name: def.name, file }); } fs.writeFileSync(path.join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 1)}\n`); console.log(`\nWrote ${levels.length} levels + manifest to ${OUT_DIR}`);