208 lines
8.7 KiB
JavaScript
208 lines
8.7 KiB
JavaScript
// 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}`);
|