fertig-classic-games/src/games/superkart/SuperKartLogic.js

902 lines
34 KiB
JavaScript

// Super Kart headless race simulation. Pure ESM with no Phaser imports so
// tools/verifySuperKart.js can soak-test full races in Node. All tuning comes
// from data/superkart-rules.json; all track geometry from SuperKartTrack.js.
//
// The scene drives it at a fixed 60Hz:
// const state = createRace({ trackModel, rules, engineClass, racers, playerIndex, mode, seed });
// step(state, playerInputs); // playerInputs from kartInputsNeutral()
// ...then reads state.karts / state.events for rendering.
import {
SURFACE, surfaceAt, projectToSplineNear, normAngle,
} from './SuperKartTrack.js';
const TAU = Math.PI * 2;
export const STEP_MS = 1000 / 60;
export const COUNTDOWN_MS = 3400;
// Once the player finishes, the field is given this long (sim time) to finish
// too before the race is forced to end — a safety net against a stuck AI,
// not the normal path (state.karts.every(finished) ends it sooner).
const POST_FINISH_SAFETY_MS = 90000;
export function mulberry32(seed) {
let a = seed >>> 0;
return function rng() {
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;
};
}
export function kartInputsNeutral() {
return { steer: 0, accel: false, brake: false, hop: false, item: false };
}
// ── Stat formulas (exported for the verify script and select-screen bars) ───
export function topSpeedOf(stats, physics, classMult = 1) {
return (physics.baseTopSpeed + (stats.topSpeed - 0.5) * physics.topSpeedSpread) * classMult;
}
export function accelOf(stats, physics) {
return physics.baseAccel + (stats.accel - 0.5) * physics.accelSpread;
}
export function turnRateOf(stats, physics, speedFrac) {
const base = physics.turnRateBase + (stats.handling - 0.5) * physics.turnRateSpread;
return base * (1 - physics.speedTurnPenalty * Math.min(1, Math.max(0, speedFrac)));
}
export function offroadMultOf(stats, physics) {
return physics.offroadMultMin + (physics.offroadMultMax - physics.offroadMultMin) * stats.offroad;
}
export function surfaceMult(code, stats, physics) {
if (code === SURFACE.OFFROAD) return offroadMultOf(stats, physics);
if (code === SURFACE.DEEP) return physics.deepMult;
return 1; // road, curb, and boost pads are all full speed
}
export function pointsFor(position, rules) {
return rules.pointsTable[Math.min(position - 1, rules.pointsTable.length - 1)] ?? 0;
}
// Position-weighted item roulette. position is 1-based.
export function rollItem(rng, position, rules) {
const col = Math.max(0, Math.min(8, position - 1));
const table = rules.itemWeights.byPosition;
const ids = rules.items.map((it) => it.id).filter((id) => table[id]);
let total = 0;
for (const id of ids) total += table[id][col];
let roll = rng() * total;
for (const id of ids) {
roll -= table[id][col];
if (roll <= 0) return id;
}
return ids[ids.length - 1];
}
// ── Race construction ───────────────────────────────────────────────────────
function makeKart(index, racer, isPlayer, slot, physics) {
return {
index,
racer,
isPlayer,
aiSkill: racer.aiSkill ?? 0.5,
x: slot.x,
y: slot.y,
heading: slot.heading,
velAngle: slot.heading, // velocity direction chases heading (drift feel)
speed: 0,
hopMs: 0,
hopCooldown: 0,
airborne: false,
drifting: false,
driftDir: 0,
driftChargeMs: 0, // mini-turbo charge accrued while committed to the slide
prevHop: false,
prevItem: false,
surface: SURFACE.ROAD,
offroadMs: 0,
spinMs: 0,
spinSpin: 0, // visual rotation accumulator while spinning
squashMs: 0,
invulnMs: 0,
boostMs: 0,
boostMult: 1,
starMs: 0,
empMs: 0,
wallHitCooldown: 0,
coins: 0,
item: null,
rouletteMs: 0,
lap: 1,
cpIndex: 0,
splineS: slot.s, // continuity anchor for windowed spline projection
sRel: 0,
progress: 0,
position: index + 1,
finished: false,
finishTimeMs: 0,
lapStartMs: 0,
bestLapMs: 0,
rescueMs: 0,
cpMissMs: 0, // blink/banner window after a missed-checkpoint correction
stuckMs: 0,
reverseMs: 0,
deepMs: 0,
aiLane: 0,
aiLaneLap: 0,
aiItemDelay: 0,
radius: physics.kartRadius,
};
}
// racers: array of racer defs (from superkart-racers.json), one per grid slot,
// in grid order. playerIndex marks which of them the human drives (-1 = none,
// e.g. verify soaks). mode 'tt' skips AI karts entirely.
export function createRace({ trackModel, rules, engineClass, racers, playerIndex = 0, mode = 'gp', seed = 1 }) {
const rng = mulberry32(seed);
const physics = rules.physics;
const roster = mode === 'tt' ? [racers[playerIndex]] : racers;
const pIdx = mode === 'tt' ? 0 : playerIndex;
const state = {
mode,
rules,
physics,
engineClass,
model: trackModel,
laps: trackModel.laps,
rng,
seed,
tick: 0,
timeMs: 0,
phase: 'countdown',
countdownMs: COUNTDOWN_MS,
playerIndex: pIdx,
karts: roster.map((r, i) => makeKart(i, r, i === pIdx, trackModel.gridSlots[i], physics)),
itemBoxes: trackModel.itemBoxes.map((b) => ({ x: b.x, y: b.y, respawnAt: 0 })),
coins: trackModel.coins.map((c) => ({ x: c.x, y: c.y, taken: false })),
hazardsDropped: [],
projectiles: [],
finishOrder: [],
events: [],
};
for (const k of state.karts) {
k.aiLane = (rng() * 2 - 1) * 0.5;
k.aiItemDelay = 400 + rng() * 1200;
}
return state;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function emit(state, type, data) {
state.events.push({ type, ...data });
}
function classSpeedMult(state) { return state.engineClass?.speedMult ?? 1; }
// Gap-to-player rubber-banding: AI karts trailing the player get a top-speed
// boost, karts ahead get held back, scaled to 0 at the player's own
// progress and saturating at +/-rubberBandRange world units of gap. Engine
// class scales the whole effect (rubberBandMult) — Cruiser leans on this
// hard for forgiving rivals, Turbo barely uses it to stay ruthless.
function rubberBandMultOf(state, kart) {
if (kart.isPlayer) return 1;
const player = state.karts[state.playerIndex];
if (!player?.isPlayer) return 1;
const { physics } = state;
const gain = physics.rubberBandGain * (state.engineClass?.rubberBandMult ?? 1);
if (gain <= 0) return 1;
const gap = player.progress - kart.progress; // + = kart trails the player
const t = Math.max(-1, Math.min(1, gap / physics.rubberBandRange));
return 1 + gain * t;
}
function effTopSpeed(state, kart) {
const { physics } = state;
let mult = classSpeedMult(state) * rubberBandMultOf(state, kart);
mult *= 1 + kart.coins * physics.coinTopSpeedBonus;
if (kart.starMs > 0) mult *= itemParams(state, 'overdrive').mult ?? 1.2;
if (kart.empMs > 0) mult *= itemParams(state, 'emp').mult ?? 0.6;
if (kart.squashMs > 0) mult *= physics.squashSpeedMult;
if (!kart.airborne && kart.starMs <= 0) {
mult *= surfaceMult(kart.surface, kart.racer.stats, physics);
}
if (kart.boostMs > 0) mult *= kart.boostMult;
return topSpeedOf(kart.racer.stats, physics, mult);
}
function itemParams(state, id) {
return state.rules.items.find((it) => it.id === id)?.params ?? {};
}
function respawnAtCheckpoint(state, kart) {
const cps = state.model.checkpoints;
const cp = cps[kart.cpIndex % cps.length];
kart.x = cp.x;
kart.y = cp.y;
kart.heading = Math.atan2(cp.ty, cp.tx);
kart.velAngle = kart.heading;
kart.speed = 0;
kart.drifting = false;
kart.driftChargeMs = 0;
kart.airborne = false;
kart.hopMs = 0;
kart.offroadMs = 0;
kart.deepMs = 0;
kart.splineS = cp.s;
kart.invulnMs = state.physics.invulnMs;
}
function startSpin(state, kart, cause) {
if (kart.invulnMs > 0 || kart.starMs > 0 || kart.spinMs > 0) return false;
kart.spinMs = state.physics.spinMs;
kart.spinSpin = 0;
kart.drifting = false;
kart.driftChargeMs = 0;
const dropped = Math.min(kart.coins, state.physics.coinsLostOnHit);
kart.coins -= dropped;
emit(state, 'spin', { kart: kart.index, cause });
return true;
}
// ── AI ──────────────────────────────────────────────────────────────────────
function aiSkillOf(state, kart) {
return Math.min(1, kart.aiSkill * (state.engineClass?.aiSkillMult ?? 1));
}
function computeAiInputs(state, kart) {
const inputs = kartInputsNeutral();
// Finished karts (including the autopiloted player) keep actively driving
// laps through the results/standings screens rather than coasting to a
// stop — the scene only stops stepping this state once it tears the race
// down (next race start, or the cup-final podium).
const { model, physics } = state;
const skill = aiSkillOf(state, kart);
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
const top = effTopSpeed(state, kart);
const speedFrac = top > 0 ? kart.speed / top : 0;
const lookahead = Math.round(
physics.aiLookaheadMin + (physics.aiLookaheadMax - physics.aiLookaheadMin)
* Math.min(1, speedFrac) * (0.7 + 0.3 * skill),
);
const target = model.samples[(idx + Math.max(2, lookahead)) % model.samples.length];
const tx = target.x + -target.ty * kart.aiLane * target.w * 0.8;
const ty = target.y + target.tx * kart.aiLane * target.w * 0.8;
let want = Math.atan2(ty - kart.y, tx - kart.x);
if (kart.reverseMs > 0) {
// Backing out of a stuck spot: reverse while steering toward the target.
inputs.brake = true;
inputs.steer = Math.max(-1, Math.min(1, -normAngle(want - kart.heading)));
return inputs;
}
// Steering noise shrinks with skill.
want += (state.rng() * 2 - 1) * 0.06 * (1 - skill);
inputs.steer = Math.max(-1, Math.min(1, physics.aiSteerGain * normAngle(want - kart.heading)));
// Brake when the curvature over the lookahead window outruns our speed.
let curv = 0;
for (let i = 0; i < lookahead; i += 1) {
const a = model.samples[(idx + i) % model.samples.length];
const b = model.samples[(idx + i + 1) % model.samples.length];
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
}
const curvPerUnit = curv / Math.max(1, lookahead * model.step);
const limit = physics.aiBrakeCurvature * (0.8 + 0.6 * skill);
inputs.accel = true;
if (curvPerUnit * kart.speed > limit) {
inputs.accel = false;
if (curvPerUnit * kart.speed > limit * 1.6) inputs.brake = true;
}
// Item use.
if (kart.item && kart.rouletteMs <= 0) {
kart.aiItemDelay -= STEP_MS;
if (kart.aiItemDelay <= 0) {
inputs.item = aiWantsItem(state, kart);
if (inputs.item) kart.aiItemDelay = 600 + state.rng() * 1400;
}
}
return inputs;
}
function aiWantsItem(state, kart) {
const straight = isOnStraight(state, kart);
switch (kart.item) {
case 'bolt':
case 'seeker': {
const target = nextKartAhead(state, kart);
if (!target) return kart.item === 'bolt' && state.rng() < 0.002;
const dist = Math.hypot(target.x - kart.x, target.y - kart.y);
return dist < 700;
}
case 'oil': return straight && state.rng() < 0.02;
case 'turbo': return straight && kart.boostMs <= 0;
case 'overdrive': return true;
case 'emp': return kart.position > 3;
case 'coins': return true;
default: return false;
}
}
function isOnStraight(state, kart) {
const { model } = state;
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
let curv = 0;
for (let i = 0; i < 6; i += 1) {
const a = model.samples[(idx + i) % model.samples.length];
const b = model.samples[(idx + i + 1) % model.samples.length];
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
}
return curv < 0.25;
}
function nextKartAhead(state, kart) {
let best = null;
for (const other of state.karts) {
if (other === kart || other.finished) continue;
if (other.progress <= kart.progress) continue;
if (!best || other.progress < best.progress) best = other;
}
return best;
}
// ── Per-kart physics step ───────────────────────────────────────────────────
function stepKart(state, kart, inputs, dt) {
const { physics, model } = state;
for (const key of ['spinMs', 'squashMs', 'invulnMs', 'boostMs', 'starMs', 'empMs', 'hopCooldown', 'wallHitCooldown', 'cpMissMs']) {
if (kart[key] > 0) kart[key] = Math.max(0, kart[key] - STEP_MS);
}
if (kart.rescueMs > 0) {
kart.rescueMs -= STEP_MS;
if (kart.rescueMs <= 0) respawnAtCheckpoint(state, kart);
return;
}
const locked = state.phase === 'countdown' || kart.spinMs > 0;
const steer = locked ? 0 : Math.max(-1, Math.min(1, inputs.steer || 0));
const accel = locked ? false : !!inputs.accel;
const brake = locked ? false : !!inputs.brake;
// Hop / powerslide.
if (!locked && inputs.hop && !kart.prevHop && kart.hopMs <= 0 && kart.hopCooldown <= 0 && !kart.airborne) {
kart.hopMs = physics.hopMs;
kart.airborne = true;
kart.hopCooldown = physics.hopMs + physics.hopCooldownMs;
if (kart.drifting) kart.drifting = false;
emit(state, 'hop', { kart: kart.index });
}
kart.prevHop = !!inputs.hop;
if (kart.airborne) {
kart.hopMs -= STEP_MS;
if (kart.hopMs <= 0) {
kart.airborne = false;
// Landing with hop held and a direction committed starts the powerslide.
if (inputs.hop && steer !== 0 && kart.speed > effTopSpeed(state, kart) * 0.3) {
kart.drifting = true;
kart.driftDir = Math.sign(steer);
kart.driftChargeMs = 0;
emit(state, 'drift-start', { kart: kart.index });
}
}
}
if (kart.drifting) {
kart.driftChargeMs += STEP_MS;
const top = effTopSpeed(state, kart);
const speedDropped = kart.speed < top * 0.25;
const steerReversed = steer !== 0 && Math.sign(steer) !== kart.driftDir;
if (!inputs.hop || speedDropped || steerReversed) {
kart.drifting = false;
// Mini-turbo: only a clean release (hop let go, not a stall/reversal
// cutting the slide short) with enough committed charge pays out —
// stronger the longer the slide, capped at miniTurboMaxMs.
if (!speedDropped && !steerReversed && kart.driftChargeMs >= physics.miniTurboMinMs) {
const span = Math.max(1, physics.miniTurboMaxMs - physics.miniTurboMinMs);
const t = Math.min(1, (kart.driftChargeMs - physics.miniTurboMinMs) / span);
kart.boostMs = physics.miniTurboBoostMs;
kart.boostMult = physics.miniTurboBoostMultMin
+ (physics.miniTurboBoostMultMax - physics.miniTurboBoostMultMin) * t;
emit(state, 'boost', { kart: kart.index, pad: false });
}
kart.driftChargeMs = 0;
emit(state, 'drift-end', { kart: kart.index });
}
}
// Longitudinal speed.
const top = effTopSpeed(state, kart);
if (kart.spinMs > 0) {
kart.speed = Math.max(0, kart.speed - physics.brakeDecel * 0.8 * dt);
kart.spinSpin += dt * 9;
} else if (accel && !brake) {
kart.speed += accelOf(kart.racer.stats, physics) * classSpeedMult(state) * dt;
} else if (brake) {
kart.speed -= physics.brakeDecel * dt;
if (kart.speed < 0) kart.speed = Math.max(kart.speed, -physics.reverseTopSpeed);
} else {
kart.speed -= kart.speed * physics.coastDrag * dt;
}
if (kart.speed > top) {
// Ease down toward the cap (boost expiry / entering grass) instead of snapping.
kart.speed = Math.max(top, kart.speed - physics.brakeDecel * 1.5 * dt);
}
if (kart.speed < -physics.reverseTopSpeed) kart.speed = -physics.reverseTopSpeed;
// Steering. Drifting multiplies the turn rate in the committed direction.
if (kart.spinMs <= 0 && Math.abs(kart.speed) > 1) {
const baseTop = topSpeedOf(kart.racer.stats, physics, classSpeedMult(state));
let rate = turnRateOf(kart.racer.stats, physics, Math.abs(kart.speed) / baseTop);
if (kart.drifting && Math.sign(steer || kart.driftDir) === kart.driftDir) {
rate *= physics.driftTurnMult;
}
const dir = kart.speed >= 0 ? 1 : -1;
kart.heading = normAngle(kart.heading + steer * rate * dir * dt);
}
// Velocity direction chases the heading; grip is what separates a clean
// line from a powerslide. High drift stat = tighter, more controlled slides.
const grip = kart.drifting
? 2.2 + 3.2 * kart.racer.stats.drift
: physics.slipDecay + 5.5;
kart.velAngle = normAngle(kart.velAngle + normAngle(kart.heading - kart.velAngle) * Math.min(1, grip * dt));
// Integrate + wall handling (axis-separated slide).
const vx = Math.cos(kart.velAngle) * kart.speed * dt;
const vy = Math.sin(kart.velAngle) * kart.speed * dt;
const blocked = (x, y) => {
const c = surfaceAt(model, x, y);
return c === SURFACE.WALL || c === SURFACE.OOB;
};
if (!blocked(kart.x + vx, kart.y + vy)) {
kart.x += vx;
kart.y += vy;
} else if (!blocked(kart.x + vx, kart.y)) {
kart.x += vx;
hitWall(state, kart);
} else if (!blocked(kart.x, kart.y + vy)) {
kart.y += vy;
hitWall(state, kart);
} else {
hitWall(state, kart);
kart.speed = 0;
}
// Surface effects.
kart.surface = surfaceAt(model, kart.x, kart.y);
if (!kart.airborne) {
if (kart.surface === SURFACE.BOOST && kart.boostMs <= 0) {
kart.boostMs = physics.boostPadMs;
kart.boostMult = physics.boostPadMult;
emit(state, 'boost', { kart: kart.index, pad: true });
}
if (kart.surface === SURFACE.WATER) {
if (kart.rescueMs <= 0) {
kart.rescueMs = physics.respawnMs;
emit(state, 'splash', { kart: kart.index });
}
} else if (kart.surface === SURFACE.DEEP) {
kart.deepMs += STEP_MS;
if (kart.deepMs > 700 && kart.rescueMs <= 0) {
kart.deepMs = 0;
kart.rescueMs = physics.respawnMs;
emit(state, 'sink', { kart: kart.index });
}
} else {
kart.deepMs = 0;
}
if (kart.surface === SURFACE.OFFROAD) kart.offroadMs += STEP_MS;
else kart.offroadMs = 0;
if (kart.offroadMs > physics.offroadRescueMs) {
kart.offroadMs = 0;
kart.rescueMs = physics.respawnMs;
emit(state, 'rescue', { kart: kart.index });
}
}
// Stuck detection feeds the AI reverse-out behavior.
if (!kart.isPlayer) {
if (Math.abs(kart.speed) < 25 && state.phase === 'racing' && kart.spinMs <= 0 && !kart.finished) {
kart.stuckMs += STEP_MS;
} else kart.stuckMs = 0;
if (kart.reverseMs > 0) kart.reverseMs -= STEP_MS;
if (kart.stuckMs > physics.aiStuckMs && kart.reverseMs <= 0) {
kart.stuckMs = 0;
kart.reverseMs = physics.aiReverseMs;
kart.reverseCount = (kart.reverseCount ?? 0) + 1;
if (kart.reverseCount % 3 === 0) {
// Three failed reverse-outs: give up and take the rescue.
kart.rescueMs = physics.respawnMs;
emit(state, 'rescue', { kart: kart.index });
}
}
}
// Lap / checkpoint / progress tracking (windowed projection keeps pinched
// track sections from flipping a kart onto the wrong branch).
const proj = projectToSplineNear(model, kart.x, kart.y, kart.splineS);
kart.splineS = proj.s;
const L = model.totalLength;
let sRel = ((proj.s - model.startS) % L + L) % L;
kart.sRel = sRel;
const cps = model.checkpoints.length;
const cpSize = L / cps;
const cp = Math.floor(sRel / cpSize) % cps;
// How far ahead of the last validated gate we are, going forward round the
// ring. 0 = same gate, 1 = the normal advance. A big forward jump means the
// kart shortcut across geometry and the spline projection snapped branches;
// anything past the halfway mark is the kart *behind* its own cpIndex
// (driving backwards, or sitting on the pre-start grid) and is left alone.
const delta = (cp - kart.cpIndex + cps) % cps;
if (delta >= physics.checkpointMissSkip && delta < cps / 2) {
// Missed checkpoint: stop the player dead and put them back on the last
// gate they legitimately crossed — i.e. just behind the one they skipped —
// instead of leaving the lap chain silently stalled until they guess which
// gate it was and drive backwards to it. cpIndex is deliberately untouched,
// so the next step reads delta 0 and this can't retrigger in a loop; a miss
// straddling the start line still owes the player the line crossing.
if (kart.isPlayer && state.phase === 'racing' && !kart.finished
&& kart.cpMissMs <= 0 && kart.rescueMs <= 0) {
const missed = (kart.cpIndex + 1) % cps;
respawnAtCheckpoint(state, kart);
kart.cpMissMs = physics.checkpointMissMs;
// Re-derive arc-length from the teleported anchor so this frame's
// progress reflects where the kart actually is, not the skipped-to spot.
sRel = ((kart.splineS - model.startS) % L + L) % L;
kart.sRel = sRel;
emit(state, 'checkpoint-miss', { kart: kart.index, checkpoint: missed });
}
} else if (delta === 1) {
kart.cpIndex = cp;
if (cp === 0) {
kart.lap += 1;
const lapMs = state.timeMs - kart.lapStartMs;
if (kart.bestLapMs === 0 || lapMs < kart.bestLapMs) kart.bestLapMs = lapMs;
kart.lapStartMs = state.timeMs;
if (kart.lap > state.laps && !kart.finished) {
kart.finished = true;
kart.finishTimeMs = state.timeMs;
state.finishOrder.push(kart.index);
emit(state, 'finish', { kart: kart.index, place: state.finishOrder.length });
} else if (!kart.finished) {
emit(state, 'lap', { kart: kart.index, lap: kart.lap });
}
}
}
const within = Math.max(0, Math.min(sRel - kart.cpIndex * cpSize, 2 * cpSize));
kart.progress = (kart.lap - 1) * L + kart.cpIndex * cpSize + within;
// Item pickup + roulette.
if (kart.rouletteMs > 0) {
kart.rouletteMs -= STEP_MS;
if (kart.rouletteMs <= 0) {
kart.item = rollItem(state.rng, kart.position, state.rules);
emit(state, 'item-get', { kart: kart.index, item: kart.item });
}
} else if (!kart.item && state.mode !== 'tt') {
for (const box of state.itemBoxes) {
if (box.respawnAt > state.timeMs) continue;
const dx = box.x - kart.x;
const dy = box.y - kart.y;
const r = physics.itemBoxRadius + kart.radius;
if (dx * dx + dy * dy < r * r) {
box.respawnAt = state.timeMs + physics.itemBoxRespawnMs;
kart.rouletteMs = physics.rouletteMs;
emit(state, 'item-box', { kart: kart.index });
break;
}
}
}
// Coins.
for (const coin of state.coins) {
if (coin.taken || kart.coins >= physics.maxCoins) continue;
const dx = coin.x - kart.x;
const dy = coin.y - kart.y;
const r = physics.coinPickupRadius;
if (dx * dx + dy * dy < r * r) {
coin.taken = true;
kart.coins += 1;
emit(state, 'coin', { kart: kart.index });
}
}
// Dropped hazards (oil slicks).
for (const hz of state.hazardsDropped) {
if (hz.dead) continue;
if (hz.owner === kart.index && hz.armMs > 0) continue; // just dropped it
const dx = hz.x - kart.x;
const dy = hz.y - kart.y;
const r = hz.radius + kart.radius;
if (dx * dx + dy * dy < r * r && startSpin(state, kart, 'oil')) hz.dead = true;
}
// Item use (edge-triggered).
if (inputs.item && !kart.prevItem && kart.item && kart.rouletteMs <= 0 && !locked) {
useItem(state, kart);
}
kart.prevItem = !!inputs.item;
}
function hitWall(state, kart) {
if (kart.wallHitCooldown > 0) return;
kart.wallHitCooldown = 250;
kart.speed *= 1 - state.physics.wallBumpSpeedLoss;
emit(state, 'wallhit', { kart: kart.index });
}
// ── Items ───────────────────────────────────────────────────────────────────
function useItem(state, kart) {
const id = kart.item;
const params = itemParams(state, id);
kart.item = null;
emit(state, 'item-use', { kart: kart.index, item: id });
switch (id) {
case 'bolt':
case 'seeker': {
const target = id === 'seeker' ? nextKartAhead(state, kart) : null;
state.projectiles.push({
id,
owner: kart.index,
x: kart.x + Math.cos(kart.heading) * (kart.radius + 12),
y: kart.y + Math.sin(kart.heading) * (kart.radius + 12),
heading: kart.heading,
speed: params.speed ?? 850,
bounces: params.bounces ?? 0,
homing: !!params.homing,
target: target ? target.index : -1,
lifeMs: params.lifeMs ?? 7000,
radius: params.radius ?? 10,
graceMs: 350, // can't hit its own thrower immediately
dead: false,
});
break;
}
case 'oil':
state.hazardsDropped.push({
id: 'oil',
owner: kart.index,
x: kart.x - Math.cos(kart.heading) * (kart.radius + 16),
y: kart.y - Math.sin(kart.heading) * (kart.radius + 16),
radius: params.radius ?? 18,
armMs: 600,
dead: false,
});
break;
case 'turbo':
kart.boostMs = params.ms ?? 1400;
kart.boostMult = params.mult ?? 1.5;
emit(state, 'boost', { kart: kart.index, pad: false });
break;
case 'overdrive':
kart.starMs = params.ms ?? 7000;
emit(state, 'star', { kart: kart.index });
break;
case 'emp': {
const targets = [];
for (const other of state.karts) {
if (other === kart || other.finished) continue;
if (other.starMs > 0 || other.invulnMs > 0) continue;
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
other.drifting = false;
targets.push(other.index);
}
emit(state, 'emp', { kart: kart.index, targets });
break;
}
case 'coins':
kart.coins = Math.min(state.physics.maxCoins, kart.coins + (params.coins ?? 2));
emit(state, 'coin', { kart: kart.index, pack: true });
break;
default: break;
}
}
function stepProjectiles(state, dt) {
const { model } = state;
for (const p of state.projectiles) {
if (p.dead) continue;
p.lifeMs -= STEP_MS;
if (p.graceMs > 0) p.graceMs -= STEP_MS;
if (p.lifeMs <= 0) { p.dead = true; continue; }
if (p.homing && p.target >= 0) {
const t = state.karts[p.target];
if (t && !t.finished) {
const want = Math.atan2(t.y - p.y, t.x - p.x);
const rate = itemParams(state, 'seeker').homingTurnRate ?? 3.2;
const diff = normAngle(want - p.heading);
p.heading = normAngle(p.heading + Math.max(-rate * dt, Math.min(rate * dt, diff)));
}
}
const nx = p.x + Math.cos(p.heading) * p.speed * dt;
const ny = p.y + Math.sin(p.heading) * p.speed * dt;
const code = surfaceAt(model, nx, ny);
if (code === SURFACE.WALL || code === SURFACE.OOB) {
if (p.bounces > 0) {
p.bounces -= 1;
// Reflect off whichever axis is blocked (cheap but effective on the grid).
const xBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, nx, p.y));
const yBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, p.x, ny));
if (xBlocked) p.heading = normAngle(Math.PI - p.heading);
if (yBlocked || (!xBlocked && !yBlocked)) p.heading = normAngle(-p.heading);
emit(state, 'bounce', { x: p.x, y: p.y });
} else {
p.dead = true;
emit(state, 'shatter', { x: p.x, y: p.y });
continue;
}
} else {
p.x = nx;
p.y = ny;
}
for (const kart of state.karts) {
if (kart.finished) continue;
if (p.graceMs > 0 && kart.index === p.owner) continue;
const dx = kart.x - p.x;
const dy = kart.y - p.y;
const r = kart.radius + p.radius;
if (dx * dx + dy * dy < r * r) {
p.dead = true;
if (kart.starMs > 0 || kart.invulnMs > 0) emit(state, 'shatter', { x: p.x, y: p.y });
else startSpin(state, kart, p.id);
break;
}
}
}
if (state.tick % 60 === 0) {
state.projectiles = state.projectiles.filter((p) => !p.dead);
state.hazardsDropped = state.hazardsDropped.filter((h) => !h.dead);
}
for (const hz of state.hazardsDropped) if (hz.armMs > 0) hz.armMs -= STEP_MS;
}
// Kart-vs-kart bumping: separation shared by inverse weight, plus a shove.
function stepCollisions(state) {
const { karts, physics } = state;
for (let i = 0; i < karts.length; i += 1) {
for (let j = i + 1; j < karts.length; j += 1) {
const a = karts[i];
const b = karts[j];
if (a.finished || b.finished || a.rescueMs > 0 || b.rescueMs > 0) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const minDist = a.radius + b.radius;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const nx = dx / d;
const ny = dy / d;
const overlap = minDist - d;
const wa = 0.2 + a.racer.stats.weight;
const wb = 0.2 + b.racer.stats.weight;
const shareA = wb / (wa + wb);
const shareB = wa / (wa + wb);
a.x -= nx * overlap * shareA;
a.y -= ny * overlap * shareA;
b.x += nx * overlap * shareB;
b.y += ny * overlap * shareB;
// The lighter kart's velocity direction gets knocked away from the
// contact normal (a → b); the heavier one barely notices.
const kick = physics.bumpImpulse / 600;
const away = (kart, sign) => {
const cross = Math.cos(kart.velAngle) * ny * sign - Math.sin(kart.velAngle) * nx * sign;
return Math.sign(cross || 1);
};
a.velAngle = normAngle(a.velAngle + away(a, -1) * kick * shareA);
b.velAngle = normAngle(b.velAngle + away(b, 1) * kick * shareB);
// Star karts flatten whoever they touch.
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
// A heavy kart running over an EMP-shrunk one squashes it.
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) {
a.squashMs = physics.squashMs;
emit(state, 'squash', { kart: a.index });
} else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) {
b.squashMs = physics.squashMs;
emit(state, 'squash', { kart: b.index });
}
emit(state, 'bump', { a: a.index, b: b.index });
}
}
}
function updatePositions(state) {
const order = [...state.karts].sort((a, b) => {
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
if (af !== bf) return af - bf;
return b.progress - a.progress;
});
const prevPlayerPos = state.karts[state.playerIndex]?.position;
order.forEach((kart, i) => { kart.position = i + 1; });
const nowPlayerPos = state.karts[state.playerIndex]?.position;
if (prevPlayerPos !== undefined && nowPlayerPos !== prevPlayerPos && state.phase === 'racing') {
emit(state, 'player-position', { from: prevPlayerPos, to: nowPlayerPos });
}
}
// ── Public step ─────────────────────────────────────────────────────────────
export function step(state, playerInputs = kartInputsNeutral()) {
state.events = [];
const dt = STEP_MS / 1000;
state.tick += 1;
state.timeMs += STEP_MS;
if (state.phase === 'countdown') {
state.countdownMs -= STEP_MS;
if (state.countdownMs <= 0) {
state.phase = 'racing';
for (const k of state.karts) k.lapStartMs = state.timeMs;
emit(state, 'go', {});
}
}
for (const kart of state.karts) {
// Fresh racing line each lap keeps the AI field from single-filing.
// (Also applies once the player's kart is finished and autopiloted, so
// it doesn't robotically hug the centerline during the victory lap.)
if ((!kart.isPlayer || kart.finished) && kart.lap !== kart.aiLaneLap) {
kart.aiLaneLap = kart.lap;
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
}
// A finished player kart is handed off to the AI driver for the post-race
// vignette — computeAiInputs already works for any kart unmodified.
const inputs = (kart.isPlayer && !kart.finished) ? playerInputs : computeAiInputs(state, kart);
stepKart(state, kart, inputs, dt);
}
stepCollisions(state);
stepProjectiles(state, dt);
updatePositions(state);
if (state.phase === 'racing') {
const player = state.karts[state.playerIndex];
if (state.mode === 'tt') {
if (player?.finished) state.phase = 'finished';
} else if (state.karts.every((k) => k.finished)) {
state.phase = 'finished';
} else if (player?.finished) {
// Player done: wait for the rest of the field to finish too (the
// victory-lap vignette plays during this window), with a generous
// safety cap in case an AI kart never gets home.
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
if (state.postFinishMs > POST_FINISH_SAFETY_MS) state.phase = 'finished';
}
}
return state;
}
// Final standings. Unfinished karts are ranked by progress and given
// extrapolated finish times so the results table always fills in.
export function finalizeRace(state) {
const ranked = [...state.karts].sort((a, b) => {
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
if (af !== bf) return af - bf;
return b.progress - a.progress;
});
return ranked.map((kart, i) => ({
kartIndex: kart.index,
racerId: kart.racer.id,
position: i + 1,
finished: kart.finished,
timeMs: kart.finished ? kart.finishTimeMs : Math.round(state.timeMs + (i * 800)),
bestLapMs: kart.bestLapMs,
points: pointsFor(i + 1, state.rules),
}));
}