// Super Kart headless track model. Pure ESM with no Phaser or canvas imports so // tools/verifySuperKart.js and tools/genSuperKartTracks.js can drive it in Node. // // A track JSON (assets/gamedata/superkart/track-NNN.json) is a closed // Catmull-Rom centerline with a per-control-point half-width. Everything that // belongs to the road (start line, item rows, boost pads) is addressed by // arc-length `s` plus a lane offset (-1..1 across the width) so spline edits // carry them along. buildTrackModel() turns that JSON into the one structure // the rasterizer, the physics sim, the AI, and the minimap all share. export const SURFACE = { OOB: 0, // outside the world OFFROAD: 1, // theme terrain — slow ROAD: 2, CURB: 3, // striped edge band — drivable, slight grip change only visually BOOST: 4, // boost pad on the road DEEP: 5, // pit/lava/deep sand — crawl + rescue WATER: 6, // water — rescue WALL: 7, // impassable }; export const CURB_WIDTH = 8; // world units, each road edge export const STRIPE_LEN = 32; // world units per white/red curb stripe export const SAMPLE_STEP = 16; // centerline resample spacing export const CHECKPOINT_STEP = 64; export const GRID_CELLS = 256; // surfaceGrid is GRID_CELLS × GRID_CELLS const TAU = Math.PI * 2; // ── Catmull-Rom (closed, centripetal-ish uniform) ─────────────────────────── function catmull(p0, p1, p2, p3, t) { const t2 = t * t; const t3 = t2 * t; return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); } // Evaluates the closed loop through pts at segment i, local t in [0,1). function evalSpline(pts, i, t) { const n = pts.length; const a = pts[(i - 1 + n) % n]; const b = pts[i % n]; const c = pts[(i + 1) % n]; const d = pts[(i + 2) % n]; return { x: catmull(a.x, b.x, c.x, d.x, t), y: catmull(a.y, b.y, c.y, d.y, t), w: b.w + (c.w - b.w) * t, }; } // ── Model construction ────────────────────────────────────────────────────── // Resamples the control loop into evenly spaced samples {x, y, tx, ty, w, s}. function resample(pts) { const SUBDIV = 32; const fine = []; const ctrlS = new Array(pts.length); // arc-length at each control point let acc = 0; let prev = null; for (let i = 0; i < pts.length; i += 1) { ctrlS[i] = acc; for (let k = 0; k < SUBDIV; k += 1) { const p = evalSpline(pts, i, k / SUBDIV); if (prev) acc += Math.hypot(p.x - prev.x, p.y - prev.y); p.s = acc; fine.push(p); prev = p; } } const total = acc + Math.hypot(fine[0].x - prev.x, fine[0].y - prev.y); const count = Math.max(32, Math.round(total / SAMPLE_STEP)); const step = total / count; const samples = new Array(count); let fi = 0; for (let i = 0; i < count; i += 1) { const target = i * step; while (fi < fine.length - 1 && fine[fi + 1].s < target) fi += 1; const a = fine[fi]; const b = fine[(fi + 1) % fine.length]; const span = Math.max(1e-6, (fi === fine.length - 1 ? total : b.s) - a.s); const t = Math.min(1, Math.max(0, (target - a.s) / span)); samples[i] = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, w: a.w + (b.w - a.w) * t, s: target, tx: 0, ty: 0, }; } // Central-difference tangents around the loop. for (let i = 0; i < count; i += 1) { const p = samples[(i - 1 + count) % count]; const q = samples[(i + 1) % count]; const dx = q.x - p.x; const dy = q.y - p.y; const len = Math.hypot(dx, dy) || 1; samples[i].tx = dx / len; samples[i].ty = dy / len; } return { samples, totalLength: count * step, step, ctrlS }; } // Spatial hash of sample indices for nearest-centerline queries. function buildHash(samples, world) { const cell = 64; const cols = Math.ceil(world / cell); const hash = new Map(); for (let i = 0; i < samples.length; i += 1) { const cx = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].x / cell))); const cy = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].y / cell))); const key = cy * cols + cx; if (!hash.has(key)) hash.set(key, []); hash.get(key).push(i); } return { hash, cell, cols }; } function nearbySampleIndices(model, x, y, ring) { const { hash, cell, cols } = model._hash; const cx = Math.floor(x / cell); const cy = Math.floor(y / cell); const out = []; for (let dy = -ring; dy <= ring; dy += 1) { for (let dx = -ring; dx <= ring; dx += 1) { const gx = cx + dx; const gy = cy + dy; if (gx < 0 || gy < 0 || gx >= cols || gy >= cols) continue; const bucket = hash.get(gy * cols + gx); if (bucket) out.push(...bucket); } } return out; } // Distance from (x,y) to the segment samples[i] → samples[i+1]. function segDist(samples, i, x, y) { const a = samples[i]; const b = samples[(i + 1) % samples.length]; const abx = b.x - a.x; const aby = b.y - a.y; const len2 = abx * abx + aby * aby || 1e-6; let t = ((x - a.x) * abx + (y - a.y) * aby) / len2; t = Math.max(0, Math.min(1, t)); const px = a.x + abx * t; const py = a.y + aby * t; return { d: Math.hypot(x - px, y - py), t, px, py }; } function pointInPoly(poly, x, y) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) { const [xi, yi] = poly[i]; const [xj, yj] = poly[j]; if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } // Interpolated centerline point at arc-length s (wraps). export function sampleAt(model, s) { const { samples, totalLength, step } = model; let ss = s % totalLength; if (ss < 0) ss += totalLength; const i = Math.floor(ss / step) % samples.length; const a = samples[i]; const b = samples[(i + 1) % samples.length]; const t = (ss - i * step) / step; return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, tx: a.tx + (b.tx - a.tx) * t, ty: a.ty + (b.ty - a.ty) * t, w: a.w + (b.w - a.w) * t, s: ss, }; } // World position for an (s, lane) road address. lane -1..1 spans the width. export function roadPoint(model, s, lane = 0) { const p = sampleAt(model, s); return { x: p.x + -p.ty * lane * p.w, y: p.y + p.tx * lane * p.w, tx: p.tx, ty: p.ty, w: p.w, }; } export function surfaceAt(model, x, y) { if (x < 0 || y < 0 || x >= model.world || y >= model.world) return SURFACE.OOB; const cs = model.world / GRID_CELLS; const gx = Math.floor(x / cs); const gy = Math.floor(y / cs); return model.surfaceGrid[gy * GRID_CELLS + gx]; } // Continuity-aware projection: searches only a window of samples around a // known previous arc-length. Where two track sections pinch close together // (pretzel lobes, hairpins) the global nearest-point search can flip to the // wrong branch — karts track their own splineS and use this instead. export function projectToSplineNear(model, x, y, prevS, windowSamples = 40) { const { samples } = model; const n = samples.length; const i0 = Math.round((((prevS % model.totalLength) + model.totalLength) % model.totalLength) / model.step) % n; let best = null; for (let di = -windowSamples; di <= windowSamples; di += 1) { const i = (i0 + di + n) % n; const r = segDist(samples, i, x, y); if (!best || r.d < best.d) best = { ...r, i }; } // Way off the window (post-rescue teleports, etc.): fall back to global. if (!best || best.d > 500) return projectToSpline(model, x, y); const a = samples[best.i]; const cross = a.tx * (y - best.py) - a.ty * (x - best.px); const s = (a.s + best.t * model.step) % model.totalLength; const w = Math.max(1, a.w); return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) }; } // Nearest point on the centerline: {s, d, laneT} where laneT is the signed // lateral offset in units of the local half-width (negative = left of travel). export function projectToSpline(model, x, y) { const { samples } = model; let ring = 1; let cands = nearbySampleIndices(model, x, y, ring); while (cands.length === 0 && ring < 40) { ring += ring; // expand until something is in range (far off-track queries) cands = nearbySampleIndices(model, x, y, ring); } let best = null; for (const i of cands) { const r = segDist(samples, i, x, y); if (!best || r.d < best.d) best = { ...r, i }; } if (!best) return { s: 0, d: Infinity, laneT: 0 }; const a = samples[best.i]; const cross = a.tx * (y - best.py) - a.ty * (x - best.px); const s = (a.s + best.t * model.step) % model.totalLength; const w = Math.max(1, a.w); return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) }; } function buildSurfaceGrid(model, json) { const grid = new Uint8Array(GRID_CELLS * GRID_CELLS).fill(SURFACE.OFFROAD); const cs = model.world / GRID_CELLS; const maxW = model.samples.reduce((m, p) => Math.max(m, p.w), 0) + CURB_WIDTH; const reach = Math.ceil((maxW + cs) / 64) + 1; for (let gy = 0; gy < GRID_CELLS; gy += 1) { for (let gx = 0; gx < GRID_CELLS; gx += 1) { const x = (gx + 0.5) * cs; const y = (gy + 0.5) * cs; let best = null; for (const i of nearbySampleIndices(model, x, y, reach)) { const r = segDist(model.samples, i, x, y); if (!best || r.d < best.d) best = { ...r, i }; } if (!best) continue; const a = model.samples[best.i]; const b = model.samples[(best.i + 1) % model.samples.length]; const w = a.w + (b.w - a.w) * best.t; if (best.d < w) grid[gy * GRID_CELLS + gx] = SURFACE.ROAD; else if (best.d < w + CURB_WIDTH) grid[gy * GRID_CELLS + gx] = SURFACE.CURB; } } // Painted patches only override terrain — the road stays drivable. const paint = (test, code) => { for (let gy = 0; gy < GRID_CELLS; gy += 1) { for (let gx = 0; gx < GRID_CELLS; gx += 1) { const idx = gy * GRID_CELLS + gx; if (grid[idx] !== SURFACE.OFFROAD) continue; if (test((gx + 0.5) * cs, (gy + 0.5) * cs)) grid[idx] = code; } } }; for (const surf of json.surfaces ?? []) { const code = surf.type === 'water' ? SURFACE.WATER : SURFACE.DEEP; if (surf.poly) paint((x, y) => pointInPoly(surf.poly, x, y), code); else if (surf.circle) { const [cx, cy, r] = surf.circle; paint((x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r, code); } } // Boost pads stamp BOOST onto road cells around their road point. for (const boost of model.boosts) { const r = Math.ceil(24 / cs); const gx0 = Math.floor(boost.x / cs); const gy0 = Math.floor(boost.y / cs); for (let gy = gy0 - r; gy <= gy0 + r; gy += 1) { for (let gx = gx0 - r; gx <= gx0 + r; gx += 1) { if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue; const idx = gy * GRID_CELLS + gx; if (grid[idx] === SURFACE.ROAD) grid[idx] = SURFACE.BOOST; } } } // Wall polylines rasterize as thick impassable bands (override everything). for (const wall of json.walls ?? []) { const pts = wall.pts ?? []; const end = wall.closed ? pts.length : pts.length - 1; for (let i = 0; i < end; i += 1) { const [ax, ay] = pts[i]; const [bx, by] = pts[(i + 1) % pts.length]; const len = Math.hypot(bx - ax, by - ay); const steps = Math.max(1, Math.ceil(len / (cs * 0.5))); for (let k = 0; k <= steps; k += 1) { const x = ax + ((bx - ax) * k) / steps; const y = ay + ((by - ay) * k) / steps; const gx = Math.floor(x / cs); const gy = Math.floor(y / cs); if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue; grid[gy * GRID_CELLS + gx] = SURFACE.WALL; } } } return grid; } export function buildTrackModel(json) { const world = json.world ?? 4096; const pts = json.spline.map((p) => ({ x: p.x, y: p.y, w: p.w })); const { samples, totalLength, step, ctrlS } = resample(pts); const model = { id: json.id, name: json.name ?? json.id, theme: json.theme ?? 'speedway', laps: json.laps ?? 5, world, samples, totalLength, step, json, }; model._hash = buildHash(samples, world); // Start line + grid. startIndex picks the control point the line sits at. const startS = ctrlS[Math.min(json.startIndex ?? 0, pts.length - 1)] % totalLength; const sp = sampleAt(model, startS); model.startS = startS; model.startPose = { x: sp.x, y: sp.y, heading: Math.atan2(sp.ty, sp.tx) }; model.gridSlots = []; for (let i = 0; i < 9; i += 1) { const row = Math.floor(i / 2); const lane = i % 2 === 0 ? -0.4 : 0.4; const s = ((startS - 70 - row * 46) % totalLength + totalLength) % totalLength; const p = roadPoint(model, s, lane); model.gridSlots.push({ x: p.x, y: p.y, heading: Math.atan2(p.ty, p.tx), s }); } // Checkpoints: ordered gates the sim uses for lap validation, positions, // and AI targets. Index 0 sits on the start line. const cpCount = Math.max(8, Math.round(totalLength / CHECKPOINT_STEP)); model.checkpoints = []; for (let i = 0; i < cpCount; i += 1) { const s = (startS + (i * totalLength) / cpCount) % totalLength; const p = sampleAt(model, s); model.checkpoints.push({ x: p.x, y: p.y, tx: p.tx, ty: p.ty, w: p.w, s }); } // Item boxes from itemRows {s, count}: a row of boxes across the road. model.itemBoxes = []; for (const row of json.itemRows ?? []) { const count = Math.max(1, row.count ?? 4); for (let j = 0; j < count; j += 1) { const lane = count === 1 ? 0 : -0.6 + (1.2 * j) / (count - 1); const p = roadPoint(model, row.s, lane); model.itemBoxes.push({ x: p.x, y: p.y, s: row.s, lane }); } } // Boost pads {s, lane} → world points (surface stamping happens below). model.boosts = (json.boosts ?? []).map((bp) => { const p = roadPoint(model, bp.s, bp.lane ?? 0); return { x: p.x, y: p.y, angle: Math.atan2(p.ty, p.tx), s: bp.s, lane: bp.lane ?? 0 }; }); model.coins = (json.coins ?? []).map((c) => ({ x: c.x, y: c.y })); model.hazards = (json.hazards ?? []).map((hz) => ({ ...hz })); model.decor = (json.decor ?? []).map((d) => ({ ...d })); model.surfaceGrid = buildSurfaceGrid(model, json); return model; } // ── Validation (shared by the editor strip, the generator, and verify) ────── function segsIntersect(a, b, c, d) { const orient = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x)); return orient(a, b, c) !== orient(a, b, d) && orient(c, d, a) !== orient(c, d, b) && orient(a, b, c) !== 0 && orient(c, d, a) !== 0; } export function validateTrack(model) { const issues = []; const { samples } = model; const n = samples.length; if (model.json.spline.length < 4) issues.push('spline needs at least 4 control points'); // Self-intersection: non-adjacent centerline segments must not cross. outer: for (let i = 0; i < n; i += 1) { for (let j = i + 2; j < n; j += 1) { if (i === 0 && j === n - 1) continue; // loop closure adjacency if (segsIntersect(samples[i], samples[(i + 1) % n], samples[j], samples[(j + 1) % n])) { issues.push(`centerline crosses itself near s=${Math.round(samples[i].s)}`); break outer; } } } // Width sanity + turn radius vs width: a hairpin tighter than the road is // wide folds the ribbon over itself. for (let i = 0; i < n; i += 1) { const p = samples[i]; if (p.w < 40) { issues.push(`road too narrow (${Math.round(p.w)}) at s=${Math.round(p.s)}`); break; } } for (let i = 0; i < n; i += 1) { const a = samples[(i - 1 + n) % n]; const b = samples[i]; const c = samples[(i + 1) % n]; const angle = Math.abs(normAngle(Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x))); const radius = angle > 1e-4 ? model.step / angle : Infinity; if (radius < b.w * 0.8) { issues.push(`turn tighter than road width at s=${Math.round(b.s)} (radius ${Math.round(radius)} vs width ${Math.round(b.w)})`); break; } } // Track must fit inside the world with margin for curbs. for (const p of samples) { const m = p.w + CURB_WIDTH + 16; if (p.x < m || p.y < m || p.x > model.world - m || p.y > model.world - m) { issues.push(`track leaves the world near s=${Math.round(p.s)}`); break; } } return issues; } export function normAngle(a) { let r = a % TAU; if (r > Math.PI) r -= TAU; if (r < -Math.PI) r += TAU; return r; }