fertig-classic-games/tools/genExcitebikeTracks.js

247 lines
9.5 KiB
JavaScript

// Builds the Excitebike track bank into assets/gamedata/excitebike/.
//
// node tools/genExcitebikeTracks.js # only writes missing files
// node tools/genExcitebikeTracks.js --force # rewrites everything
// node tools/genExcitebikeTracks.js --only=6,7 # rewrite specific tracks
//
// Tracks 1-5 are the original NES courses: their lengths and hurdle sequences
// are transcribed from the NES track maps by tools/readExcitebikeMaps.js, not
// invented. Tracks 6-10 are new, built from the same vocabulary and tuned to
// pick up where Track 5 leaves off.
//
// Qualifying times are MEASURED, never guessed: the reference rider in
// src/games/excitebike/ExcitebikeAuto.js drives each track, and the target is
// set from what it actually does. Hand-tuned files are never clobbered without
// --force, matching tools/genSuperKartTracks.js.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildTrackModel, validateTrack, HURDLES, START_PAD, FINISH_PAD } from '../src/games/excitebike/ExcitebikeTrack.js';
import { runAuto, probeTrack, AUTO_SKILL } from '../src/games/excitebike/ExcitebikeAuto.js';
import { mulberry32, MODE } from '../src/games/excitebike/ExcitebikeLogic.js';
import { TRACKS as AUTHORED } from './data/excitebikeCourses.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'assets', 'gamedata', 'excitebike');
const args = process.argv.slice(2);
const FORCE = args.includes('--force');
const ONLY = (args.find((a) => a.startsWith('--only=')) ?? '').slice(7)
.split(',').filter(Boolean).map(Number);
// ---------------------------------------------------------------------------
// Generated courses (tracks 6-10)
// ---------------------------------------------------------------------------
// Weighted hurdle pools per difficulty tier. Ramps stay common throughout —
// they are the game — while hazards thicken as the tier rises.
const POOLS = [
{ ramps: 'AABBDD', hazards: 'KLMN', gaps: '' },
{ ramps: 'ABBCDE', hazards: 'IJKL', gaps: 'OP' },
{ ramps: 'BCCEFGH', hazards: 'IJKL', gaps: 'OPOP' },
{ ramps: 'CCEFGHHS', hazards: 'IIJJKL', gaps: 'OPOPQ' },
{ ramps: 'CEFGHHHSR', hazards: 'IIJJKKLL', gaps: 'OPQQ' },
];
// Spacing tightens track by track. It is the main difficulty dial: hurdles
// close together leave no room to pick a line, dry the heat meter out, or set
// up for the next landing.
// Tracks 6-10 pick up where the originals leave off, so they start around
// where Track 5 sits and climb from there.
const NEW_TRACKS = [
{ n: 6, length: 6600, theme: 'dusk', tier: 1, spacing: 145 },
{ n: 7, length: 6900, theme: 'desert', tier: 2, spacing: 130 },
{ n: 8, length: 7200, theme: 'night', tier: 3, spacing: 120 },
{ n: 9, length: 7400, theme: 'snow', tier: 3, spacing: 110 },
{ n: 10, length: 7800, theme: 'night', tier: 4, spacing: 105 },
];
function pick(rng, str) {
return str[Math.floor(rng() * str.length)];
}
/**
* Lay hurdles down the track at a rising cadence. Two rules keep a course
* rideable: nothing overlaps its neighbour's footprint, and a cool zone is
* never more than `coolEvery` away, so heat is always manageable if you look
* for it.
*/
function generateHurdles(spec, seed) {
const rng = mulberry32(seed);
const pool = POOLS[spec.tier];
const hurdles = [];
const first = START_PAD + 160;
const last = spec.length - FINISH_PAD - 220;
const coolEvery = 950;
let x = first;
let sinceCool = 0;
while (x < last) {
let t;
if (sinceCool > coolEvery) {
t = rng() < 0.5 ? 'M' : 'N';
sinceCool = 0;
} else {
const roll = rng();
if (roll < 0.5) t = pick(rng, pool.ramps);
else if (roll < 0.82) t = pick(rng, pool.hazards);
else if (pool.gaps) t = pick(rng, pool.gaps);
else t = pick(rng, pool.ramps);
if (t === 'M' || t === 'N') sinceCool = 0;
}
let footprint = HURDLES[t].profile.length;
if (t === 'Q') {
// A hole across all four lanes is only a hurdle if you can get airborne
// over it. Always build the launcher first, edge flush with the near lip.
const ramp = HURDLES.C.profile.length;
hurdles.push({ t: 'C', x: Math.round(x) });
hurdles.push({ t: 'Q', x: Math.round(x) + ramp });
footprint += ramp;
} else {
hurdles.push({ t, x: Math.round(x) });
}
const gap = spec.spacing * (0.72 + rng() * 0.6);
x += footprint + gap;
sinceCool += footprint + gap;
}
// Whoops sections, a couple per course, always on one half so there is a
// clean line for a rider who spots them.
const rough = [];
const roughCount = 1 + spec.tier / 2;
for (let i = 0; i < roughCount; i += 1) {
const rx = first + ((i + 0.5) / roughCount) * (last - first) + rng() * 120;
rough.push({ x: Math.round(rx), lanes: rng() < 0.5 ? [0, 1] : [2, 3] });
}
return { hurdles, rough };
}
// ---------------------------------------------------------------------------
// Timing
// ---------------------------------------------------------------------------
/**
* Set the qualifying target from what a fallible rider actually manages.
*
* Not the expert: the expert has perfect information and no reaction time, and
* timing a course off it would set a target nobody holding a controller could
* reach. The `human` probe misjudges landings and reacts on a delay, which is
* the behaviour a target should be priced against. The margin on top is small
* because that rider is already making mistakes.
*
* The other end is asserted by the verifier: a rider who just holds the
* throttle down has to miss the target from track 4 on.
*/
const QUALIFY_MARGIN = 1.06;
// ...but never tighter than this over the theoretical best. The human probe is
// jittered, so on any given course it can happen to beat the expert, and a
// target priced purely off it would then leave a perfect ride no room at all.
const EXPERT_MARGIN = 1.12;
function measure(json) {
const model = buildTrackModel(json);
const probe = probeTrack(model);
const expert = runAuto(model, { skill: AUTO_SKILL.expert, seed: 9 });
const naive = runAuto(model, { skill: AUTO_SKILL.naive, seed: 9 });
return { model, probe, expert, naive };
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
function buildTrack(spec) {
const authored = AUTHORED[spec.n];
const base = {
version: 1,
id: `track-${String(spec.n).padStart(2, '0')}`,
name: `TRACK ${spec.n}`,
theme: spec.theme,
length: spec.length,
laps: 2,
mainLaps: 2,
qualifyMs: 90000,
};
const body = authored
? { hurdles: authored.hurdles, rough: authored.rough ?? [] }
: generateHurdles(spec, 0x5b1ce7 + spec.n * 7919);
const json = { ...base, ...body };
const { model, probe, expert, naive } = measure(json);
if (!expert.finished) throw new Error(`${json.id}: the reference rider could not finish it`);
if (probe.medianMs == null) throw new Error(`${json.id}: the human probe could not finish it`);
json.qualifyMs = Math.round(Math.max(
probe.medianMs * QUALIFY_MARGIN,
expert.ms * EXPERT_MARGIN,
) / 100) * 100;
const errors = validateTrack(buildTrackModel(json), json);
return { json, model, probe, expert, naive, errors };
}
function main() {
mkdirSync(OUT, { recursive: true });
const specs = [
...Object.values(AUTHORED).map((a) => ({
n: a.n, length: a.length, theme: a.theme, tier: a.tier, spacing: 0,
})),
...NEW_TRACKS,
].sort((a, b) => a.n - b.n);
const index = { version: 1, tracks: [] };
let wrote = 0;
for (const spec of specs) {
if (ONLY.length && !ONLY.includes(spec.n)) {
const file = join(OUT, `track-${String(spec.n).padStart(2, '0')}.json`);
if (existsSync(file)) {
const existing = JSON.parse(readFileSync(file, 'utf8'));
index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` });
continue;
}
}
const { json, probe, expert, naive, errors } = buildTrack(spec);
const file = join(OUT, `${json.id}.json`);
if (errors.length) {
console.error(`FAIL ${json.id}:`);
for (const e of errors) console.error(` ${e}`);
process.exitCode = 1;
}
if (existsSync(file) && !FORCE && !ONLY.includes(spec.n)) {
const existing = JSON.parse(readFileSync(file, 'utf8'));
index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` });
console.log(`skip ${json.id} (exists; --force to rewrite)`);
continue;
}
writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
wrote += 1;
index.tracks.push({ n: spec.n, id: json.id, name: json.name, theme: json.theme, file: `${json.id}.json` });
const s = (ms) => (ms == null ? ' DNF' : `${(ms / 1000).toFixed(1)}`.padStart(6));
console.log(
`${json.id} len ${String(json.length).padStart(5)} hurdles ${String(json.hurdles.length).padStart(2)}`
+ ` expert ${s(expert.ms)} human ${s(probe.medianMs)} target ${s(json.qualifyMs)}`
+ ` naive ${s(naive.finished ? naive.ms : null)}`
+ ` crash/kpx ${probe.crashesPerKpx.toFixed(2)}`
+ `${naive.finished && naive.ms <= json.qualifyMs ? ' <-- TOO EASY' : ''}`,
);
}
writeFileSync(join(OUT, 'tracks.json'), `${JSON.stringify(index, null, 2)}\n`);
console.log(`\n${wrote} track file(s) written, index has ${index.tracks.length}`);
}
main();