// 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';