247 lines
9.5 KiB
JavaScript
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();
|