Files
leggenda-del-regolo/src/engine/gara.js
T

411 lines
25 KiB
JavaScript

// Il GP di Fortezza (idea di Andrea, 28 ottobre): la gara dei cinquantini sul giro delle mura della Fortezza di San
// Martino, vista dall'alto come le vecchie gare da sala giochi con tutta la pista in una schermata. Il tracciato segue
// la planimetria generale 1:500 che ha mandato Andrea (`assets/raw/fortezza-planimetria.png`), schiacciata nel
// formato del gioco (640x360): x = 90 + (x0 - 215)·0,7, y = 20 + (y0 - 42)·0,49.
//
// Si tiene premuto per il gas (mouse, dito, Spazio o ↑), si sterza con ← → (o indicando la direzione con uno swipe, o coi
// pulsanti), ↓ frena. Sulla pista: acceleratori, buche (andandoci forte si casca) e lattine di miscela da raccogliere.
// Fuori strada c'è l'erba che frena, e poi la paglia: chi ci arriva forte si ferma.
// La linea di mezzo della strada, in senso orario a partire dal traguardo sul rettilineo delle mura a levante: prima
// tracciata sulla planimetria, poi ricalcata sulla strada del quadro di Codex (assets/closeups/gp-fortezza.png).
export const CONTROL = [
[570, 210], [572, 222], [574, 233], [577, 245], [580, 256], [583, 268], [585, 280], [589, 291], [589, 303], [589, 315],
[585, 327], [576, 335], [565, 340], [553, 341], [541, 340], [529, 339], [518, 336], [506, 334], [495, 330], [483, 327],
[471, 324], [460, 321], [448, 318], [436, 316], [424, 315], [412, 314], [400, 314], [389, 317], [378, 320], [372, 331],
[363, 339], [352, 345], [340, 344], [328, 341], [317, 337], [305, 334], [294, 329], [284, 324], [273, 319], [262, 314],
[251, 308], [241, 303], [231, 296], [220, 291], [210, 284], [199, 280], [188, 274], [178, 269], [167, 263], [157, 256],
[148, 248], [143, 237], [147, 225], [149, 214], [148, 203], [142, 192], [135, 182], [127, 174], [118, 166], [110, 156],
[104, 146], [100, 135], [98, 123], [101, 112], [106, 100], [113, 91], [124, 86], [136, 81], [147, 77], [157, 70],
[165, 61], [173, 53], [185, 49], [197, 47], [208, 45], [220, 41], [232, 39], [243, 36], [255, 33], [266, 30],
[278, 26], [290, 24], [301, 22], [313, 20], [325, 20], [337, 19], [349, 20], [361, 23], [372, 26], [384, 29],
[396, 32], [407, 36], [418, 40], [430, 43], [441, 47], [453, 50], [464, 55], [475, 59], [486, 63], [498, 67],
[509, 71], [521, 74], [532, 76], [544, 75], [557, 77], [566, 85], [570, 96], [572, 108], [574, 120], [574, 132],
[574, 144], [573, 156], [573, 168], [572, 180], [571, 192], [570, 204],
];
export const LAPS = 3, STEP = 2, ROAD = 9, VERGE = 15, COUNTDOWN = 3, FIXED_STEP = 1 / 120;
// La presentazione prima del semaforo: uno alla volta i piloti arrivano in griglia col loro motorino (INTRO.each
// secondi ciascuno, da INTRO.from pixel di strada più indietro, in INTRO.ride secondi); un tocco passa al prossimo.
export const INTRO = { each: 4, ride: 1.8, from: 110, after: 1 };
// Sterzo (rad/s a velocità piena di sterzo), freno, rallentamento senza gas, e quanto si scivola di lato in curva.
export const TURN = 3.4, BRAKE = 150, COAST = 45, GRIP = 9, SLIDE = 0.5;
export const SWIPE = { dead: 10, reach: 60, gain: 2.6 };
export const BOOST = { speed: 1.35, time: 1.2 }, CAN = { time: 1.6, respawn: 6 }, HOLE = { fast: 45, out: 1.1 };
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
// I piloti sono dati (story.closeups.gara.riders, da piloti.js): `motore` da 1 (di serie) a 5 (truccato da far
// paura, 6 truccatissimo), `testa` da 1 (matto) a 5 (prudente). Ne vengono velocità massima e accelerazione in px/s, tenuta, e per gli
// altri quanto si fidano delle curve (`caution`) e quanto spesso rischiano troppo (`slip`).
export function rider({ motore = 3, testa = 3, ...r }) {
// Il 6 è il truccatissimo: lo SR di Puntella (Andrea, 28 ottobre).
const m = clamp(motore, 1, 6), t = clamp(testa, 1, 5);
return { ...r, motore: m, testa: t, vmax: 74 + 6.5 * m, accel: 40 + 5 * m, grip: 1.1 - 0.03 * m, caution: 1.0025 - 0.0125 * t, slip: 0.4275 - 0.0775 * t };
}
// Il gruppo di prova, quando la storia non ne dà uno.
export const RIDERS = [
{ id: 'andrea', name: 'Andrea', bike: 'il Fifty', motore: 3, testa: 3, color: '#d8402f', trim: '#1c1c1c' },
{ id: 'arcu', name: 'L\'Arcu', bike: 'il Betino', motore: 1, testa: 5, color: '#2f7a3a', trim: '#e8d9a0' },
];
// I livelli: quanto vanno gli altri, quanto si resta fermi nella paglia, e la guida disegnata in pista.
export const LEVELS = {
bona: { rivals: 0.78, stun: 0.4, guide: 'zone' },
seria: { rivals: 0.84, stun: 0.9, guide: 'coni' },
male: { rivals: 0.93, stun: 1.4, guide: null },
};
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return [0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3));
}
// Il tracciato campionato ogni STEP pixel: posizione, direzione, raggio di curva e velocità massima di ogni punto.
function buildTrack() {
const dense = [], n = CONTROL.length;
for (let i = 0; i < n; i++) {
const p = [CONTROL[(i - 1 + n) % n], CONTROL[i], CONTROL[(i + 1) % n], CONTROL[(i + 2) % n]];
for (let k = 0; k < 40; k++) dense.push(catmull(...p, k / 40));
}
const pts = [dense[0]];
let carry = 0;
for (let i = 1; i <= dense.length; i++) {
const a = dense[i - 1], b = dense[i % dense.length];
let seg = Math.hypot(b[0] - a[0], b[1] - a[1]), t = 0;
while (carry + seg >= STEP) {
const need = STEP - carry; t += need / Math.hypot(b[0] - a[0], b[1] - a[1]); seg -= need; carry = 0;
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
carry += seg;
}
if (Math.hypot(pts.at(-1)[0] - pts[0][0], pts.at(-1)[1] - pts[0][1]) < STEP * 0.5) pts.pop();
const m = pts.length, heading = pts.map((p, i) => { const q = pts[(i + 1) % m]; return Math.atan2(q[1] - p[1], q[0] - p[0]); });
const W = 6, radius = pts.map((_, i) => (2 * W * STEP) / Math.max(Math.abs(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])), 1e-6));
// A sterzo pieno si gira di TURN rad/s: in una curva di raggio R si passa al massimo a TURN·R.
const limit = radius.map(r => TURN * r);
// Da che parte gira (+1 destra, -1 sinistra).
const side = pts.map((_, i) => Math.sign(wrapAngle(heading[(i + W) % m] - heading[(i - W + m) % m])) || 1);
return { pts, heading, radius, limit, side, length: m * STEP };
}
export const TRACK = buildTrack();
const N = TRACK.pts.length;
const at = s => Math.floor((((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP) % N;
export const limitAt = s => TRACK.limit[at(s)];
// Il punto dello schermo a distanza s dalla partenza, spostato di lato (positivo: a destra della direzione di marcia).
export function pointAt(s, lane = 0) {
const f = (((s % TRACK.length) + TRACK.length) % TRACK.length) / STEP, i = Math.floor(f) % N, t = f - Math.floor(f);
const a = TRACK.pts[i], b = TRACK.pts[(i + 1) % N], h = TRACK.heading[i];
return { x: a[0] + (b[0] - a[0]) * t - Math.sin(h) * lane, y: a[1] + (b[1] - a[1]) * t + Math.cos(h) * lane, heading: h };
}
// Le curve vere (dove il Fifty a tutto gas non passa): per la guida, i coni e la paglia.
export const CURVES = (() => {
const out = [], v = rider({ motore: 3 }).vmax * 0.85;
let inside = false, start = 0, min = Infinity, apex = 0;
TRACK.limit.forEach((l, i) => {
if (l < v && !inside) { inside = true; start = i; min = l; apex = i; }
else if (l < v) { if (l < min) { min = l; apex = i; } }
else if (inside) { inside = false; out.push({ start: start * STEP, apex: apex * STEP, end: i * STEP, speed: min, side: TRACK.side[apex] }); }
});
if (inside) out.push({ start: start * STEP, apex: apex * STEP, end: TRACK.length, speed: min, side: TRACK.side[apex] });
return out;
})();
// Le cose in pista, a distanza dal traguardo (s) e di lato (lane): acceleratori e buche fissi, lattine che ricompaiono.
// Sui rettilinei, lontano dalle curve, perché si vedano arrivare.
function straights() {
const out = [];
let from = 0;
for (const k of [...CURVES, { start: TRACK.length + (CURVES[0]?.start ?? 0) }]) { if (k.start - from > 70) out.push([from + 20, k.start - 20]); from = k.end ?? from; }
return out;
}
export const PICKUPS = (() => {
const list = [], parts = straights();
parts.forEach(([a, b], i) => {
const mid = (a + b) / 2, len = b - a;
if (i % 3 === 0) list.push({ kind: 'boost', s: a + len * 0.3, lane: 0 });
else if (i % 3 === 1) list.push({ kind: 'buca', s: mid, lane: i % 2 ? 4 : -4 });
else list.push({ kind: 'miscela', s: mid, lane: i % 2 ? -4 : 4 });
if (len > 160) list.push({ kind: i % 2 ? 'buca' : 'miscela', s: a + len * 0.75, lane: i % 2 ? -4 : 4 });
});
return list.map((p, k) => ({ ...p, s: ((p.s % TRACK.length) + TRACK.length) % TRACK.length, id: k, ...pointAt(p.s, p.lane) }));
})();
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
// La posizione sulla strada: il punto del tracciato più vicino (cercato vicino all'ultimo, così non si salta di lato).
function locate(r) {
let best = r.idx, bd = Infinity;
for (let d = -12; d <= 24; d++) {
const i = (r.idx + d + N) % N, p = TRACK.pts[i], q = (p[0] - r.x) ** 2 + (p[1] - r.y) ** 2;
if (q < bd) { bd = q; best = i; }
}
let delta = best - r.idx; if (delta > N / 2) delta -= N; if (delta < -N / 2) delta += N;
r.idx = best; r.s += delta * STEP;
const h = TRACK.heading[best], p = TRACK.pts[best];
r.lat = -Math.sin(h) * (r.x - p[0]) + Math.cos(h) * (r.y - p[1]);
}
export function newGara(seed = Date.now() >>> 0, def = {}) {
const level = LEVELS[def.level] ? def.level : 'seria', skill = LEVELS[level];
// In griglia nell'ordine della lista, due per fila, dietro il traguardo.
const riders = (def.riders ?? RIDERS).map((r, k) => {
const base = rider(r), factor = r.id === 'andrea' ? 1 : skill.rivals, s = -16 - 14 * Math.floor(k / 2), lane = k % 2 ? 4 : -4, p = pointAt(s, lane);
return { ...base, vmax: base.vmax * factor, accel: base.accel * factor, lane, x: p.x, y: p.y, a: p.heading, v: 0, u: 0, s, idx: at(s), lat: lane,
gas: false, brake: false, steer: 0, out: 0, boost: 0, laps: 0, finished: null, offs: 0, falls: 0, risk: 1 };
});
if (!riders.some(r => r.id === 'andrea')) throw new Error('Nella gara manca Andrea');
for (const r of riders) { r.grid = r.s; r.shown = false; }
return { step: 'intro', t: 0, age: 0, seed, level, skill, riders, holding: false, braking: false, keys: { left: false, right: false, gas: new Set(), brake: false },
pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0, anchor: null, finger: null,
power: 0, accumulator: 0, events: [], result: null, order: riders.map(r => r.id), taken: {} };
}
export const player = c => c.riders.find(r => r.id === 'andrea');
export const lapOf = r => clamp(Math.floor(Math.max(0, r.s) / TRACK.length) + 1, 1, LAPS);
export const canThrow = c => c.step === 'intro' || c.step === 'start' || c.step === 'race';
export const canAdjust = c => c.step === 'race'; // lo sterzo del pannello
export const aimFree = () => true;
export const nudge = () => false;
export const setPower = () => false;
// Il gas ha più fonti, ognuna con la sua: i tasti tenuti giù (Spazio, Invio, ↑), il dito o il mouse premuto sulla
// scena (uno solo, col suo pointerId) e il pulsante Gas del pannello, che resta acceso finché non lo si ritocca.
// Lasciarne una non spegne le altre. `holding` è la loro somma.
function sync(c) {
c.holding = c.keys.gas.size > 0 || c.pointer !== null || c.toggle;
c.braking = c.keys.brake || c.brakeButton;
if (c.pointer === null) { c.drag = 0; c.anchor = null; c.finger = null; }
}
// Il primo tocco (o tasto) salta la presentazione, senza dare gas.
export function press(c, id = 'press') {
if (!canThrow(c)) return false;
if (c.step === 'intro') return nextIntro(c);
if (c.pointer !== null && c.pointer !== id) return false; // un secondo dito sulla scena non conta
c.pointer = id; sync(c);
return true;
}
export function release(c, id) {
if (c.pointer === null || (id !== undefined && id !== c.pointer)) return false;
c.pointer = null; sync(c);
return true;
}
export function cancel(c) {
Object.assign(c, { pointer: null, toggle: false, brakeButton: false, drag: 0, buttons: 0 });
Object.assign(c.keys, { left: false, right: false, brake: false }); c.keys.gas.clear();
sync(c);
return true;
}
// Un tocco durante la presentazione: chi arrivava è già al suo posto, e tocca al prossimo (dopo l'ultimo, il semaforo).
function nextIntro(c) {
const k = Math.floor(c.t / INTRO.each) + 1;
if (k >= c.riders.length) return skipIntro(c);
c.t = k * INTRO.each;
return true;
}
// Finita (o saltata) la presentazione tutti sono in griglia, e si va al semaforo.
function skipIntro(c) {
for (const r of c.riders) place(r, r.grid, true);
c.step = 'start'; c.t = 0;
return true;
}
function place(r, s, shown) {
const p = pointAt(s, r.lane);
Object.assign(r, { s, x: p.x, y: p.y, a: p.heading, idx: at(s), lat: r.lane, shown });
}
// Chi si sta presentando adesso (o null fra uno e l'altro, e alla fine).
export const presenting = c => c.step === 'intro' ? c.riders[Math.floor(c.t / INTRO.each)] ?? null : null;
// Il pulsante Gas del pannello: un tocco lo accende, un altro lo spegne (per chi non può tenere premuto).
export function shoot(c) { if (!canThrow(c)) return false; if (c.step === 'intro') return nextIntro(c); c.toggle = !c.toggle; sync(c); return true; }
// Lo swipe indica dove andare sullo schermo. L'origine segue il dito oltre i 60 px.
export function aimScreen(c, [x, y], id = 'press') {
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
if (c.pointer !== null && c.pointer !== id) return false;
if (c.pointer === null || !c.anchor) c.anchor = [x, y];
c.finger = [x, y];
const dx = x - c.anchor[0], dy = y - c.anchor[1], distance = Math.hypot(dx, dy);
if (distance > SWIPE.reach) c.anchor = [x - dx * SWIPE.reach / distance, y - dy * SWIPE.reach / distance];
return false;
}
// La tastiera: ← → sterzano, ↑ Spazio e Invio sono il gas, ↓ il freno; finché si tengono giù.
const GAS_KEYS = ['ArrowUp', ' ', 'Enter'];
export function key(c, name, down) {
if (name === 'ArrowLeft') c.keys.left = down;
else if (name === 'ArrowRight') c.keys.right = down;
else if (name === 'ArrowDown') c.keys.brake = down;
else if (GAS_KEYS.includes(name)) {
if (down && c.step === 'intro') { nextIntro(c); return true; }
if (down && canThrow(c)) c.keys.gas.add(name); else c.keys.gas.delete(name);
} else return false;
sync(c);
return true;
}
// I pulsanti del pannello tenuti premuti: Sterza (-1, 0, 1) e Frena.
export function steerButton(c, dir) { c.buttons = dir; return true; }
export function brakeButton(c, down) { c.brakeButton = down; sync(c); return true; }
export function steering(c) {
c.drag = 0;
if (c.pointer !== null && c.anchor && c.finger) {
const dx = c.finger[0] - c.anchor[0], dy = c.finger[1] - c.anchor[1];
// Si ricalcola anche col dito fermo: il motorino intanto cambia direzione.
if (Math.hypot(dx, dy) >= SWIPE.dead) c.drag = clamp(wrapAngle(Math.atan2(dy, dx) - player(c).a) * SWIPE.gain, -1, 1);
}
return clamp((c.keys.right ? 1 : 0) - (c.keys.left ? 1 : 0) + c.buttons + c.drag, -1, 1);
}
// La distanza davanti, sul giro, da s a un punto della pista (sempre fra 0 e la lunghezza del giro).
export const ahead = (from, to) => ((((to - from) % TRACK.length) + TRACK.length) % TRACK.length);
// Gli avversari: guardano un punto più avanti sulla loro corsia e ci sterzano; danno gas finché, frenando da qui,
// arriverebbero a ogni curva davanti sotto il limite (un po' meno, quanto si fidano). Chi ha poca testa ogni tanto
// si fida troppo.
function drive(c, r) {
const aim = pointAt(r.s + 14 + r.v * 0.18, r.lane * 0.6);
r.steer = clamp(wrapAngle(Math.atan2(aim.y - r.y, aim.x - r.x) - r.a) * 2.6, -1, 1);
const curve = CURVES.find(k => { const s = ((r.s % TRACK.length) + TRACK.length) % TRACK.length; return s >= k.start - 60 && s < k.start - 57; });
if (curve && r.lastCurve !== curve) { r.lastCurve = curve; r.risk = random(c) < r.slip ? 1.15 : 1; }
const horizon = (r.v * r.v) / (2 * BRAKE) + 20;
r.gas = true; r.brake = false;
for (let d = 0; d <= horizon; d += STEP * 2) {
const allowed = limitAt(r.s + d) * 0.82 * r.grip * r.caution * r.risk;
if (r.v * r.v > allowed * allowed + 2 * BRAKE * d) { r.gas = false; r.brake = d < 30; break; }
}
// Le buche si scansano (chi ha testa); una lattina ogni tanto la vanno a prendere (deciso una volta per lattina).
const look = 30 + r.v * 0.6, next = PICKUPS.find(p => p.kind !== 'boost' && ahead(r.s, p.s) < look);
if (next && r.seen !== next.id) { r.seen = next.id; r.wants = random(c) < 0.35; }
// Chi ha davanti uno più lento lo passa dall'altra parte.
const slow = c.riders.find(o => o !== r && o.s - r.s > 0 && o.s - r.s < 22 && Math.abs(o.lat - r.lat) < 7 && o.v < r.v + 5);
if (slow) r.lane = slow.lat > 0 ? -5 : 5;
if (next?.kind === 'buca' && r.testa >= 3) r.lane = next.lane > 0 ? -5 : 5;
else if (next?.kind === 'miscela' && r.wants) r.lane = next.lane;
}
function stepRider(c, r, dt) {
if (r.finished !== null) { r.gas = false; r.brake = false; drive(c, r); r.gas = false; }
else if (r.out > 0) { r.out = Math.max(0, r.out - dt); r.v = 0; r.u = 0; return; }
else if (r.id === 'andrea') { r.gas = c.holding; r.brake = c.braking; r.steer = steering(c); }
else drive(c, r);
if (r.finished !== null) r.gas = false;
const grass = Math.abs(r.lat) > ROAD, top = r.vmax * (r.boost > 0 ? BOOST.speed : 1) * (grass ? 0.5 : 1);
// Il freno vince sul gas: chi li tiene tutti e due frena.
if (r.brake) r.v -= BRAKE * dt;
else if (r.gas) r.v += r.accel * dt * (1 - 0.4 * r.v / top) * (r.boost > 0 ? 1.6 : 1);
else r.v -= COAST * dt;
if (r.v > top) r.v = Math.max(top, r.v - 120 * dt);
r.v = Math.max(0, r.v);
r.boost = Math.max(0, r.boost - dt);
// Lo sterzo: da fermi non si gira; in curva una parte della velocità resta per traverso e si scivola fuori.
const turn = r.steer * TURN * Math.min(1, r.v / 22) * dt;
r.a = wrapAngle(r.a + turn);
r.u -= r.v * Math.sin(turn) * SLIDE * (r.v / r.vmax) / r.grip;
r.u *= Math.exp(-GRIP * r.grip * dt);
r.x += (Math.cos(r.a) * r.v - Math.sin(r.a) * r.u) * dt;
r.y += (Math.sin(r.a) * r.v + Math.cos(r.a) * r.u) * dt;
locate(r);
// La paglia (fuori) e le mura (dentro): si rimbalza in strada; arrivandoci forte ci si ferma.
if (Math.abs(r.lat) > VERGE) {
const h = TRACK.heading[r.idx], p = TRACK.pts[r.idx], side = Math.sign(r.lat), back = side * (VERGE - 1);
r.x = p[0] - Math.sin(h) * back; r.y = p[1] + Math.cos(h) * back; r.lat = back; r.u = 0;
if (r.v > 40 && r.finished === null) {
r.out = c.skill.stun * (r.id === 'andrea' ? 1 : 0.7); r.offs++; r.risk = 1;
c.events.push({ kind: 'gara-fuori', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 });
}
r.v *= 0.35; r.a = wrapAngle(h + clamp(wrapAngle(r.a - h), -0.5, 0.5));
}
if (r.finished === null) pickups(c, r);
const laps = Math.floor(Math.max(0, r.s) / TRACK.length);
if (laps > r.laps && r.finished === null) {
r.laps = laps;
if (laps >= LAPS) { r.finished = c.t; c.events.push({ kind: 'gara-arrivo', who: r.id, power: r.id === 'andrea' ? 1 : 0.5 }); }
else if (r.id === 'andrea') c.events.push({ kind: 'gara-giro', who: r.id, power: 0.6 });
}
}
// Acceleratori (una spinta), lattine di miscela (spinta più lunga, poi la lattina ricompare) e buche (forte si casca).
function pickups(c, r) {
for (const p of PICKUPS) {
if (Math.hypot(p.x - r.x, p.y - r.y) > 6) continue;
if (p.kind === 'boost') { if (r.boost < BOOST.time * 0.5) { r.boost = BOOST.time; c.events.push({ kind: 'gara-turbo', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 }); } }
else if (p.kind === 'miscela') {
if ((c.taken[p.id] ?? -Infinity) > c.t - CAN.respawn) continue;
c.taken[p.id] = c.t; r.boost = CAN.time;
c.events.push({ kind: 'gara-miscela', who: r.id, power: r.id === 'andrea' ? 1 : 0.3 });
} else if (p.kind === 'buca' && r.lastHole !== p.id) {
r.lastHole = p.id;
if (r.v > HOLE.fast) { r.out = HOLE.out; r.falls++; r.v = 0; c.events.push({ kind: 'gara-buca', who: r.id, power: r.id === 'andrea' ? 1 : 0.4 }); }
else { r.v *= 0.7; c.events.push({ kind: 'gara-sobbalzo', who: r.id, power: 0.3 }); }
}
}
if (r.lastHole !== undefined && Math.hypot(PICKUPS[r.lastHole].x - r.x, PICKUPS[r.lastHole].y - r.y) > 14) r.lastHole = undefined;
}
// I motorini si urtano: si allontanano un po', e chi tampona perde velocità.
function bump(c) {
for (let i = 0; i < c.riders.length; i++) for (let j = i + 1; j < c.riders.length; j++) {
const a = c.riders[i], b = c.riders[j], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
if (d >= 7 || d < 1e-6) continue;
const push = (7 - d) / 2, nx = dx / d, ny = dy / d;
a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push;
const behind = a.s < b.s ? a : b; behind.v *= 0.97;
}
}
// La classifica: chi è arrivato, in ordine d'arrivo; poi chi corre ancora, per strada fatta.
export function standings(c) {
return [...c.riders].sort((a, b) => (a.finished ?? Infinity) - (b.finished ?? Infinity) || b.s - a.s);
}
export const placeOf = (c, id = 'andrea') => standings(c).findIndex(r => r.id === id) + 1;
export function update(c, dt) {
c.age += dt;
if (c.step === 'intro') {
c.t += dt;
c.riders.forEach((r, k) => {
const t = (c.t - k * INTRO.each) / INTRO.ride;
if (t < 0) return;
if (!r.shown) c.events.push({ kind: 'gara-motore', who: r.id, power: 0.3 + 0.14 * r.motore });
const e = 1 - (1 - Math.min(1, t)) ** 2; // arriva forte e rallenta
place(r, r.grid - INTRO.from * (1 - e), true);
r.v = t < 1 ? INTRO.from * 2 * (1 - Math.min(1, t)) / INTRO.ride : 0;
});
if (c.t >= c.riders.length * INTRO.each + INTRO.after) { skipIntro(c); for (const r of c.riders) r.v = 0; }
return;
}
if (c.step === 'start') {
const before = Math.ceil(COUNTDOWN - c.t);
c.t += dt;
const now = Math.ceil(COUNTDOWN - c.t);
if (now !== before && now > 0) c.events.push({ kind: 'gara-semaforo', power: 0.4 });
if (c.t >= COUNTDOWN) { c.step = 'race'; c.t = 0; c.events.push({ kind: 'gara-via', power: 1 }); }
return;
}
if (c.step !== 'race') return;
c.accumulator += Math.min(dt, 0.1);
while (c.accumulator >= FIXED_STEP) {
c.accumulator -= FIXED_STEP;
c.t += FIXED_STEP;
for (const r of c.riders) stepRider(c, r, FIXED_STEP);
bump(c);
for (const r of c.riders) locate(r);
if (player(c).finished !== null) { finish(c); return; }
}
c.order = standings(c).map(r => r.id);
}
function finish(c) {
const order = standings(c);
// Chi non è ancora arrivato: il tempo stimato con la sua media fin qui.
const times = Object.fromEntries(order.map(r => [r.id, r.finished ?? c.t + (LAPS * TRACK.length - r.s) / Math.max(20, r.s / Math.max(c.t, 1))]));
const ranked = [...order].sort((a, b) => times[a.id] - times[b.id]);
const place = ranked.findIndex(r => r.id === 'andrea') + 1;
c.step = 'result';
cancel(c);
c.order = ranked.map(r => r.id);
c.result = { winner: place === 1 ? 'player' : 'cpu', place, times, order: c.order, offs: player(c).offs, falls: player(c).falls };
}
export function status(c) {
if (c.step === 'intro') {
const r = presenting(c);
return r ? `${r.name} con ${r.bike}. (Un tocco: il prossimo.)` : 'Tutti in griglia!';
}
if (c.step === 'start') return c.t < COUNTDOWN - 0.01 ? `GP di Fortezza: si parte fra ${Math.ceil(COUNTDOWN - c.t)}… Tieni premuto per il gas, sterza con ← →.` : 'Via!';
if (c.step === 'result') {
const who = c.riders.find(r => r.id === c.result.order[0]).name;
return c.result.place === 1 ? `Primo al GP di Fortezza! ${LAPS} giri in ${c.result.times.andrea.toFixed(1)} secondi.` : `${c.result.place}° posto. Ha vinto ${who}.`;
}
const me = player(c), place = placeOf(c);
if (me.out > 0) return me.lastHole !== undefined ? 'Nella buca! Ci si rialza…' : 'Nella paglia! Si riparte…';
if (me.boost > 0) return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. Miscela!`;
return `Giro ${lapOf(me)} di ${LAPS}, ${place}° posto. ${c.braking ? 'Freno!' : c.holding ? 'Gas!' : 'Senza gas.'}`;
}
export const button = c => c.step === 'intro' ? 'Avanti' : c.step === 'result' ? 'Finita' : c.holding ? 'Molla' : 'Gas';