Static site
Browse files- index.html +2 -2
- static/city.js +7 -4
- 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=
|
| 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=
|
| 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
|
| 639 |
-
let
|
| 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.
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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
|