Death242 commited on
Commit
72ae0f5
·
verified ·
1 Parent(s): 585bfe0

Static site

Browse files
Files changed (3) hide show
  1. index.html +2 -2
  2. static/city.js +7 -4
  3. static/main.js +28 -16
index.html CHANGED
@@ -8,7 +8,7 @@
8
  <meta name="theme-color" content="#10131b">
9
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
11
- <link rel="stylesheet" href="/static/style.css?v=1793426655">
12
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
13
  <script type="importmap">
14
  { "imports": {
@@ -219,6 +219,6 @@
219
  <div id="toast" role="status"></div>
220
  <noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
221
  <script src="/static/hub.js" data-app="void" defer></script>
222
- <script type="module" src="/static/main.js?v=1793426655"></script>
223
  </body>
224
  </html>
 
8
  <meta name="theme-color" content="#10131b">
9
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/style.css?v=1793427139">
12
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
13
  <script type="importmap">
14
  { "imports": {
 
219
  <div id="toast" role="status"></div>
220
  <noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
221
  <script src="/static/hub.js" data-app="void" defer></script>
222
+ <script type="module" src="/static/main.js?v=1793427139"></script>
223
  </body>
224
  </html>
static/city.js CHANGED
@@ -632,15 +632,18 @@ export async function buildCity(scene, canvasTex, COARSE) {
632
  const TILE = 640, tileOf = (x, z) => Math.floor(x / TILE) * 1000 + Math.floor(z / TILE);
633
  const byMat = new Map();
634
  for (const o of B.values()) { if (!o.pos.length) continue; if (!byMat.has(o.m)) byMat.set(o.m, []); byMat.get(o.m).push(o); }
 
635
  for (const [mtl, list] of byMat) {
 
636
  const tiles = new Map();
 
 
637
  for (const o of list) for (let t = 0, u = 0; t < o.pos.length; t += 9, u += 6) {
638
- const k = tileOf((o.pos[t] + o.pos[t + 3] + o.pos[t + 6]) / 3, (o.pos[t + 2] + o.pos[t + 5] + o.pos[t + 8]) / 3);
639
- let a = tiles.get(k); if (!a) tiles.set(k, a = { pos: [], uv: [], tile: [] });
640
- for (let i = 0; i < 9; i++) a.pos.push(o.pos[t + i]); for (let i = 0; i < 6; i++) a.uv.push(o.uv[u + i]); a.tile.push(o.tile, o.tile, o.tile);
641
  }
642
  for (const a of tiles.values()) {
643
- const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(a.pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(a.uv, 2)); g.setAttribute("aTile", new THREE.Float32BufferAttribute(a.tile, 1)); g.computeVertexNormals();
644
  const m = new THREE.Mesh(g, mtl); m.userData.solid = true; scene.add(m);
645
  }
646
  }
 
632
  const TILE = 640, tileOf = (x, z) => Math.floor(x / TILE) * 1000 + Math.floor(z / TILE);
633
  const byMat = new Map();
634
  for (const o of B.values()) { if (!o.pos.length) continue; if (!byMat.has(o.m)) byMat.set(o.m, []); byMat.get(o.m).push(o); }
635
+ const triTile = (p, t) => tileOf((p[t] + p[t + 3] + p[t + 6]) / 3, (p[t + 2] + p[t + 5] + p[t + 8]) / 3);
636
  for (const [mtl, list] of byMat) {
637
+ // count each tile's triangles first, then fill typed arrays of exactly that size (growing plain arrays was most of the load time here)
638
  const tiles = new Map();
639
+ for (const o of list) for (let t = 0; t < o.pos.length; t += 9) { const k = triTile(o.pos, t), a = tiles.get(k); if (a) a.n++; else tiles.set(k, { n: 1 }); }
640
+ for (const a of tiles.values()) { a.pos = new Float32Array(a.n * 9); a.uv = new Float32Array(a.n * 6); a.tile = new Float32Array(a.n * 3); a.i = 0; }
641
  for (const o of list) for (let t = 0, u = 0; t < o.pos.length; t += 9, u += 6) {
642
+ const a = tiles.get(triTile(o.pos, t)), i = a.i++;
643
+ for (let j = 0; j < 9; j++) a.pos[i * 9 + j] = o.pos[t + j]; for (let j = 0; j < 6; j++) a.uv[i * 6 + j] = o.uv[u + j]; a.tile[i * 3] = a.tile[i * 3 + 1] = a.tile[i * 3 + 2] = o.tile;
 
644
  }
645
  for (const a of tiles.values()) {
646
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.BufferAttribute(a.pos, 3)); g.setAttribute("uv", new THREE.BufferAttribute(a.uv, 2)); g.setAttribute("aTile", new THREE.BufferAttribute(a.tile, 1)); g.computeVertexNormals();
647
  const m = new THREE.Mesh(g, mtl); m.userData.solid = true; scene.add(m);
648
  }
649
  }
static/main.js CHANGED
@@ -8,7 +8,8 @@ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
8
  Object.defineProperty(THREE.Material.prototype, "forceSinglePass", { get: () => true, set() {} });
9
  const V = new URL(import.meta.url).search;
10
  const $ = id => document.getElementById(id);
11
- const setLoad = p => { $("ldbar").style.width = p + "%"; $("ldpct").textContent = Math.round(p); };
 
12
  setLoad(15);
13
  const [{ createWorld, clamp, lerp, smooth, frame, PAD_R, ZONES }, { buildCar, MODELS }, { addInterior, HEAD }, { Sound, BAR }, { createWatch, TOTAL, CHAPTERS }, { createGame }, { createLife, personGeo }] = await Promise.all(
14
  ["world", "car", "interior", "sound", "watch", "game", "npc"].map(n => import(`./${n}.js${V}`)));
@@ -54,11 +55,11 @@ const interior = addInterior(car, renderer);
54
  const sound = new Sound();
55
  const watch = createWatch(W, car, interior);
56
  const DENSITY = { few: .55, normal: 1, busy: 1.3 }[(store.get("void.settings") || {}).crowd || (LITE ? "few" : "normal")] || 1;
57
- const life = createLife(scene, W.roads, W.walkways, COARSE || LITE, DENSITY);
58
  const TIMES = ["night", "sunset", "day"];
59
  function cycleTime() { const k = TIMES[(TIMES.indexOf(W.timeOfDay) + 1) % 3]; W.setTime(k); store.set("void.time", k); toast({ night: "Night in the city", sunset: "Golden hour", day: "Daytime" }[k]); cubeN = 0; }
60
  let police, races, nav;
61
- const net = createNet();
62
  setInterval(() => { if (document.hidden) net.tick(.5, null); }, 500); // a hosting tab in the background still relays everyone
63
  // settings: graphics quality, look speed, auto gas, touch steering (kept on this device)
64
  // a strong graphics card starts on High; everything else starts on Medium (it can be raised in the Menu)
@@ -81,9 +82,9 @@ const gameOpts = {
81
  mx.font = "bold 12px sans-serif"; mx.textAlign = "center"; for (const st of W.city.stations) { const [a, b] = P(st.x, st.z); if (Math.abs(a) < 90 && Math.abs(b) < 90) { mx.save(); mx.translate(a, b); mx.rotate(g.yaw + Math.PI / 2); mx.fillText("⛽", 0, 4); mx.restore(); } }
82
  },
83
  };
84
- const game = createGame(W, car, sound, life, gameOpts);
85
- const vehicles = createVehicles(scene, game.api, W); gameOpts.vehicles = vehicles;
86
- const atmos = createAtmos(W, sound, COARSE);
87
  // trophies: a banner slides in at the top when a challenge is done
88
  const goals = createGoals(gl => { const t = document.getElementById("gTrophy"); t.innerHTML = `<b>${gl.icon}</b><div><span>Challenge complete</span>${gl.name}<em>+1000</em></div>`; t.classList.remove("on"); void t.offsetWidth; t.classList.add("on"); if (game.state) { game.state.score += 1000; } sound.win(); renderGoals(); });
89
  function renderGoals() {
@@ -140,10 +141,18 @@ document.getElementById("gSens").addEventListener("input", e => { SET.sens = +e.
140
  }
141
  // headlights that light the road at night
142
  const heads = new THREE.SpotLight(0xfff1d6, 0, 70, .55, .6, 1.2); heads.position.set(2.2, .7, 0); heads.target.position.set(18, -1.5, 0); scene.add(heads, heads.target); // in the scene (it follows the car each frame), so it never disappears with the car
143
- police = createPolice(scene, game.api, sound, life);
144
- races = createRaces(scene, game.api, sound, W.canvasTex, net);
145
- nav = createNav(scene, game.api, W, ZONES, races.starts, () => clock.elapsedTime);
146
- const extras = createExtras(W, game.api, nav, sound);
 
 
 
 
 
 
 
 
147
  document.getElementById("gTaxi").onclick = () => extras.toggle();
148
  // people knocked over: a thud, a shake, and the police notice; they bounce off walls instead of flying through them
149
  life.onHit = sp => { sound.thud && sound.thud(Math.min(1, sp / 22)); if (game.state) game.state.shake = Math.min(1, (game.state.shake || 0) + .25); police && police.event("ped"); };
@@ -231,7 +240,7 @@ function dailyProg(k) { const [stat, need] = DAILY[k]; return Math.min(1, ((goal
231
  function dailyCheck() { for (const k of daily.picks) if (!daily.done.includes(k) && dailyProg(k) >= 1) { daily.done.push(k); store.set("void.daily", daily); cash += 2500; earn(0); toast(`Daily challenge done: ${DAILY[k][2]} · +$2,500`); sound.win(); } }
232
  function renderDaily() { document.getElementById("gDaily").innerHTML = daily.picks.map(k => { const v = dailyProg(k), ok = daily.done.includes(k); return `<div class="goal${ok ? " ok" : ""}"><b>${DAILY[k][2].split(" ")[0]}</b><div><span>${DAILY[k][2].split(" ").slice(1).join(" ")}</span><small>$2,500</small><i style="--v:${v}"></i></div>${ok ? "✅" : `<em>${Math.floor(v * 100)}%</em>`}</div>`; }).join(""); }
233
  // contracts: hybrid drive + fly jobs and pursuits with medals
234
- const contracts = createContracts(W, game.api, nav, sound, police);
235
  function renderContracts() { document.getElementById("gContracts").innerHTML = contracts.CONTRACTS.map(c => `<button class="tile con" data-con="${c.id}"><b>${c.icon}</b>${c.name}<small>${c.text}</small><i>${["Not done", "🥉 Bronze", "🥈 Silver", "🥇 Gold"][contracts.best[c.id] || 0]}</i></button>`).join("") + (contracts.active ? `<button class="tile con" id="gConStop"><b>✕</b>Cancel contract</button>` : ""); }
236
  document.getElementById("gContracts").addEventListener("click", e => { const b = e.target.closest("[data-con]"); if (b) { if (extras.job.on) extras.toggle(false); contracts.start(b.dataset.con); } else if (e.target.closest("#gConStop")) contracts.cancel(); });
237
  // backups: every void.* key in one code, so progress survives a new browser or device
@@ -288,7 +297,8 @@ function renderPlayers() { document.getElementById("gPlayers").innerHTML = net.o
288
  $("gEmotes").innerHTML = EMOTES.map((e, k) => `<button class="pill" data-e="${k}">${e}</button>`).join("");
289
  $("gEmotes").onclick = ev => { const b = ev.target.closest("[data-e]"); if (!b || !net.online()) return; net.send({ t: "emote", e: +b.dataset.e }); feed(`You: ${EMOTES[+b.dataset.e]}`); };
290
  }
291
- W.setTime(store.get("void.time") || "night");
 
292
  // fill the car's reflection cube once before the first frame, so the paint never samples an empty one
293
 
294
  $("osmCredit").textContent = W.credit;
@@ -339,7 +349,7 @@ $("share").onclick = async () => {
339
  const url = location.origin + location.pathname + "#b=" + Object.keys(DEFAULT).map(k => build[k]).join(".");
340
  try { await navigator.clipboard.writeText(url); toast("Link to your build copied"); } catch { prompt("Copy your build link", url); }
341
  };
342
- applyBuild();
343
  // ---------- performance upgrades: every point you score is also cash, spent here (kept on this device) ----------
344
  const UPG = { engine: ["⚙️", "Engine", "More power and top speed"], grip: ["🛞", "Tyres", "Grip in corners, longer drifts"], nitro: ["🔥", "Nitro", "A bigger nitro tank"], armor: ["🛡️", "Armour", "Takes less damage"] };
345
  const PRICE = [2500, 6000, 15000];
@@ -428,7 +438,7 @@ function resize() {
428
  }
429
  addEventListener("resize", resize); addEventListener("orientationchange", () => setTimeout(resize, 200));
430
  if (window.visualViewport) visualViewport.addEventListener("resize", resize);
431
- resize();
432
  const fovFor = f => { const a = camera.aspect; if (a >= 1.5) return f; const h = 2 * Math.atan(Math.tan(f * Math.PI / 360) * 1.5); return Math.min(88, 2 * Math.atan(Math.tan(h / 2) / a) * 180 / Math.PI); };
433
 
434
  // ---------- the showroom pose (home and build) ----------
@@ -484,7 +494,7 @@ function cullSmall(time, det) {
484
  }
485
  }
486
  const perf = { t: 0, n: 0, pr: renderer.getPixelRatio(), max: renderer.getPixelRatio(), down: 0 };
487
- applySettings();
488
  let lastHud = 0, prevGear = 1, feedFlip = 0;
489
  function frameLoop(fixed) {
490
  const raw = fixed ? 0 : clock.getDelta(), dt = fixed || Math.min(.05, raw), time = clock.elapsedTime;
@@ -672,6 +682,7 @@ function frameLoop(fixed) {
672
  composer.render();
673
  }
674
 
 
675
  // the flying VOID cars in traffic wear your car's real shape: everything visible on it, baked into one mesh (paint left white so each one gets its own colour)
676
  try {
677
  carRoot.updateMatrixWorld(true);
@@ -686,7 +697,7 @@ try {
686
  if (parts.length) { const geo = mergeGeometries(parts); geo.translate(0, -.02, 0); life.setVoidGeo(geo); life.setVoidDetail(W.quality !== "lite"); window.__voidTris = geo.attributes.position.count / 3; }
687
  } catch (e) { console.warn("VOID flyers keep the simple shape", e); }
688
  setLoad(95);
689
- renderer.compile(scene, camera);
690
  // warm-up: draw every mesh once into a tiny target so its buffers and textures are on the graphics card before you play.
691
  // Otherwise each new part of the city uploads the first time it comes into view, and that is a stutter.
692
  try {
@@ -696,6 +707,7 @@ try {
696
  renderer.setRenderTarget(rt); renderer.render(scene, camera); renderer.setRenderTarget(null); rt.dispose(); renderer.shadowMap.autoUpdate = sh;
697
  for (const [o, v, f, m] of saved) { o.visible = v; o.frustumCulled = f; o.layers.mask = m; } for (const [o, v] of parents) o.visible = v;
698
  } catch (e) { console.warn("warm-up skipped", e); }
 
699
  let raf;
700
  let frameErr = 0;
701
  const loop = () => { raf = requestAnimationFrame(loop); try { frameLoop(); } catch (e) { if (frameErr++ < 3) console.error(e); } }; // one bad frame never stops the game
 
8
  Object.defineProperty(THREE.Material.prototype, "forceSinglePass", { get: () => true, set() {} });
9
  const V = new URL(import.meta.url).search;
10
  const $ = id => document.getElementById(id);
11
+ const LT = window.__loadTimes = [], lt = k => LT.push([k, Math.round(performance.now())]); // where the loading time goes (window.__loadTimes)
12
+ const setLoad = p => { $("ldbar").style.width = p + "%"; $("ldpct").textContent = Math.round(p); lt(p); };
13
  setLoad(15);
14
  const [{ createWorld, clamp, lerp, smooth, frame, PAD_R, ZONES }, { buildCar, MODELS }, { addInterior, HEAD }, { Sound, BAR }, { createWatch, TOTAL, CHAPTERS }, { createGame }, { createLife, personGeo }] = await Promise.all(
15
  ["world", "car", "interior", "sound", "watch", "game", "npc"].map(n => import(`./${n}.js${V}`)));
 
55
  const sound = new Sound();
56
  const watch = createWatch(W, car, interior);
57
  const DENSITY = { few: .55, normal: 1, busy: 1.3 }[(store.get("void.settings") || {}).crowd || (LITE ? "few" : "normal")] || 1;
58
+ const life = createLife(scene, W.roads, W.walkways, COARSE || LITE, DENSITY); lt("life");
59
  const TIMES = ["night", "sunset", "day"];
60
  function cycleTime() { const k = TIMES[(TIMES.indexOf(W.timeOfDay) + 1) % 3]; W.setTime(k); store.set("void.time", k); toast({ night: "Night in the city", sunset: "Golden hour", day: "Daytime" }[k]); cubeN = 0; }
61
  let police, races, nav;
62
+ const net = createNet(); lt("net");
63
  setInterval(() => { if (document.hidden) net.tick(.5, null); }, 500); // a hosting tab in the background still relays everyone
64
  // settings: graphics quality, look speed, auto gas, touch steering (kept on this device)
65
  // a strong graphics card starts on High; everything else starts on Medium (it can be raised in the Menu)
 
82
  mx.font = "bold 12px sans-serif"; mx.textAlign = "center"; for (const st of W.city.stations) { const [a, b] = P(st.x, st.z); if (Math.abs(a) < 90 && Math.abs(b) < 90) { mx.save(); mx.translate(a, b); mx.rotate(g.yaw + Math.PI / 2); mx.fillText("⛽", 0, 4); mx.restore(); } }
83
  },
84
  };
85
+ const game = createGame(W, car, sound, life, gameOpts); lt("game");
86
+ const vehicles = createVehicles(scene, game.api, W); gameOpts.vehicles = vehicles; lt("vehicles");
87
+ const atmos = createAtmos(W, sound, COARSE); lt("atmos");
88
  // trophies: a banner slides in at the top when a challenge is done
89
  const goals = createGoals(gl => { const t = document.getElementById("gTrophy"); t.innerHTML = `<b>${gl.icon}</b><div><span>Challenge complete</span>${gl.name}<em>+1000</em></div>`; t.classList.remove("on"); void t.offsetWidth; t.classList.add("on"); if (game.state) { game.state.score += 1000; } sound.win(); renderGoals(); });
90
  function renderGoals() {
 
141
  }
142
  // headlights that light the road at night
143
  const heads = new THREE.SpotLight(0xfff1d6, 0, 70, .55, .6, 1.2); heads.position.set(2.2, .7, 0); heads.target.position.set(18, -1.5, 0); scene.add(heads, heads.target); // in the scene (it follows the car each frame), so it never disappears with the car
144
+ police = createPolice(scene, game.api, sound, life); lt("police");
145
+ races = createRaces(scene, game.api, sound, W.canvasTex, net); lt("races");
146
+ nav = createNav(scene, game.api, W, ZONES, races.starts, () => clock.elapsedTime); lt("nav");
147
+ // every light now exists (they are part of each shader), so build all the shaders on the graphics driver's own threads
148
+ // while the rest loads; the first city render would otherwise compile them one at a time (about 3 s)
149
+ function precompile() {
150
+ const rt0 = renderer.getRenderTarget(); renderer.setRenderTarget(composer.renderTarget1); // the game always draws into the composer's target
151
+ let p = null; try { p = renderer.compileAsync(scene, camera); } catch (e) { console.warn("precompile", e); }
152
+ renderer.setRenderTarget(rt0); return Promise.race([p || Promise.resolve(), new Promise(r => setTimeout(r, 8000))]);
153
+ }
154
+ const shadersReady = precompile();
155
+ const extras = createExtras(W, game.api, nav, sound); lt("extras");
156
  document.getElementById("gTaxi").onclick = () => extras.toggle();
157
  // people knocked over: a thud, a shake, and the police notice; they bounce off walls instead of flying through them
158
  life.onHit = sp => { sound.thud && sound.thud(Math.min(1, sp / 22)); if (game.state) game.state.shake = Math.min(1, (game.state.shake || 0) + .25); police && police.event("ped"); };
 
240
  function dailyCheck() { for (const k of daily.picks) if (!daily.done.includes(k) && dailyProg(k) >= 1) { daily.done.push(k); store.set("void.daily", daily); cash += 2500; earn(0); toast(`Daily challenge done: ${DAILY[k][2]} · +$2,500`); sound.win(); } }
241
  function renderDaily() { document.getElementById("gDaily").innerHTML = daily.picks.map(k => { const v = dailyProg(k), ok = daily.done.includes(k); return `<div class="goal${ok ? " ok" : ""}"><b>${DAILY[k][2].split(" ")[0]}</b><div><span>${DAILY[k][2].split(" ").slice(1).join(" ")}</span><small>$2,500</small><i style="--v:${v}"></i></div>${ok ? "✅" : `<em>${Math.floor(v * 100)}%</em>`}</div>`; }).join(""); }
242
  // contracts: hybrid drive + fly jobs and pursuits with medals
243
+ const contracts = createContracts(W, game.api, nav, sound, police); lt("contracts");
244
  function renderContracts() { document.getElementById("gContracts").innerHTML = contracts.CONTRACTS.map(c => `<button class="tile con" data-con="${c.id}"><b>${c.icon}</b>${c.name}<small>${c.text}</small><i>${["Not done", "🥉 Bronze", "🥈 Silver", "🥇 Gold"][contracts.best[c.id] || 0]}</i></button>`).join("") + (contracts.active ? `<button class="tile con" id="gConStop"><b>✕</b>Cancel contract</button>` : ""); }
245
  document.getElementById("gContracts").addEventListener("click", e => { const b = e.target.closest("[data-con]"); if (b) { if (extras.job.on) extras.toggle(false); contracts.start(b.dataset.con); } else if (e.target.closest("#gConStop")) contracts.cancel(); });
246
  // backups: every void.* key in one code, so progress survives a new browser or device
 
297
  $("gEmotes").innerHTML = EMOTES.map((e, k) => `<button class="pill" data-e="${k}">${e}</button>`).join("");
298
  $("gEmotes").onclick = ev => { const b = ev.target.closest("[data-e]"); if (!b || !net.online()) return; net.send({ t: "emote", e: +b.dataset.e }); feed(`You: ${EMOTES[+b.dataset.e]}`); };
299
  }
300
+ await shadersReady; lt("shaders");
301
+ W.setTime(store.get("void.time") || "night"); lt("setTime");
302
  // fill the car's reflection cube once before the first frame, so the paint never samples an empty one
303
 
304
  $("osmCredit").textContent = W.credit;
 
349
  const url = location.origin + location.pathname + "#b=" + Object.keys(DEFAULT).map(k => build[k]).join(".");
350
  try { await navigator.clipboard.writeText(url); toast("Link to your build copied"); } catch { prompt("Copy your build link", url); }
351
  };
352
+ applyBuild(); lt("applyBuild");
353
  // ---------- performance upgrades: every point you score is also cash, spent here (kept on this device) ----------
354
  const UPG = { engine: ["⚙️", "Engine", "More power and top speed"], grip: ["🛞", "Tyres", "Grip in corners, longer drifts"], nitro: ["🔥", "Nitro", "A bigger nitro tank"], armor: ["🛡️", "Armour", "Takes less damage"] };
355
  const PRICE = [2500, 6000, 15000];
 
438
  }
439
  addEventListener("resize", resize); addEventListener("orientationchange", () => setTimeout(resize, 200));
440
  if (window.visualViewport) visualViewport.addEventListener("resize", resize);
441
+ resize(); lt("resize");
442
  const fovFor = f => { const a = camera.aspect; if (a >= 1.5) return f; const h = 2 * Math.atan(Math.tan(f * Math.PI / 360) * 1.5); return Math.min(88, 2 * Math.atan(Math.tan(h / 2) / a) * 180 / Math.PI); };
443
 
444
  // ---------- the showroom pose (home and build) ----------
 
494
  }
495
  }
496
  const perf = { t: 0, n: 0, pr: renderer.getPixelRatio(), max: renderer.getPixelRatio(), down: 0 };
497
+ applySettings(); lt("applySettings");
498
  let lastHud = 0, prevGear = 1, feedFlip = 0;
499
  function frameLoop(fixed) {
500
  const raw = fixed ? 0 : clock.getDelta(), dt = fixed || Math.min(.05, raw), time = clock.elapsedTime;
 
682
  composer.render();
683
  }
684
 
685
+ lt("pre-flyers");
686
  // the flying VOID cars in traffic wear your car's real shape: everything visible on it, baked into one mesh (paint left white so each one gets its own colour)
687
  try {
688
  carRoot.updateMatrixWorld(true);
 
697
  if (parts.length) { const geo = mergeGeometries(parts); geo.translate(0, -.02, 0); life.setVoidGeo(geo); life.setVoidDetail(W.quality !== "lite"); window.__voidTris = geo.attributes.position.count / 3; }
698
  } catch (e) { console.warn("VOID flyers keep the simple shape", e); }
699
  setLoad(95);
700
+ await precompile(); lt("compile");
701
  // warm-up: draw every mesh once into a tiny target so its buffers and textures are on the graphics card before you play.
702
  // Otherwise each new part of the city uploads the first time it comes into view, and that is a stutter.
703
  try {
 
707
  renderer.setRenderTarget(rt); renderer.render(scene, camera); renderer.setRenderTarget(null); rt.dispose(); renderer.shadowMap.autoUpdate = sh;
708
  for (const [o, v, f, m] of saved) { o.visible = v; o.frustumCulled = f; o.layers.mask = m; } for (const [o, v] of parents) o.visible = v;
709
  } catch (e) { console.warn("warm-up skipped", e); }
710
+ lt("warm-up");
711
  let raf;
712
  let frameErr = 0;
713
  const loop = () => { raf = requestAnimationFrame(loop); try { frameLoop(); } catch (e) { if (frameErr++ < 3) console.error(e); } }; // one bad frame never stops the game