// Offline generator for Zuma levels. // // Six hand-designed path shapes (parametric control-point emitters in 1920x1080 // canvas space) crossed with a hand-written 20-row difficulty table. Each level // is validated against the same geometry rules verifyZuma.js lints: path long // enough for its ball quota, samples in bounds past the off-screen lead-in, // curvature wide enough for the marbles, and the frog clear of the path. // Writes ordered levels to public/data/zuma.json. // // Usage: // node server/scripts/genZuma.js [outFile] // // 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 { buildPath, TUNING } from '../../public/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, '../../public/data/zuma.json'); const rad = (deg) => (deg * Math.PI) / 180; const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]); // ── Shapes: { points, frog } — first point is the off-screen spawn lead-in, // the last is the skull hole ───────────────────────────────────────────── function sCurve() { return { points: [ [-80, 300], [240, 220], [560, 300], [860, 460], [1120, 640], [1400, 760], [1660, 700], [1790, 520], [1700, 330], [1500, 260], ], frog: [960, 920], }; } function horseshoe() { return { points: [ [-80, 1000], [160, 900], [170, 650], [300, 380], [560, 190], [960, 130], [1360, 190], [1620, 380], [1750, 650], [1700, 900], [1520, 990], ], frog: [960, 620], }; } function spiral() { const cx = 960, cy = 580, rx = 820, ry = 430, turns = 2.1, fEnd = 0.36; const points = [[-80, cy]]; const steps = Math.round(turns * 16); // a control point every 22.5° for (let i = 0; i <= steps; i++) { const u = i / steps; const th = Math.PI + 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 { points: rp(points), frog: [cx, cy] }; } function zigzag() { return { points: [ [-80, 190], [300, 190], [800, 190], [1300, 190], [1560, 190], [1720, 235], [1785, 355], [1720, 475], [1560, 520], [1100, 520], [600, 520], [360, 520], [200, 565], [135, 685], [200, 805], [360, 850], [900, 850], [1400, 850], [1640, 880], [1750, 960], ], frog: [960, 685], }; } function doubleLoop() { const A = { x: 540, y: 560, rx: 360, ry: 330 }; const B = { x: 1380, y: 560, rx: 360, ry: 330 }; const points = [[-80, 180], [200, 255]]; for (let d = -90; d <= 200; d += 24) { points.push([A.x + A.rx * Math.cos(rad(d)), A.y + A.ry * Math.sin(rad(d))]); } points.push([300, 210], [700, 120]); // arc over loop A to loop B's top for (let d = -90; d <= 200; d += 24) { points.push([B.x + B.rx * Math.cos(rad(d)), B.y + B.ry * Math.sin(rad(d))]); } // hole hook: continue the ring's exit direction, then curl into the center points.push([1090, 330], [1200, 260], [1330, 300], [1390, 420], [1330, 520]); return { points: rp(points), frog: [A.x, A.y] }; } function figureEight() { // 1:2 Lissajous traced once: enters mid-left, crosses itself at center, // ends in the lower-left lobe. const cx = 960, cy = 560, ax = 820, ay = 430; const t0 = 1.5 * Math.PI; const t1 = t0 + 2 * Math.PI - 0.55; // the left tip has a vertical tangent, so the lead-in climbs from below const points = [[-80, 940], [60, 750]]; const n = 44; 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: [550, 560] }; } const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight }; // ── Difficulty table ───────────────────────────────────────────────────────── const TABLE = [ // level, name, shape, colors, quota, intro, push, powerUpRate [1, 'Riverbend', 'sCurve', 4, 28, 10, 22, 0.07], [2, 'Temple Gate', 'horseshoe', 4, 32, 10, 24, 0.07], [3, 'Twin Pools', 'doubleLoop', 4, 36, 10, 26, 0.065], [4, 'Switchbacks', 'zigzag', 4, 40, 12, 26, 0.065], [5, 'Serpent Coil', 'spiral', 4, 46, 12, 28, 0.06], [6, 'Crossroads', 'figureEight', 4, 42, 12, 28, 0.06], [7, 'Rapids', 'sCurve', 4, 30, 10, 34, 0.06], [8, 'Sun Court', 'horseshoe', 5, 36, 10, 30, 0.055], [9, 'Thunder Steps', 'zigzag', 5, 44, 12, 30, 0.055], [10, 'Twin Serpents', 'doubleLoop', 5, 42, 12, 32, 0.055], [11, 'Deep Coil', 'spiral', 5, 52, 14, 32, 0.05], [12, 'Tangled Path', 'figureEight', 5, 46, 12, 34, 0.05], [13, 'Lightning Run', 'zigzag', 5, 50, 14, 36, 0.05], [14, 'Whirlpool', 'spiral', 5, 58, 14, 36, 0.05], [15, 'Obsidian Gate', 'horseshoe', 6, 38, 10, 38, 0.05], [16, 'Twin Tempests', 'doubleLoop', 6, 46, 12, 40, 0.05], [17, 'Stormsteps', 'zigzag', 6, 54, 14, 42, 0.045], [18, 'Maelstrom Cross', 'figureEight', 6, 50, 12, 44, 0.045], [19, 'Abyss Coil', 'spiral', 6, 62, 14, 46, 0.045], [20, 'The Final Coil', 'spiral', 6, 66, 16, 48, 0.045], ]; // Calibrated against a headless aimbot (accurate shot every 450ms scores // ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching. function starScores(quota, pushSpeed) { const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 10) * 10; return [Math.round((top * 0.5) / 10) * 10, Math.round((top * 0.75) / 10) * 10, top]; } // ── Validation (mirrors verifyZuma.js bank lint) ───────────────────────────── function validate(level) { const errs = []; const p = buildPath(level.points); if (p.length < level.quota * TUNING.BALL_SPACING * 1.6) { errs.push(`path ${p.length.toFixed(0)}px too short for quota ${level.quota}`); } let minFrog = Infinity, minRadius = Infinity, minRadiusS = 0; for (let i = 0; i < p.samples.length; i++) { const s = p.samples[i]; minFrog = Math.min(minFrog, Math.hypot(s.x - level.frog[0], s.y - level.frog[1])); if (s.s > 200 && (s.x < 40 || s.x > 1880 || s.y < 40 || s.y > 1040)) { errs.push(`sample out of bounds at s=${s.s.toFixed(0)} (${s.x.toFixed(0)},${s.y.toFixed(0)})`); break; } if (i > 0 && i < p.samples.length - 1 && s.s > 200) { const a = p.samples[i - 1], c = p.samples[i + 1]; const v1x = s.x - a.x, v1y = s.y - a.y, v2x = c.x - s.x, v2y = c.y - s.y; const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y); if (l1 > 0.01 && l2 > 0.01) { const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2))); const theta = Math.acos(cos); if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = s.s; } } } } if (minFrog < 140) errs.push(`frog only ${minFrog.toFixed(0)}px from path`); if (minRadius < TUNING.BALL_RADIUS * 1.7) errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} of ${p.length.toFixed(0)}`); return { errs, length: p.length, minFrog, minRadius }; } // ── 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 } = validate(def); 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=${quota} 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}`);