File size: 23,619 Bytes
eada153 1dbfbfb 1e96444 1dbfbfb 1e96444 1dbfbfb 1e96444 1dbfbfb 1e96444 1dbfbfb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | // Street races round New Legion: drive into a start marker and press Enter (or tap Race). Gates on the real streets,
// three AI rivals in sports cars (solo), or everybody in your online room. A countdown, a timer, live positions,
// best times kept on this device. The Sky Rings race is a flying time trial through gates in the air.
// Ghosts: your best run on every route is recorded and races beside you next time, with a split at every gate.
// Your own routes: drop gates while you drive or fly, save them, race them against your ghost, share them as a link.
import * as THREE from "three";
import { GRID } from "./city.js";
import { KINDS, vehicle } from "./npc.js";
import { makeDriver } from "./police.js";
const G = (i, j) => [GRID.ax(i), 0, GRID.sz(j)];
export const ROUTES = [
{ id: "midtown", name: "Midtown Sprint", pts: [G(7, 8), G(7, 4), G(9, 4), G(9, 1), G(5, 1), G(5, 6), G(8, 6)] },
{ id: "park", name: "Park Loop ×2", pts: [G(2, 12), G(5, 12), G(5, 18), G(2, 18), G(2, 12), G(5, 12), G(5, 18), G(2, 18), G(2, 13)] },
{ id: "bridge", name: "Bridge Run", pts: [G(7, 10), G(12, 10), [1400, 0, 0], [1690, 0, 0]] },
{ id: "downtown", name: "Downtown Dash", pts: [G(8, 13), G(12, 13), G(12, 19), G(9, 19), G(9, 15), G(11, 15)] },
{ id: "drag", name: "Runway Drag", pts: [[-2600, 0, 0], [-2350, 0, 0], [-2100, 0, 0], [-1960, 0, 0]] },
{ id: "hill", name: "Hill Climb", terrain: true, noBots: true, pts: [[2640, 0, 0], [2780, 0, 30], [2920, 0, -50], [3060, 0, 40], [3200, 0, -90], [3330, 0, -30], [3450, 0, -180], [3560, 0, -110], [3660, 0, -280], [3780, 0, -220], [3880, 0, -360], [3900, 0, -380]] },
{ id: "sky", name: "Sky Rings", air: true, pts: [[420, 0, -40], [420, 90, -160], [520, 120, -300], [430, 150, -450], [250, 170, -520], [60, 150, -460], [-60, 120, -330], [-120, 90, -180], [-260, 70, 60], [-400, 60, 250]] },
];
const RIVALS = [["VIPER", 0xd21f2b], ["BLAZE", 0xff6a00], ["NOVA", 0x1f6fe0]];
const fmt = t => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { } } };
export function createRaces(scene, api, sound, canvasTex, net) {
const $ = id => document.getElementById(id);
const D = makeDriver(api);
const label = (text, col) => { const t = canvasTex(512, 128, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.6)"; g.fillRect(0, 0, w, h); g.strokeStyle = col; g.lineWidth = 6; g.strokeRect(4, 4, w - 8, h - 8); g.fillStyle = col; g.font = "900 54px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText(text, w / 2, 84); }); return new THREE.Sprite(new THREE.SpriteMaterial({ map: t, toneMapped: false, depthTest: true })); };
// start markers: a column of light and a floating name
const custom = (store.get("void.routes") || []).filter(r => r && Array.isArray(r.pts) && r.pts.length >= 3);
const routes = [...ROUTES, ...custom];
const makeStart = r => {
const [x, , z] = r.pts[0], [nx, , nz] = r.pts[1], back = Math.atan2(nz - z, nx - x);
const sx = x - Math.cos(back) * 35, sz = z - Math.sin(back) * 35;
const col = r.custom ? "#c07dff" : r.air ? "#3dfcff" : "#ffd23d";
const pillar = new THREE.Mesh(new THREE.CylinderGeometry(6, 6, 60, 32, 1, true).translate(0, 30, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(col).multiplyScalar(1.6), transparent: true, opacity: .12, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
pillar.position.set(sx, 0, sz); scene.add(pillar);
const ring = new THREE.Mesh(new THREE.RingGeometry(5.4, 6, 48).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: new THREE.Color(col).multiplyScalar(2), toneMapped: false })); ring.position.set(sx, .15, sz); scene.add(ring);
const s = label("🏁 " + r.name.toUpperCase(), col); s.scale.set(16, 4, 1); s.position.set(sx, 9, sz); scene.add(s);
return { r, x: sx, z: sz, yaw: back, pillar, ring, s };
};
const starts = routes.map(makeStart);
// gates: two posts and a banner across the road
const gateMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd23d).multiplyScalar(2), toneMapped: false }), dimMat = new THREE.MeshBasicMaterial({ color: 0x555555 });
const checker = canvasTex(256, 32, (g, w, h) => { for (let x = 0; x < w; x += 16) for (let y = 0; y < h; y += 16) { g.fillStyle = ((x + y) / 16) % 2 ? "#111" : "#fff"; g.fillRect(x, y, 16, 16); } });
const gates = [];
function buildGates(r) {
clearGates();
r.pts.forEach((p, k) => {
const q = r.pts[Math.max(0, k - 1)], o = r.pts[Math.min(r.pts.length - 1, k + 1)], dir = k ? Math.atan2(p[2] - q[2], p[0] - q[0]) : Math.atan2(o[2] - p[2], o[0] - p[0]);
const g = new THREE.Group(); g.position.set(p[0], r.terrain && api.terrainH ? api.terrainH(p[0], p[2]) : p[1], p[2]); g.rotation.y = -dir;
const fin = k === r.pts.length - 1;
if (r.air) { const ring = new THREE.Mesh(new THREE.TorusGeometry(10, .7, 10, 40), gateMat.clone()); ring.rotation.y = Math.PI / 2; g.add(ring); g.userData.parts = [ring]; }
else {
const W = 11, parts = [];
for (const s of [-1, 1]) { const post = new THREE.Mesh(new THREE.BoxGeometry(.6, 7, .6).translate(0, 3.5, 0), gateMat.clone()); post.position.z = s * W; g.add(post); parts.push(post); }
const banner = new THREE.Mesh(new THREE.BoxGeometry(.3, 1.4, W * 2 + .6), fin ? new THREE.MeshBasicMaterial({ map: checker, toneMapped: false }) : gateMat.clone()); banner.position.y = 7; g.add(banner); if (!fin) parts.push(banner);
g.userData.parts = parts;
}
scene.add(g); gates.push(g);
});
}
function clearGates() { for (const g of gates) scene.remove(g); gates.length = 0; }
// rivals: sports cars with their own colours
const sport = KINDS.find(k => k.id === "sports"), sg = vehicle(sport);
const rivals = RIVALS.map(([name, col]) => {
const g = new THREE.Group(); g.add(new THREE.Mesh(sg.body, new THREE.MeshStandardMaterial({ vertexColors: true, color: col, roughness: .25, metalness: .6 })), new THREE.Mesh(sg.lights, new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, color: new THREE.Color(2, 2, 2) })));
const tag = label(name, "#" + col.toString(16).padStart(6, "0")); tag.scale.set(5, 1.25, 1); tag.position.y = 3.4; g.add(tag);
g.visible = false; scene.add(g); return { name, g, x: 0, z: 0, yaw: 0, v: 0, cp: 1, done: 0, skill: 1 };
});
// the ghost: a see-through copy of your best run on this route
const ghost = new THREE.Group(); ghost.add(new THREE.Mesh(sg.body, new THREE.MeshBasicMaterial({ color: new THREE.Color(.3, 1.6, 2), transparent: true, opacity: .28, depthWrite: false, toneMapped: false })));
{ const gt = label("GHOST", "#3dfcff"); gt.scale.set(5, 1.25, 1); gt.position.y = 3.4; ghost.add(gt); } ghost.visible = false; scene.add(ghost);
const ghostOf = id => { const gh = store.get("void.ghost." + id); return gh && Array.isArray(gh.p) && gh.p.length >= 8 ? gh : null; };
let R = null; // the race in progress
function begin(route, opts = {}) {
const p = api.state; if (!p) return;
api.policeClear && api.policeClear();
const st = starts.find(s => s.r === route);
buildGates(route);
const slot = opts.slot ?? 0, side = [0, 1, -1, 2, -2, 3, -3][slot % 7] * 4.2, row = Math.floor(slot / 7) * 8;
const fx = Math.cos(st.yaw), fz = Math.sin(st.yaw);
api.teleport(st.x + fx * (20 - row) - fz * side, st.z + fz * (20 - row) + fx * side, st.yaw);
api.freeze(opts.wait ?? 3.2);
// bots: in an online race every player runs the same bots from the same seed on a fixed clock, so they match
let sd = (opts.seed || Math.floor(Math.random() * 1e9)) % 2147483647 || 7; const rng = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
R = { route, t: -(opts.wait ?? 3.2), cp: 1, done: 0, online: !!opts.online, results: [], lastBeep: 4, rng, acc: 0, bt: 0, rec: [], recT: 0, splits: [], ghost: ghostOf(route.id) };
ghost.visible = !!R.ghost;
rivals.forEach((r, k) => {
r.g.visible = !route.air && !route.custom && !route.noBots && (!R.online || opts.bots !== false);
const sd = [-1, 1, 2][k] * 4.2, rw = k === 2 ? 8 : 0;
Object.assign(r, { x: st.x + fx * (20 - rw) - fz * sd, z: st.z + fz * (20 - rw) + fx * sd, yaw: st.yaw, v: 0, cp: 1, done: 0, skill: .9 + rng() * .16, accel: 13, stuck: 0 });
});
$("gRace").hidden = false; $("gRaceRes").hidden = true;
}
function finish(msg) {
const p = api.state, best = store.get("void.race." + R.route.id);
const me = R.done, rank = standings().findIndex(s => s.me) + 1;
if (me && (!best || me < best)) { store.set("void.race." + R.route.id, me); if (R.rec.length >= 8) store.set("void.ghost." + R.route.id, { t: me, s: R.splits, p: R.rec.slice(0, 12000) }); }
const gdiff = me && R.ghost ? me - R.ghost.t : null;
const reward = me ? [0, 1500, 800, 400, 200][Math.min(4, rank)] || 100 : 0;
if (reward) api.addScore(reward, `${R.route.name}: ${["", "1st", "2nd", "3rd"][rank] || rank + "th"}`);
$("gRaceRes").innerHTML = `<h3>${R.route.name}</h3><p>${msg || (me ? `You finished ${["", "1st", "2nd", "3rd"][rank] || rank + "th"} in ${fmt(me)}` : "Race over")}</p><p class="small">${best ? `Best: ${fmt(Math.min(best, me || 1e9))}` : me ? "New best time!" : ""}${gdiff != null ? ` · vs ghost ${gdiff <= 0 ? "−" : "+"}${Math.abs(gdiff).toFixed(2)} s${gdiff < 0 ? " (new ghost saved)" : ""}` : me && !R.ghost ? " · ghost saved" : ""}</p><ol>${standings().map(s => `<li${s.me ? ' class="me"' : ""}>${s.name}<span>${s.done ? fmt(s.done) : "DNF"}</span></li>`).join("")}</ol><button class="pill solid" id="gRaceOk">OK</button>`;
$("gRaceRes").hidden = false; $("gRaceOk").onclick = () => { $("gRaceRes").hidden = true; };
$("gRace").hidden = true; clearGates(); rivals.forEach(r => r.g.visible = false); ghost.visible = false; R = null; void p;
}
// everyone's progress: checkpoints passed plus how far along to the next
const progOf = (x, y, z, cp, route) => { const a = route.pts[cp - 1], b = route.pts[Math.min(route.pts.length - 1, cp)]; const L = Math.hypot(b[0] - a[0], b[2] - a[2]) || 1, d = Math.hypot(b[0] - x, b[2] - z); return cp + Math.max(0, 1 - d / L); };
function standings() {
if (!R) return [];
const p = api.state, out = [{ name: "YOU", me: true, prog: R.done ? 1e6 - R.done : progOf(p.x, p.y, p.z, R.cp, R.route), done: R.done }];
for (const r of rivals) if (r.g.visible) out.push({ name: r.name, prog: r.done ? 1e6 - r.done : progOf(r.x, 0, r.z, r.cp, R.route), done: r.done });
if (R.online && net) for (const q of net.players()) if (q.race === R.route.id) out.push({ name: q.name, prog: q.rf ? 1e6 - q.rf : q.rp || 0, done: q.rf || 0 });
return out.sort((a, b) => b.prog - a.prog);
}
function nearStart() { const p = api.state; if (!p || R || B) return null; return starts.find(s => Math.hypot(p.x - s.x, p.z - s.z) < 14) || null; }
addEventListener("keydown", e => { if (e.code === "Enter" && api.active()) { const s = nearStart(); if (s) start(s.r); } });
$("gRaceGo").onclick = () => { const s = nearStart(); if (s) start(s.r); };
function start(route) {
if (net && net.online() && !route.custom) { const seed = Math.floor(Math.random() * 1e9); net.send({ t: "race", id: route.id, wait: 6, seed }); begin(route, { online: true, wait: 6, slot: 0, seed }); }
else begin(route);
}
// ---- the route builder ----
let B = null;
const saveCustom = () => store.set("void.routes", custom.map(({ id, name, air, pts }) => ({ id, name, air, pts, custom: true })));
function addRoute(r) { r.custom = true; custom.push(r); routes.push(r); starts.push(makeStart(r)); while (custom.length > 12) removeRoute(custom[0].id); saveCustom(); return r; }
function removeRoute(id) {
const k = custom.findIndex(r => r.id === id); if (k < 0) return; const r = custom[k]; custom.splice(k, 1); routes.splice(routes.indexOf(r), 1);
const s = starts.findIndex(st => st.r === r); if (s >= 0) { const st = starts[s]; scene.remove(st.pillar, st.ring, st.s); starts.splice(s, 1); }
try { localStorage.removeItem("void.ghost." + id); localStorage.removeItem("void.race." + id); } catch { } saveCustom();
}
const buildMarks = [];
function builder(on) {
if (on && R) return api.say("Finish the race first", 2);
for (const m of buildMarks) scene.remove(m); buildMarks.length = 0;
B = on ? { pts: [] } : null; $("gBuild").hidden = !on; if (on) { dropGate(); api.say("Route builder: drive or fly your route, press B (or the button) to drop each gate", 4); }
renderBuild();
}
function dropGate() {
const p = api.state; if (!B || !p) return;
const pt = [Math.round(p.x), p.mode === "air" && p.y > 6 ? Math.round(p.y) : 0, Math.round(p.z)], last = B.pts[B.pts.length - 1];
if (last && Math.hypot(pt[0] - last[0], pt[1] - last[1], pt[2] - last[2]) < 30) return api.say("Too close to the last gate (30 m)", 1.5);
B.pts.push(pt); sound.pop();
const m = new THREE.Mesh(new THREE.TorusGeometry(pt[1] ? 10 : 6, .5, 8, 32), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xc07dff).multiplyScalar(2), toneMapped: false })); m.position.set(pt[0], pt[1] || 6, pt[2]); m.rotation.y = -p.yaw + Math.PI / 2; scene.add(m); buildMarks.push(m);
renderBuild();
}
function saveBuild() {
if (!B || B.pts.length < 3) return api.say("A route needs a start and at least 2 more gates", 2);
const r = addRoute({ id: "c" + Date.now().toString(36), name: "My route " + (custom.length + 1), air: B.pts.some(q => q[1] > 0), pts: B.pts });
builder(false); api.say(`Saved "${r.name}" and copied its link. The purple marker is its start`, 3.5); renderList(); share(r, true);
}
function renderBuild() { if (!B) return; $("gBuild").innerHTML = `<b>🛠 Route builder</b><span>${B.pts.length === 1 ? "Start set" : `${B.pts.length - 1} gate${B.pts.length > 2 ? "s" : ""}`}</span><button class="pill" id="gBDrop">Drop gate (B)</button><button class="pill solid" id="gBSave">Save</button><button class="pill" id="gBCancel">Cancel</button>`; $("gBDrop").onclick = dropGate; $("gBSave").onclick = saveBuild; $("gBCancel").onclick = () => builder(false); }
addEventListener("keydown", e => { if (e.code === "KeyB" && B && api.active() && !e.repeat) dropGate(); });
// share a route as a link: everything is in the address, nothing is uploaded
const b64 = o => btoa(unescape(encodeURIComponent(JSON.stringify(o)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
async function share(r, quiet) {
const url = location.origin + location.pathname + "#r=" + b64({ n: r.name, p: r.pts });
try { await navigator.clipboard.writeText(url); if (!quiet) api.say("Route link copied: send it to a friend", 2.5); } catch { if (!quiet) prompt("Copy this route link", url); }
}
try {
const h = new URLSearchParams(location.hash.slice(1)).get("r");
if (h) {
const o = JSON.parse(decodeURIComponent(escape(atob(h.replace(/-/g, "+").replace(/_/g, "/")))));
const pts = (o.p || []).slice(0, 40).map(q => [+q[0] || 0, Math.max(0, Math.min(400, +q[1] || 0)), +q[2] || 0]).filter(q => q.every(Number.isFinite) && Math.abs(q[0]) < 4000 && Math.abs(q[2]) < 4000);
if (pts.length >= 3 && !custom.some(r => JSON.stringify(r.pts) === JSON.stringify(pts))) addRoute({ id: "c" + Date.now().toString(36), name: String(o.n || "Shared route").replace(/[<>&"]/g, "").slice(0, 28), air: pts.some(q => q[1] > 0), pts });
history.replaceState(null, "", location.pathname + location.search);
}
} catch { }
// the Routes list in the Menu: best time, ghost, go there, share, delete
function renderList() {
const el = $("gRoutes"); if (!el) return;
el.innerHTML = routes.map(r => { const b = store.get("void.race." + r.id), gh = !!ghostOf(r.id); return `<div class="route${r.custom ? " mine" : ""}"><div><span>${r.air ? "✈️" : "🏁"} ${r.name}</span><small>${b ? `Best ${fmt(b)}` : "No time yet"}${gh ? " · 👻 ghost" : ""}${r.custom ? " · yours" : ""}</small></div><button class="pill" data-rgo="${r.id}">Go</button>${r.custom ? `<button class="pill" data-share="${r.id}">Share</button><button class="pill" data-del="${r.id}">✕</button>` : ""}</div>`; }).join("") + `<button class="tile wide-tile" id="gBuildBtn"><b>🛠</b>Make your own route</button>`;
}
document.addEventListener("click", e => {
const t = e.target.closest("[data-rgo],[data-share],[data-del],#gBuildBtn"); if (!t || !t.closest("#gRoutes")) return;
if (t.id === "gBuildBtn") return builder(true);
const r = routes.find(x => x.id === (t.dataset.rgo || t.dataset.share || t.dataset.del)); if (!r) return;
if (t.dataset.rgo) { const st = starts.find(s => s.r === r); if (st && !R) { api.teleport(st.x, st.z, st.yaw); api.say(`${r.name}: press Enter (or Start) at the marker`, 3); } }
if (t.dataset.share) share(r);
if (t.dataset.del && confirm(`Delete "${r.name}" and its ghost?`)) { removeRoute(r.id); renderList(); }
});
if (net) net.on("race", (m, from) => { const r = ROUTES.find(x => x.id === m.id); if (r && !R && !B) { begin(r, { online: true, wait: Math.max(2, Math.min(10, +m.wait || 6)) - .3, seed: Math.abs(m.seed | 0) || 7, slot: 1 + (net.players().findIndex(q => q.id === from) + 1) % 6 }); api.say(`${net.nameOf(from)} started ${r.name}. Get ready!`, 3); } });
return {
get race() { return R; }, rivals, renderList, get building() { return !!B; },
progress() { return R ? { race: R.route.id, rp: R.done ? 0 : progOf(api.state.x, api.state.y, api.state.z, R.cp, R.route), rf: R.done } : null; },
starts,
update(dt, time) {
const p = api.state;
for (const s of starts) { s.pillar.material.opacity = .1 + .05 * Math.sin(time * 3); s.s.visible = s.ring.visible = s.pillar.visible = !R; }
const ns = nearStart(); $("gRaceGo").hidden = !ns; if (ns) $("gRaceGo").textContent = `Start ${ns.r.name} (Enter)`;
if (!R || !p) return;
R.t += dt;
// countdown
if (R.t < 0) { const n = Math.min(3, Math.ceil(-R.t)); $("gCount").textContent = n; $("gCount").hidden = false; if (n < R.lastBeep) { R.lastBeep = n; sound.pop(); } }
else if (R.t < .8) { $("gCount").textContent = "GO!"; $("gCount").hidden = false; if (R.lastBeep > 0) { R.lastBeep = 0; sound.win(); } }
else $("gCount").hidden = true;
const route = R.route, N = route.pts.length;
// gates: the next one bright, the one after dim, the rest hidden
gates.forEach((g, k) => { g.visible = k >= R.cp && k <= R.cp + 1; for (const m of g.userData.parts || []) m.material.color.set(k === R.cp ? 0xffd23d : 0x666666).multiplyScalar(k === R.cp ? 2 : 1); });
if (R.t > 0 && !R.done) {
const c = route.pts[R.cp], d = route.air ? Math.hypot(p.x - c[0], p.y - c[1], p.z - c[2]) : Math.hypot(p.x - c[0], p.z - c[2]);
if (d < (route.air ? (route.custom ? 14 : 11) : 13)) { R.splits[R.cp] = +R.t.toFixed(2); const gs = R.ghost && R.ghost.s && R.ghost.s[R.cp]; if (gs && R.cp < N - 1) { const df = R.t - gs; api.say(`Gate ${R.cp} ${df <= 0 ? "−" : "+"}${Math.abs(df).toFixed(2)} s vs ghost`, 1.6); } R.cp++; sound.pop(); if (R.cp >= N) { R.done = R.t; sound.win(); api.say(`Finished in ${fmt(R.t)}!`, 3); R.endAt = R.t + (R.online ? 15 : 25); } }
}
if (R.t > 0 && !R.done && (R.recT += dt) >= .1) { R.recT -= .1; R.rec.push(Math.round(p.x * 10) / 10, Math.round(p.y * 10) / 10, Math.round(p.z * 10) / 10, Math.round(p.yaw * 100) / 100); }
if (R.ghost) {
const gp = R.ghost.p, n = gp.length / 4, f = Math.max(0, R.t) * 10, i = Math.min(n - 2, Math.floor(f)), k = Math.min(1, f - i), a = i * 4, b = a + 4;
if (f >= n - 1) ghost.visible = false;
else { let dy = gp[b + 3] - gp[a + 3]; if (dy > Math.PI) dy -= 2 * Math.PI; if (dy < -Math.PI) dy += 2 * Math.PI; ghost.position.set(gp[a] + (gp[b] - gp[a]) * k, gp[a + 1] + (gp[b + 1] - gp[a + 1]) * k, gp[a + 2] + (gp[b + 2] - gp[a + 2]) * k); ghost.rotation.y = -(gp[a + 3] + dy * k); }
}
// rivals: stepped on a fixed 1/60 s clock from the green light (the same on every player's screen when online)
if (!route.air && !route.custom && !route.noBots && R.t > 0) {
R.acc += dt;
while (R.acc >= 1 / 60 && R.bt < R.t) {
R.acc -= 1 / 60; R.bt += 1 / 60;
for (const r of rivals) {
if (!r.g.visible || r.done) continue;
const c = route.pts[Math.min(N - 1, r.cp)];
let band = 1;
if (!R.online) { const mine = progOf(p.x, 0, p.z, R.cp, route), theirs = progOf(r.x, 0, r.z, r.cp, route); band = mine - theirs > .8 ? 1.12 : theirs - mine > .8 ? .9 : 1; }
// brake for the corner at the next gate, and get back on the road if it ever ends up stuck
const prev = route.pts[Math.max(0, r.cp - 1)], nxt = route.pts[Math.min(N - 1, r.cp + 1)];
const a1 = Math.atan2(c[2] - prev[2], c[0] - prev[0]), a2 = Math.atan2(nxt[2] - c[2], nxt[0] - c[0]);
let turnA = Math.abs(a2 - a1); if (turnA > Math.PI) turnA = 2 * Math.PI - turnA;
const dc = Math.hypot(c[0] - r.x, c[2] - r.z), top = 62 * r.skill * band;
const want = turnA > .5 && r.cp < N - 1 ? Math.min(top, 13 + Math.max(0, dc - 8) * .9) : top;
D.step(r, c[0], c[2], want, 1 / 60, R.rng);
if (r.stuck > 1.2) {
const dx = c[0] - prev[0], dz = c[2] - prev[2], L2 = dx * dx + dz * dz || 1, t = Math.max(0, Math.min(1, ((r.x - prev[0]) * dx + (r.z - prev[2]) * dz) / L2));
r.x = prev[0] + dx * t; r.z = prev[2] + dz * t; r.yaw = Math.atan2(c[2] - r.z, c[0] - r.x); r.v = 6; r.stuck = 0;
}
if (Math.hypot(r.x - c[0], r.z - c[2]) < 12) { r.cp++; if (r.cp >= N) r.done = R.bt; }
}
}
}
for (const r of rivals) {
if (!r.g.visible) continue;
r.g.position.set(r.x, 0, r.z); r.g.rotation.y = -r.yaw;
// they can't drive through you (you get pushed; they keep their line so every screen agrees)
const dx = p.x - r.x, dz = p.z - r.z, dd = Math.hypot(dx, dz);
if (dd < 3.4 && dd > .01 && p.y < 2) { p.x += dx / dd * (3.4 - dd); p.z += dz / dd * (3.4 - dd); if (!R.online) r.v *= .9; }
}
const st = standings(), pos = st.findIndex(s => s.me) + 1;
// (the banner is built once per race and only its text changes, so the Quit button stays clickable)
if (!$("gRaceT")) { $("gRace").innerHTML = `<b id="gRaceN"></b><span id="gRaceT"></span><span id="gRaceG"></span><span id="gRaceP"></span><button class="pill" id="gRaceQuit2">Quit</button>`; $("gRaceQuit2").onclick = () => R && finish("You left the race"); }
$("gRaceN").textContent = route.name; $("gRaceT").textContent = R.t < 0 ? "Get ready" : fmt(Math.max(0, R.done || R.t)); $("gRaceG").textContent = `Gate ${Math.min(R.cp, N - 1)}/${N - 1}`; $("gRaceP").textContent = `${pos}/${st.length}`;
const next = route.pts[Math.min(N - 1, R.cp)]; R.next = next;
if (R.done && (R.t > R.endAt || (!R.online && rivals.every(r => r.done || !r.g.visible)))) { finish(); return; }
if (R && R.t > 600) finish("Out of time");
},
cancel() { if (R) finish("Race cancelled"); },
};
}
|