// Generates the 12 launch tracks + cups.json for Super Kart into // assets/gamedata/superkart/. Run: node tools/genSuperKartTracks.js [--force] // // Contract (same as genPeggleLevels.js): tracks are generated ONCE and then // hand-tuned in the editor (?superkart-editor=1). Without --force, existing // track files are left untouched so tuning is never clobbered. // // Layouts are star-shaped polar curves r(θ) = base + Σ aᵢ·sin(iθ+φᵢ): any // curve monotone in θ around the center cannot self-intersect, so every // family is safe by construction and only width/turn-radius interplay needs // validation (buildTrackModel + validateTrack, the same checks the editor // and verifier run). import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { buildTrackModel, validateTrack, projectToSpline, SURFACE, surfaceAt, normAngle, } from '../src/games/superkart/SuperKartTrack.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(ROOT, 'assets', 'gamedata', 'superkart'); const FORCE = process.argv.includes('--force'); const WORLD = 4096; const CENTER = WORLD / 2; const TAU = Math.PI * 2; const rules = JSON.parse(readFileSync(join(ROOT, 'data', 'superkart-rules.json'), 'utf8')); function mulberry32(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ── Layout families ───────────────────────────────────────────────────────── // Each returns { spline, laps } — control points around the world center. function polarSpline(rng, { points, base, harmonics, width, widthJitter = 0, narrowAtCurves = 0 }) { const phases = harmonics.map(() => rng() * TAU); const rAt = (theta) => { let r = base; harmonics.forEach(([freq, amp], i) => { r += Math.sin(theta * freq + phases[i]) * amp; }); return Math.max(560, Math.min(r, CENTER - 420)); }; const spline = []; for (let i = 0; i < points; i += 1) { const theta = (i / points) * TAU; const r = rAt(theta); spline.push({ x: Math.round(CENTER + Math.cos(theta) * r), y: Math.round(CENTER + Math.sin(theta) * r), w: 0, }); } // Width: base ± jitter, narrowed where the local polyline turns hard. for (let i = 0; i < points; i += 1) { const a = spline[(i - 1 + points) % points]; const b = spline[i]; const c = spline[(i + 1) % points]; const turn = Math.abs(normAngle( Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x), )); let w = width + (rng() * 2 - 1) * widthJitter; w -= turn * narrowAtCurves; spline[i].w = Math.round(Math.max(72, Math.min(180, w))); } return spline; } const FAMILIES = { oval: (rng) => polarSpline(rng, { points: 12, base: 1280, width: 132, widthJitter: 10, harmonics: [[2, 220 + rng() * 120]], }), kidney: (rng) => polarSpline(rng, { points: 14, base: 1260, width: 120, widthJitter: 14, narrowAtCurves: 20, harmonics: [[2, 180 + rng() * 90], [3, 150 + rng() * 110], [4, 60 + rng() * 60]], }), hairpin: (rng) => polarSpline(rng, { points: 16, base: 1240, width: 108, widthJitter: 12, narrowAtCurves: 34, harmonics: [[3, 260 + rng() * 120], [5, 130 + rng() * 90]], }), pretzel: (rng) => polarSpline(rng, { points: 18, base: 1200, width: 102, widthJitter: 10, narrowAtCurves: 30, harmonics: [[2, 340 + rng() * 130], [5, 150 + rng() * 80], [7, 60 + rng() * 40]], }), speedway: (rng) => polarSpline(rng, { points: 12, base: 1330, width: 150, widthJitter: 6, harmonics: [[2, 160 + rng() * 80], [3, 70 + rng() * 50]], }), }; // ── Furniture: item rows, coins, boosts, decor, patches ───────────────────── function curvatureAt(model, idx, span = 4) { const n = model.samples.length; const a = model.samples[(idx - span + n) % n]; const b = model.samples[idx]; const c = model.samples[(idx + span) % n]; const turn = Math.abs(normAngle( Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x), )); return turn / (2 * span * model.step); } function furnish(track, model, rng, theme) { const L = model.totalLength; const n = model.samples.length; // Item rows at thirds of the lap, measured from the start line. track.itemRows = [0.28, 0.58, 0.86].map((f) => ({ s: Math.round((model.startS + f * L) % L), count: 4, })); // Curvature thresholds adapt to each layout: "straight" is the gentlest // 30% of the lap, "hard corner" the sharpest 10%. const curvs = []; for (let i = 0; i < n; i += 1) curvs.push(curvatureAt(model, i)); const sorted = [...curvs].sort((a, b) => a - b); const straightThresh = sorted[Math.floor(n * 0.30)]; const hardThresh = sorted[Math.floor(n * 0.90)]; // Coins on straights: runs of low curvature get a small arc of coins. track.coins = []; let run = 0; for (let i = 0; i < n && track.coins.length < 24; i += 1) { if (curvs[i] <= straightThresh) run += 1; else run = 0; if (run >= 10 && i % 4 === 0) { const p = model.samples[i]; const lane = Math.sin(i * 0.7) * 0.45; track.coins.push({ x: Math.round(p.x + -p.ty * lane * p.w), y: Math.round(p.y + p.tx * lane * p.w), }); } } // Boost pads on corner exits: hard corner followed by an easing section. track.boosts = []; for (let i = 0; i < n && track.boosts.length < 3; i += 1) { const hard = curvs[i] >= hardThresh; const clearAhead = curvs[(i + 12) % n] <= straightThresh * 1.5; if (hard && clearAhead) { const p = model.samples[(i + 14) % n]; track.boosts.push({ s: Math.round(p.s), lane: Math.round((rng() * 2 - 1) * 4) / 10 }); i += 60; // one pad per corner complex } } // Roadside decor: offroad points near the track edge. track.decor = []; const decorList = theme.decor ?? ['tree']; let guard = 0; while (track.decor.length < 16 && guard < 400) { guard += 1; const p = model.samples[Math.floor(rng() * n)]; const side = rng() < 0.5 ? -1 : 1; const dist = p.w + 60 + rng() * 240; const x = Math.round(p.x + -p.ty * side * dist); const y = Math.round(p.y + p.tx * side * dist); if (surfaceAt(model, x, y) !== SURFACE.OFFROAD) continue; track.decor.push({ sprite: decorList[track.decor.length % decorList.length], x, y }); } // One or two scenic hazard patches well clear of the racing line. track.surfaces = []; const patchType = ['beach', 'swamp', 'nightcity'].includes(track.theme) ? 'water' : 'deep'; guard = 0; while (track.surfaces.length < 2 && guard < 300) { guard += 1; const x = 500 + rng() * (WORLD - 1000); const y = 500 + rng() * (WORLD - 1000); const proj = projectToSpline(model, x, y); const maxW = 200; if (proj.d < maxW + 320) continue; track.surfaces.push({ type: patchType, circle: [Math.round(x), Math.round(y), Math.round(140 + rng() * 140)] }); } track.hazards = []; track.walls = []; } // ── Track plan ────────────────────────────────────────────────────────────── const PLAN = [ // Sprocket Cup — wide, forgiving. { id: 'track-001', cup: 0, name: 'Gerome Speedway', theme: 'speedway', family: 'oval', laps: 5 }, { id: 'track-002', cup: 0, name: 'Kona Cove', theme: 'beach', family: 'kidney', laps: 5 }, { id: 'track-003', cup: 0, name: 'Bayou Bend', theme: 'swamp', family: 'kidney', laps: 5 }, { id: 'track-004', cup: 0, name: 'Scrap Run', theme: 'scrapyard', family: 'oval', laps: 5 }, // Flywheel Cup — quicker corners. { id: 'track-005', cup: 1, name: 'Sunwash Shore', theme: 'beach', family: 'hairpin', laps: 5 }, { id: 'track-006', cup: 1, name: 'Cinder Peaks', theme: 'volcano', family: 'kidney', laps: 5 }, { id: 'track-007', cup: 1, name: 'Neon Nights', theme: 'nightcity', family: 'pretzel', laps: 5 }, { id: 'track-008', cup: 1, name: 'Speedway Classic', theme: 'speedway', family: 'speedway', laps: 5 }, // Nitro Cup — narrow and technical. { id: 'track-009', cup: 2, name: "Croc's Crossing", theme: 'swamp', family: 'hairpin', laps: 5 }, { id: 'track-010', cup: 2, name: "Smasher's Junction", theme: 'scrapyard', family: 'pretzel', laps: 5 }, { id: 'track-011', cup: 2, name: 'Fireball Furnace', theme: 'volcano', family: 'hairpin', laps: 5 }, { id: 'track-012', cup: 2, name: 'Blackwind Boulevard', theme: 'nightcity', family: 'pretzel', laps: 5 }, ]; const CUPS = [ { id: 'cup-1', name: 'Sprocket Cup' }, { id: 'cup-2', name: 'Flywheel Cup' }, { id: 'cup-3', name: 'Nitro Cup' }, ]; // ── Main ──────────────────────────────────────────────────────────────────── mkdirSync(OUT, { recursive: true }); let written = 0; let skipped = 0; let failed = 0; PLAN.forEach((plan, ti) => { const file = join(OUT, `${plan.id}.json`); if (existsSync(file) && !FORCE) { console.log(`skip ${plan.id} (exists — hand-tuned files are never rewritten; use --force)`); skipped += 1; return; } const theme = rules.themes[plan.theme]; let track = null; for (let attempt = 0; attempt < 40; attempt += 1) { const rng = mulberry32(0xC0FFEE + ti * 977 + attempt * 131071); const spline = FAMILIES[plan.family](rng); const candidate = { version: 1, id: plan.id, name: plan.name, theme: plan.theme, laps: plan.laps, world: WORLD, spline, startIndex: 0, surfaces: [], walls: [], boosts: [], itemRows: [], hazards: [], decor: [], coins: [], }; let model; try { model = buildTrackModel(candidate); } catch (_) { continue; } if (validateTrack(model).length) continue; furnish(candidate, model, rng, theme); // Furniture can't break geometry, but re-validate with everything in place. const finalModel = buildTrackModel(candidate); const issues = validateTrack(finalModel); if (issues.length) continue; track = candidate; console.log(`write ${plan.id} ${plan.name.padEnd(20)} ${plan.family.padEnd(9)} lap ${Math.round(finalModel.totalLength)}u, ${candidate.coins.length} coins, ${candidate.boosts.length} boosts (attempt ${attempt + 1})`); break; } if (!track) { console.error(`FAIL ${plan.id} — no valid layout found`); failed += 1; return; } writeFileSync(file, `${JSON.stringify(track, null, 2)}\n`); written += 1; }); const cupsJson = { version: 1, cups: CUPS.map((cup, i) => ({ ...cup, tracks: PLAN.filter((p) => p.cup === i).map((p) => p.id), })), tracks: PLAN.map((p) => ({ id: p.id, name: p.name, theme: p.theme, file: `${p.id}.json` })), }; const cupsFile = join(OUT, 'cups.json'); if (!existsSync(cupsFile) || FORCE || written > 0) { writeFileSync(cupsFile, `${JSON.stringify(cupsJson, null, 2)}\n`); console.log('write cups.json'); } console.log(`\ndone: ${written} written, ${skipped} skipped, ${failed} failed`); process.exit(failed ? 1 : 0);