fertig-classic-games/tools/genZuma.js

238 lines
10 KiB
JavaScript

// 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}`);