// 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 [outFile] // // 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, TUNING } from '../src/games/zuma/ZumaLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[2] ? path.resolve(process.argv[2]) : path.join(__dirname, '../data/zuma.json'); 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 skull hole ───────────────────────────────────────────── // 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. const TABLE = [ // level, name, shape, colors, quota, intro, push, powerUpRate [1, 'Riverbend', 'sCurve', 4, 22, 7, 25, 0.07], [2, 'Temple Gate', 'horseshoe', 4, 26, 8, 32, 0.07], [3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 35, 0.065], [4, 'Switchbacks', 'zigzag', 4, 32, 10, 35, 0.065], [5, 'Serpent Coil', 'spiral', 4, 38, 11, 37, 0.06], [6, 'Crossroads', 'figureEight', 4, 34, 10, 37, 0.06], [7, 'Rapids', 'sCurve', 4, 26, 8, 45, 0.06], [8, 'Sun Court', 'horseshoe', 5, 30, 9, 40, 0.055], [9, 'Thunder Steps', 'zigzag', 5, 36, 11, 40, 0.055], [10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 43, 0.055], [11, 'Deep Coil', 'spiral', 5, 42, 12, 43, 0.05], [12, 'Tangled Path', 'figureEight', 5, 38, 11, 45, 0.05], [13, 'Lightning Run', 'zigzag', 5, 34, 10, 48, 0.05], [14, 'Whirlpool', 'spiral', 5, 46, 13, 48, 0.05], [15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 51, 0.05], [16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 53, 0.05], [17, 'Stormsteps', 'zigzag', 6, 34, 10, 50, 0.045], [18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 50, 0.045], [19, 'Abyss Coil', 'spiral', 6, 50, 14, 61, 0.045], [20, 'The Final Coil', 'spiral', 6, 52, 14, 64, 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 ──────────────────────────────────────────────────────────── const levels = []; let bad = 0; for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate] of TABLE) { const { points, frog } = SHAPES[shape](); const def = { level, name, shape, points, frog, colors, quota, introBalls, pushSpeed, powerUpRate, seed: 1000 + level * 7919, starScores: starScores(quota, pushSpeed), }; const { errs, length, minFrog, minRadius } = 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 { 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)}`); } levels.push(def); } if (bad) { console.error(`\n${bad} invalid level(s) — not writing ${OUT_FILE}`); process.exit(1); } fs.writeFileSync(OUT_FILE, JSON.stringify({ generatedAt: new Date().toISOString(), count: levels.length, levels, }, null, 1)); console.log(`\nWrote ${levels.length} levels to ${OUT_FILE}`);