Death242 commited on
Commit
a2b07e0
·
verified ·
1 Parent(s): 60f1548

Static site

Browse files
Files changed (10) hide show
  1. index.html +7 -3
  2. static/atmos.js +122 -0
  3. static/car.js +411 -385
  4. static/city.js +7 -2
  5. static/game.js +1 -0
  6. static/goals.js +44 -0
  7. static/main.js +492 -473
  8. static/sound.js +3 -1
  9. static/style.css +19 -0
  10. static/world.js +19 -7
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=1792864551">
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": {
@@ -107,8 +107,10 @@
107
  <div class="hud-tr">
108
  <canvas class="g-map" id="gMap" width="180" height="180" aria-label="Minimap (tap for GPS)"></canvas>
109
  <div class="g-btns"><button class="pill solid" id="gFly">Fly</button><button class="pill" id="gMenuBtn" aria-haspopup="dialog">☰ Menu</button></div>
 
110
  <div class="g-players" id="gPlayers"></div>
111
  </div>
 
112
  <div class="hud-top">
113
  <div class="g-race" id="gRace" hidden></div>
114
  <div class="g-gps" id="gGps"></div>
@@ -135,19 +137,21 @@
135
  </div>
136
  <div class="g-menu" id="gMenu" hidden role="dialog" aria-label="Menu">
137
  <div class="menu-head"><h2>Menu</h2><button class="pill solid" id="gMenuClose">Resume</button></div>
 
138
  <div class="menu-grid">
139
  <section><h4>Vehicle</h4><div class="tiles">
140
  <button class="tile" data-veh="car"><b>🚗</b>Car</button><button class="tile" data-veh="plane"><b>✈️</b>Plane</button><button class="tile" data-veh="heli"><b>🚁</b>Helicopter</button><button class="tile" data-veh="boat"><b>🚤</b>Boat</button><button class="tile" id="gGarage"><b>🔧</b>Garage</button></div></section>
141
  <section><h4>Go somewhere</h4><div class="tiles">
142
  <button class="tile" id="gGpsBtn"><b>🧭</b>GPS</button><button class="tile" id="gAuto"><b>🤖</b>Autopilot</button><button class="tile" id="gFuelNav"><b>⛽</b>Nearest fuel</button><button class="tile" id="gNetBtn"><b>🌐</b>Online</button></div></section>
143
  <section><h4>World</h4><div class="tiles">
144
- <button class="tile" id="gTime"><b>🌗</b>Time of day</button><button class="tile" id="gView"><b>🎥</b>Front seat</button><button class="tile" id="gPhoto"><b>📷</b>Photo mode</button><button class="tile" id="gHorn"><b>📯</b>Horn</button></div></section>
145
  <section><h4>Settings</h4>
146
  <label class="set">Graphics<span class="seg" data-set="q"><button data-v="low">Low</button><button data-v="medium">Medium</button><button data-v="high">High</button><button data-v="ultra">Ultra</button></span></label>
147
  <label class="set">Look speed<input type="range" id="gSens" min="0.4" max="2.5" step="0.1"></label>
148
  <label class="set">Auto gas<span class="seg" data-set="autogas"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
149
  <label class="set">Touch steering<span class="seg" data-set="steer"><button data-v="stick">Joystick</button><button data-v="buttons">Buttons</button></span></label>
150
  </section>
 
151
  <section class="wide"><h4>Controls</h4><p class="small"><b>Drive:</b> W A S D or arrows · Space handbrake/drift · Shift nitro · F fly · L land · C camera · R reset · H horn · T time · M GPS · O autopilot · Enter starts a race at a 🏁 marker. <b>Look:</b> click the game, then move the mouse. <b>Phone:</b> joystick on the left, pedals on the right, drag anywhere to look. <b>Fuel:</b> about 10 min driving, 5 min flying; stop at ⛽ to fill up and repair, or drive through a green 🔧.</p></section>
152
  </div>
153
  <div class="menu-foot"><button class="pill" id="gExit">Exit to home</button></div>
@@ -196,6 +200,6 @@
196
  <div id="toast" role="status"></div>
197
  <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>
198
  <script src="/static/hub.js" data-app="void" defer></script>
199
- <script type="module" src="/static/main.js?v=1792864551"></script>
200
  </body>
201
  </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=1792865033">
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": {
 
107
  <div class="hud-tr">
108
  <canvas class="g-map" id="gMap" width="180" height="180" aria-label="Minimap (tap for GPS)"></canvas>
109
  <div class="g-btns"><button class="pill solid" id="gFly">Fly</button><button class="pill" id="gMenuBtn" aria-haspopup="dialog">☰ Menu</button></div>
110
+ <div class="chips" id="gChips"></div>
111
  <div class="g-players" id="gPlayers"></div>
112
  </div>
113
+ <div class="g-trophy" id="gTrophy" aria-live="polite"></div>
114
  <div class="hud-top">
115
  <div class="g-race" id="gRace" hidden></div>
116
  <div class="g-gps" id="gGps"></div>
 
137
  </div>
138
  <div class="g-menu" id="gMenu" hidden role="dialog" aria-label="Menu">
139
  <div class="menu-head"><h2>Menu</h2><button class="pill solid" id="gMenuClose">Resume</button></div>
140
+ <div class="stats-row" id="gStatsRow"></div>
141
  <div class="menu-grid">
142
  <section><h4>Vehicle</h4><div class="tiles">
143
  <button class="tile" data-veh="car"><b>🚗</b>Car</button><button class="tile" data-veh="plane"><b>✈️</b>Plane</button><button class="tile" data-veh="heli"><b>🚁</b>Helicopter</button><button class="tile" data-veh="boat"><b>🚤</b>Boat</button><button class="tile" id="gGarage"><b>🔧</b>Garage</button></div></section>
144
  <section><h4>Go somewhere</h4><div class="tiles">
145
  <button class="tile" id="gGpsBtn"><b>🧭</b>GPS</button><button class="tile" id="gAuto"><b>🤖</b>Autopilot</button><button class="tile" id="gFuelNav"><b>⛽</b>Nearest fuel</button><button class="tile" id="gNetBtn"><b>🌐</b>Online</button></div></section>
146
  <section><h4>World</h4><div class="tiles">
147
+ <button class="tile" id="gTime"><b>🌗</b>Time of day</button><button class="tile" id="gView"><b>🎥</b>Front seat</button><button class="tile" id="gPhoto"><b>📷</b>Photo mode</button><button class="tile" id="gHorn"><b>📯</b>Horn</button><button class="tile" id="gWeather"><b>☀️</b>Weather</button><button class="tile" id="gRadio"><b>🎵</b>Radio</button></div></section>
148
  <section><h4>Settings</h4>
149
  <label class="set">Graphics<span class="seg" data-set="q"><button data-v="low">Low</button><button data-v="medium">Medium</button><button data-v="high">High</button><button data-v="ultra">Ultra</button></span></label>
150
  <label class="set">Look speed<input type="range" id="gSens" min="0.4" max="2.5" step="0.1"></label>
151
  <label class="set">Auto gas<span class="seg" data-set="autogas"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
152
  <label class="set">Touch steering<span class="seg" data-set="steer"><button data-v="stick">Joystick</button><button data-v="buttons">Buttons</button></span></label>
153
  </section>
154
+ <section class="wide"><h4>Challenges</h4><div class="goals" id="gGoals"></div></section>
155
  <section class="wide"><h4>Controls</h4><p class="small"><b>Drive:</b> W A S D or arrows · Space handbrake/drift · Shift nitro · F fly · L land · C camera · R reset · H horn · T time · M GPS · O autopilot · Enter starts a race at a 🏁 marker. <b>Look:</b> click the game, then move the mouse. <b>Phone:</b> joystick on the left, pedals on the right, drag anywhere to look. <b>Fuel:</b> about 10 min driving, 5 min flying; stop at ⛽ to fill up and repair, or drive through a green 🔧.</p></section>
156
  </div>
157
  <div class="menu-foot"><button class="pill" id="gExit">Exit to home</button></div>
 
200
  <div id="toast" role="status"></div>
201
  <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>
202
  <script src="/static/hub.js" data-app="void" defer></script>
203
+ <script type="module" src="/static/main.js?v=1792865033"></script>
204
  </body>
205
  </html>
static/atmos.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Atmosphere: weather (clear, rain with wet shiny roads, thunderstorms, fog, snow), clouds, a sun lens flare,
2
+ // skid marks when you slide or brake hard, and fireworks over Central Park at night.
3
+ import * as THREE from "three";
4
+ import { Lensflare, LensflareElement } from "three/addons/objects/Lensflare.js";
5
+
6
+ const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
7
+ export const WEATHERS = ["clear", "rain", "storm", "fog", "snow"];
8
+
9
+ export function createAtmos(W, sound, COARSE) {
10
+ const { scene, camera } = W;
11
+ const tex = (draw, w = 128, h = 128) => { const c = document.createElement("canvas"); c.width = w; c.height = h; draw(c.getContext("2d"), w, h); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; };
12
+ const radial = (stops) => tex((g, w) => { const r = g.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); stops.forEach(([o, c]) => r.addColorStop(o, c)); g.fillStyle = r; g.fillRect(0, 0, w, w); });
13
+
14
+ // ---- rain and snow: a box of particles that rides with the camera ----
15
+ const RN = COARSE ? 1400 : 4000, BOX = 70, H = 40;
16
+ const rainPos = new Float32Array(RN * 6), rainGeo = new THREE.BufferGeometry(); rainGeo.setAttribute("position", new THREE.BufferAttribute(rainPos, 3));
17
+ const drops = Array.from({ length: RN }, () => [Math.random() * BOX - BOX / 2, Math.random() * H, Math.random() * BOX - BOX / 2, 18 + Math.random() * 8]);
18
+ const rain = new THREE.LineSegments(rainGeo, new THREE.LineBasicMaterial({ color: 0xaecbe8, transparent: true, opacity: .38, depthWrite: false })); rain.frustumCulled = false; rain.visible = false; scene.add(rain);
19
+ const SN = COARSE ? 1200 : 3000, snowPos = new Float32Array(SN * 3), snowGeo = new THREE.BufferGeometry(); snowGeo.setAttribute("position", new THREE.BufferAttribute(snowPos, 3));
20
+ const flakes = Array.from({ length: SN }, () => [Math.random() * BOX - BOX / 2, Math.random() * H, Math.random() * BOX - BOX / 2, Math.random() * 6.28]);
21
+ const snow = new THREE.Points(snowGeo, new THREE.PointsMaterial({ color: 0xffffff, size: .18, map: radial([[0, "rgba(255,255,255,1)"], [1, "rgba(255,255,255,0)"]]), transparent: true, depthWrite: false })); snow.frustumCulled = false; snow.visible = false; scene.add(snow);
22
+
23
+ // ---- clouds: a big soft layer high up, drifting ----
24
+ const cloudTex = tex((g, w, h) => {
25
+ const img = g.createImageData(w, h), N = (x, y, s) => { const xi = Math.floor(x / s), yi = Math.floor(y / s), f = (a, b) => { const v = Math.sin((a % 64) * 127.1 + (b % 64) * 311.7) * 43758.5; return v - Math.floor(v); }; const fx = x / s - xi, fy = y / s - yi, u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy); return (f(xi, yi) * (1 - u) + f(xi + 1, yi) * u) * (1 - v) + (f(xi, yi + 1) * (1 - u) + f(xi + 1, yi + 1) * u) * v; };
26
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let n = 0, a = .5; for (const s of [64, 32, 16, 8]) { n += N(x, y, s) * a; a *= .5; } const k = (y * w + x) * 4, c = clamp((n - .45) * 2.2); img.data[k] = img.data[k + 1] = img.data[k + 2] = 255; img.data[k + 3] = c * 220; }
27
+ g.putImageData(img, 0, 0);
28
+ }, 256, 256);
29
+ cloudTex.wrapS = cloudTex.wrapT = THREE.RepeatWrapping; cloudTex.repeat.set(5, 5);
30
+ const clouds = new THREE.Mesh(new THREE.PlaneGeometry(9000, 9000).rotateX(Math.PI / 2), new THREE.MeshBasicMaterial({ map: cloudTex, transparent: true, depthWrite: false, fog: false, opacity: .85, side: THREE.DoubleSide }));
31
+ clouds.frustumCulled = false; clouds.renderOrder = -1; scene.add(clouds);
32
+
33
+ // ---- sun lens flare ----
34
+ const flareLight = new THREE.PointLight(0xffffff, 0, 1); scene.add(flareLight);
35
+ const lf = new Lensflare();
36
+ lf.addElement(new LensflareElement(radial([[0, "rgba(255,250,235,1)"], [.2, "rgba(255,230,180,.6)"], [1, "rgba(255,200,120,0)"]]), 380, 0));
37
+ const ring = radial([[0, "rgba(255,255,255,0)"], [.6, "rgba(255,255,255,0)"], [.7, "rgba(180,220,255,.35)"], [1, "rgba(255,255,255,0)"]]), dot = radial([[0, "rgba(255,220,180,.5)"], [1, "rgba(255,220,180,0)"]]);
38
+ for (const [s, d, t] of [[60, .4, dot], [90, .6, ring], [40, .75, dot], [140, .95, ring]]) lf.addElement(new LensflareElement(t, s, d));
39
+ flareLight.add(lf);
40
+
41
+ // ---- skid marks: a ring buffer of dark quads laid where the rear tyres slide ----
42
+ const SK = 1600, skPos = new Float32Array(SK * 18), skGeo = new THREE.BufferGeometry(); skGeo.setAttribute("position", new THREE.BufferAttribute(skPos, 3));
43
+ const skids = new THREE.Mesh(skGeo, new THREE.MeshBasicMaterial({ color: 0x0a0a0a, transparent: true, opacity: .55, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 })); skids.frustumCulled = false; scene.add(skids);
44
+ let skI = 0; const lastT = [null, null];
45
+ function lay(k, x, z, px, pz) {
46
+ const dx = x - px, dz = z - pz, L = Math.hypot(dx, dz); if (L < .05 || L > 3) return;
47
+ const nx = -dz / L * .14, nz = dx / L * .14, y = .045, o = (skI++ % SK) * 18;
48
+ skPos.set([px + nx, y, pz + nz, px - nx, y, pz - nz, x - nx, y, z - nz, px + nx, y, pz + nz, x - nx, y, z - nz, x + nx, y, z + nz], o);
49
+ skGeo.attributes.position.needsUpdate = true; skGeo.setDrawRange(0, Math.min(skI, SK) * 6);
50
+ }
51
+
52
+ // ---- fireworks over the park at night ----
53
+ const FW = 600, fwPos = new Float32Array(FW * 3), fwCol = new Float32Array(FW * 3), fwGeo = new THREE.BufferGeometry();
54
+ fwGeo.setAttribute("position", new THREE.BufferAttribute(fwPos, 3)); fwGeo.setAttribute("color", new THREE.BufferAttribute(fwCol, 3));
55
+ const fw = new THREE.Points(fwGeo, new THREE.PointsMaterial({ size: 2.4, vertexColors: true, map: radial([[0, "rgba(255,255,255,1)"], [1, "rgba(255,255,255,0)"]]), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false, toneMapped: false }));
56
+ fw.frustumCulled = false; scene.add(fw);
57
+ const sparks = Array.from({ length: FW }, () => ({ life: 0, p: new THREE.Vector3(), v: new THREE.Vector3(), c: new THREE.Color() })); let fwNext = 4, fwI = 0;
58
+ function burst() {
59
+ const L = W.city.lawn, at = new THREE.Vector3(L.x + (Math.random() - .5) * 200, 120 + Math.random() * 80, L.z + (Math.random() - .5) * 200), col = new THREE.Color().setHSL(Math.random(), .9, .6);
60
+ for (let k = 0; k < 120; k++) { const s = sparks[fwI++ % FW], th = Math.random() * 6.28, ph = Math.acos(2 * Math.random() - 1), sp = 18 + Math.random() * 10; s.life = 1.6 + Math.random() * .6; s.p.copy(at); s.v.set(Math.sin(ph) * Math.cos(th) * sp, Math.cos(ph) * sp, Math.sin(ph) * Math.sin(th) * sp); s.c.copy(col).multiplyScalar(2.5); }
61
+ if (sound.on && sound.ctx) { const d = camera.position.distanceTo(at); if (d < 900) sound.pop(); }
62
+ }
63
+
64
+ let weather = "clear", flash = 0, nextBolt = 6;
65
+ const asphalt = W.city.asphaltMat, asphalt0 = asphalt ? { r: asphalt.roughness, c: asphalt.color.clone(), e: asphalt.envMapIntensity } : null;
66
+ function setWeather(w) {
67
+ weather = w;
68
+ const wet = w === "rain" || w === "storm";
69
+ rain.visible = wet; snow.visible = w === "snow";
70
+ if (asphalt) { asphalt.roughness = wet ? .18 : w === "snow" ? .95 : asphalt0.r; asphalt.color.copy(asphalt0.c).multiplyScalar(wet ? .55 : w === "snow" ? 1.9 : 1); asphalt.envMapIntensity = wet ? 1.6 : asphalt0.e; }
71
+ W.weatherFog = w === "fog" ? .3 : w === "storm" ? .55 : wet ? .7 : w === "snow" ? .6 : 1;
72
+ W.weatherFog = w === "fog" ? .3 : w === "storm" ? .55 : wet ? .7 : w === "snow" ? .6 : 1;
73
+ W.setWeatherLook(w === "storm" ? .55 : wet ? .72 : w === "fog" ? .85 : w === "snow" ? .9 : 1, w !== "clear");
74
+ }
75
+
76
+ return {
77
+ get weather() { return weather; }, setWeather,
78
+ update(dt, time, S, g) {
79
+ const cam = camera.position, tod = W.timeOfDay;
80
+ // rain / snow follow the camera
81
+ if (rain.visible) {
82
+ const wx = 3, wz = 1.5;
83
+ for (let i = 0; i < RN; i++) { const d = drops[i]; d[1] -= d[3] * dt; if (d[1] < 0) { d[1] += H; d[0] = Math.random() * BOX - BOX / 2; d[2] = Math.random() * BOX - BOX / 2; } const x = cam.x + d[0], y = cam.y - 10 + d[1], z = cam.z + d[2], o = i * 6; rainPos[o] = x; rainPos[o + 1] = y; rainPos[o + 2] = z; rainPos[o + 3] = x + wx * .05; rainPos[o + 4] = y + .9; rainPos[o + 5] = z + wz * .05; }
84
+ rainGeo.attributes.position.needsUpdate = true;
85
+ }
86
+ if (snow.visible) {
87
+ for (let i = 0; i < SN; i++) { const f = flakes[i]; f[1] -= 1.6 * dt; f[3] += dt; if (f[1] < 0) f[1] += H; snowPos[i * 3] = cam.x + f[0] + Math.sin(f[3]) * .6; snowPos[i * 3 + 1] = cam.y - 10 + f[1]; snowPos[i * 3 + 2] = cam.z + f[2] + Math.cos(f[3] * .7) * .6; }
88
+ snowGeo.attributes.position.needsUpdate = true;
89
+ }
90
+ // lightning in a storm
91
+ if (weather === "storm" && (nextBolt -= dt) <= 0) { nextBolt = 5 + Math.random() * 10; flash = 1; if (sound.on) setTimeout(() => sound.crash(), 300 + Math.random() * 1200); }
92
+ flash = Math.max(0, flash - dt * 3); W.setFlash(flash * (Math.random() < .5 ? 1 : .4));
93
+ // clouds: day and sunset (grey and lower in rain)
94
+ clouds.visible = tod !== "night" || weather !== "clear";
95
+ clouds.position.set(cam.x, weather === "clear" ? 520 : 260, cam.z);
96
+ cloudTex.offset.set((time * .002 + cam.x / 9000 * 5) % 1, (time * .001 + cam.z / 9000 * 5) % 1);
97
+ clouds.material.color.set(weather !== "clear" ? 0x6a7280 : tod === "sunset" ? 0xffc8a0 : 0xffffff); clouds.material.opacity = weather === "storm" ? 1 : weather !== "clear" ? .95 : tod === "night" ? .25 : .8;
98
+ // the lens flare sits on the sun, only on a clear day or sunset
99
+ flareLight.visible = tod !== "night" && weather === "clear";
100
+ if (flareLight.visible && W.sunDir) flareLight.position.copy(cam).addScaledVector(W.sunDir, 800);
101
+ // skid marks: sliding sideways or braking hard on the road
102
+ if (g && g.mode === "drive" && (g.veh || "car") === "car" && g.y < .2) {
103
+ const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw), u = g.vel.x * fx + g.vel.z * fz, w = -g.vel.x * fz + g.vel.z * fx;
104
+ const sliding = Math.abs(w) > 3.5 || (g.lastU != null && g.lastU - u > 14 * dt && u > 6);
105
+ g.lastU = u;
106
+ for (const k of [0, 1]) {
107
+ const side = k ? .8 : -.8, x = g.x - fx * 1.4 - fz * side, z = g.z - fz * 1.4 + fx * side;
108
+ if (sliding && lastT[k]) lay(k, x, z, lastT[k][0], lastT[k][1]);
109
+ lastT[k] = sliding ? [x, z] : null;
110
+ }
111
+ } else lastT[0] = lastT[1] = null;
112
+ // fireworks at night
113
+ if (tod === "night" && weather !== "storm" && (fwNext -= dt) <= 0) { fwNext = 5 + Math.random() * 9; burst(); }
114
+ for (let i = 0; i < FW; i++) {
115
+ const s = sparks[i];
116
+ if (s.life > 0) { s.life -= dt; s.v.y -= 6 * dt; s.v.multiplyScalar(Math.pow(.6, dt)); s.p.addScaledVector(s.v, dt); const f = clamp(s.life); fwPos.set([s.p.x, s.p.y, s.p.z], i * 3); fwCol.set([s.c.r * f, s.c.g * f, s.c.b * f], i * 3); }
117
+ else fwPos[i * 3 + 1] = -999;
118
+ }
119
+ fwGeo.attributes.position.needsUpdate = fwGeo.attributes.color.needsUpdate = true;
120
+ },
121
+ };
122
+ }
static/car.js CHANGED
@@ -1,385 +1,411 @@
1
- // The VOID car (shape inspired by the McLaren 765LT), modelled in code: the body and canopy are lofted from side-profile curves
2
- // (a rounded superellipse cross-section at every station), the wheel wells are carved out of
3
- // the sides, and a blueprint of grid lines can be swept away by a clipping plane to "build" it.
4
- import * as THREE from "three";
5
- import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
6
-
7
- const X0 = -2.3, LEN = 4.6; // rear tip to nose, along +x
8
- const WHEELS = [1.42, -1.38]; // front and rear axle x (a 2.8 m wheelbase)
9
- const WR = 0.36, ARCH = 0.44, WELL = 0.62; // wheel radius, arch radius, how far in the wells are carved
10
-
11
- // cosine interpolation through [u, value] keys (u from 0 at the rear to 1 at the nose)
12
- function curve(keys, u) {
13
- if (u <= keys[0][0]) return keys[0][1];
14
- for (let i = 1; i < keys.length; i++) {
15
- const [u1, v1] = keys[i], [u0, v0] = keys[i - 1];
16
- if (u <= u1) { const s = (1 - Math.cos(Math.PI * (u - u0) / (u1 - u0))) / 2; return v0 + (v1 - v0) * s; }
17
- }
18
- return keys[keys.length - 1][1];
19
- }
20
- const spow = (v, p) => Math.sign(v) * Math.pow(Math.abs(v), p);
21
-
22
- // long-tail proportions: a tall, blunt tail, wide rear haunches, a pinched waist where the door
23
- // scoop is, wide front fenders and a low, narrow nose
24
- const BODY = {
25
- x0: X0, len: LEN, n: 6, tumble: .3,
26
- top: [[0, .84], [.03, .93], [.12, .96], [.25, .96], [.4, .9], [.55, .84], [.66, .8], [.78, .68], [.9, .54], [.97, .44], [1, .36]],
27
- bot: [[0, .3], [.04, .2], [.12, .16], [.88, .16], [.96, .2], [1, .28]],
28
- half: [[0, .86], [.05, .95], [.18, 1.0], [.35, .96], [.5, .91], [.62, .93], [.8, .99], [.93, .93], [1, .62]],
29
- ends: [.012, .06], fenders: true, scoop: true,
30
- };
31
- // a cab-forward teardrop: the screen starts far forward, the roof peaks over the doors and the
32
- // glass runs back in a long taper over the engine
33
- const CANOPY = {
34
- x0: -1.35, len: 2.35, n: 4, tumble: .42,
35
- top: [[0, .92], [.12, 1.0], [.3, 1.12], [.5, 1.19], [.65, 1.17], [.8, 1.06], [.92, .92], [1, .8]],
36
- bot: [[0, .72], [1, .72]],
37
- half: [[0, .42], [.2, .6], [.5, .72], [.75, .72], [.92, .64], [1, .5]],
38
- ends: [.15, .06], fenders: false,
39
- };
40
-
41
- // the other cars in the garage, lofted the same way
42
- export const MODELS = {
43
- hydra: { name: "Hydra LTE242", body: BODY, canopy: CANOPY },
44
- vandal: { name: "Vandal GT", note: "Muscle fastback", body: { x0: X0, len: LEN, n: 8, tumble: .2, fenders: true, scoop: false, ends: [.02, .03],
45
- top: [[0, .92], [.06, .98], [.2, 1.0], [.35, .98], [.5, .95], [.62, .93], [.75, .9], [.9, .84], [.98, .74], [1, .66]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]],
46
- half: [[0, .86], [.06, .93], [.2, .96], [.5, .92], [.8, .94], [.95, .9], [1, .72]] },
47
- canopy: { x0: -1.25, len: 2.0, n: 4, tumble: .35, ends: [.2, .08], fenders: false, top: [[0, .98], [.15, 1.05], [.35, 1.25], [.6, 1.28], [.8, 1.2], [.95, 1.0], [1, .95]], bot: [[0, .88], [1, .88]], half: [[0, .6], [.3, .72], [.7, .72], [1, .6]] } },
48
- wraith: { name: "Wraith X", note: "Wedge hypercar", body: { x0: X0, len: LEN, n: 5, tumble: .32, fenders: true, scoop: true, ends: [.01, .04],
49
- top: [[0, .8], [.05, .86], [.2, .88], [.4, .84], [.6, .74], [.8, .6], [.95, .45], [1, .34]], bot: [[0, .26], [.04, .16], [.12, .13], [.88, .13], [.96, .16], [1, .22]],
50
- half: [[0, .9], [.05, 1.0], [.2, 1.02], [.4, .96], [.6, .95], [.82, .98], [.95, .9], [1, .55]] },
51
- canopy: { x0: -.9, len: 2.3, n: 4, tumble: .45, ends: [.2, .05], fenders: false, top: [[0, .82], [.2, 1.0], [.5, 1.07], [.75, 1.0], [.95, .8], [1, .7]], bot: [[0, .68], [1, .62]], half: [[0, .38], [.3, .62], [.6, .66], [.9, .55], [1, .45]] } },
52
- titan: { name: "Titan RS", note: "Super SUV", body: { x0: X0, len: LEN, n: 10, tumble: .12, fenders: true, scoop: false, ends: [.02, .03],
53
- top: [[0, 1.15], [.05, 1.2], [.6, 1.2], [.75, 1.12], [.9, 1.0], [1, .9]], bot: [[0, .38], [.05, .28], [.12, .26], [.88, .26], [.95, .28], [1, .36]], half: [[0, .92], [.05, .98], [.5, 1.0], [.95, .98], [1, .85]] },
54
- canopy: { x0: -2.15, len: 3.3, n: 10, tumble: .15, ends: [.02, .12], fenders: false, top: [[0, 1.15], [.05, 1.62], [.7, 1.66], [.85, 1.5], [1, 1.2]], bot: [[0, 1.1], [1, 1.1]], half: [[0, .86], [.5, .9], [1, .86]] } },
55
- cab: { name: "Big Apple Cab", note: "A yellow cab with a jet kit", body: { x0: X0, len: LEN, n: 9, tumble: .15, fenders: true, scoop: false, ends: [.02, .03],
56
- top: [[0, .9], [.05, .95], [.25, .96], [.35, .95], [.65, .93], [.8, .9], [.95, .84], [1, .76]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]], half: [[0, .86], [.05, .92], [.5, .93], [.95, .9], [1, .75]] },
57
- canopy: { x0: -1.35, len: 2.5, n: 8, tumble: .2, ends: [.1, .1], fenders: false, top: [[0, .95], [.15, 1.35], [.75, 1.38], [.9, 1.2], [1, .95]], bot: [[0, .9], [1, .9]], half: [[0, .75], [.5, .82], [1, .75]] } },
58
- };
59
-
60
- // a (nu+1) x nv grid of points on the lofted surface
61
- function loft(spec, nu, nv) {
62
- const P = new Float32Array((nu + 1) * nv * 3);
63
- for (let i = 0; i <= nu; i++) {
64
- const u = i / nu, x = spec.x0 + spec.len * u;
65
- const t = curve(spec.top, u), b = curve(spec.bot, u), w = curve(spec.half, u);
66
- const c = (t + b) / 2, h = (t - b) / 2;
67
- const e = Math.pow(Math.max(0, Math.min(1, u / spec.ends[0], (1 - u) / spec.ends[1])), .35);
68
- for (let j = 0; j < nv; j++) {
69
- const th = (j / nv) * Math.PI * 2, cz = Math.cos(th), sy = Math.sin(th);
70
- let z = w * spow(cz, 2 / spec.n), y = h * spow(sy, 2 / spec.n);
71
- if (sy > 0) z *= 1 - spec.tumble * sy * sy;
72
- if (spec.fenders && sy > 0) for (const xw of WHEELS) y += (xw > 0 ? .2 : .14) * Math.exp(-(((x - xw) / .55) ** 2)) * Math.abs(cz) ** 3;
73
- z *= e; y = c + y * e;
74
- if (spec.scoop && Math.abs(cz) > .3) { // the big intake carved into each door, deepest at its front
75
- const xr = (x + .35) / .62, yr = (y - .6) / .17;
76
- if (Math.abs(xr) < 1 && Math.abs(yr) < 1) z *= 1 - .17 * (1 - xr * xr) * Math.sqrt(1 - yr * yr) * (1.15 - .3 * xr);
77
- }
78
- if (spec.fenders) for (const xw of WHEELS) {
79
- if ((x - xw) ** 2 + (y - WR) ** 2 < ARCH * ARCH && Math.abs(z) > WELL) z = Math.sign(z) * WELL;
80
- }
81
- const k = (i * nv + j) * 3; P[k] = x; P[k + 1] = y; P[k + 2] = z;
82
- }
83
- }
84
- return { P, nu, nv };
85
- }
86
-
87
- function surface({ P, nu, nv }) {
88
- const idx = [];
89
- for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
90
- const a = i * nv + j, b = i * nv + (j + 1) % nv, c = (i + 1) * nv + j, d = (i + 1) * nv + (j + 1) % nv;
91
- idx.push(a, c, b, b, c, d);
92
- }
93
- const g = new THREE.BufferGeometry();
94
- g.setAttribute("position", new THREE.BufferAttribute(P, 3));
95
- g.setIndex(idx); g.computeVertexNormals();
96
- return g;
97
- }
98
-
99
- // blueprint: every few rings across the car, and every few lines along it
100
- function gridLines({ P, nu, nv }, ringStep, lineStep) {
101
- const pts = [], at = (i, j) => { const k = (i * nv + j) * 3; return [P[k], P[k + 1], P[k + 2]]; };
102
- for (let i = 0; i <= nu; i += ringStep) for (let j = 0; j < nv; j++) pts.push(...at(i, j), ...at(i, (j + 1) % nv));
103
- for (let j = 0; j < nv; j += lineStep) for (let i = 0; i < nu; i++) pts.push(...at(i, j), ...at(i + 1, j));
104
- const g = new THREE.BufferGeometry();
105
- g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
106
- return g;
107
- }
108
-
109
- export function buildCar(model = "hydra") {
110
- const M = MODELS[model] || MODELS.hydra, BS = M.body, CS = M.canopy;
111
- const car = new THREE.Group();
112
- const solid = new THREE.Group(), wire = new THREE.Group();
113
- car.add(solid, wire);
114
-
115
- // the reveal: solid shows behind the scan plane, blueprint in front of it
116
- const solidClip = new THREE.Plane(new THREE.Vector3(-1, 0, 0), -3);
117
- const wireClip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 3);
118
- const clip = [solidClip];
119
-
120
- const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .35, roughness: .38, clearcoat: .6, clearcoatRoughness: .2, envMapIntensity: .9, clippingPlanes: clip });
121
- const glass = new THREE.MeshPhysicalMaterial({ color: 0x0a0d12, metalness: .1, roughness: .03, clearcoat: 1, envMapIntensity: 1.0, transparent: true, opacity: .6, depthWrite: false, clippingPlanes: clip }); // tinted, so you can see the cabin
122
- const carbon = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, metalness: .4, roughness: .55, clippingPlanes: clip });
123
- const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: .9, clippingPlanes: clip });
124
- const rim = new THREE.MeshStandardMaterial({ color: 0x121215, metalness: .9, roughness: .28, clippingPlanes: clip });
125
- const caliper = new THREE.MeshStandardMaterial({ color: 0xc1121f, metalness: .3, roughness: .4, clippingPlanes: clip });
126
- const front = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xe8fbff).multiplyScalar(3), toneMapped: false, clippingPlanes: clip });
127
- const rear = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a3a).multiplyScalar(2.6), toneMapped: false, clippingPlanes: clip });
128
- const trim = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .4, roughness: .3, clearcoat: 1, clippingPlanes: clip }); // the red accents
129
- const ti2 = new THREE.MeshStandardMaterial({ color: 0x8d9099, metalness: 1, roughness: .25, side: THREE.DoubleSide, clippingPlanes: clip });
130
- const pipeOf = () => new THREE.CylinderGeometry(.06, .06, .2, 20, 1, true).rotateZ(Math.PI / 2);
131
- const lineMat = new THREE.LineBasicMaterial({ color: 0xeef0f5, transparent: true, opacity: .75, clippingPlanes: [wireClip] });
132
-
133
- const bodyHi = loft(BS, 220, 96), canHi = loft(CS, 90, 64);
134
- const bodyMesh = new THREE.Mesh(surface(bodyHi), paint), canMesh = new THREE.Mesh(surface(canHi), glass);
135
- solid.add(bodyMesh, canMesh);
136
- wire.add(new THREE.LineSegments(gridLines(loft(BS, 110, 48), 5, 4), lineMat), new THREE.LineSegments(gridLines(loft(CS, 40, 32), 5, 4), lineMat));
137
- const topAt = u => curve(BS.top, u), halfAt = u => curve(BS.half, u);
138
-
139
- const add = (geo, mat, x, y, z, parent = solid) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parent.add(m); return m; };
140
-
141
- // details: eye-socket headlights, hood vents, splitter, side blades and sills, flying
142
- // buttresses, engine louvers, a full-width wing, a mesh tail with four high exhausts, a diffuser
143
- const beam = (a, b, w, h, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, a.distanceTo(b), h), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); solid.add(m); return m; };
144
- const exhausts = [];
145
- if (model === "hydra") {
146
- for (const s of [-1, 1]) {
147
- const sock = add(new THREE.SphereGeometry(.2, 28, 14), carbon, 1.97, .52, s * .62);
148
- sock.scale.set(.7, .42, 1.15); sock.rotation.y = s * .55; sock.rotation.x = s * .25;
149
- const led = add(new THREE.BoxGeometry(.03, .022, .3), front, 2.04, .56, s * .64); led.rotation.y = s * .6; led.rotation.x = s * -.35;
150
- add(new THREE.BoxGeometry(.03, .02, .12), front, 2.07, .49, s * .5).rotation.y = s * .25;
151
- const vent = add(new THREE.BoxGeometry(.36, .012, .24), carbon, 1.5, .64, s * .3); vent.rotation.z = -.43;
152
- const blade = add(new THREE.BoxGeometry(.62, .07, .02), carbon, .88, .4, s * .975); blade.rotation.z = .22;
153
- add(new THREE.BoxGeometry(2.05, .07, .03), carbon, -.05, .21, s * .955);
154
- add(new THREE.BoxGeometry(2.05, .012, .034), trim, -.05, .248, s * .956); // red pinstripe on the sill
155
- beam(new THREE.Vector3(-.45, 1.1, s * .6), new THREE.Vector3(-2.05, .95, s * .86), .07, .11, paint);
156
- const tl = add(new THREE.BoxGeometry(.03, .03, .56), rear, -2.29, .82, s * .52); tl.rotation.x = s * .05;
157
- add(new THREE.BoxGeometry(.03, .2, .03), rear, -2.28, .72, s * .8);
158
- }
159
- for (let k = 0; k < 6; k++) add(new THREE.BoxGeometry(.06, .015, .92), carbon, -1.42 - k * .11, .975, 0);
160
- add(new THREE.BoxGeometry(.3, .03, 1.62), carbon, 2.06, .175, 0);
161
- add(new THREE.BoxGeometry(.02, .032, 1.62), trim, 2.2, .175, 0); // red splitter lip
162
- const wing = add(new THREE.BoxGeometry(.38, .03, 1.94), carbon, -2.12, 1.02, 0); wing.rotation.z = .12;
163
- for (const z of [-.97, .97]) add(new THREE.BoxGeometry(.42, .16, .02), trim, -2.1, .98, z);
164
- add(new THREE.BoxGeometry(.04, .32, 1.5), carbon, -2.3, .58, 0);
165
- for (let k = -3; k <= 3; k++) add(new THREE.BoxGeometry(.6, .2, .025), carbon, -2.06, .25, k * .2).rotation.z = -.42;
166
- const ti = new THREE.MeshStandardMaterial({ color: 0x6d7079, metalness: 1, roughness: .3, side: THREE.DoubleSide, clippingPlanes: clip });
167
- const pipe = new THREE.CylinderGeometry(.05, .05, .22, 24, 1, true).rotateZ(Math.PI / 2), soot = new THREE.CircleGeometry(.045, 20).rotateY(-Math.PI / 2);
168
- for (const y of [.6, .72]) for (const z of [-.065, .065]) { add(pipe, ti, -2.28, y, z); add(soot, rubber, -2.37, y, z); }
169
- for (const y of [.6, .72]) for (const z of [-.065, .065]) exhausts.push([-2.38, y, z]);
170
- } else {
171
- // shared details: headlights, tail lights, a splitter, sills, mirrors, twin exhausts
172
- const nose = X0 + LEN, ny = topAt(.96) - .1, rearY = topAt(.03) - .12;
173
- for (const s of [-1, 1]) {
174
- add(new THREE.BoxGeometry(.05, .07, .36), front, nose - .1, ny, s * halfAt(.94) * .62).rotation.y = s * .35;
175
- add(new THREE.BoxGeometry(.05, .08, .5), rear, X0 + .03, rearY, s * halfAt(.04) * .62);
176
- add(new THREE.BoxGeometry(2.1, .07, .03), carbon, 0, curve(BS.bot, .5) + .05, s * (halfAt(.5) - .02));
177
- const mir = add(new THREE.BoxGeometry(.12, .08, .16), paint, CS.x0 + CS.len * .82, curve(CS.bot, .8) + .06, s * (curve(CS.half, .8) + .1)); mir.rotation.y = s * .2;
178
- add(pipeOf(), ti2, X0 + .02, curve(BS.bot, .05) + .1, s * .35); exhausts.push([X0 - .08, curve(BS.bot, .05) + .1, s * .35]);
179
- }
180
- add(new THREE.BoxGeometry(.3, .03, halfAt(.97) * 1.6), carbon, nose - .2, curve(BS.bot, .97) + .02, 0);
181
- add(new THREE.BoxGeometry(.02, .032, halfAt(.97) * 1.6), trim, nose - .06, curve(BS.bot, .97) + .02, 0);
182
- add(new THREE.BoxGeometry(.02, .05, halfAt(.02) * 1.4), rear, X0 + .01, rearY + .07, 0);
183
- if (model === "cab") { // the roof sign and the checker stripe
184
- const signT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#fff6d0"; g.fillRect(0, 0, 256, 64); g.fillStyle = "#111"; g.font = "900 40px sans-serif"; g.textAlign = "center"; g.fillText("TAXI", 128, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
185
- const sign = add(new THREE.BoxGeometry(.55, .22, .9), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6), clippingPlanes: clip }), CS.x0 + CS.len * .45, curve(CS.top, .45) + .1, 0);
186
- sign.rotation.y = Math.PI / 2;
187
- }
188
- }
189
- add(new THREE.BoxGeometry(3.6, .03, 1.7), carbon, -.1, .16, 0); // flat floor
190
-
191
- // wheels: a rounded tyre, a deep-dish rim with twin spokes and a centre lock, a brake disc
192
- // (all spinning) and a fixed caliper
193
- const wheels = [];
194
- const V2 = (x, y) => new THREE.Vector2(x, y);
195
- const tyreG = new THREE.LatheGeometry([V2(.265, -.15), V2(.32, -.156), V2(.35, -.145), V2(.362, -.115), V2(WR + .004, 0), V2(.362, .115), V2(.35, .145), V2(.32, .156), V2(.265, .15)], 72).rotateX(Math.PI / 2);
196
- const barrelG = new THREE.LatheGeometry([V2(.262, .15), V2(.27, .13), V2(.268, -.13), V2(.24, -.14)], 64).rotateX(Math.PI / 2);
197
- const lipG = new THREE.TorusGeometry(.266, .009, 8, 64);
198
- const discG = new THREE.CylinderGeometry(.2, .2, .03, 48).rotateX(Math.PI / 2), hatG = new THREE.CylinderGeometry(.09, .09, .06, 32).rotateX(Math.PI / 2);
199
- const nutG = new THREE.CylinderGeometry(.045, .045, .05, 6).rotateX(Math.PI / 2);
200
- const spokeG = new THREE.BoxGeometry(.026, .2, .028).translate(0, .155, 0);
201
- const disc = new THREE.MeshStandardMaterial({ color: 0x5a5c62, metalness: .8, roughness: .45, clippingPlanes: clip });
202
- const wireWheel = new THREE.EdgesGeometry(new THREE.CylinderGeometry(WR, WR, .3, 28).rotateX(Math.PI / 2), 20);
203
- for (const xw of WHEELS) for (const s of [-1, 1]) {
204
- const spin = new THREE.Group(); spin.position.set(xw, WR, s * .83); solid.add(spin);
205
- spin.add(new THREE.Mesh(tyreG, rubber), new THREE.Mesh(barrelG, carbon));
206
- const lip = new THREE.Mesh(lipG, rim); lip.position.z = s * .14; spin.add(lip);
207
- const d = new THREE.Mesh(discG, disc); d.position.z = -s * .04; spin.add(d);
208
- const hat = new THREE.Mesh(hatG, rim); hat.position.z = s * .04; spin.add(hat);
209
- const nut = new THREE.Mesh(nutG, caliper); nut.position.z = s * .12; spin.add(nut);
210
- for (let k = 0; k < 5; k++) for (const off of [-.11, .11]) { // five twin spokes, dished in toward the hub
211
- const sp = new THREE.Mesh(spokeG, rim); sp.position.z = s * .1;
212
- sp.rotation.z = k / 5 * Math.PI * 2 + off; sp.rotation.x = s * .12; spin.add(sp);
213
- }
214
- const cal = add(new THREE.BoxGeometry(.24, .13, .08), caliper, xw - .16, WR + .13, s * .7); cal.rotation.z = .65;
215
- const wl = new THREE.LineSegments(wireWheel, lineMat); wl.position.set(xw, WR, s * .83); wire.add(wl);
216
- wheels.push(spin);
217
- }
218
-
219
- // the glowing scan plane that sweeps along the car during the build
220
- const scanTex = (() => {
221
- const c = document.createElement("canvas"); c.width = 4; c.height = 128;
222
- const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 128);
223
- gr.addColorStop(0, "rgba(255,255,255,0)"); gr.addColorStop(.5, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
224
- g.fillStyle = gr; g.fillRect(0, 0, 4, 128); return new THREE.CanvasTexture(c);
225
- })();
226
- const scan = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 1.6), new THREE.MeshBasicMaterial({ map: scanTex, transparent: true, opacity: .0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
227
- scan.rotation.y = Math.PI / 2; scan.rotation.z = Math.PI / 2; scan.position.y = .7; car.add(scan);
228
-
229
- // flight kit: four thrusters that swing from straight down (lift-off) to straight back (cruise),
230
- // and two swept wings that slide out of the sides, with nav lights on the tips
231
- const housing = new THREE.MeshStandardMaterial({ color: 0x18181c, metalness: .85, roughness: .3 });
232
- const core = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xdff6ff).multiplyScalar(4), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
233
- const outer = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4f8dff).multiplyScalar(2.2), transparent: true, opacity: .55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
234
- const nozG = new THREE.CylinderGeometry(.12, .16, .26, 24, 1, true), ringG = new THREE.TorusGeometry(.15, .025, 8, 28).rotateX(Math.PI / 2);
235
- const flameG = new THREE.ConeGeometry(.14, 1, 20, 1, true).rotateX(Math.PI).translate(0, -.5, 0);
236
- const thrusters = [];
237
- for (const [x, y] of [[.9, .3], [-.95, .38]]) for (const s of [-1, 1]) {
238
- const pivot = new THREE.Group(); pivot.position.set(x, y, s * 1.0); car.add(pivot);
239
- const noz = new THREE.Mesh(nozG, housing); noz.position.y = -.05;
240
- const ring = new THREE.Mesh(ringG, front); ring.position.y = -.18;
241
- const fo = new THREE.Mesh(flameG, outer), fc = new THREE.Mesh(flameG, core);
242
- fo.position.y = fc.position.y = -.18; fc.scale.set(.45, 1, .45);
243
- pivot.add(noz, ring, fo, fc); pivot.scale.setScalar(0);
244
- thrusters.push({ pivot, fo, fc, seed: Math.random() * 10 });
245
- }
246
- const glowLight = new THREE.PointLight(0x6aa8ff, 0, 9, 2); glowLight.position.set(0, -.4, 0); car.add(glowLight);
247
-
248
- const wingShape = new THREE.Shape([[.7, 0], [-1.05, 0], [-1.35, 2.25], [-.75, 2.25]].map(([a, b]) => new THREE.Vector2(a, b)));
249
- const wingG = new THREE.ExtrudeGeometry(wingShape, { depth: .05, bevelEnabled: true, bevelThickness: .015, bevelSize: .02, bevelSegments: 2 }).rotateX(-Math.PI / 2);
250
- const wings = [];
251
- for (const s of [-1, 1]) {
252
- const w = new THREE.Mesh(wingG, paint); w.position.set(-.15, .6, s * .85); w.scale.set(1, 1, 0); car.add(w);
253
- const nav = new THREE.Mesh(new THREE.SphereGeometry(.05, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false }));
254
- nav.position.set(-1.1, .04, -2.25); w.add(nav);
255
- const edge = new THREE.Mesh(new THREE.BoxGeometry(1.8, .02, .02), front); edge.position.set(-.18, .06, -.02); w.add(edge);
256
- wings.push({ w, s, nav });
257
- }
258
-
259
- // fewer draw calls: the fixed detail parts (lights, vents, splitter, sills...) and each wheel's parts are merged per material
260
- const mergeStatic = (grp, keep) => {
261
- const by = new Map();
262
- for (const o of [...grp.children]) {
263
- if (!o.isMesh || keep.has(o) || o.children.length || o.material.map) continue;
264
- o.updateMatrix(); const g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone(); g.applyMatrix4(o.matrix);
265
- for (const k of Object.keys(g.attributes)) if (k !== "position" && k !== "normal") g.deleteAttribute(k);
266
- if (!g.attributes.normal) g.computeVertexNormals();
267
- if (!by.has(o.material)) by.set(o.material, []); by.get(o.material).push(g); grp.remove(o);
268
- }
269
- for (const [m, list] of by) { const mesh = new THREE.Mesh(mergeGeometries(list), m); mesh.castShadow = true; grp.add(mesh); }
270
- };
271
- mergeStatic(solid, new Set([bodyMesh, canMesh]));
272
- for (const w of wheels) mergeStatic(w, new Set());
273
- // ---- garage parts: spoilers, liveries, exhaust flames ----
274
- const spoilers = new THREE.Group(); solid.add(spoilers);
275
- function setSpoiler(k) {
276
- spoilers.clear();
277
- const y0 = topAt(.04), w = Math.max(1.5, halfAt(.05) * 2), x = X0 + .18;
278
- const part = (geo, m, px, py, pz, rz = 0) => { const o = new THREE.Mesh(geo, m); o.position.set(px, py, pz); o.rotation.z = rz; spoilers.add(o); o.castShadow = true; return o; };
279
- if (k === "ducktail") part(new THREE.BoxGeometry(.32, .05, w * .9), paint, x + .05, y0 + .04, 0, .28);
280
- if (k === "gt") { part(new THREE.BoxGeometry(.45, .035, w + .25), carbon, x - .05, y0 + .42, 0, .1); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.08, .42, .03), carbon, x, y0 + .21, z * w * .3); part(new THREE.BoxGeometry(.5, .2, .02), trim, x - .05, y0 + .42, z * (w + .25) / 2); } }
281
- if (k === "swan") { part(new THREE.BoxGeometry(.5, .04, w + .3), carbon, x - .1, y0 + .55, 0, .14); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.06, .6, .03), carbon, x + .05, y0 + .3, z * w * .28, -.35); part(new THREE.BoxGeometry(.55, .24, .02), trim, x - .1, y0 + .55, z * (w + .3) / 2); } }
282
- if (k === "rally") { part(new THREE.BoxGeometry(.4, .04, w * .95), paint, x + .25, topAt(.12) + .12, 0, .05); for (const z of [-1, 1]) part(new THREE.BoxGeometry(.32, .14, .02), paint, x + .25, topAt(.12) + .06, z * w * .46); }
283
- }
284
- const liveryMat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: .3, clearcoat: 1, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2 });
285
- const livery = new THREE.Group(); solid.add(livery);
286
- const LP = bodyHi.P, LNV = bodyHi.nv, LNU = bodyHi.nu, pt = (i, j) => { const k = (i * LNV + ((j % LNV) + LNV) % LNV) * 3; return [LP[k], LP[k + 1], LP[k + 2]]; };
287
- const patch = (i0, i1, js, push) => { // a strip of the body surface, lifted a hair off the paint
288
- const pos = [];
289
- for (let i = i0; i < i1; i++) for (let a = 0; a < js.length - 1; a++) {
290
- const q = [pt(i, js[a]), pt(i + 1, js[a]), pt(i + 1, js[a + 1]), pt(i, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]);
291
- pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]);
292
- }
293
- const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.computeVertexNormals();
294
- livery.add(new THREE.Mesh(g, liveryMat));
295
- };
296
- const T4 = LNV / 4;
297
- function setLivery(k, hex) {
298
- livery.clear(); liveryMat.color.set(hex || "#ffffff");
299
- if (k === "stripes") { patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]); }
300
- if (k === "center") patch(4, LNU - 2, [T4 - 2, T4 - 1, T4, T4 + 1, T4 + 2], [0, .006, 0]);
301
- if (k === "side") { patch(20, LNU - 30, [-1, 0, 1], [0, 0, .006]); patch(20, LNU - 30, [LNV / 2 - 1, LNV / 2, LNV / 2 + 1], [0, 0, .006]); }
302
- if (k === "race") {
303
- patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]);
304
- const c = document.createElement("canvas"); c.width = c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.beginPath(); g.arc(64, 64, 60, 0, 7); g.fill(); g.fillStyle = "#111"; g.font = "900 70px sans-serif"; g.textAlign = "center"; g.fillText("42", 64, 89);
305
- const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
306
- for (const side of [0, LNV / 2]) { const [x, y, z] = pt(Math.round(LNU * .5), side); const d = new THREE.Mesh(new THREE.CircleGeometry(.2, 24), new THREE.MeshBasicMaterial({ map: t, transparent: true, clippingPlanes: clip })); d.position.set(x, y + .02, z + Math.sign(z) * .012); d.rotation.y = z > 0 ? 0 : Math.PI; livery.add(d); }
307
- }
308
- }
309
- const flameMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4fa8ff).multiplyScalar(3), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
310
- const flames = exhausts.map(([x, y, z]) => { const f = new THREE.Mesh(new THREE.ConeGeometry(.07, 1, 12, 1, true).rotateZ(Math.PI / 2).translate(-.5, 0, 0), flameMat); f.position.set(x, y, z); f.visible = false; solid.add(f); return f; });
311
-
312
- const bodyBase = bodyMesh.geometry.attributes.position.array.slice(), canBase = canMesh.geometry.attributes.position.array.slice();
313
- const wear = { total: 0, burn: 0, cracked: false }; let paintHex = "#0d0d10";
314
- const crackTex = () => { // a starburst of cracks on the screen
315
- const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"); g.fillStyle = "#9aa4ae"; g.fillRect(0, 0, 256, 256);
316
- g.strokeStyle = "rgba(255,255,255,.95)"; g.lineWidth = 1.2;
317
- for (let n = 0; n < 3; n++) { const ox = 60 + Math.random() * 140, oy = 60 + Math.random() * 140; for (let k = 0; k < 14; k++) { let x = ox, y = oy, a = Math.random() * 6.28; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 7; j++) { a += (Math.random() - .5) * .7; x += Math.cos(a) * 14; y += Math.sin(a) * 14; g.lineTo(x, y); } g.stroke(); } for (let r = 8; r < 50; r += 12) { g.beginPath(); g.arc(ox, oy, r, 0, 6.3); g.stroke(); } }
318
- const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(1.5, 1.5); return t;
319
- };
320
- return {
321
- group: car, wheels, clip, materials: { carbon, front }, model, name: M.name,
322
- setSpoiler, setLivery,
323
- setTint(k) { const T = { light: [0x1a2a3a, .32, .1], dark: [0x0a0d12, .6, .1], limo: [0x030405, .9, .1], mirror: [0x9aa4b0, .88, .95] }[k] || [0x0a0d12, .6, .1]; glass.color.setHex(T[0]); glass.opacity = T[1]; glass.metalness = T[2]; },
324
- setFlameColor(hex) { flameMat.color.set(hex).multiplyScalar(3); },
325
- setFlame(power, time) { for (const f of flames) { f.visible = power > .02; f.scale.set(.4 + power * (1.1 + .3 * Math.sin(time * 60 + f.position.z * 9)), 1 + power * .6, 1 + power * .6); } },
326
- // power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
327
- setThrust(power, angle, deploy, time) {
328
- for (const t of thrusters) {
329
- t.pivot.scale.setScalar(Math.max(.0001, deploy));
330
- t.pivot.rotation.z = -angle * Math.PI / 2;
331
- const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
332
- t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
333
- t.fc.scale.set(.45, Math.max(.001, f * 1.5), .45);
334
- t.fo.visible = t.fc.visible = power > .01;
335
- }
336
- glowLight.intensity = power * 30;
337
- },
338
- setWings(t, time) {
339
- for (const { w, s, nav } of wings) {
340
- w.scale.z = Math.max(.0001, t) * -s; w.rotation.x = s * (1 - t) * .4; w.visible = t > .01;
341
- nav.visible = t > .6 && (Math.sin(time * 6 + s) > -.2);
342
- }
343
- },
344
- setReveal(t) {
345
- const x = t >= .999 ? 1e5 : -2.7 + 5.4 * t; // once built, never clip (the car drives far from the origin)
346
- solidClip.constant = x; wireClip.constant = -x;
347
- scan.position.x = x; scan.material.opacity = t > .01 && t < .99 ? .55 : 0;
348
- wire.visible = t < .999;
349
- },
350
- setPaint(hex) { paint.color.set(hex); paintHex = hex; if (wear.burn) paint.color.lerp(new THREE.Color(0x141210), wear.burn); },
351
- // damage you can see: dents pushed into the bodywork where the hit was, a cracking windscreen, scorched paint
352
- damage(local, power) {
353
- wear.total += power;
354
- for (const [mesh, kk] of [[bodyMesh, 1], [canMesh, .6]]) {
355
- const pos = mesh.geometry.attributes.position, r = .55 + Math.min(1.1, power * .025), depth = Math.min(.32, power * .006) * kk;
356
- let hit = false;
357
- for (let i = 0; i < pos.count; i++) {
358
- const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i), d = Math.hypot(x - local.x, (y - local.y) * 1.4, z - local.z);
359
- if (d > r) continue;
360
- // pushed in toward the middle of the car (and back from the nose or tail on a head-on hit)
361
- const f = (1 - d / r) ** 2 * depth, dy = .6 - y, dz = -z, l = Math.hypot(dy, dz) || 1, ax = Math.abs(local.x) > 1.5 ? -Math.sign(local.x) * 1.4 : 0;
362
- pos.setXYZ(i, x + ax * f, y + dy / l * f * .7, z + dz / l * f * 1.3); hit = true;
363
- }
364
- if (hit) { pos.needsUpdate = true; mesh.geometry.computeVertexNormals(); }
365
- }
366
- if (wear.total > 25 && !wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; }
367
- wear.burn = Math.min(.85, Math.max(wear.burn, (wear.total - 60) / 160)); if (wear.burn > 0) { paint.color.set(paintHex).lerp(new THREE.Color(0x141210), wear.burn); paint.roughness = Math.max(paint.roughness, .3 + wear.burn * .6); }
368
- },
369
- wreck() { wear.burn = .9; paint.color.set(0x161412); paint.roughness = .95; paint.clearcoat = 0; glass.opacity = .25; if (!wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; } spoilers.visible = false; },
370
- repair() {
371
- bodyMesh.geometry.attributes.position.array.set(bodyBase); bodyMesh.geometry.attributes.position.needsUpdate = true; bodyMesh.geometry.computeVertexNormals();
372
- canMesh.geometry.attributes.position.array.set(canBase); canMesh.geometry.attributes.position.needsUpdate = true; canMesh.geometry.computeVertexNormals();
373
- wear.total = 0; wear.burn = 0; wear.cracked = false; glass.map = null; glass.needsUpdate = true; paint.color.set(paintHex); spoilers.visible = true;
374
- },
375
- get wear() { return wear; },
376
- setFinish(f) {
377
- const F = { gloss: [.35, .38, .6], satin: [.3, .55, .3], matte: [.15, .78, 0] }[f] || [.35, .38, .6];
378
- paint.metalness = F[0]; paint.roughness = F[1]; paint.clearcoat = F[2];
379
- },
380
- setAccent(hex) { caliper.color.set(hex); },
381
- setTrim(hex) { trim.color.set(hex); },
382
- setRim(hex) { rim.color.set(hex); },
383
- setLight(hex) { front.color.set(hex).multiplyScalar(3); },
384
- };
385
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The VOID car (shape inspired by the McLaren 765LT), modelled in code: the body and canopy are lofted from side-profile curves
2
+ // (a rounded superellipse cross-section at every station), the wheel wells are carved out of
3
+ // the sides, and a blueprint of grid lines can be swept away by a clipping plane to "build" it.
4
+ import * as THREE from "three";
5
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
6
+
7
+ const X0 = -2.3, LEN = 4.6; // rear tip to nose, along +x
8
+ const WHEELS = [1.42, -1.38]; // front and rear axle x (a 2.8 m wheelbase)
9
+ const WR = 0.36, ARCH = 0.44, WELL = 0.62; // wheel radius, arch radius, how far in the wells are carved
10
+
11
+ // cosine interpolation through [u, value] keys (u from 0 at the rear to 1 at the nose)
12
+ function curve(keys, u) {
13
+ if (u <= keys[0][0]) return keys[0][1];
14
+ for (let i = 1; i < keys.length; i++) {
15
+ const [u1, v1] = keys[i], [u0, v0] = keys[i - 1];
16
+ if (u <= u1) { const s = (1 - Math.cos(Math.PI * (u - u0) / (u1 - u0))) / 2; return v0 + (v1 - v0) * s; }
17
+ }
18
+ return keys[keys.length - 1][1];
19
+ }
20
+ const spow = (v, p) => Math.sign(v) * Math.pow(Math.abs(v), p);
21
+
22
+ // long-tail proportions: a tall, blunt tail, wide rear haunches, a pinched waist where the door
23
+ // scoop is, wide front fenders and a low, narrow nose
24
+ const BODY = {
25
+ x0: X0, len: LEN, n: 6, tumble: .3,
26
+ top: [[0, .84], [.03, .93], [.12, .96], [.25, .96], [.4, .9], [.55, .84], [.66, .8], [.78, .68], [.9, .54], [.97, .44], [1, .36]],
27
+ bot: [[0, .3], [.04, .2], [.12, .16], [.88, .16], [.96, .2], [1, .28]],
28
+ half: [[0, .86], [.05, .95], [.18, 1.0], [.35, .96], [.5, .91], [.62, .93], [.8, .99], [.93, .93], [1, .62]],
29
+ ends: [.012, .06], fenders: true, scoop: true,
30
+ };
31
+ // a cab-forward teardrop: the screen starts far forward, the roof peaks over the doors and the
32
+ // glass runs back in a long taper over the engine
33
+ const CANOPY = {
34
+ x0: -1.35, len: 2.35, n: 4, tumble: .42,
35
+ top: [[0, .92], [.12, 1.0], [.3, 1.12], [.5, 1.19], [.65, 1.17], [.8, 1.06], [.92, .92], [1, .8]],
36
+ bot: [[0, .72], [1, .72]],
37
+ half: [[0, .42], [.2, .6], [.5, .72], [.75, .72], [.92, .64], [1, .5]],
38
+ ends: [.15, .06], fenders: false,
39
+ };
40
+
41
+ // the other cars in the garage, lofted the same way
42
+ export const MODELS = {
43
+ hydra: { name: "Hydra LTE242", body: BODY, canopy: CANOPY },
44
+ vandal: { name: "Vandal GT", note: "Muscle fastback", body: { x0: X0, len: LEN, n: 8, tumble: .2, fenders: true, scoop: false, ends: [.02, .03],
45
+ top: [[0, .92], [.06, .98], [.2, 1.0], [.35, .98], [.5, .95], [.62, .93], [.75, .9], [.9, .84], [.98, .74], [1, .66]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]],
46
+ half: [[0, .86], [.06, .93], [.2, .96], [.5, .92], [.8, .94], [.95, .9], [1, .72]] },
47
+ canopy: { x0: -1.25, len: 2.0, n: 4, tumble: .35, ends: [.2, .08], fenders: false, top: [[0, .98], [.15, 1.05], [.35, 1.25], [.6, 1.28], [.8, 1.2], [.95, 1.0], [1, .95]], bot: [[0, .88], [1, .88]], half: [[0, .6], [.3, .72], [.7, .72], [1, .6]] } },
48
+ wraith: { name: "Wraith X", note: "Wedge hypercar", body: { x0: X0, len: LEN, n: 5, tumble: .32, fenders: true, scoop: true, ends: [.01, .04],
49
+ top: [[0, .8], [.05, .86], [.2, .88], [.4, .84], [.6, .74], [.8, .6], [.95, .45], [1, .34]], bot: [[0, .26], [.04, .16], [.12, .13], [.88, .13], [.96, .16], [1, .22]],
50
+ half: [[0, .9], [.05, 1.0], [.2, 1.02], [.4, .96], [.6, .95], [.82, .98], [.95, .9], [1, .55]] },
51
+ canopy: { x0: -.9, len: 2.3, n: 4, tumble: .45, ends: [.2, .05], fenders: false, top: [[0, .82], [.2, 1.0], [.5, 1.07], [.75, 1.0], [.95, .8], [1, .7]], bot: [[0, .68], [1, .62]], half: [[0, .38], [.3, .62], [.6, .66], [.9, .55], [1, .45]] } },
52
+ titan: { name: "Titan RS", note: "Super SUV", body: { x0: X0, len: LEN, n: 10, tumble: .12, fenders: true, scoop: false, ends: [.02, .03],
53
+ top: [[0, 1.15], [.05, 1.2], [.6, 1.2], [.75, 1.12], [.9, 1.0], [1, .9]], bot: [[0, .38], [.05, .28], [.12, .26], [.88, .26], [.95, .28], [1, .36]], half: [[0, .92], [.05, .98], [.5, 1.0], [.95, .98], [1, .85]] },
54
+ canopy: { x0: -2.15, len: 3.3, n: 10, tumble: .15, ends: [.02, .12], fenders: false, top: [[0, 1.15], [.05, 1.62], [.7, 1.66], [.85, 1.5], [1, 1.2]], bot: [[0, 1.1], [1, 1.1]], half: [[0, .86], [.5, .9], [1, .86]] } },
55
+ cab: { name: "Big Apple Cab", note: "A yellow cab with a jet kit", body: { x0: X0, len: LEN, n: 9, tumble: .15, fenders: true, scoop: false, ends: [.02, .03],
56
+ top: [[0, .9], [.05, .95], [.25, .96], [.35, .95], [.65, .93], [.8, .9], [.95, .84], [1, .76]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]], half: [[0, .86], [.05, .92], [.5, .93], [.95, .9], [1, .75]] },
57
+ canopy: { x0: -1.35, len: 2.5, n: 8, tumble: .2, ends: [.1, .1], fenders: false, top: [[0, .95], [.15, 1.35], [.75, 1.38], [.9, 1.2], [1, .95]], bot: [[0, .9], [1, .9]], half: [[0, .75], [.5, .82], [1, .75]] } },
58
+ };
59
+
60
+ // a (nu+1) x nv grid of points on the lofted surface
61
+ function loft(spec, nu, nv) {
62
+ const P = new Float32Array((nu + 1) * nv * 3);
63
+ for (let i = 0; i <= nu; i++) {
64
+ const u = i / nu, x = spec.x0 + spec.len * u;
65
+ const t = curve(spec.top, u), b = curve(spec.bot, u), w = curve(spec.half, u);
66
+ const c = (t + b) / 2, h = (t - b) / 2;
67
+ const e = Math.pow(Math.max(0, Math.min(1, u / spec.ends[0], (1 - u) / spec.ends[1])), .35);
68
+ for (let j = 0; j < nv; j++) {
69
+ const th = (j / nv) * Math.PI * 2, cz = Math.cos(th), sy = Math.sin(th);
70
+ let z = w * spow(cz, 2 / spec.n), y = h * spow(sy, 2 / spec.n);
71
+ if (sy > 0) z *= 1 - spec.tumble * sy * sy;
72
+ if (spec.fenders && sy > 0) for (const xw of WHEELS) y += (xw > 0 ? .2 : .14) * Math.exp(-(((x - xw) / .55) ** 2)) * Math.abs(cz) ** 3;
73
+ z *= e; y = c + y * e;
74
+ if (spec.scoop && Math.abs(cz) > .3) { // the big intake carved into each door, deepest at its front
75
+ const xr = (x + .35) / .62, yr = (y - .6) / .17;
76
+ if (Math.abs(xr) < 1 && Math.abs(yr) < 1) z *= 1 - .17 * (1 - xr * xr) * Math.sqrt(1 - yr * yr) * (1.15 - .3 * xr);
77
+ }
78
+ if (spec.fenders) for (const xw of WHEELS) {
79
+ if ((x - xw) ** 2 + (y - WR) ** 2 < ARCH * ARCH && Math.abs(z) > WELL) z = Math.sign(z) * WELL;
80
+ }
81
+ const k = (i * nv + j) * 3; P[k] = x; P[k + 1] = y; P[k + 2] = z;
82
+ }
83
+ }
84
+ return { P, nu, nv };
85
+ }
86
+
87
+ function surface({ P, nu, nv }) {
88
+ const idx = [];
89
+ for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
90
+ const a = i * nv + j, b = i * nv + (j + 1) % nv, c = (i + 1) * nv + j, d = (i + 1) * nv + (j + 1) % nv;
91
+ idx.push(a, c, b, b, c, d);
92
+ }
93
+ const g = new THREE.BufferGeometry();
94
+ g.setAttribute("position", new THREE.BufferAttribute(P, 3));
95
+ g.setIndex(idx); g.computeVertexNormals();
96
+ return g;
97
+ }
98
+
99
+ // blueprint: every few rings across the car, and every few lines along it
100
+ function gridLines({ P, nu, nv }, ringStep, lineStep) {
101
+ const pts = [], at = (i, j) => { const k = (i * nv + j) * 3; return [P[k], P[k + 1], P[k + 2]]; };
102
+ for (let i = 0; i <= nu; i += ringStep) for (let j = 0; j < nv; j++) pts.push(...at(i, j), ...at(i, (j + 1) % nv));
103
+ for (let j = 0; j < nv; j += lineStep) for (let i = 0; i < nu; i++) pts.push(...at(i, j), ...at(i + 1, j));
104
+ const g = new THREE.BufferGeometry();
105
+ g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
106
+ return g;
107
+ }
108
+
109
+ export function buildCar(model = "hydra") {
110
+ const M = MODELS[model] || MODELS.hydra, BS = M.body, CS = M.canopy;
111
+ const car = new THREE.Group();
112
+ const solid = new THREE.Group(), wire = new THREE.Group();
113
+ car.add(solid, wire);
114
+
115
+ // the reveal: solid shows behind the scan plane, blueprint in front of it
116
+ const solidClip = new THREE.Plane(new THREE.Vector3(-1, 0, 0), -3);
117
+ const wireClip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 3);
118
+ const clip = [solidClip];
119
+
120
+ const flakes = (() => { // a fine noise normal map: the metallic flake that sparkles under the clear coat
121
+ const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"), img = g.createImageData(256, 256);
122
+ for (let i = 0; i < 256 * 256; i++) { const a = Math.random() * 6.28, r = Math.random() * .55; img.data[i * 4] = 128 + Math.cos(a) * r * 127; img.data[i * 4 + 1] = 128 + Math.sin(a) * r * 127; img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255; }
123
+ g.putImageData(img, 0, 0); const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(30, 30); return t;
124
+ })();
125
+ const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .6, roughness: .32, normalMap: flakes, normalScale: new THREE.Vector2(.12, .12), clearcoat: 1, clearcoatRoughness: .03, envMapIntensity: 1.5, clippingPlanes: clip });
126
+ const glass = new THREE.MeshPhysicalMaterial({ color: 0x0a0d12, metalness: .2, roughness: .02, clearcoat: 1, envMapIntensity: 1.8, transparent: true, opacity: .6, depthWrite: false, clippingPlanes: clip }); // tinted, so you can see the cabin
127
+ const carbon = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, metalness: .4, roughness: .55, clippingPlanes: clip });
128
+ const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: .9, clippingPlanes: clip });
129
+ const rim = new THREE.MeshStandardMaterial({ color: 0x121215, metalness: 1, roughness: .16, envMapIntensity: 1.6, clippingPlanes: clip });
130
+ const caliper = new THREE.MeshStandardMaterial({ color: 0xc1121f, metalness: .3, roughness: .4, clippingPlanes: clip });
131
+ const front = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xe8fbff).multiplyScalar(3), toneMapped: false, clippingPlanes: clip });
132
+ const rear = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a3a).multiplyScalar(2.6), toneMapped: false, clippingPlanes: clip });
133
+ const trim = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .4, roughness: .3, clearcoat: 1, clippingPlanes: clip }); // the red accents
134
+ const ti2 = new THREE.MeshStandardMaterial({ color: 0x8d9099, metalness: 1, roughness: .25, side: THREE.DoubleSide, clippingPlanes: clip });
135
+ const pipeOf = () => new THREE.CylinderGeometry(.06, .06, .2, 20, 1, true).rotateZ(Math.PI / 2);
136
+ const lineMat = new THREE.LineBasicMaterial({ color: 0xeef0f5, transparent: true, opacity: .75, clippingPlanes: [wireClip] });
137
+
138
+ const bodyHi = loft(BS, 220, 96), canHi = loft(CS, 90, 64);
139
+ const bodyMesh = new THREE.Mesh(surface(bodyHi), paint), canMesh = new THREE.Mesh(surface(canHi), glass);
140
+ solid.add(bodyMesh, canMesh);
141
+ wire.add(new THREE.LineSegments(gridLines(loft(BS, 110, 48), 5, 4), lineMat), new THREE.LineSegments(gridLines(loft(CS, 40, 32), 5, 4), lineMat));
142
+ const topAt = u => curve(BS.top, u), halfAt = u => curve(BS.half, u);
143
+
144
+ const add = (geo, mat, x, y, z, parent = solid) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parent.add(m); return m; };
145
+
146
+ // details: eye-socket headlights, hood vents, splitter, side blades and sills, flying
147
+ // buttresses, engine louvers, a full-width wing, a mesh tail with four high exhausts, a diffuser
148
+ const beam = (a, b, w, h, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, a.distanceTo(b), h), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); solid.add(m); return m; };
149
+ const exhausts = [];
150
+ if (model === "hydra") {
151
+ for (const s of [-1, 1]) {
152
+ const sock = add(new THREE.SphereGeometry(.2, 28, 14), carbon, 1.97, .52, s * .62);
153
+ sock.scale.set(.7, .42, 1.15); sock.rotation.y = s * .55; sock.rotation.x = s * .25;
154
+ const led = add(new THREE.BoxGeometry(.03, .022, .3), front, 2.04, .56, s * .64); led.rotation.y = s * .6; led.rotation.x = s * -.35;
155
+ add(new THREE.BoxGeometry(.03, .02, .12), front, 2.07, .49, s * .5).rotation.y = s * .25;
156
+ const vent = add(new THREE.BoxGeometry(.36, .012, .24), carbon, 1.5, .64, s * .3); vent.rotation.z = -.43;
157
+ const blade = add(new THREE.BoxGeometry(.62, .07, .02), carbon, .88, .4, s * .975); blade.rotation.z = .22;
158
+ add(new THREE.BoxGeometry(2.05, .07, .03), carbon, -.05, .21, s * .955);
159
+ add(new THREE.BoxGeometry(2.05, .012, .034), trim, -.05, .248, s * .956); // red pinstripe on the sill
160
+ beam(new THREE.Vector3(-.45, 1.1, s * .6), new THREE.Vector3(-2.05, .95, s * .86), .07, .11, paint);
161
+ const tl = add(new THREE.BoxGeometry(.03, .03, .56), rear, -2.29, .82, s * .52); tl.rotation.x = s * .05;
162
+ add(new THREE.BoxGeometry(.03, .2, .03), rear, -2.28, .72, s * .8);
163
+ }
164
+ for (let k = 0; k < 6; k++) add(new THREE.BoxGeometry(.06, .015, .92), carbon, -1.42 - k * .11, .975, 0);
165
+ add(new THREE.BoxGeometry(.3, .03, 1.62), carbon, 2.06, .175, 0);
166
+ add(new THREE.BoxGeometry(.02, .032, 1.62), trim, 2.2, .175, 0); // red splitter lip
167
+ const wing = add(new THREE.BoxGeometry(.38, .03, 1.94), carbon, -2.12, 1.02, 0); wing.rotation.z = .12;
168
+ for (const z of [-.97, .97]) add(new THREE.BoxGeometry(.42, .16, .02), trim, -2.1, .98, z);
169
+ add(new THREE.BoxGeometry(.04, .32, 1.5), carbon, -2.3, .58, 0);
170
+ for (let k = -3; k <= 3; k++) add(new THREE.BoxGeometry(.6, .2, .025), carbon, -2.06, .25, k * .2).rotation.z = -.42;
171
+ const ti = new THREE.MeshStandardMaterial({ color: 0x6d7079, metalness: 1, roughness: .3, side: THREE.DoubleSide, clippingPlanes: clip });
172
+ const pipe = new THREE.CylinderGeometry(.05, .05, .22, 24, 1, true).rotateZ(Math.PI / 2), soot = new THREE.CircleGeometry(.045, 20).rotateY(-Math.PI / 2);
173
+ for (const y of [.6, .72]) for (const z of [-.065, .065]) { add(pipe, ti, -2.28, y, z); add(soot, rubber, -2.37, y, z); }
174
+ for (const y of [.6, .72]) for (const z of [-.065, .065]) exhausts.push([-2.38, y, z]);
175
+ } else {
176
+ // shared details: headlights, tail lights, a splitter, sills, mirrors, twin exhausts
177
+ const nose = X0 + LEN, ny = topAt(.96) - .1, rearY = topAt(.03) - .12;
178
+ for (const s of [-1, 1]) {
179
+ add(new THREE.BoxGeometry(.05, .07, .36), front, nose - .1, ny, s * halfAt(.94) * .62).rotation.y = s * .35;
180
+ add(new THREE.BoxGeometry(.05, .08, .5), rear, X0 + .03, rearY, s * halfAt(.04) * .62);
181
+ add(new THREE.BoxGeometry(2.1, .07, .03), carbon, 0, curve(BS.bot, .5) + .05, s * (halfAt(.5) - .02));
182
+ const mir = add(new THREE.BoxGeometry(.12, .08, .16), paint, CS.x0 + CS.len * .82, curve(CS.bot, .8) + .06, s * (curve(CS.half, .8) + .1)); mir.rotation.y = s * .2;
183
+ add(pipeOf(), ti2, X0 + .02, curve(BS.bot, .05) + .1, s * .35); exhausts.push([X0 - .08, curve(BS.bot, .05) + .1, s * .35]);
184
+ }
185
+ add(new THREE.BoxGeometry(.3, .03, halfAt(.97) * 1.6), carbon, nose - .2, curve(BS.bot, .97) + .02, 0);
186
+ add(new THREE.BoxGeometry(.02, .032, halfAt(.97) * 1.6), trim, nose - .06, curve(BS.bot, .97) + .02, 0);
187
+ add(new THREE.BoxGeometry(.02, .05, halfAt(.02) * 1.4), rear, X0 + .01, rearY + .07, 0);
188
+ if (model === "cab") { // the roof sign and the checker stripe
189
+ const signT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#fff6d0"; g.fillRect(0, 0, 256, 64); g.fillStyle = "#111"; g.font = "900 40px sans-serif"; g.textAlign = "center"; g.fillText("TAXI", 128, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
190
+ const sign = add(new THREE.BoxGeometry(.55, .22, .9), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6), clippingPlanes: clip }), CS.x0 + CS.len * .45, curve(CS.top, .45) + .1, 0);
191
+ sign.rotation.y = Math.PI / 2;
192
+ }
193
+ }
194
+ add(new THREE.BoxGeometry(3.6, .03, 1.7), carbon, -.1, .16, 0); // flat floor
195
+
196
+ // wheels: a rounded tyre, a deep-dish rim with twin spokes and a centre lock, a brake disc
197
+ // (all spinning) and a fixed caliper
198
+ const wheels = [];
199
+ const V2 = (x, y) => new THREE.Vector2(x, y);
200
+ const tyreG = new THREE.LatheGeometry([V2(.265, -.15), V2(.32, -.156), V2(.35, -.145), V2(.362, -.115), V2(WR + .004, 0), V2(.362, .115), V2(.35, .145), V2(.32, .156), V2(.265, .15)], 72).rotateX(Math.PI / 2);
201
+ const barrelG = new THREE.LatheGeometry([V2(.262, .15), V2(.27, .13), V2(.268, -.13), V2(.24, -.14)], 64).rotateX(Math.PI / 2);
202
+ const lipG = new THREE.TorusGeometry(.266, .009, 8, 64);
203
+ const discG = new THREE.CylinderGeometry(.2, .2, .03, 48).rotateX(Math.PI / 2), hatG = new THREE.CylinderGeometry(.09, .09, .06, 32).rotateX(Math.PI / 2);
204
+ const nutG = new THREE.CylinderGeometry(.045, .045, .05, 6).rotateX(Math.PI / 2);
205
+ const spokeG = new THREE.BoxGeometry(.026, .2, .028).translate(0, .155, 0);
206
+ const disc = new THREE.MeshStandardMaterial({ color: 0x5a5c62, metalness: .8, roughness: .45, clippingPlanes: clip });
207
+ const wireWheel = new THREE.EdgesGeometry(new THREE.CylinderGeometry(WR, WR, .3, 28).rotateX(Math.PI / 2), 20);
208
+ for (const xw of WHEELS) for (const s of [-1, 1]) {
209
+ const spin = new THREE.Group(); spin.position.set(xw, WR, s * .83); solid.add(spin);
210
+ spin.add(new THREE.Mesh(tyreG, rubber), new THREE.Mesh(barrelG, carbon));
211
+ const lip = new THREE.Mesh(lipG, rim); lip.position.z = s * .14; spin.add(lip);
212
+ const d = new THREE.Mesh(discG, disc); d.position.z = -s * .04; spin.add(d);
213
+ const hat = new THREE.Mesh(hatG, rim); hat.position.z = s * .04; spin.add(hat);
214
+ const nut = new THREE.Mesh(nutG, caliper); nut.position.z = s * .12; spin.add(nut);
215
+ for (let k = 0; k < 5; k++) for (const off of [-.11, .11]) { // five twin spokes, dished in toward the hub
216
+ const sp = new THREE.Mesh(spokeG, rim); sp.position.z = s * .1;
217
+ sp.rotation.z = k / 5 * Math.PI * 2 + off; sp.rotation.x = s * .12; spin.add(sp);
218
+ }
219
+ const cal = add(new THREE.BoxGeometry(.24, .13, .08), caliper, xw - .16, WR + .13, s * .7); cal.rotation.z = .65;
220
+ const wl = new THREE.LineSegments(wireWheel, lineMat); wl.position.set(xw, WR, s * .83); wire.add(wl);
221
+ wheels.push(spin);
222
+ }
223
+
224
+ // the glowing scan plane that sweeps along the car during the build
225
+ const scanTex = (() => {
226
+ const c = document.createElement("canvas"); c.width = 4; c.height = 128;
227
+ const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 128);
228
+ gr.addColorStop(0, "rgba(255,255,255,0)"); gr.addColorStop(.5, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
229
+ g.fillStyle = gr; g.fillRect(0, 0, 4, 128); return new THREE.CanvasTexture(c);
230
+ })();
231
+ const scan = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 1.6), new THREE.MeshBasicMaterial({ map: scanTex, transparent: true, opacity: .0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
232
+ scan.rotation.y = Math.PI / 2; scan.rotation.z = Math.PI / 2; scan.position.y = .7; car.add(scan);
233
+
234
+ // flight kit: four thrusters that swing from straight down (lift-off) to straight back (cruise),
235
+ // and two swept wings that slide out of the sides, with nav lights on the tips
236
+ const housing = new THREE.MeshStandardMaterial({ color: 0x18181c, metalness: .85, roughness: .3 });
237
+ const core = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xdff6ff).multiplyScalar(4), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
238
+ const outer = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4f8dff).multiplyScalar(2.2), transparent: true, opacity: .55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
239
+ const nozG = new THREE.CylinderGeometry(.12, .16, .26, 24, 1, true), ringG = new THREE.TorusGeometry(.15, .025, 8, 28).rotateX(Math.PI / 2);
240
+ const flameG = new THREE.ConeGeometry(.14, 1, 20, 1, true).rotateX(Math.PI).translate(0, -.5, 0);
241
+ const thrusters = [];
242
+ for (const [x, y] of [[.9, .3], [-.95, .38]]) for (const s of [-1, 1]) {
243
+ const pivot = new THREE.Group(); pivot.position.set(x, y, s * 1.0); car.add(pivot);
244
+ const noz = new THREE.Mesh(nozG, housing); noz.position.y = -.05;
245
+ const ring = new THREE.Mesh(ringG, front); ring.position.y = -.18;
246
+ const fo = new THREE.Mesh(flameG, outer), fc = new THREE.Mesh(flameG, core);
247
+ fo.position.y = fc.position.y = -.18; fc.scale.set(.45, 1, .45);
248
+ pivot.add(noz, ring, fo, fc); pivot.scale.setScalar(0);
249
+ thrusters.push({ pivot, fo, fc, seed: Math.random() * 10 });
250
+ }
251
+ const glowLight = new THREE.PointLight(0x6aa8ff, 0, 9, 2); glowLight.position.set(0, -.4, 0); car.add(glowLight);
252
+
253
+ const wingShape = new THREE.Shape([[.7, 0], [-1.05, 0], [-1.35, 2.25], [-.75, 2.25]].map(([a, b]) => new THREE.Vector2(a, b)));
254
+ const wingG = new THREE.ExtrudeGeometry(wingShape, { depth: .05, bevelEnabled: true, bevelThickness: .015, bevelSize: .02, bevelSegments: 2 }).rotateX(-Math.PI / 2);
255
+ const wings = [];
256
+ for (const s of [-1, 1]) {
257
+ const w = new THREE.Mesh(wingG, paint); w.position.set(-.15, .6, s * .85); w.scale.set(1, 1, 0); car.add(w);
258
+ const nav = new THREE.Mesh(new THREE.SphereGeometry(.05, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false }));
259
+ nav.position.set(-1.1, .04, -2.25); w.add(nav);
260
+ const edge = new THREE.Mesh(new THREE.BoxGeometry(1.8, .02, .02), front); edge.position.set(-.18, .06, -.02); w.add(edge);
261
+ wings.push({ w, s, nav });
262
+ }
263
+
264
+ // fewer draw calls: the fixed detail parts (lights, vents, splitter, sills...) and each wheel's parts are merged per material
265
+ const mergeStatic = (grp, keep) => {
266
+ const by = new Map();
267
+ for (const o of [...grp.children]) {
268
+ if (!o.isMesh || keep.has(o) || o.children.length || o.material.map) continue;
269
+ o.updateMatrix(); const g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone(); g.applyMatrix4(o.matrix);
270
+ for (const k of Object.keys(g.attributes)) if (k !== "position" && k !== "normal") g.deleteAttribute(k);
271
+ if (!g.attributes.normal) g.computeVertexNormals();
272
+ if (!by.has(o.material)) by.set(o.material, []); by.get(o.material).push(g); grp.remove(o);
273
+ }
274
+ for (const [m, list] of by) { const mesh = new THREE.Mesh(mergeGeometries(list), m); mesh.castShadow = true; grp.add(mesh); }
275
+ };
276
+ mergeStatic(solid, new Set([bodyMesh, canMesh]));
277
+ for (const w of wheels) mergeStatic(w, new Set());
278
+ // ---- garage parts: spoilers, liveries, exhaust flames ----
279
+ const spoilers = new THREE.Group(); solid.add(spoilers);
280
+ function setSpoiler(k) {
281
+ spoilers.clear();
282
+ const y0 = topAt(.04), w = Math.max(1.5, halfAt(.05) * 2), x = X0 + .18;
283
+ const part = (geo, m, px, py, pz, rz = 0) => { const o = new THREE.Mesh(geo, m); o.position.set(px, py, pz); o.rotation.z = rz; spoilers.add(o); o.castShadow = true; return o; };
284
+ if (k === "ducktail") part(new THREE.BoxGeometry(.32, .05, w * .9), paint, x + .05, y0 + .04, 0, .28);
285
+ if (k === "gt") { part(new THREE.BoxGeometry(.45, .035, w + .25), carbon, x - .05, y0 + .42, 0, .1); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.08, .42, .03), carbon, x, y0 + .21, z * w * .3); part(new THREE.BoxGeometry(.5, .2, .02), trim, x - .05, y0 + .42, z * (w + .25) / 2); } }
286
+ if (k === "swan") { part(new THREE.BoxGeometry(.5, .04, w + .3), carbon, x - .1, y0 + .55, 0, .14); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.06, .6, .03), carbon, x + .05, y0 + .3, z * w * .28, -.35); part(new THREE.BoxGeometry(.55, .24, .02), trim, x - .1, y0 + .55, z * (w + .3) / 2); } }
287
+ if (k === "rally") { part(new THREE.BoxGeometry(.4, .04, w * .95), paint, x + .25, topAt(.12) + .12, 0, .05); for (const z of [-1, 1]) part(new THREE.BoxGeometry(.32, .14, .02), paint, x + .25, topAt(.12) + .06, z * w * .46); }
288
+ }
289
+ const liveryMat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: .3, clearcoat: 1, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2 });
290
+ const livery = new THREE.Group(); solid.add(livery);
291
+ const LP = bodyHi.P, LNV = bodyHi.nv, LNU = bodyHi.nu, pt = (i, j) => { const k = (i * LNV + ((j % LNV) + LNV) % LNV) * 3; return [LP[k], LP[k + 1], LP[k + 2]]; };
292
+ const patch = (i0, i1, js, push) => { // a strip of the body surface, lifted a hair off the paint
293
+ const pos = [];
294
+ for (let i = i0; i < i1; i++) for (let a = 0; a < js.length - 1; a++) {
295
+ const q = [pt(i, js[a]), pt(i + 1, js[a]), pt(i + 1, js[a + 1]), pt(i, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]);
296
+ pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]);
297
+ }
298
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.computeVertexNormals();
299
+ livery.add(new THREE.Mesh(g, liveryMat));
300
+ };
301
+ const T4 = LNV / 4;
302
+ function setLivery(k, hex) {
303
+ livery.clear(); liveryMat.color.set(hex || "#ffffff");
304
+ if (k === "stripes") { patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]); }
305
+ if (k === "center") patch(4, LNU - 2, [T4 - 2, T4 - 1, T4, T4 + 1, T4 + 2], [0, .006, 0]);
306
+ if (k === "side") { patch(20, LNU - 30, [-1, 0, 1], [0, 0, .006]); patch(20, LNU - 30, [LNV / 2 - 1, LNV / 2, LNV / 2 + 1], [0, 0, .006]); }
307
+ if (k === "race") {
308
+ patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]);
309
+ const c = document.createElement("canvas"); c.width = c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.beginPath(); g.arc(64, 64, 60, 0, 7); g.fill(); g.fillStyle = "#111"; g.font = "900 70px sans-serif"; g.textAlign = "center"; g.fillText("42", 64, 89);
310
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
311
+ for (const side of [0, LNV / 2]) { const [x, y, z] = pt(Math.round(LNU * .5), side); const d = new THREE.Mesh(new THREE.CircleGeometry(.2, 24), new THREE.MeshBasicMaterial({ map: t, transparent: true, clippingPlanes: clip })); d.position.set(x, y + .02, z + Math.sign(z) * .012); d.rotation.y = z > 0 ? 0 : Math.PI; livery.add(d); }
312
+ }
313
+ }
314
+ const flameMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4fa8ff).multiplyScalar(3), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
315
+ const flames = exhausts.map(([x, y, z]) => { const f = new THREE.Mesh(new THREE.ConeGeometry(.07, 1, 12, 1, true).rotateZ(Math.PI / 2).translate(-.5, 0, 0), flameMat); f.position.set(x, y, z); f.visible = false; solid.add(f); return f; });
316
+
317
+ const bodyBase = bodyMesh.geometry.attributes.position.array.slice(), canBase = canMesh.geometry.attributes.position.array.slice();
318
+ const wear = { total: 0, burn: 0, cracked: false }; let paintHex = "#0d0d10";
319
+ const crackTex = () => { // a starburst of cracks on the screen
320
+ const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"); g.fillStyle = "#9aa4ae"; g.fillRect(0, 0, 256, 256);
321
+ g.strokeStyle = "rgba(255,255,255,.95)"; g.lineWidth = 1.2;
322
+ for (let n = 0; n < 3; n++) { const ox = 60 + Math.random() * 140, oy = 60 + Math.random() * 140; for (let k = 0; k < 14; k++) { let x = ox, y = oy, a = Math.random() * 6.28; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 7; j++) { a += (Math.random() - .5) * .7; x += Math.cos(a) * 14; y += Math.sin(a) * 14; g.lineTo(x, y); } g.stroke(); } for (let r = 8; r < 50; r += 12) { g.beginPath(); g.arc(ox, oy, r, 0, 6.3); g.stroke(); } }
323
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(1.5, 1.5); return t;
324
+ };
325
+ // ---- finishing details: panel seams (doors, hood), LED light strips, number plates ----
326
+ {
327
+ const seamMat = new THREE.MeshBasicMaterial({ color: 0x050505, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2, transparent: true, opacity: .85 });
328
+ const seamPatch = (i0, js, push) => {
329
+ const pos = [];
330
+ for (let a = 0; a < js.length - 1; a++) { const q = [pt(i0, js[a]), pt(i0 + 1, js[a]), pt(i0 + 1, js[a + 1]), pt(i0, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]); pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]); }
331
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); solid.add(new THREE.Mesh(g, seamMat));
332
+ };
333
+ const sideJ = (c) => Array.from({ length: 11 }, (_, k) => c - 5 + k); // the side of the car from the sill up to the waist
334
+ for (const u of [.36, .55]) { const i = Math.round(LNU * u); seamPatch(i, sideJ(0), [0, 0, .004]); seamPatch(i, sideJ(LNV / 2), [0, 0, .004]); }
335
+ const hoodI = Math.round(LNU * .8); seamPatch(hoodI, Array.from({ length: 13 }, (_, k) => T4 - 6 + k), [0, .004, 0]);
336
+ // LED strips: a light bar across the nose and a full-width tail bar
337
+ const nose = X0 + LEN;
338
+ const drl = new THREE.Mesh(new THREE.BoxGeometry(.03, .025, halfAt(.93) * 1.5), front); drl.position.set(nose - .14, topAt(.93) - .02, 0); solid.add(drl);
339
+ const tail = new THREE.Mesh(new THREE.BoxGeometry(.03, .04, halfAt(.03) * 1.7), rear); tail.position.set(X0 + .02, topAt(.03) - .07, 0); solid.add(tail);
340
+ // number plates
341
+ const plateT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#f4f4f0"; g.fillRect(0, 0, 256, 64); g.strokeStyle = "#111"; g.lineWidth = 4; g.strokeRect(3, 3, 250, 58); g.fillStyle = "#111"; g.font = "900 40px Inter, sans-serif"; g.textAlign = "center"; g.fillText("VOID·242", 128, 47); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
342
+ const plateM = new THREE.MeshStandardMaterial({ map: plateT, roughness: .4, metalness: .2, clippingPlanes: clip });
343
+ const pf = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pf.position.set(nose + .005, curve(BS.bot, .98) + .14, 0); pf.rotation.y = Math.PI / 2; solid.add(pf);
344
+ const pr = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pr.position.set(X0 - .005, curve(BS.bot, .03) + .22, 0); pr.rotation.y = -Math.PI / 2; solid.add(pr);
345
+ }
346
+ return {
347
+ group: car, wheels, clip, materials: { carbon, front }, model, name: M.name,
348
+ setSpoiler, setLivery,
349
+ setTint(k) { const T = { light: [0x1a2a3a, .32, .1], dark: [0x0a0d12, .6, .1], limo: [0x030405, .9, .1], mirror: [0x9aa4b0, .88, .95] }[k] || [0x0a0d12, .6, .1]; glass.color.setHex(T[0]); glass.opacity = T[1]; glass.metalness = T[2]; },
350
+ setFlameColor(hex) { flameMat.color.set(hex).multiplyScalar(3); },
351
+ setFlame(power, time) { for (const f of flames) { f.visible = power > .02; f.scale.set(.4 + power * (1.1 + .3 * Math.sin(time * 60 + f.position.z * 9)), 1 + power * .6, 1 + power * .6); } },
352
+ // power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
353
+ setThrust(power, angle, deploy, time) {
354
+ for (const t of thrusters) {
355
+ t.pivot.scale.setScalar(Math.max(.0001, deploy));
356
+ t.pivot.rotation.z = -angle * Math.PI / 2;
357
+ const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
358
+ t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
359
+ t.fc.scale.set(.45, Math.max(.001, f * 1.5), .45);
360
+ t.fo.visible = t.fc.visible = power > .01;
361
+ }
362
+ glowLight.intensity = power * 30;
363
+ },
364
+ setWings(t, time) {
365
+ for (const { w, s, nav } of wings) {
366
+ w.scale.z = Math.max(.0001, t) * -s; w.rotation.x = s * (1 - t) * .4; w.visible = t > .01;
367
+ nav.visible = t > .6 && (Math.sin(time * 6 + s) > -.2);
368
+ }
369
+ },
370
+ setReveal(t) {
371
+ const x = t >= .999 ? 1e5 : -2.7 + 5.4 * t; // once built, never clip (the car drives far from the origin)
372
+ solidClip.constant = x; wireClip.constant = -x;
373
+ scan.position.x = x; scan.material.opacity = t > .01 && t < .99 ? .55 : 0;
374
+ wire.visible = t < .999;
375
+ },
376
+ setPaint(hex) { paint.color.set(hex); paintHex = hex; if (wear.burn) paint.color.lerp(new THREE.Color(0x141210), wear.burn); },
377
+ // damage you can see: dents pushed into the bodywork where the hit was, a cracking windscreen, scorched paint
378
+ damage(local, power) {
379
+ wear.total += power;
380
+ for (const [mesh, kk] of [[bodyMesh, 1], [canMesh, .6]]) {
381
+ const pos = mesh.geometry.attributes.position, r = .55 + Math.min(1.1, power * .025), depth = Math.min(.32, power * .006) * kk;
382
+ let hit = false;
383
+ for (let i = 0; i < pos.count; i++) {
384
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i), d = Math.hypot(x - local.x, (y - local.y) * 1.4, z - local.z);
385
+ if (d > r) continue;
386
+ // pushed in toward the middle of the car (and back from the nose or tail on a head-on hit)
387
+ const f = (1 - d / r) ** 2 * depth, dy = .6 - y, dz = -z, l = Math.hypot(dy, dz) || 1, ax = Math.abs(local.x) > 1.5 ? -Math.sign(local.x) * 1.4 : 0;
388
+ pos.setXYZ(i, x + ax * f, y + dy / l * f * .7, z + dz / l * f * 1.3); hit = true;
389
+ }
390
+ if (hit) { pos.needsUpdate = true; mesh.geometry.computeVertexNormals(); }
391
+ }
392
+ if (wear.total > 25 && !wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; }
393
+ wear.burn = Math.min(.85, Math.max(wear.burn, (wear.total - 60) / 160)); if (wear.burn > 0) { paint.color.set(paintHex).lerp(new THREE.Color(0x141210), wear.burn); paint.roughness = Math.max(paint.roughness, .3 + wear.burn * .6); }
394
+ },
395
+ wreck() { wear.burn = .9; paint.color.set(0x161412); paint.roughness = .95; paint.clearcoat = 0; glass.opacity = .25; if (!wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; } spoilers.visible = false; },
396
+ repair() {
397
+ bodyMesh.geometry.attributes.position.array.set(bodyBase); bodyMesh.geometry.attributes.position.needsUpdate = true; bodyMesh.geometry.computeVertexNormals();
398
+ canMesh.geometry.attributes.position.array.set(canBase); canMesh.geometry.attributes.position.needsUpdate = true; canMesh.geometry.computeVertexNormals();
399
+ wear.total = 0; wear.burn = 0; wear.cracked = false; glass.map = null; glass.needsUpdate = true; paint.color.set(paintHex); spoilers.visible = true;
400
+ },
401
+ get wear() { return wear; },
402
+ setFinish(f) {
403
+ const F = { gloss: [.6, .32, 1, .12], satin: [.4, .5, .35, .06], matte: [.15, .8, 0, 0] }[f] || [.6, .32, 1, .12];
404
+ paint.metalness = F[0]; paint.roughness = F[1]; paint.clearcoat = F[2]; paint.normalScale.setScalar(F[3]);
405
+ },
406
+ setAccent(hex) { caliper.color.set(hex); },
407
+ setTrim(hex) { trim.color.set(hex); },
408
+ setRim(hex) { rim.color.set(hex); },
409
+ setLight(hex) { front.color.set(hex).multiplyScalar(3); },
410
+ };
411
+ }
static/city.js CHANGED
@@ -147,7 +147,8 @@ export async function buildCity(scene, canvasTex, COARSE) {
147
  const g = new THREE.PlaneGeometry(b - a, 40000).rotateX(-Math.PI / 2), uv = g.attributes.uv; for (let k = 0; k < uv.count; k++) uv.setXY(k, uv.getX(k) * (b - a) / 10, uv.getY(k) * 4000);
148
  const m = new THREE.Mesh(g, grassMat); m.position.set((a + b) / 2, -.12, 0); scene.add(m);
149
  }
150
- const asphalt = new THREE.Mesh(new THREE.PlaneGeometry(XB - XA + 40, ZB - ZA + 40).rotateX(-Math.PI / 2), mat({ map: asphaltTex, roughness: .82, envMapIntensity: .35 }));
 
151
  asphaltTex.repeat.set((XB - XA) / 8, (ZB - ZA) / 8); asphalt.position.set((XA + XB) / 2, 0, (ZA + ZB) / 2); scene.add(asphalt);
152
  const walkG = [], parkG = [];
153
  const slab = (list, x0, x1, z0, z1, y) => list.push(new THREE.PlaneGeometry(x1 - x0, z1 - z0).rotateX(-Math.PI / 2).translate((x0 + x1) / 2, y, (z0 + z1) / 2));
@@ -447,6 +448,10 @@ export async function buildCity(scene, canvasTex, COARSE) {
447
  const leafGeo = mergeGeometries([new THREE.IcosahedronGeometry(2.2, 0).translate(0, 4.4, 0), new THREE.IcosahedronGeometry(1.7, 0).translate(.8, 5.8, .3), new THREE.IcosahedronGeometry(1.5, 0).translate(-.9, 5.3, -.4)].map(g => g.toNonIndexed()));
448
  instT(leafGeo, mat({ roughness: .85, flatShading: true }), P.trees, ([x, z, s], i) => at(x, 0, z, i, s), () => new THREE.Color().setHSL(.26 + rnd() * .08, .45 + rnd() * .2, .26 + rnd() * .12), 520);
449
  // street lamps: a pole, an arm and a warm head
 
 
 
 
450
  const lampHead = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffcf8a).multiplyScalar(2.4), toneMapped: false }); emissive.push(lampHead); lampHead.userData.glow = 2.4; lampHead.userData.base = new THREE.Color(0xffcf8a);
451
  instT(mergeGeometries([new THREE.CylinderGeometry(.1, .14, 8, 6).translate(0, 4, 0), new THREE.BoxGeometry(.1, .1, 2).translate(0, 7.9, 1)].map(g => g.toNonIndexed())), darkM, P.lamps, ([x, z, ry]) => at(x, 0, z, ry));
452
  instT(new THREE.BoxGeometry(.5, .14, .8), lampHead, P.lamps, ([x, z, ry]) => { at(0, 0, 0, ry); v.set(0, 7.85, 1.8).applyQuaternion(q0); m4.compose(v.add(new THREE.Vector3(x, 0, z)), q0, sc.set(1, 1, 1)); });
@@ -521,7 +526,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
521
  buildings, footprints, roads, parks: [[[PX0, PZ0], [PX1, PZ0], [PX1, PZ1], [PX0, PZ1]]], water: [[[HW0, -9000], [W1, -9000], [W1, 9000], [HW0, 9000]], [[W2, -9000], [EW1, -9000], [EW1, 9000], [W2, 9000]], Array.from({ length: 24 }, (_, k) => [lake.x + Math.cos(k / 24 * 6.283) * lake.rx, lake.z + Math.sin(k / 24 * 6.283) * lake.rz])],
522
  WATER_Y, rivers: { hudson: [HW0, W1], east: [W2, EW1] },
523
  beaches: [], walkways: [], bounds: { x0: XA - 220, x1: brooklyn + 300, z0: ZA - 160, z1: ZB + 160 }, credit: "a made-up city in code",
524
- near, nearR, nearFast, helipads, lawn, lake, stations, emissive, glassMats, billboards,
525
  // ground under a point: rooftops count (so you can land and drive on them), water is -1
526
  roofAt(x, z) { let h = 0; for (const b of near(x, z)) if (Math.abs(x - b.x) < b.hw && Math.abs(z - b.z) < b.hd && b.h > h) h = b.h; return h; },
527
  isWater(x, z) {
 
147
  const g = new THREE.PlaneGeometry(b - a, 40000).rotateX(-Math.PI / 2), uv = g.attributes.uv; for (let k = 0; k < uv.count; k++) uv.setXY(k, uv.getX(k) * (b - a) / 10, uv.getY(k) * 4000);
148
  const m = new THREE.Mesh(g, grassMat); m.position.set((a + b) / 2, -.12, 0); scene.add(m);
149
  }
150
+ const asphaltMat = mat({ map: asphaltTex, roughness: .82, envMapIntensity: .35 });
151
+ const asphalt = new THREE.Mesh(new THREE.PlaneGeometry(XB - XA + 40, ZB - ZA + 40).rotateX(-Math.PI / 2), asphaltMat);
152
  asphaltTex.repeat.set((XB - XA) / 8, (ZB - ZA) / 8); asphalt.position.set((XA + XB) / 2, 0, (ZA + ZB) / 2); scene.add(asphalt);
153
  const walkG = [], parkG = [];
154
  const slab = (list, x0, x1, z0, z1, y) => list.push(new THREE.PlaneGeometry(x1 - x0, z1 - z0).rotateX(-Math.PI / 2).translate((x0 + x1) / 2, y, (z0 + z1) / 2));
 
448
  const leafGeo = mergeGeometries([new THREE.IcosahedronGeometry(2.2, 0).translate(0, 4.4, 0), new THREE.IcosahedronGeometry(1.7, 0).translate(.8, 5.8, .3), new THREE.IcosahedronGeometry(1.5, 0).translate(-.9, 5.3, -.4)].map(g => g.toNonIndexed()));
449
  instT(leafGeo, mat({ roughness: .85, flatShading: true }), P.trees, ([x, z, s], i) => at(x, 0, z, i, s), () => new THREE.Color().setHSL(.26 + rnd() * .08, .45 + rnd() * .2, .26 + rnd() * .12), 520);
450
  // street lamps: a pole, an arm and a warm head
451
+ // pools of warm light under every lamp (only at night)
452
+ const poolTex = canvasTex(128, 128, (g, w) => { const r = g.createRadialGradient(64, 64, 0, 64, 64, 64); r.addColorStop(0, "rgba(255,200,130,.55)"); r.addColorStop(.5, "rgba(255,180,110,.18)"); r.addColorStop(1, "rgba(255,170,100,0)"); g.fillStyle = r; g.fillRect(0, 0, w, w); });
453
+ const poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -3 });
454
+ instT(new THREE.PlaneGeometry(16, 16).rotateX(-Math.PI / 2), poolMat, P.lamps, ([x, z, ry]) => { at(0, 0, 0, ry); v.set(0, .08, 1.8).applyQuaternion(q0); m4.compose(v.add(new THREE.Vector3(x, 0, z)), q0, sc.set(1, 1, 1)); }, null, 260);
455
  const lampHead = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffcf8a).multiplyScalar(2.4), toneMapped: false }); emissive.push(lampHead); lampHead.userData.glow = 2.4; lampHead.userData.base = new THREE.Color(0xffcf8a);
456
  instT(mergeGeometries([new THREE.CylinderGeometry(.1, .14, 8, 6).translate(0, 4, 0), new THREE.BoxGeometry(.1, .1, 2).translate(0, 7.9, 1)].map(g => g.toNonIndexed())), darkM, P.lamps, ([x, z, ry]) => at(x, 0, z, ry));
457
  instT(new THREE.BoxGeometry(.5, .14, .8), lampHead, P.lamps, ([x, z, ry]) => { at(0, 0, 0, ry); v.set(0, 7.85, 1.8).applyQuaternion(q0); m4.compose(v.add(new THREE.Vector3(x, 0, z)), q0, sc.set(1, 1, 1)); });
 
526
  buildings, footprints, roads, parks: [[[PX0, PZ0], [PX1, PZ0], [PX1, PZ1], [PX0, PZ1]]], water: [[[HW0, -9000], [W1, -9000], [W1, 9000], [HW0, 9000]], [[W2, -9000], [EW1, -9000], [EW1, 9000], [W2, 9000]], Array.from({ length: 24 }, (_, k) => [lake.x + Math.cos(k / 24 * 6.283) * lake.rx, lake.z + Math.sin(k / 24 * 6.283) * lake.rz])],
527
  WATER_Y, rivers: { hudson: [HW0, W1], east: [W2, EW1] },
528
  beaches: [], walkways: [], bounds: { x0: XA - 220, x1: brooklyn + 300, z0: ZA - 160, z1: ZB + 160 }, credit: "a made-up city in code",
529
+ near, nearR, nearFast, helipads, lawn, lake, stations, asphaltMat, poolMat, emissive, glassMats, billboards,
530
  // ground under a point: rooftops count (so you can land and drive on them), water is -1
531
  roofAt(x, z) { let h = 0; for (const b of near(x, z)) if (Math.abs(x - b.x) < b.hw && Math.abs(z - b.z) < b.hd && b.h > h) h = b.h; return h; },
532
  isWater(x, z) {
static/game.js CHANGED
@@ -128,6 +128,7 @@ export function createGame(W, car, sound, life, opts = {}) {
128
  if (g.comboT > 0) g.combo = Math.min(8, g.combo + 1); else g.combo = 1; g.comboT = 4;
129
  const pts = Math.round(n * g.combo); g.score += pts; $("gScore").textContent = g.score; if (g.score > best()) store.set("void.best3", g.score);
130
  say(`${why} +${pts}${g.combo > 1 ? ` ×${g.combo}` : ""}`, 2.2); if (!quiet) sound.win();
 
131
  }
132
  // where the car was hit and how hard, for the dents (main.js turns these into real damage on the model)
133
  const impact = (x, y, z, p) => { if (p > 3 && g.impacts.length < 8) g.impacts.push({ x, y, z, p }); };
 
128
  if (g.comboT > 0) g.combo = Math.min(8, g.combo + 1); else g.combo = 1; g.comboT = 4;
129
  const pts = Math.round(n * g.combo); g.score += pts; $("gScore").textContent = g.score; if (g.score > best()) store.set("void.best3", g.score);
130
  say(`${why} +${pts}${g.combo > 1 ? ` ×${g.combo}` : ""}`, 2.2); if (!quiet) sound.win();
131
+ opts.onScore && opts.onScore(n, why);
132
  }
133
  // where the car was hit and how hard, for the dents (main.js turns these into real damage on the model)
134
  const impact = (x, y, z, p) => { if (p > 3 && g.impacts.length < 8) g.impacts.push({ x, y, z, p }); };
static/goals.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Challenges: twelve goals tracked from what you do (kept on this device), each worth a trophy and 1000 points.
2
+ 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 { } } };
3
+ export const GOALS = [
4
+ { id: "speed", icon: "🏎️", name: "Speed demon", text: "Hit 300 km/h", stat: "top", need: 300 },
5
+ { id: "smash", icon: "💥", name: "Wrecking ball", text: "Smash 25 cars", stat: "smashed", need: 25 },
6
+ { id: "drift", icon: "🌀", name: "Drift king", text: "One drift worth 1500", stat: "drift", need: 1500 },
7
+ { id: "air", icon: "✈️", name: "Frequent flyer", text: "Spend 5 minutes in the air", stat: "air", need: 300 },
8
+ { id: "pads", icon: "🛬", name: "Rooftop lander", text: "Land on 3 helipads", stat: "pads", need: 3 },
9
+ { id: "butter", icon: "🧈", name: "Butter", text: "Make a butter-smooth landing", stat: "butter", need: 1 },
10
+ { id: "race", icon: "🏁", name: "Champion", text: "Win a race", stat: "wins", need: 1 },
11
+ { id: "cops", icon: "🚓", name: "Getaway", text: "Escape the police", stat: "escapes", need: 1 },
12
+ { id: "rings", icon: "💍", name: "Ring master", text: "Fly through 10 rings", stat: "rings", need: 10 },
13
+ { id: "road", icon: "🛣️", name: "Road trip", text: "Travel 25 km", stat: "km", need: 25 },
14
+ { id: "fuel", icon: "⛽", name: "Pit stop", text: "Fill up at a station", stat: "refuels", need: 1 },
15
+ { id: "brooklyn", icon: "🌉", name: "Bridge and tunnel", text: "Cross the bridge to Brooklyn", stat: "brooklyn", need: 1 },
16
+ ];
17
+ export function createGoals(onDone) {
18
+ const S = Object.assign({ top: 0, smashed: 0, drift: 0, air: 0, pads: 0, butter: 0, wins: 0, escapes: 0, rings: 0, km: 0, refuels: 0, brooklyn: 0, best: 0 }, store.get("void.stats") || {});
19
+ const done = new Set(store.get("void.goals") || []);
20
+ let saveT = 0, lastSmash = null, lastRings = null, lastFuel = null, pads = new Set();
21
+ function check() { for (const g of GOALS) if (!done.has(g.id) && S[g.stat] >= g.need) { done.add(g.id); store.set("void.goals", [...done]); onDone(g); } }
22
+ return {
23
+ stats: S, done, GOALS,
24
+ // words from the score feed tell us about races, escapes and landings
25
+ note(why) {
26
+ if (/1st/.test(why)) S.wins++; if (/got away/i.test(why)) S.escapes++; if (/Butter/.test(why)) S.butter++;
27
+ if (/^Landed at (?!PARK)/.test(why)) S.pads++; if (/^Drift/.test(why)) { const n = +(why.match(/\d+/) || [0])[0]; }
28
+ check();
29
+ },
30
+ drift(points) { S.drift = Math.max(S.drift, points); check(); },
31
+ update(dt, st, speedKmh) {
32
+ if (!st || !st.started) return;
33
+ S.top = Math.max(S.top, speedKmh); S.km += speedKmh / 3600 * dt;
34
+ if (st.mode === "air") S.air += dt;
35
+ if (lastSmash != null && st.smashed > lastSmash) S.smashed += st.smashed - lastSmash; lastSmash = st.smashed;
36
+ if (lastRings != null && st.rings > lastRings) S.rings += st.rings - lastRings; lastRings = st.rings;
37
+ if (lastFuel != null && st.fuel - lastFuel > .02 && Math.hypot(st.vel.x, st.vel.z) < 4) { if (!st._fueling) { S.refuels++; st._fueling = true; } } else if (st.fuel < lastFuel) st._fueling = false;
38
+ lastFuel = st.fuel;
39
+ if (st.x > 1760 && Math.abs(st.z) < 900) S.brooklyn = 1;
40
+ S.best = Math.max(S.best, st.score || 0);
41
+ if ((saveT += dt) > 3) { saveT = 0; store.set("void.stats", S); check(); }
42
+ },
43
+ };
44
+ }
static/main.js CHANGED
@@ -1,473 +1,492 @@
1
- // VOID: one WebGL canvas, three modes on top of a showroom home screen.
2
- // Watch: a 48 s film whose clock is the music. Play: drive it, fly it, land it. Build: paint it.
3
- import * as THREE from "three";
4
-
5
- const V = new URL(import.meta.url).search;
6
- const $ = id => document.getElementById(id);
7
- const setLoad = p => { $("ldbar").style.width = p + "%"; $("ldpct").textContent = Math.round(p); };
8
- setLoad(15);
9
- const [{ createWorld, clamp, lerp, smooth, frame, PAD_R, ZONES }, { buildCar, MODELS }, { addInterior, HEAD }, { Sound, BAR }, { createWatch, TOTAL, CHAPTERS }, { createGame }, { createLife }] = await Promise.all(
10
- ["world", "car", "interior", "sound", "watch", "game", "npc"].map(n => import(`./${n}.js${V}`)));
11
- const [{ createPolice }, { createRaces }, { createNet, EMOTES }, { createNav }, { createVehicles }] = await Promise.all(["police", "race", "net", "nav", "vehicles"].map(n => import(`./${n}.js${V}`)));
12
- setLoad(40);
13
-
14
- const COARSE = matchMedia("(pointer: coarse)").matches, REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
15
- 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 { } } };
16
- const W = await createWorld($("gl"), COARSE), { renderer, scene, camera, composer, bloom, camLight, fx } = W;
17
- setLoad(55);
18
-
19
- // the car you drive: a fixed root the game and the film move around, with the chosen model swapped inside it
20
- const carRoot = new THREE.Group(); scene.add(carRoot);
21
- const car = { group: carRoot, wheels: [] };
22
- const carMats = new Set();
23
- let inner = null;
24
- function useModel(id, force) {
25
- if (inner && inner.model === id && !force) return;
26
- if (inner) carRoot.remove(inner.group);
27
- inner = buildCar(id); carRoot.add(inner.group);
28
- for (const k of Object.keys(inner)) if (typeof inner[k] === "function") car[k] = inner[k];
29
- Object.assign(car, { wheels: inner.wheels, clip: car.clip || inner.clip, materials: inner.materials, model: id, name: inner.name });
30
- carMats.clear();
31
- inner.group.traverse(o => { if (o.isMesh) { o.castShadow = true; if (o.material && o.material.isMeshStandardMaterial) carMats.add(o.material); } });
32
- for (const m of carMats) { m.envMap = null; m.needsUpdate = true; } // the paint reflects the city capture (scene.environment) // phones skip the live cube and use the city reflection
33
- }
34
- // live reflections on the paint: a small cube camera that rides with the car
35
- const carEnv = new THREE.WebGLCubeRenderTarget(COARSE ? 64 : 128, { type: THREE.HalfFloatType, generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter });
36
- const carCube = new THREE.CubeCamera(.5, 320, carEnv);
37
- for (const c of carCube.children) c.layers.set(0);
38
- camera.layers.enable(1); // layer 1: the crowds, seen by the main camera only
39
- let cubeN = 0;
40
- useModel((store.get("void.build3") || {}).model != null ? Object.keys(MODELS)[(store.get("void.build3") || {}).model] || "hydra" : "hydra");
41
- setLoad(80);
42
- const interior = addInterior(car, renderer);
43
- const sound = new Sound();
44
- const watch = createWatch(W, car, interior);
45
- const life = createLife(scene, W.roads, W.walkways, COARSE);
46
- const TIMES = ["night", "sunset", "day"];
47
- 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; }
48
- let police, races, nav;
49
- const net = createNet();
50
- setInterval(() => { if (document.hidden) net.tick(.5, null); }, 500); // a hosting tab in the background still relays everyone
51
- // settings: graphics quality, look speed, auto gas, touch steering (kept on this device)
52
- const SET = Object.assign({ q: COARSE ? "medium" : "high", sens: 1, autogas: "off", steer: "stick" }, store.get("void.settings") || {});
53
- const gameOpts = {
54
- garage: () => openGarage(true), cycleTime, hornType: () => build.horn, autoGas: () => SET.autogas === "on", onRepair: () => car.repair && car.repair(),
55
- onSmash: kind => police && police.event("smash", kind), policeClear: () => police && police.clear(),
56
- autopilot: (g, dt, over) => nav ? nav.controls(g, dt, over) : null, openGps: () => document.getElementById("gGpsBtn").click(),
57
- mapExtra(mx, g, MM) {
58
- const P = (x, z) => [(x - g.x) / MM, (z - g.z) / MM];
59
- if (nav && nav.pts.length > 1) { mx.strokeStyle = "#3dfcff"; mx.lineWidth = 3; mx.beginPath(); nav.pts.forEach(([x, z], k) => { const [a, b] = P(x, z); k ? mx.lineTo(a, b) : mx.moveTo(a, b); }); mx.stroke(); }
60
- if (police) for (const c of police.cops) if (c.on) { const [a, b] = P(c.x, c.z); mx.fillStyle = Math.sin(performance.now() / 90) > 0 ? "#ff3040" : "#3060ff"; mx.beginPath(); mx.arc(a, b, 4, 0, 7); mx.fill(); }
61
- if (races && races.race && races.race.next) { const [a, b] = P(races.race.next[0], races.race.next[2]); mx.strokeStyle = "#ffd23d"; mx.lineWidth = 3; mx.beginPath(); mx.arc(a, b, 7, 0, 7); mx.stroke(); }
62
- for (const r of remotes.values()) if (r.at) { const [a, b] = P(r.at.x, r.at.z); mx.fillStyle = "#c07dff"; mx.beginPath(); mx.arc(a, b, 4.5, 0, 7); mx.fill(); }
63
- 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(); } }
64
- },
65
- };
66
- const game = createGame(W, car, sound, life, gameOpts);
67
- const vehicles = createVehicles(scene, game.api, W); gameOpts.vehicles = vehicles;
68
- // ---------- the menu (☰ or Esc): everything that isn't driving lives here ----------
69
- const menuEl = document.getElementById("gMenu");
70
- function menu(open) {
71
- if (open == null) open = menuEl.hidden;
72
- menuEl.hidden = !open; document.body.classList.toggle("g-menu-open", open);
73
- if (open && document.pointerLockElement) document.exitPointerLock();
74
- if (!net.online()) game.pause(open);
75
- }
76
- document.getElementById("gMenuBtn").onclick = () => menu(true);
77
- document.getElementById("gMenuClose").onclick = () => menu(false);
78
- document.getElementById("gExit").onclick = () => { menu(false); go("home"); };
79
- menuEl.addEventListener("click", e => { const t = e.target.closest(".tile"); if (t && !t.closest(".set")) setTimeout(() => menu(false), 60); if (e.target === menuEl) menu(false); });
80
- menuEl.querySelectorAll("[data-veh]").forEach(b => b.onclick = () => game.api.setVehicle(b.dataset.veh));
81
- document.getElementById("gFuelNav").onclick = () => { const st = game.state; if (!st) return; const best = W.city.stations.slice().sort((a, b) => Math.hypot(a.x - st.x, a.z - st.z) - Math.hypot(b.x - st.x, b.z - st.z))[0]; nav.setDest({ name: "⛽ Fuel station", x: best.x, z: best.z }); };
82
- function applySettings(save = true) {
83
- for (const seg of menuEl.querySelectorAll(".seg[data-set]")) for (const b of seg.children) b.classList.toggle("on", SET[seg.dataset.set] === b.dataset.v);
84
- document.getElementById("gSens").value = SET.sens; game.api.sens = +SET.sens;
85
- document.body.classList.toggle("steer-buttons", SET.steer === "buttons");
86
- const cap = W.setQuality(SET.q); perf.max = cap; perf.pr = Math.min(perf.pr, cap); if (renderer.getPixelRatio() > cap) { renderer.setPixelRatio(cap); composer.setPixelRatio(cap); lastW = 0; resize(); }
87
- if (save) store.set("void.settings", SET);
88
- }
89
- menuEl.querySelectorAll(".seg[data-set]").forEach(seg => seg.addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; e.preventDefault(); SET[seg.dataset.set] = b.dataset.v; if (seg.dataset.set === "q") { perf.pr = W.QUALITY[b.dataset.v].pr; perf.down = 0; } applySettings(); }));
90
- document.getElementById("gSens").addEventListener("input", e => { SET.sens = +e.target.value; applySettings(); });
91
- // the touch joystick: drag the knob; left/right steers, up/down pitches when flying
92
- {
93
- const st = document.getElementById("gStick"), knob = st.querySelector("i"); let id = null, cx = 0, cy = 0;
94
- const set = (x, y) => { const r = st.clientWidth / 2 - 10, l = Math.min(1, Math.hypot(x, y) / r), a = Math.atan2(y, x), kx = Math.cos(a) * l * r, ky = Math.sin(a) * l * r; knob.style.transform = `translate(${kx}px, ${ky}px)`; game.api.setStick(kx / r, ky / r); };
95
- st.addEventListener("pointerdown", e => { id = e.pointerId; const b = st.getBoundingClientRect(); cx = b.left + b.width / 2; cy = b.top + b.height / 2; try { st.setPointerCapture(id); } catch { } set(e.clientX - cx, e.clientY - cy); e.preventDefault(); });
96
- st.addEventListener("pointermove", e => { if (e.pointerId === id) set(e.clientX - cx, e.clientY - cy); });
97
- const end = e => { if (e.pointerId !== id) return; id = null; knob.style.transform = ""; game.api.setStick(0, 0); };
98
- st.addEventListener("pointerup", end); st.addEventListener("pointercancel", end);
99
- }
100
- // headlights that light the road at night
101
- 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); carRoot.add(heads, heads.target);
102
- police = createPolice(scene, game.api, sound, life);
103
- races = createRaces(scene, game.api, sound, W.canvasTex, net);
104
- nav = createNav(scene, game.api, W, ZONES, races.starts);
105
-
106
- // ---------- online: other real players' cars, name tags, emotes, proximity voice ----------
107
- const remotes = new Map();
108
- const tagTex = (text, col = "#ffffff") => W.canvasTex(512, 96, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.55)"; g.fillRect(0, 0, w, h); g.fillStyle = col; g.font = "800 50px Inter, sans-serif"; g.textAlign = "center"; g.fillText(text, w / 2, 66); });
109
- function remoteFor(p) {
110
- let r = remotes.get(p.id);
111
- const model = Object.keys(MODELS)[p.model | 0] || "hydra";
112
- if (r && r.model !== model) { scene.remove(r.root); remotes.delete(p.id); r = null; }
113
- if (!r) {
114
- const c = buildCar(model), root = new THREE.Group(); root.add(c.group); c.setReveal(1); c.setThrust(0, 0, 0, 0); c.setWings(0, 0);
115
- c.group.traverse(o => { if (o.isMesh) o.castShadow = true; });
116
- const tag = new THREE.Sprite(new THREE.SpriteMaterial({ map: tagTex(p.name), depthTest: false, toneMapped: false })); tag.scale.set(5, .95, 1); tag.position.y = 2.6; root.add(tag);
117
- const emo = new THREE.Sprite(new THREE.SpriteMaterial({ map: tagTex(""), depthTest: false, toneMapped: false })); emo.scale.set(6, 1.15, 1); emo.position.y = 3.8; emo.visible = false; root.add(emo);
118
- scene.add(root); r = { root, car: c, tag, emo, model, name: p.name, paint: null, emoT: 0 }; remotes.set(p.id, r);
119
- }
120
- if (r.name !== p.name) { r.name = p.name; r.tag.material.map = tagTex(p.name); }
121
- if (p.paint && r.paint !== p.paint) { r.paint = p.paint; r.car.setPaint(p.paint); }
122
- return r;
123
- }
124
- net.on("change", () => { for (const id of [...remotes.keys()]) if (!net.players().some(p => p.id === id)) { scene.remove(remotes.get(id).root); remotes.delete(id); } renderPlayers(); });
125
- net.on("status", t => { document.getElementById("gNetStatus").textContent = t; document.getElementById("gNetBtn").classList.toggle("on", net.online()); });
126
- net.on("mic", on => { const b = document.getElementById("gMic"); b.classList.toggle("on", on); b.textContent = on ? "🎙 Mic on" : "🎙 Voice chat"; });
127
- net.on("emote", (m, from) => { const r = remotes.get(from), e = EMOTES[m.e | 0]; if (r) { r.emo.material.map = tagTex(e, "#ffd23d"); r.emo.visible = true; r.emoT = 4; } feed(`${net.nameOf(from)}: ${e}`); });
128
- function feed(t) { const f = document.getElementById("gFeed"); const d = document.createElement("div"); d.textContent = t; f.prepend(d); setTimeout(() => d.remove(), 7000); while (f.children.length > 5) f.lastChild.remove(); }
129
- function renderPlayers() { document.getElementById("gPlayers").innerHTML = net.online() ? [`<b>${net.name} (you)</b>`, ...net.players().map(p => `<span>${p.vc ? "🎙 " : ""}${p.name}</span>`)].join("") : ""; }
130
- {
131
- const $ = id => document.getElementById(id), panel = $("gNetPanel");
132
- $("gNetName").value = store.get("void.name") || "";
133
- $("gNetBtn").onclick = () => { panel.hidden = !panel.hidden; };
134
- $("gNetClose").onclick = () => panel.hidden = true;
135
- const go = room => { const nm = $("gNetName").value; store.set("void.name", nm); net.join(room, nm); panel.hidden = true; };
136
- $("gNetPublic").onclick = () => go("public-1");
137
- $("gNetCreate").onclick = () => { const code = Math.random().toString(36).slice(2, 7); $("gNetCode").value = code; go("room-" + code); toast(`Room code: ${code}. Tell your friends!`); };
138
- $("gNetJoin").onclick = () => { const code = $("gNetCode").value.trim().toLowerCase(); if (code) go("room-" + code); };
139
- $("gNetLeave").onclick = () => { net.leave(); panel.hidden = true; };
140
- $("gMic").onclick = () => net.setMic(!net.mic);
141
- $("gEmotes").innerHTML = EMOTES.map((e, k) => `<button class="pill" data-e="${k}">${e}</button>`).join("");
142
- $("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]}`); };
143
- }
144
- W.setTime(store.get("void.time") || "night");
145
- // fill the car's reflection cube once before the first frame, so the paint never samples an empty one
146
-
147
- $("osmCredit").textContent = W.credit;
148
- // the garage: the Build panel over the game, the game paused underneath
149
- function openGarage(on) { document.body.classList.toggle("garage", on); game.pause(on); if (on && car.repair) { car.repair(); applyBuild(); if (game.state) game.state.hp = 100; } }
150
- $("gGarageClose").onclick = () => openGarage(false);
151
- const nozzles = (car.nozzles || [[.9, .3, 1], [.9, .3, -1], [-.95, .38, 1], [-.95, .38, -1]]).map(([x, y, s]) => new THREE.Vector3(x, y - .5, s));
152
-
153
- // ---------- configurator ----------
154
- const OPTS = {
155
- paint: { label: "Paint", items: [["Void Black", "#0a0a0c"], ["Stealth Grey", "#2b2d32"], ["Liquid Silver", "#9fa3aa"], ["Ghost White", "#e9e9ea"], ["Blood Red", "#6f0a12"], ["Ember Orange", "#c9480c"], ["Ion Blue", "#123a8c"], ["Venom Green", "#2d6a1e"]] },
156
- finish: { label: "Finish", seg: true, items: [["Gloss", "gloss"], ["Satin", "satin"], ["Matte", "matte"]] },
157
- rim: { label: "Wheels", items: [["Black", "#121215"], ["Gunmetal", "#3a3d44"], ["Silver", "#b8bcc4"], ["Bronze", "#7a5a32"]] },
158
- cal: { label: "Calipers", items: [["Red", "#c1121f"], ["Black", "#2a2a30"], ["Yellow", "#e0b000"], ["White", "#e8e8e8"], ["Ion", "#3df5ff"]] },
159
- trim: { label: "Accents", items: [["Red", "#c1121f"], ["Black", "#16161a"], ["White", "#e8e8e8"], ["Gold", "#b8912e"], ["Ion", "#3df5ff"]] },
160
- light: { label: "Lights", items: [["Ice", "#e8fbff"], ["Amber", "#ffb347"], ["Ion", "#6ad8ff"], ["Violet", "#b38cff"]] },
161
- glow: { label: "Underglow", items: [["Off", ""], ["White", "#ffffff"], ["Red", "#ff2233"], ["Ion", "#3df5ff"], ["Violet", "#9b5cff"]] },
162
- spoiler: { label: "Spoiler", seg: true, items: [["Stock", "none"], ["Ducktail", "ducktail"], ["GT wing", "gt"], ["Swan neck", "swan"], ["Rally", "rally"]] },
163
- livery: { label: "Livery", seg: true, items: [["Clean", "none"], ["Twin stripes", "stripes"], ["Centre stripe", "center"], ["Side stripe", "side"], ["Race #42", "race"]] },
164
- livc: { label: "Livery colour", items: [["White", "#f2f2f2"], ["Red", "#c1121f"], ["Black", "#101012"], ["Gold", "#c9a227"], ["Ion", "#3df5ff"], ["Orange", "#ff6a00"]] },
165
- tint: { label: "Windows", seg: true, items: [["Light", "light"], ["Dark", "dark"], ["Limo", "limo"], ["Mirror", "mirror"]] },
166
- flame: { label: "Nitro flames", items: [["Blue", "#4fa8ff"], ["Orange", "#ff7a1a"], ["Green", "#3dff6a"], ["Purple", "#b05cff"], ["Pink", "#ff4fd8"]] },
167
- horn: { label: "Horn", seg: true, items: [["Classic", 0], ["Air horn", 1], ["Tune", 2]] },
168
- };
169
- OPTS.model = { label: "Car", seg: true, items: Object.entries(MODELS).map(([k, m]) => [m.name, k]) };
170
- const DEFAULT = { model: 0, paint: 0, finish: 0, rim: 0, cal: 0, trim: 0, light: 0, glow: 0, spoiler: 0, livery: 0, livc: 0, tint: 1, flame: 0, horn: 0 };
171
- let build = { ...DEFAULT, ...(store.get("void.build2") || {}), ...(store.get("void.build3") || {}) };
172
- try { const h = new URLSearchParams(location.hash.slice(1)).get("b"); if (h) { const v = h.split(".").map(Number); Object.keys(DEFAULT).forEach((k, i) => { if (OPTS[k].items[v[i]]) build[k] = v[i]; }); } } catch { }
173
- function applyBuild() {
174
- useModel(OPTS.model.items[build.model][1]);
175
- car.setSpoiler(OPTS.spoiler.items[build.spoiler][1]); car.setLivery(OPTS.livery.items[build.livery][1], OPTS.livc.items[build.livc][1]);
176
- car.setTint(OPTS.tint.items[build.tint][1]); car.setFlameColor(OPTS.flame.items[build.flame][1]);
177
- car.setPaint(OPTS.paint.items[build.paint][1]); car.setFinish(OPTS.finish.items[build.finish][1]);
178
- car.setRim(OPTS.rim.items[build.rim][1]); car.setAccent(OPTS.cal.items[build.cal][1]); car.setTrim(OPTS.trim.items[build.trim][1]); car.setLight(OPTS.light.items[build.light][1]);
179
- const gl = OPTS.glow.items[build.glow][1]; if (gl) fx.glow.material.color.set(gl).multiplyScalar(1.4);
180
- store.set("void.build3", build);
181
- $("sum").innerHTML = `<b>${car.name.toUpperCase()} &middot; ${OPTS.paint.items[build.paint][0]}</b>${OPTS.spoiler.items[build.spoiler][0]} spoiler, ${OPTS.livery.items[build.livery][0].toLowerCase()} livery, ${OPTS.finish.items[build.finish][0]} paint, ${OPTS.rim.items[build.rim][0].toLowerCase()} wheels, ${OPTS.cal.items[build.cal][0].toLowerCase()} calipers, ${OPTS.trim.items[build.trim][0].toLowerCase()} accents, ${OPTS.light.items[build.light][0].toLowerCase()} lights${gl ? ", " + OPTS.glow.items[build.glow][0].toLowerCase() + " underglow" : ""}`;
182
- for (const b of document.querySelectorAll("#cfgBody button")) b.setAttribute("aria-pressed", String(build[b.dataset.k] === +b.dataset.i));
183
- for (const k in OPTS) { const n = document.querySelector(`[data-name="${k}"]`); if (n) n.textContent = OPTS[k].items[build[k]][0]; }
184
- }
185
- const ORDER = ["model", "paint", "finish", "livery", "livc", "spoiler", "rim", "cal", "trim", "tint", "light", "glow", "flame", "horn"];
186
- $("cfgBody").innerHTML = ORDER.map(k => [k, OPTS[k]]).map(([k, o]) => `<div class="grp"><div class="grp-h">${o.label}<b data-name="${k}"></b></div><div class="${o.seg ? "seg" : "sw"}">${o.items.map(([n, c], i) => o.seg
187
- ? `<button data-k="${k}" data-i="${i}">${n}</button>`
188
- : `<button data-k="${k}" data-i="${i}" title="${n}" aria-label="${o.label}: ${n}" style="background:${c || "repeating-linear-gradient(45deg,#222 0 4px,#111 4px 8px)"}"></button>`).join("")}</div></div>`).join("");
189
- $("cfgBody").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; build[b.dataset.k] = +b.dataset.i; applyBuild(); });
190
- $("reset").onclick = () => { build = { ...DEFAULT, model: build.model }; applyBuild(); toast("Back to black and red"); };
191
- $("share").onclick = async () => {
192
- const url = location.origin + location.pathname + "#b=" + Object.keys(DEFAULT).map(k => build[k]).join(".");
193
- try { await navigator.clipboard.writeText(url); toast("Link to your build copied"); } catch { prompt("Copy your build link", url); }
194
- };
195
- applyBuild();
196
- let toastT;
197
- function toast(t) { const el = $("toast"); el.textContent = t; el.classList.add("on"); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove("on"), 2200); }
198
-
199
- // ---------- sound ----------
200
- function setSound(on) { sound.setOn(on); $("snd").setAttribute("aria-pressed", String(on)); $("snd").textContent = on ? "Sound on" : "Sound off"; if (on && mode === "watch" && w.playing) sound.startSong(w.t); }
201
- $("snd").onclick = () => setSound(!sound.on);
202
- $("rev").onclick = () => { if (!sound.on) setSound(true); sound.rev(); };
203
-
204
- // ---------- modes ----------
205
- let mode = "home";
206
- const w = { t: 0, playing: false, dragging: false };
207
- function go(next) {
208
- if (next === mode) return;
209
- $("fade").classList.add("on");
210
- setTimeout(() => {
211
- if (mode === "play") { game.exit(); openGarage(false); }
212
- if (mode === "watch") { sound.stopSong(); w.playing = false; }
213
- mode = next; document.body.dataset.mode = next;
214
- if (next === "home") scrollTo(0, 0);
215
- if (next === "watch") { if (!sound.on) setSound(true); playFrom(0); }
216
- if (next === "play") game.enter();
217
- $("fade").classList.remove("on");
218
- }, 320);
219
- }
220
- document.addEventListener("click", e => { const b = e.target.closest("[data-go]"); if (b) { e.preventDefault(); go(b.dataset.go); } });
221
- $("exit").onclick = () => go("home");
222
- addEventListener("keydown", e => {
223
- if (e.code === "Escape" && mode === "play" && !document.body.classList.contains("garage")) { menu(); return; }
224
- if (e.code === "Escape" && mode !== "home") go("home");
225
- if (mode === "watch") {
226
- if (e.code === "Space") { e.preventDefault(); togglePlay(); }
227
- if (e.code === "ArrowRight") seek(w.t + 4); if (e.code === "ArrowLeft") seek(w.t - 4);
228
- }
229
- });
230
-
231
- // watch player
232
- function playFrom(t) { w.t = clamp(t, 0, TOTAL); w.playing = true; $("wEnd").classList.remove("on"); if (sound.on) sound.startSong(w.t); setPlayIcon(); }
233
- function pause() { w.playing = false; sound.stopSong(); setPlayIcon(); }
234
- function togglePlay() { if (w.playing) pause(); else playFrom(w.t >= TOTAL - .05 ? 0 : w.t); }
235
- function seek(t) { w.t = clamp(t, 0, TOTAL); if (w.playing) playFrom(w.t); }
236
- function setPlayIcon() { $("wPlay").innerHTML = w.playing ? "&#10074;&#10074;" : "&#9654;"; $("wPlay").setAttribute("aria-label", w.playing ? "Pause" : "Play"); }
237
- $("wPlay").onclick = togglePlay;
238
- $("wAgain").onclick = () => playFrom(0);
239
- const scrub = $("wScrub"); scrub.max = TOTAL;
240
- scrub.addEventListener("input", () => { w.dragging = true; w.t = +scrub.value; if (w.playing) { sound.stopSong(); } });
241
- scrub.addEventListener("change", () => { w.dragging = false; if (w.playing) playFrom(+scrub.value); });
242
- $("wTicks").innerHTML = CHAPTERS.filter(c => c[1]).map(c => `<button style="left:${c[0] * BAR / TOTAL * 100}%" data-t="${c[0] * BAR}">${c[1]}</button>`).join("");
243
- $("wTicks").addEventListener("click", e => { const b = e.target.closest("button"); if (b) { seek(+b.dataset.t); if (!w.playing) playFrom(+b.dataset.t); } });
244
- const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
245
-
246
- // drag to turn the car on the home and build screens
247
- const orbit = { a: .7, v: 0, drag: false, x: 0 };
248
- addEventListener("pointerdown", e => { if ((mode !== "build" && mode !== "home") || e.target !== $("gl") && !e.target.closest(".drag-hint, #buildUI") || e.target.closest(".cfg, button")) return; orbit.drag = true; orbit.x = e.clientX; });
249
- addEventListener("pointermove", e => { if (!orbit.drag) return; orbit.v = (e.clientX - orbit.x) * -.006; orbit.a += orbit.v; orbit.x = e.clientX; });
250
- addEventListener("pointerup", () => { orbit.drag = false; });
251
-
252
- // ---------- resize: ignore the phone address bar sliding, follow rotation and folding ----------
253
- let lastW = 0, lastH = 0;
254
- function resize() {
255
- const ww = innerWidth, hh = Math.max(innerHeight, document.documentElement.clientHeight);
256
- if (!ww || !hh) return;
257
- if (ww === lastW && Math.abs(hh - lastH) < 140 && lastW) return;
258
- lastW = ww; lastH = hh; renderer.setSize(ww, hh, false); composer.setSize(ww, hh);
259
- bloom.resolution.set(Math.max(1, ww * (COARSE ? .35 : .5)), Math.max(1, hh * (COARSE ? .35 : .5))); camera.aspect = ww / hh;
260
- }
261
- addEventListener("resize", resize); addEventListener("orientationchange", () => setTimeout(resize, 200));
262
- if (window.visualViewport) visualViewport.addEventListener("resize", resize);
263
- resize();
264
- 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); };
265
-
266
- // ---------- the showroom pose (home and build) ----------
267
- const showFr = frame(); W.track.sample(W.track.D[W.track.idxX(0)], showFr);
268
- const intro = { t: -1 }; // the car drives in once the loader lifts
269
- function showroom(dt, time) {
270
- let introX = 0, introV = 0;
271
- if (intro.t >= 0 && intro.t < 3.2) { intro.t += dt; const u = Math.min(1, intro.t / 3.2); introX = -46 * Math.pow(1 - u, 3); introV = 46 * 3 * Math.pow(1 - u, 2) / 3.2; }
272
- else if (intro.t < 0) introX = -46;
273
- if (!orbit.drag) { orbit.v *= .94; orbit.a += orbit.v + (REDUCED ? 0 : dt * (mode === "home" ? .16 : .1)); }
274
- const hf = 2 * Math.atan(Math.tan(fovFor(38) * Math.PI / 360) * camera.aspect), fit = 3.1 / Math.tan(hf / 2); // fit the whole car across the screen
275
- const scrolled = mode === "home" ? clamp(scrollY / innerHeight) : 0, dist = Math.max(mode === "build" ? 8.8 : 8.2, fit) + scrolled * 2;
276
- const cam = { pos: new THREE.Vector3(Math.cos(orbit.a) * dist, 1.5 + scrolled * 4, Math.sin(orbit.a) * dist), look: new THREE.Vector3(0, .5 - scrolled * .4, 0), fov: 38 };
277
- if (camera.aspect < 1) cam.look.y = mode === "build" ? -1.1 : -.2;
278
- else if (mode === "build") { const right = new THREE.Vector3().subVectors(cam.look, cam.pos).cross(new THREE.Vector3(0, 1, 0)).normalize(); cam.look.addScaledVector(right, 1.7); }
279
- const pos = showFr.p.clone(); pos.x += introX; const fr = { ...showFr, p: pos };
280
- if (introX < -.01) cam.look.x = Math.max(introX * .35, -8);
281
- return { pos, quat: new THREE.Quaternion(), fr, speed: introV * 3.6, g: 1, alt: 0, vs: 0, thrust: { power: 0, angle: 0, deploy: 0 }, wings: 0, air: false, cam, inside: false, screenOn: false, reveal: 1, rpm: 900, gear: 1, load: .1, engine: .6, mode: "PARK", zones: [] };
282
- }
283
-
284
- // ---------- per frame ----------
285
- const clock = new THREE.Clock();
286
- let lastGauge = 0;
287
- // the speedometer: a dial with a glowing arc, the speed, the gear (or altitude when flying) and what you're in
288
- const gauge = document.getElementById("gGauge"), gx = gauge.getContext("2d");
289
- function drawGauge(S) {
290
- const W2 = gauge.width, c = W2 / 2, r = c - 22, a0 = Math.PI * .75, a1 = Math.PI * 2.25, max = S.veh === "plane" ? 600 : S.veh === "boat" ? 140 : 360, f = Math.min(1, S.speed / max);
291
- gx.clearRect(0, 0, W2, W2);
292
- gx.beginPath(); gx.arc(c, c, r + 14, 0, 7); gx.fillStyle = "rgba(6,8,14,.62)"; gx.fill();
293
- gx.lineCap = "round"; gx.lineWidth = 14; gx.beginPath(); gx.arc(c, c, r, a0, a1); gx.strokeStyle = "rgba(255,255,255,.12)"; gx.stroke();
294
- const grd = gx.createLinearGradient(0, W2, W2, 0); grd.addColorStop(0, "#3df5ff"); grd.addColorStop(.6, "#ffffff"); grd.addColorStop(1, "#ff2a3a");
295
- gx.beginPath(); gx.arc(c, c, r, a0, a0 + (a1 - a0) * f); gx.strokeStyle = grd; gx.shadowColor = "#3df5ff"; gx.shadowBlur = 16; gx.stroke(); gx.shadowBlur = 0;
296
- gx.lineWidth = 2; gx.strokeStyle = "rgba(255,255,255,.5)"; for (let k = 0; k <= 12; k++) { const a = a0 + (a1 - a0) * k / 12, i = r - 18, o = r - (k % 3 ? 24 : 30); gx.beginPath(); gx.moveTo(c + Math.cos(a) * i, c + Math.sin(a) * i); gx.lineTo(c + Math.cos(a) * o, c + Math.sin(a) * o); gx.stroke(); }
297
- gx.fillStyle = "#fff"; gx.textAlign = "center"; gx.font = `900 ${W2 * .22}px Unbounded, Inter, sans-serif`; gx.fillText(Math.round(S.speed), c, c + W2 * .06);
298
- gx.font = `700 ${W2 * .055}px Inter, sans-serif`; gx.fillStyle = "rgba(255,255,255,.6)"; gx.fillText("KM/H", c, c + W2 * .14);
299
- gx.font = `800 ${W2 * .07}px Unbounded, Inter, sans-serif`; gx.fillStyle = S.air ? "#3df5ff" : "#ffd23d";
300
- gx.fillText(S.air || S.veh === "plane" || S.veh === "heli" ? `${Math.round(S.alt)} m` : S.veh === "boat" ? "BOAT" : `GEAR ${S.gear}`, c, c + W2 * .3);
301
- if (S.nitro > .1) { gx.font = `800 ${W2 * .05}px Inter, sans-serif`; gx.fillStyle = "#3df5ff"; gx.fillText("NITRO", c, c - W2 * .16); }
302
- }
303
- let wasWrecked = false, wreckPose = null; const wrecks = [];
304
- const perf = { t: 0, n: 0, pr: renderer.getPixelRatio(), max: renderer.getPixelRatio(), down: 0 };
305
- applySettings();
306
- let lastHud = 0, prevGear = 1, feedFlip = 0;
307
- function frameLoop(fixed) {
308
- const raw = fixed ? 0 : clock.getDelta(), dt = fixed || Math.min(.05, raw), time = clock.elapsedTime;
309
- // adaptive resolution: drop the pixel count when frames run slow, bring it back when there is headroom
310
- if (!fixed && raw < .5) {
311
- perf.t += raw; perf.n++;
312
- if (perf.t > 1.5) {
313
- const avg = perf.t / perf.n, was = perf.pr;
314
- if (avg > 1 / 45 && perf.pr > .6) perf.pr = Math.max(.6, perf.pr - (avg > 1 / 25 ? .25 : .12));
315
- else if (avg > 1 / 40 && mode === "play" && ++perf.down >= 3 && SET.q !== "low") { // still slow at the lowest resolution: drop a quality level
316
- perf.down = 0; SET.q = { ultra: "high", high: "medium", medium: "low" }[SET.q]; perf.pr = W.QUALITY[SET.q].pr; applySettings(false); toast(`Graphics set to ${SET.q} to keep it smooth`);
317
- }
318
- else if (avg < 1 / 58 && perf.pr < perf.max) perf.pr = Math.min(perf.max, perf.pr + .08);
319
- if (perf.pr !== was) { renderer.setPixelRatio(perf.pr); composer.setPixelRatio(perf.pr); lastW = 0; resize(); }
320
- perf.t = perf.n = 0;
321
- }
322
- }
323
- let S;
324
- if (mode === "watch") {
325
- if (w.playing && !w.dragging) {
326
- const st = sound.on ? sound.songTime() : null;
327
- w.t = st != null ? st : w.t + dt;
328
- if (w.t >= TOTAL) { w.t = TOTAL; pause(); $("wEnd").classList.add("on"); }
329
- }
330
- S = watch.at(w.t, time, camera.aspect, fovFor);
331
- if (!w.dragging) scrub.value = w.t;
332
- scrub.style.setProperty("--p", (w.t / TOTAL * 100) + "%");
333
- $("wTime").textContent = `${fmt(w.t)} / ${fmt(TOTAL)}`;
334
- const ch = S.chapter, cap = $("wCap");
335
- if (cap.dataset.ch !== ch[1]) { cap.dataset.ch = ch[1]; cap.classList.add("hide"); setTimeout(() => { $("wNum").textContent = ch[1] ? String(CHAPTERS.indexOf(ch) + 1).padStart(2, "0") : ""; $("wTitle").textContent = ch[1]; $("wSub").textContent = ch[2]; cap.classList.toggle("hide", !ch[1]); }, 250); }
336
- $("telem").style.opacity = S.bar > 2 && !S.inside && S.bar < 17.5 ? 1 : 0;
337
- $("tSpd").textContent = Math.round(S.speed); $("tG").textContent = S.g.toFixed(1); $("tAlt").textContent = Math.round(S.alt);
338
- $("dream").style.opacity = S.dream; $("fade").style.opacity = S.fade;
339
- } else if (mode === "play") S = game.update(dt, time, camera.aspect);
340
- else S = showroom(dt, time);
341
- life.update(dt, time, mode === "play" ? S.player : null, camera.position);
342
- if (mode === "play") {
343
- police.update(dt, time); races.update(dt, time); nav.update();
344
- const st = game.state, stars = police.stars;
345
- document.getElementById("gStars").textContent = stars ? "★".repeat(stars) + "☆".repeat(5 - stars) : "";
346
- document.getElementById("gStars").classList.toggle("bust", police.bust > .5);
347
- const pr = races.progress();
348
- net.tick(dt, { p: [st.x, st.y, st.z, st.yaw, st.pitch, st.roll].map(v => Math.round(v * 100) / 100), m: Object.keys(MODELS).indexOf(car.model), c: OPTS.paint.items[build.paint][1], v: Math.round(S.speed), a: S.air ? 1 : 0, r: pr ? pr.race : 0, rp: pr ? pr.rp : 0, rf: pr ? pr.rf : 0, w: stars });
349
- for (const p of net.players()) {
350
- const at = net.sample(p); const r = remoteFor(p); r.at = at;
351
- if (!at || at.stale) { r.root.visible = false; continue; }
352
- r.root.visible = true; r.root.position.set(at.x, at.y, at.z); r.root.rotation.set(0, -at.yaw, 0); r.car.group.rotation.set(at.roll, 0, at.pitch, "XYZ");
353
- r.car.setThrust(at.air ? .6 : 0, at.air && at.v > 90 ? 1 : 0, at.air ? 1 : 0, time); r.car.setWings(at.air && at.v > 100 ? 1 : 0, time);
354
- for (const wh of r.car.wheels) wh.rotation.z -= at.v / 3.6 * dt / .37;
355
- if (r.emoT > 0 && (r.emoT -= dt) <= 0) r.emo.visible = false;
356
- const dx = st.x - at.x, dz = st.z - at.z, d = Math.hypot(dx, dz);
357
- net.setVolume(p.id, 1 - d / 260);
358
- if (d < 3.4 && d > .01 && Math.abs(st.y - at.y) < 2) { st.x += dx / d * (3.4 - d) * .5; st.z += dz / d * (3.4 - d) * .5; st.vel.multiplyScalar(.9); }
359
- }
360
- } else { for (const r of remotes.values()) r.root.visible = false; net.tick(dt, null); } // the host keeps relaying from the menus
361
- W.city.update(time, camera.position);
362
- W.follow(S.pos);
363
- if (mode !== "watch") $("fade").style.opacity = "";
364
-
365
- // the car, or the plane / helicopter / boat
366
- const veh = mode === "play" ? S.veh || "car" : "car";
367
- carRoot.visible = veh === "car";
368
- vehicles.show(veh === "car" ? null : veh, S.pos, S.quat, S, time, dt);
369
- if (S.splash) for (let k = 0; k < 6; k++) { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(S.pos).add(new THREE.Vector3((Math.random() - .5) * 3, .3, (Math.random() - .5) * 3)); pf.life = 1; pf.s.visible = true; pf.grow = .8; pf.s.scale.setScalar(.8); pf.s.material.color.set(0xeaf6ff); pf.v.set((Math.random() - .5) * 6, 4 + Math.random() * 4, (Math.random() - .5) * 6); }
370
- if (veh === "boat" && S.speed > 12 && Math.random() < .6) { const back = new THREE.Vector3(-3.6, .1, (Math.random() - .5) * 2).applyQuaternion(S.quat).add(S.pos); const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(back); pf.life = 1; pf.s.visible = true; pf.grow = .5; pf.s.scale.setScalar(.5); pf.s.material.color.set(0xf4fbff); pf.v.set((Math.random() - .5) * 2, .6, (Math.random() - .5) * 2); }
371
- const night = W.timeOfDay !== "day";
372
- heads.intensity = night && veh === "car" ? (W.timeOfDay === "night" ? 260 : 120) : 0;
373
- W.sky.position.copy(camera.position); W.grade.uniforms.time.value = time % 100;
374
- if (mode === "play" && time - lastGauge > .05) { lastGauge = time; drawGauge(S); const fu = document.getElementById("gFuel"); if (fu && S.fuel != null) { fu.style.setProperty("--v", S.fuel); fu.classList.toggle("low", S.fuel < .2); } }
375
- car.group.position.copy(S.pos); car.group.quaternion.copy(S.quat);
376
- car.setReveal(S.reveal);
377
- car.setThrust(S.thrust.power, S.thrust.angle, S.thrust.deploy, time);
378
- car.setFlame(S.nitro || 0, time);
379
- // damage: dent the model where it was hit; a destroyed car is left where it crashed and a fresh one is built for you
380
- const gs = mode === "play" ? game.state : null;
381
- if (gs && gs.impacts && gs.impacts.length && car.damage) { carRoot.updateMatrixWorld(true); for (const im of gs.impacts.splice(0)) car.damage(carRoot.worldToLocal(new THREE.Vector3(im.x, im.y, im.z)), im.p); }
382
- if (S.wrecked && !wasWrecked) { car.wreck && car.wreck(); wreckPose = { p: carRoot.position.clone(), q: carRoot.quaternion.clone() }; }
383
- if (!S.wrecked && wasWrecked && wreckPose && inner) {
384
- const wg = inner.group; carRoot.remove(wg); wg.position.copy(wreckPose.p); wg.quaternion.copy(wreckPose.q); scene.add(wg);
385
- wrecks.push({ g: wg, t: 30 }); if (wrecks.length > 4) { const o = wrecks.shift(); scene.remove(o.g); }
386
- useModel(car.model, true); applyBuild(); wreckPose = null;
387
- }
388
- wasWrecked = !!S.wrecked;
389
- for (const w of wrecks) { w.t -= dt; if (Math.random() < .25 && w.t > 10) { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(w.g.position).add(new THREE.Vector3((Math.random() - .5) * 2, 1, (Math.random() - .5) * 2)); pf.life = 1; pf.s.visible = true; pf.grow = .6; pf.s.scale.setScalar(.6); pf.s.material.color.set(0x2a2a2a); pf.v.set(0, 2, 0); } }
390
- for (let k = wrecks.length - 1; k >= 0; k--) if (wrecks[k].t <= 0) { scene.remove(wrecks[k].g); wrecks.splice(k, 1); }
391
- car.setWings(S.wings, time);
392
- const roll = S.speed / 3.6 * dt / .37;
393
- for (const wh of car.wheels) wh.rotation.z -= S.air ? roll * .15 : roll;
394
- car.group.updateMatrixWorld(true);
395
-
396
- // shadow, underglow, exhaust, speed lines
397
- const fr = S.fr, ground = clamp(1 - S.pos.y / 3) * (fr.u.y > .8 ? 1 : 0);
398
- fx.shadow.position.set(S.pos.x, S.pos.y > .5 ? .07 : S.pos.y + .07, S.pos.z); fx.shadow.material.opacity = veh === "car" ? ground : 0; fx.shadow.rotation.z = Math.atan2(-fr.f.z, fr.f.x);
399
- const glowOn = !!OPTS.glow.items[build.glow][1];
400
- fx.glow.position.copy(fx.shadow.position).y += .01; fx.glow.rotation.z = fx.shadow.rotation.z; fx.glow.material.opacity = glowOn ? ground * .85 : 0;
401
- if (S.thrust.power > .3) for (const n of nozzles) if (Math.random() < .18) {
402
- const pf = fx.puffs[fx.puffI++ % fx.puffs.length], wp = n.clone().applyMatrix4(car.group.matrixWorld);
403
- pf.s.position.copy(wp); pf.life = 1; pf.s.visible = true; pf.s.scale.setScalar(.4); pf.grow = .4; pf.s.material.color.set(0xc8d2e1);
404
- pf.v.copy(fr.u).multiplyScalar(-(1 - S.thrust.angle) * 7).addScaledVector(fr.f, -S.thrust.angle * 9 - S.speed / 3.6 * .2).add(new THREE.Vector3(Math.random() - .5, Math.random() - .5, Math.random() - .5).multiplyScalar(1.5));
405
- }
406
- const emit = (p, v, col, size, life = 1) => { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(p); pf.life = life; pf.s.visible = true; pf.s.scale.setScalar(size); pf.s.material.color.set(col); pf.v.copy(v); pf.grow = size; };
407
- if (mode === "play" && S.hp != null) {
408
- const eng = new THREE.Vector3(-1.2, .9, 0).applyMatrix4(car.group.matrixWorld);
409
- if (S.hp < 55 && !S.wrecked && Math.random() < (S.hp < 25 ? .5 : .2)) emit(eng, new THREE.Vector3((Math.random() - .5), 1.5 + Math.random(), (Math.random() - .5)), S.hp < 25 ? 0x222222 : 0x9a9a9a, .5);
410
- if (S.hp < 20 && !S.wrecked && Math.random() < .35) emit(eng, new THREE.Vector3(0, 2.5, 0), 0xff7a1a, .35, .5);
411
- if (S.sparks > .5) for (let k = 0; k < 4; k++) emit(S.pos.clone().add(new THREE.Vector3(0, .5, 0)), new THREE.Vector3((Math.random() - .5) * 12, Math.random() * 6, (Math.random() - .5) * 12), 0xffd27a, .12, .35);
412
- if (S.boom > .95) for (let k = 0; k < 40; k++) emit(S.pos.clone().add(new THREE.Vector3(0, 1, 0)), new THREE.Vector3((Math.random() - .5) * 18, Math.random() * 14, (Math.random() - .5) * 18), k % 3 ? 0xff7a1a : 0x333333, 1.2, 1.4);
413
- }
414
- for (const pf of fx.puffs) if (pf.life > 0) {
415
- pf.life -= dt * .9; pf.s.position.addScaledVector(pf.v, dt); pf.v.multiplyScalar(.97);
416
- pf.s.scale.setScalar((pf.grow || .4) + (1 - pf.life) * 4.5); pf.s.material.opacity = Math.max(0, pf.life) * .5; if (pf.life <= 0) pf.s.visible = false;
417
- }
418
- const lineA = smooth(240, 320, S.speed) * (S.inside ? 0 : 1);
419
- fx.lines.material.opacity = lineA * .08;
420
- if (lineA > .01) {
421
- const d = new THREE.Object3D();
422
- for (let i = 0; i < fx.LINES; i++) {
423
- const sd = fx.lineSeed[i]; sd[0] -= S.speed / 3.6 * dt * 1.6; if (sd[0] < -40) sd[0] += 80;
424
- d.position.copy(fr.p).addScaledVector(fr.f, sd[0]).addScaledVector(fr.u, sd[1]).addScaledVector(fr.s, sd[2]); d.quaternion.copy(S.quat); d.scale.set(1.5 + S.speed * .018, 1, 1);
425
- d.updateMatrix(); fx.lines.setMatrixAt(i, d.matrix);
426
- }
427
- fx.lines.instanceMatrix.needsUpdate = true;
428
- }
429
-
430
- // the front seat and its screens
431
- if (S.screenOn) {
432
- if (!COARSE || (feedFlip++ & 1)) interior.renderFeed(scene);
433
- if (time - lastHud > .1) {
434
- lastHud = time;
435
- const hud = { time, speed: S.speed, alt: S.alt, vs: S.vs, mode: S.mode, zones: S.zones, gear: S.gear, rpm: S.rpm };
436
- interior.drawHud(hud); interior.drawCluster(hud);
437
- }
438
- }
439
- interior.wheel.rotation.x = 0;
440
-
441
- // camera
442
- const c = S.cam, shake = REDUCED ? 0 : (Math.max(0, S.g - 2) * .004 + S.thrust.power * .01 + (S.shake || 0) * .25 + (S.nitro || 0) * .02) * (S.inside ? .25 : 1);
443
- camera.position.copy(c.pos).add(new THREE.Vector3(Math.sin(time * 53) * shake, Math.sin(time * 61 + 1) * shake, Math.sin(time * 47 + 2) * shake));
444
- camera.up.copy(c.up || new THREE.Vector3(0, 1, 0)); camera.lookAt(c.look);
445
- camera.fov = fovFor(c.fov); camera.near = S.inside || S.screenOn ? .02 : .05; camera.updateProjectionMatrix();
446
- camLight.position.copy(camera.position).add(new THREE.Vector3(0, 3, 0)); camLight.target.position.copy(S.pos);
447
- scene.fog.far = lerp(950, 2600, smooth(20, 160, S.alt)); scene.fog.near = lerp(120, 400, smooth(20, 160, S.alt));
448
- camera.far = scene.fog.far * 1.1 + 150; camera.updateProjectionMatrix(); // nothing past the fog gets drawn
449
- // refresh the car's reflections every few frames (the car hides from its own camera)
450
- // (no shadows, no crowds, a short view: it only has to look right in the paint)
451
-
452
-
453
- // the car's own sound
454
- if (S.gear > prevGear && !S.air && S.speed > 20) sound.pop();
455
- prevGear = S.gear;
456
- sound.car({ rpm: S.rpm, load: S.load, engine: mode === "build" || mode === "home" ? .4 : S.engine, thrust: S.thrust.power, speed: S.speed, inside: S.inside });
457
-
458
- composer.render();
459
- }
460
-
461
- setLoad(95);
462
- renderer.compile(scene, camera);
463
- let raf;
464
- let frameErr = 0;
465
- const loop = () => { raf = requestAnimationFrame(loop); try { frameLoop(); } catch (e) { if (frameErr++ < 3) console.error(e); } }; // one bad frame never stops the game
466
- loop();
467
- setLoad(100);
468
- setTimeout(() => { $("loader").classList.add("done"); setTimeout(() => { intro.t = 0; if (sound.on) sound.rev(); }, 450); }, 900);
469
- document.addEventListener("visibilitychange", () => {
470
- if (document.hidden) { cancelAnimationFrame(raf); if (mode === "watch" && w.playing) pause(); if (sound.ctx) sound.ctx.suspend(); }
471
- else { clock.getDelta(); loop(); if (sound.on && sound.ctx) sound.ctx.resume(); }
472
- });
473
- window.VOID = { step: (n = 1, dt = 1 / 30) => { for (let i = 0; i < n; i++) frameLoop(dt); }, W, car, life, carCube, carEnv, get police() { return police; }, get races() { return races; }, get nav() { return nav; }, net, orbit, go, seek: t => { w.t = t; if (w.playing) playFrom(t); }, pause, get w() { return w; }, game }; // handy for testing in the console
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // VOID: one WebGL canvas, three modes on top of a showroom home screen.
2
+ // Watch: a 48 s film whose clock is the music. Play: drive it, fly it, land it. Build: paint it.
3
+ import * as THREE from "three";
4
+
5
+ const V = new URL(import.meta.url).search;
6
+ const $ = id => document.getElementById(id);
7
+ const setLoad = p => { $("ldbar").style.width = p + "%"; $("ldpct").textContent = Math.round(p); };
8
+ setLoad(15);
9
+ const [{ createWorld, clamp, lerp, smooth, frame, PAD_R, ZONES }, { buildCar, MODELS }, { addInterior, HEAD }, { Sound, BAR }, { createWatch, TOTAL, CHAPTERS }, { createGame }, { createLife }] = await Promise.all(
10
+ ["world", "car", "interior", "sound", "watch", "game", "npc"].map(n => import(`./${n}.js${V}`)));
11
+ const [{ createPolice }, { createRaces }, { createNet, EMOTES }, { createNav }, { createVehicles }, { createAtmos, WEATHERS }, { createGoals, GOALS }] = await Promise.all(["police", "race", "net", "nav", "vehicles", "atmos", "goals"].map(n => import(`./${n}.js${V}`)));
12
+ setLoad(40);
13
+
14
+ const COARSE = matchMedia("(pointer: coarse)").matches, REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
15
+ 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 { } } };
16
+ const W = await createWorld($("gl"), COARSE), { renderer, scene, camera, composer, bloom, camLight, fx } = W;
17
+ setLoad(55);
18
+
19
+ // the car you drive: a fixed root the game and the film move around, with the chosen model swapped inside it
20
+ const carRoot = new THREE.Group(); scene.add(carRoot);
21
+ const car = { group: carRoot, wheels: [] };
22
+ const carMats = new Set();
23
+ let inner = null;
24
+ function useModel(id, force) {
25
+ if (inner && inner.model === id && !force) return;
26
+ if (inner) carRoot.remove(inner.group);
27
+ inner = buildCar(id); carRoot.add(inner.group);
28
+ for (const k of Object.keys(inner)) if (typeof inner[k] === "function") car[k] = inner[k];
29
+ Object.assign(car, { wheels: inner.wheels, clip: car.clip || inner.clip, materials: inner.materials, model: id, name: inner.name });
30
+ carMats.clear();
31
+ inner.group.traverse(o => { if (o.isMesh) { o.castShadow = true; if (o.material && o.material.isMeshStandardMaterial) carMats.add(o.material); } });
32
+ for (const m of carMats) { m.envMap = null; m.needsUpdate = true; } // the paint reflects the city capture (scene.environment) // phones skip the live cube and use the city reflection
33
+ }
34
+ // live reflections on the paint: a small cube camera that rides with the car
35
+ const carEnv = new THREE.WebGLCubeRenderTarget(COARSE ? 64 : 128, { type: THREE.HalfFloatType, generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter });
36
+ const carCube = new THREE.CubeCamera(.5, 320, carEnv);
37
+ for (const c of carCube.children) c.layers.set(0);
38
+ camera.layers.enable(1); // layer 1: the crowds, seen by the main camera only
39
+ let cubeN = 0;
40
+ useModel((store.get("void.build3") || {}).model != null ? Object.keys(MODELS)[(store.get("void.build3") || {}).model] || "hydra" : "hydra");
41
+ setLoad(80);
42
+ const interior = addInterior(car, renderer);
43
+ const sound = new Sound();
44
+ const watch = createWatch(W, car, interior);
45
+ const life = createLife(scene, W.roads, W.walkways, COARSE);
46
+ const TIMES = ["night", "sunset", "day"];
47
+ 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; }
48
+ let police, races, nav;
49
+ const net = createNet();
50
+ setInterval(() => { if (document.hidden) net.tick(.5, null); }, 500); // a hosting tab in the background still relays everyone
51
+ // settings: graphics quality, look speed, auto gas, touch steering (kept on this device)
52
+ const SET = Object.assign({ q: COARSE ? "medium" : "high", sens: 1, autogas: "off", steer: "stick", weather: "clear", radio: "score" }, store.get("void.settings") || {});
53
+ const gameOpts = {
54
+ garage: () => openGarage(true), cycleTime, hornType: () => build.horn, autoGas: () => SET.autogas === "on", onRepair: () => car.repair && car.repair(),
55
+ onScore: (n, why) => { if (why === "Drift") goals.drift(n); goals.note(why); },
56
+ onSmash: kind => police && police.event("smash", kind), policeClear: () => police && police.clear(),
57
+ autopilot: (g, dt, over) => nav ? nav.controls(g, dt, over) : null, openGps: () => document.getElementById("gGpsBtn").click(),
58
+ mapExtra(mx, g, MM) {
59
+ const P = (x, z) => [(x - g.x) / MM, (z - g.z) / MM];
60
+ if (nav && nav.pts.length > 1) { mx.strokeStyle = "#3dfcff"; mx.lineWidth = 3; mx.beginPath(); nav.pts.forEach(([x, z], k) => { const [a, b] = P(x, z); k ? mx.lineTo(a, b) : mx.moveTo(a, b); }); mx.stroke(); }
61
+ if (police) for (const c of police.cops) if (c.on) { const [a, b] = P(c.x, c.z); mx.fillStyle = Math.sin(performance.now() / 90) > 0 ? "#ff3040" : "#3060ff"; mx.beginPath(); mx.arc(a, b, 4, 0, 7); mx.fill(); }
62
+ if (races && races.race && races.race.next) { const [a, b] = P(races.race.next[0], races.race.next[2]); mx.strokeStyle = "#ffd23d"; mx.lineWidth = 3; mx.beginPath(); mx.arc(a, b, 7, 0, 7); mx.stroke(); }
63
+ for (const r of remotes.values()) if (r.at) { const [a, b] = P(r.at.x, r.at.z); mx.fillStyle = "#c07dff"; mx.beginPath(); mx.arc(a, b, 4.5, 0, 7); mx.fill(); }
64
+ 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(); } }
65
+ },
66
+ };
67
+ const game = createGame(W, car, sound, life, gameOpts);
68
+ const vehicles = createVehicles(scene, game.api, W); gameOpts.vehicles = vehicles;
69
+ const atmos = createAtmos(W, sound, COARSE);
70
+ // trophies: a banner slides in at the top when a challenge is done
71
+ 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(); });
72
+ function renderGoals() {
73
+ const S = goals.stats;
74
+ document.getElementById("gGoals").innerHTML = GOALS.map(g => { const v = Math.min(1, (S[g.stat] || 0) / g.need), ok = goals.done.has(g.id); return `<div class="goal${ok ? " ok" : ""}"><b>${g.icon}</b><div><span>${g.name}</span><small>${g.text}</small><i style="--v:${v}"></i></div>${ok ? "🏆" : `<em>${Math.floor(v * 100)}%</em>`}</div>`; }).join("");
75
+ document.getElementById("gStatsRow").innerHTML = `<div><b>${(S.best || 0).toLocaleString()}</b><span>Best score</span></div><div><b>${Math.round(S.top)}</b><span>Top km/h</span></div><div><b>${S.km.toFixed(1)}</b><span>km travelled</span></div><div><b>${goals.done.size}/${GOALS.length}</b><span>Trophies</span></div>`;
76
+ }
77
+ const WX_ICON = { clear: "☀️ Clear", rain: "🌧 Rain", storm: "⛈ Storm", fog: "🌫 Fog", snow: "❄️ Snow" }, RADIO = { score: "🎵 VOID Score", voidfm: "📻 VOID FM", off: "🔇 Radio off" };
78
+ function chips() { document.getElementById("gChips").innerHTML = `<span>${{ night: "🌙", sunset: "🌇", day: "☀️" }[W.timeOfDay]} ${W.timeOfDay}</span><span>${WX_ICON[atmos.weather]}</span>`; document.getElementById("gWeather").innerHTML = `<b>${WX_ICON[SET.weather].split(" ")[0]}</b>${WX_ICON[SET.weather].split(" ").slice(1).join(" ")}`; document.getElementById("gRadio").innerHTML = `<b>${RADIO[SET.radio].split(" ")[0]}</b>${RADIO[SET.radio].split(" ").slice(1).join(" ")}`; }
79
+ document.getElementById("gWeather").onclick = () => { SET.weather = WEATHERS[(WEATHERS.indexOf(SET.weather) + 1) % WEATHERS.length]; atmos.setWeather(SET.weather); store.set("void.settings", SET); chips(); toast(WX_ICON[SET.weather]); };
80
+ document.getElementById("gRadio").onclick = () => { const k = Object.keys(RADIO); SET.radio = k[(k.indexOf(SET.radio) + 1) % k.length]; if (!sound.on) setSound(true); sound.setRadio(SET.radio); store.set("void.settings", SET); chips(); toast(RADIO[SET.radio]); };
81
+ // ---------- the menu (☰ or Esc): everything that isn't driving lives here ----------
82
+ const menuEl = document.getElementById("gMenu");
83
+ function menu(open) {
84
+ if (open == null) open = menuEl.hidden;
85
+ menuEl.hidden = !open; document.body.classList.toggle("g-menu-open", open); if (open) { renderGoals(); chips(); }
86
+ if (open && document.pointerLockElement) document.exitPointerLock();
87
+ if (!net.online()) game.pause(open);
88
+ }
89
+ document.getElementById("gMenuBtn").onclick = () => menu(true);
90
+ document.getElementById("gMenuClose").onclick = () => menu(false);
91
+ document.getElementById("gExit").onclick = () => { menu(false); go("home"); };
92
+ menuEl.addEventListener("click", e => { const t = e.target.closest(".tile"); if (t && !t.closest(".set")) setTimeout(() => menu(false), 60); if (e.target === menuEl) menu(false); });
93
+ menuEl.querySelectorAll("[data-veh]").forEach(b => b.onclick = () => game.api.setVehicle(b.dataset.veh));
94
+ document.getElementById("gFuelNav").onclick = () => { const st = game.state; if (!st) return; const best = W.city.stations.slice().sort((a, b) => Math.hypot(a.x - st.x, a.z - st.z) - Math.hypot(b.x - st.x, b.z - st.z))[0]; nav.setDest({ name: "⛽ Fuel station", x: best.x, z: best.z }); };
95
+ function applySettings(save = true) {
96
+ for (const seg of menuEl.querySelectorAll(".seg[data-set]")) for (const b of seg.children) b.classList.toggle("on", SET[seg.dataset.set] === b.dataset.v);
97
+ document.getElementById("gSens").value = SET.sens; game.api.sens = +SET.sens;
98
+ document.body.classList.toggle("steer-buttons", SET.steer === "buttons");
99
+ const cap = W.setQuality(SET.q); perf.max = cap; perf.pr = Math.min(perf.pr, cap); if (renderer.getPixelRatio() > cap) { renderer.setPixelRatio(cap); composer.setPixelRatio(cap); lastW = 0; resize(); }
100
+ if (save) store.set("void.settings", SET);
101
+ }
102
+ menuEl.querySelectorAll(".seg[data-set]").forEach(seg => seg.addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; e.preventDefault(); SET[seg.dataset.set] = b.dataset.v; if (seg.dataset.set === "q") { perf.pr = W.QUALITY[b.dataset.v].pr; perf.down = 0; } applySettings();
103
+ atmos.setWeather(SET.weather); }));
104
+ document.getElementById("gSens").addEventListener("input", e => { SET.sens = +e.target.value; applySettings(); });
105
+ // the touch joystick: drag the knob; left/right steers, up/down pitches when flying
106
+ {
107
+ const st = document.getElementById("gStick"), knob = st.querySelector("i"); let id = null, cx = 0, cy = 0;
108
+ const set = (x, y) => { const r = st.clientWidth / 2 - 10, l = Math.min(1, Math.hypot(x, y) / r), a = Math.atan2(y, x), kx = Math.cos(a) * l * r, ky = Math.sin(a) * l * r; knob.style.transform = `translate(${kx}px, ${ky}px)`; game.api.setStick(kx / r, ky / r); };
109
+ st.addEventListener("pointerdown", e => { id = e.pointerId; const b = st.getBoundingClientRect(); cx = b.left + b.width / 2; cy = b.top + b.height / 2; try { st.setPointerCapture(id); } catch { } set(e.clientX - cx, e.clientY - cy); e.preventDefault(); });
110
+ st.addEventListener("pointermove", e => { if (e.pointerId === id) set(e.clientX - cx, e.clientY - cy); });
111
+ const end = e => { if (e.pointerId !== id) return; id = null; knob.style.transform = ""; game.api.setStick(0, 0); };
112
+ st.addEventListener("pointerup", end); st.addEventListener("pointercancel", end);
113
+ }
114
+ // headlights that light the road at night
115
+ 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); carRoot.add(heads, heads.target);
116
+ police = createPolice(scene, game.api, sound, life);
117
+ races = createRaces(scene, game.api, sound, W.canvasTex, net);
118
+ nav = createNav(scene, game.api, W, ZONES, races.starts);
119
+
120
+ // ---------- online: other real players' cars, name tags, emotes, proximity voice ----------
121
+ const remotes = new Map();
122
+ const tagTex = (text, col = "#ffffff") => W.canvasTex(512, 96, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.55)"; g.fillRect(0, 0, w, h); g.fillStyle = col; g.font = "800 50px Inter, sans-serif"; g.textAlign = "center"; g.fillText(text, w / 2, 66); });
123
+ function remoteFor(p) {
124
+ let r = remotes.get(p.id);
125
+ const model = Object.keys(MODELS)[p.model | 0] || "hydra";
126
+ if (r && r.model !== model) { scene.remove(r.root); remotes.delete(p.id); r = null; }
127
+ if (!r) {
128
+ const c = buildCar(model), root = new THREE.Group(); root.add(c.group); c.setReveal(1); c.setThrust(0, 0, 0, 0); c.setWings(0, 0);
129
+ c.group.traverse(o => { if (o.isMesh) o.castShadow = true; });
130
+ const tag = new THREE.Sprite(new THREE.SpriteMaterial({ map: tagTex(p.name), depthTest: false, toneMapped: false })); tag.scale.set(5, .95, 1); tag.position.y = 2.6; root.add(tag);
131
+ const emo = new THREE.Sprite(new THREE.SpriteMaterial({ map: tagTex(""), depthTest: false, toneMapped: false })); emo.scale.set(6, 1.15, 1); emo.position.y = 3.8; emo.visible = false; root.add(emo);
132
+ scene.add(root); r = { root, car: c, tag, emo, model, name: p.name, paint: null, emoT: 0 }; remotes.set(p.id, r);
133
+ }
134
+ if (r.name !== p.name) { r.name = p.name; r.tag.material.map = tagTex(p.name); }
135
+ if (p.paint && r.paint !== p.paint) { r.paint = p.paint; r.car.setPaint(p.paint); }
136
+ return r;
137
+ }
138
+ net.on("change", () => { for (const id of [...remotes.keys()]) if (!net.players().some(p => p.id === id)) { scene.remove(remotes.get(id).root); remotes.delete(id); } renderPlayers(); });
139
+ net.on("status", t => { document.getElementById("gNetStatus").textContent = t; document.getElementById("gNetBtn").classList.toggle("on", net.online()); });
140
+ net.on("mic", on => { const b = document.getElementById("gMic"); b.classList.toggle("on", on); b.textContent = on ? "🎙 Mic on" : "🎙 Voice chat"; });
141
+ net.on("emote", (m, from) => { const r = remotes.get(from), e = EMOTES[m.e | 0]; if (r) { r.emo.material.map = tagTex(e, "#ffd23d"); r.emo.visible = true; r.emoT = 4; } feed(`${net.nameOf(from)}: ${e}`); });
142
+ function feed(t) { const f = document.getElementById("gFeed"); const d = document.createElement("div"); d.textContent = t; f.prepend(d); setTimeout(() => d.remove(), 7000); while (f.children.length > 5) f.lastChild.remove(); }
143
+ function renderPlayers() { document.getElementById("gPlayers").innerHTML = net.online() ? [`<b>${net.name} (you)</b>`, ...net.players().map(p => `<span>${p.vc ? "🎙 " : ""}${p.name}</span>`)].join("") : ""; }
144
+ {
145
+ const $ = id => document.getElementById(id), panel = $("gNetPanel");
146
+ $("gNetName").value = store.get("void.name") || "";
147
+ $("gNetBtn").onclick = () => { panel.hidden = !panel.hidden; };
148
+ $("gNetClose").onclick = () => panel.hidden = true;
149
+ const go = room => { const nm = $("gNetName").value; store.set("void.name", nm); net.join(room, nm); panel.hidden = true; };
150
+ $("gNetPublic").onclick = () => go("public-1");
151
+ $("gNetCreate").onclick = () => { const code = Math.random().toString(36).slice(2, 7); $("gNetCode").value = code; go("room-" + code); toast(`Room code: ${code}. Tell your friends!`); };
152
+ $("gNetJoin").onclick = () => { const code = $("gNetCode").value.trim().toLowerCase(); if (code) go("room-" + code); };
153
+ $("gNetLeave").onclick = () => { net.leave(); panel.hidden = true; };
154
+ $("gMic").onclick = () => net.setMic(!net.mic);
155
+ $("gEmotes").innerHTML = EMOTES.map((e, k) => `<button class="pill" data-e="${k}">${e}</button>`).join("");
156
+ $("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]}`); };
157
+ }
158
+ W.setTime(store.get("void.time") || "night");
159
+ // fill the car's reflection cube once before the first frame, so the paint never samples an empty one
160
+
161
+ $("osmCredit").textContent = W.credit;
162
+ // the garage: the Build panel over the game, the game paused underneath
163
+ function openGarage(on) { document.body.classList.toggle("garage", on); game.pause(on); if (on && car.repair) { car.repair(); applyBuild(); if (game.state) game.state.hp = 100; } }
164
+ $("gGarageClose").onclick = () => openGarage(false);
165
+ const nozzles = (car.nozzles || [[.9, .3, 1], [.9, .3, -1], [-.95, .38, 1], [-.95, .38, -1]]).map(([x, y, s]) => new THREE.Vector3(x, y - .5, s));
166
+
167
+ // ---------- configurator ----------
168
+ const OPTS = {
169
+ paint: { label: "Paint", items: [["Void Black", "#0a0a0c"], ["Stealth Grey", "#2b2d32"], ["Liquid Silver", "#9fa3aa"], ["Ghost White", "#e9e9ea"], ["Blood Red", "#6f0a12"], ["Ember Orange", "#c9480c"], ["Ion Blue", "#123a8c"], ["Venom Green", "#2d6a1e"]] },
170
+ finish: { label: "Finish", seg: true, items: [["Gloss", "gloss"], ["Satin", "satin"], ["Matte", "matte"]] },
171
+ rim: { label: "Wheels", items: [["Black", "#121215"], ["Gunmetal", "#3a3d44"], ["Silver", "#b8bcc4"], ["Bronze", "#7a5a32"]] },
172
+ cal: { label: "Calipers", items: [["Red", "#c1121f"], ["Black", "#2a2a30"], ["Yellow", "#e0b000"], ["White", "#e8e8e8"], ["Ion", "#3df5ff"]] },
173
+ trim: { label: "Accents", items: [["Red", "#c1121f"], ["Black", "#16161a"], ["White", "#e8e8e8"], ["Gold", "#b8912e"], ["Ion", "#3df5ff"]] },
174
+ light: { label: "Lights", items: [["Ice", "#e8fbff"], ["Amber", "#ffb347"], ["Ion", "#6ad8ff"], ["Violet", "#b38cff"]] },
175
+ glow: { label: "Underglow", items: [["Off", ""], ["White", "#ffffff"], ["Red", "#ff2233"], ["Ion", "#3df5ff"], ["Violet", "#9b5cff"]] },
176
+ spoiler: { label: "Spoiler", seg: true, items: [["Stock", "none"], ["Ducktail", "ducktail"], ["GT wing", "gt"], ["Swan neck", "swan"], ["Rally", "rally"]] },
177
+ livery: { label: "Livery", seg: true, items: [["Clean", "none"], ["Twin stripes", "stripes"], ["Centre stripe", "center"], ["Side stripe", "side"], ["Race #42", "race"]] },
178
+ livc: { label: "Livery colour", items: [["White", "#f2f2f2"], ["Red", "#c1121f"], ["Black", "#101012"], ["Gold", "#c9a227"], ["Ion", "#3df5ff"], ["Orange", "#ff6a00"]] },
179
+ tint: { label: "Windows", seg: true, items: [["Light", "light"], ["Dark", "dark"], ["Limo", "limo"], ["Mirror", "mirror"]] },
180
+ flame: { label: "Nitro flames", items: [["Blue", "#4fa8ff"], ["Orange", "#ff7a1a"], ["Green", "#3dff6a"], ["Purple", "#b05cff"], ["Pink", "#ff4fd8"]] },
181
+ horn: { label: "Horn", seg: true, items: [["Classic", 0], ["Air horn", 1], ["Tune", 2]] },
182
+ };
183
+ OPTS.model = { label: "Car", seg: true, items: Object.entries(MODELS).map(([k, m]) => [m.name, k]) };
184
+ const DEFAULT = { model: 0, paint: 0, finish: 0, rim: 0, cal: 0, trim: 0, light: 0, glow: 0, spoiler: 0, livery: 0, livc: 0, tint: 1, flame: 0, horn: 0 };
185
+ let build = { ...DEFAULT, ...(store.get("void.build2") || {}), ...(store.get("void.build3") || {}) };
186
+ try { const h = new URLSearchParams(location.hash.slice(1)).get("b"); if (h) { const v = h.split(".").map(Number); Object.keys(DEFAULT).forEach((k, i) => { if (OPTS[k].items[v[i]]) build[k] = v[i]; }); } } catch { }
187
+ function applyBuild() {
188
+ useModel(OPTS.model.items[build.model][1]);
189
+ car.setSpoiler(OPTS.spoiler.items[build.spoiler][1]); car.setLivery(OPTS.livery.items[build.livery][1], OPTS.livc.items[build.livc][1]);
190
+ car.setTint(OPTS.tint.items[build.tint][1]); car.setFlameColor(OPTS.flame.items[build.flame][1]);
191
+ car.setPaint(OPTS.paint.items[build.paint][1]); car.setFinish(OPTS.finish.items[build.finish][1]);
192
+ car.setRim(OPTS.rim.items[build.rim][1]); car.setAccent(OPTS.cal.items[build.cal][1]); car.setTrim(OPTS.trim.items[build.trim][1]); car.setLight(OPTS.light.items[build.light][1]);
193
+ const gl = OPTS.glow.items[build.glow][1]; if (gl) fx.glow.material.color.set(gl).multiplyScalar(1.4);
194
+ store.set("void.build3", build);
195
+ $("sum").innerHTML = `<b>${car.name.toUpperCase()} &middot; ${OPTS.paint.items[build.paint][0]}</b>${OPTS.spoiler.items[build.spoiler][0]} spoiler, ${OPTS.livery.items[build.livery][0].toLowerCase()} livery, ${OPTS.finish.items[build.finish][0]} paint, ${OPTS.rim.items[build.rim][0].toLowerCase()} wheels, ${OPTS.cal.items[build.cal][0].toLowerCase()} calipers, ${OPTS.trim.items[build.trim][0].toLowerCase()} accents, ${OPTS.light.items[build.light][0].toLowerCase()} lights${gl ? ", " + OPTS.glow.items[build.glow][0].toLowerCase() + " underglow" : ""}`;
196
+ for (const b of document.querySelectorAll("#cfgBody button")) b.setAttribute("aria-pressed", String(build[b.dataset.k] === +b.dataset.i));
197
+ for (const k in OPTS) { const n = document.querySelector(`[data-name="${k}"]`); if (n) n.textContent = OPTS[k].items[build[k]][0]; }
198
+ }
199
+ const ORDER = ["model", "paint", "finish", "livery", "livc", "spoiler", "rim", "cal", "trim", "tint", "light", "glow", "flame", "horn"];
200
+ $("cfgBody").innerHTML = ORDER.map(k => [k, OPTS[k]]).map(([k, o]) => `<div class="grp"><div class="grp-h">${o.label}<b data-name="${k}"></b></div><div class="${o.seg ? "seg" : "sw"}">${o.items.map(([n, c], i) => o.seg
201
+ ? `<button data-k="${k}" data-i="${i}">${n}</button>`
202
+ : `<button data-k="${k}" data-i="${i}" title="${n}" aria-label="${o.label}: ${n}" style="background:${c || "repeating-linear-gradient(45deg,#222 0 4px,#111 4px 8px)"}"></button>`).join("")}</div></div>`).join("");
203
+ $("cfgBody").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; build[b.dataset.k] = +b.dataset.i; applyBuild(); });
204
+ $("reset").onclick = () => { build = { ...DEFAULT, model: build.model }; applyBuild(); toast("Back to black and red"); };
205
+ $("share").onclick = async () => {
206
+ const url = location.origin + location.pathname + "#b=" + Object.keys(DEFAULT).map(k => build[k]).join(".");
207
+ try { await navigator.clipboard.writeText(url); toast("Link to your build copied"); } catch { prompt("Copy your build link", url); }
208
+ };
209
+ applyBuild();
210
+ let toastT;
211
+ function toast(t) { const el = $("toast"); el.textContent = t; el.classList.add("on"); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove("on"), 2200); }
212
+
213
+ // ---------- sound ----------
214
+ function setSound(on) { sound.setOn(on); $("snd").setAttribute("aria-pressed", String(on)); $("snd").textContent = on ? "Sound on" : "Sound off"; if (on && mode === "watch" && w.playing) sound.startSong(w.t); }
215
+ $("snd").onclick = () => setSound(!sound.on);
216
+ $("rev").onclick = () => { if (!sound.on) setSound(true); sound.rev(); };
217
+
218
+ // ---------- modes ----------
219
+ let mode = "home";
220
+ const w = { t: 0, playing: false, dragging: false };
221
+ function go(next) {
222
+ if (next === mode) return;
223
+ $("fade").classList.add("on");
224
+ setTimeout(() => {
225
+ if (mode === "play") { game.exit(); openGarage(false); }
226
+ if (mode === "watch") { sound.stopSong(); w.playing = false; }
227
+ mode = next; document.body.dataset.mode = next;
228
+ if (next === "home") scrollTo(0, 0);
229
+ if (next === "watch") { if (!sound.on) setSound(true); playFrom(0); }
230
+ if (next === "play") { game.enter(); if (SET.radio !== "score") sound.setRadio(SET.radio); }
231
+ if (mode !== "play" && sound.radio && sound.radio !== "score") sound.setRadio("score");
232
+ $("fade").classList.remove("on");
233
+ }, 320);
234
+ }
235
+ document.addEventListener("click", e => { const b = e.target.closest("[data-go]"); if (b) { e.preventDefault(); go(b.dataset.go); } });
236
+ $("exit").onclick = () => go("home");
237
+ addEventListener("keydown", e => {
238
+ if (e.code === "Escape" && mode === "play" && !document.body.classList.contains("garage")) { menu(); return; }
239
+ if (e.code === "Escape" && mode !== "home") go("home");
240
+ if (mode === "watch") {
241
+ if (e.code === "Space") { e.preventDefault(); togglePlay(); }
242
+ if (e.code === "ArrowRight") seek(w.t + 4); if (e.code === "ArrowLeft") seek(w.t - 4);
243
+ }
244
+ });
245
+
246
+ // watch player
247
+ function playFrom(t) { w.t = clamp(t, 0, TOTAL); w.playing = true; $("wEnd").classList.remove("on"); if (sound.on) sound.startSong(w.t); setPlayIcon(); }
248
+ function pause() { w.playing = false; sound.stopSong(); setPlayIcon(); }
249
+ function togglePlay() { if (w.playing) pause(); else playFrom(w.t >= TOTAL - .05 ? 0 : w.t); }
250
+ function seek(t) { w.t = clamp(t, 0, TOTAL); if (w.playing) playFrom(w.t); }
251
+ function setPlayIcon() { $("wPlay").innerHTML = w.playing ? "&#10074;&#10074;" : "&#9654;"; $("wPlay").setAttribute("aria-label", w.playing ? "Pause" : "Play"); }
252
+ $("wPlay").onclick = togglePlay;
253
+ $("wAgain").onclick = () => playFrom(0);
254
+ const scrub = $("wScrub"); scrub.max = TOTAL;
255
+ scrub.addEventListener("input", () => { w.dragging = true; w.t = +scrub.value; if (w.playing) { sound.stopSong(); } });
256
+ scrub.addEventListener("change", () => { w.dragging = false; if (w.playing) playFrom(+scrub.value); });
257
+ $("wTicks").innerHTML = CHAPTERS.filter(c => c[1]).map(c => `<button style="left:${c[0] * BAR / TOTAL * 100}%" data-t="${c[0] * BAR}">${c[1]}</button>`).join("");
258
+ $("wTicks").addEventListener("click", e => { const b = e.target.closest("button"); if (b) { seek(+b.dataset.t); if (!w.playing) playFrom(+b.dataset.t); } });
259
+ const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
260
+
261
+ // drag to turn the car on the home and build screens
262
+ const orbit = { a: .7, v: 0, drag: false, x: 0 };
263
+ addEventListener("pointerdown", e => { if ((mode !== "build" && mode !== "home") || e.target !== $("gl") && !e.target.closest(".drag-hint, #buildUI") || e.target.closest(".cfg, button")) return; orbit.drag = true; orbit.x = e.clientX; });
264
+ addEventListener("pointermove", e => { if (!orbit.drag) return; orbit.v = (e.clientX - orbit.x) * -.006; orbit.a += orbit.v; orbit.x = e.clientX; });
265
+ addEventListener("pointerup", () => { orbit.drag = false; });
266
+
267
+ // ---------- resize: ignore the phone address bar sliding, follow rotation and folding ----------
268
+ let lastW = 0, lastH = 0;
269
+ function resize() {
270
+ const ww = innerWidth, hh = Math.max(innerHeight, document.documentElement.clientHeight);
271
+ if (!ww || !hh) return;
272
+ if (ww === lastW && Math.abs(hh - lastH) < 140 && lastW) return;
273
+ lastW = ww; lastH = hh; renderer.setSize(ww, hh, false); composer.setSize(ww, hh);
274
+ bloom.resolution.set(Math.max(1, ww * (COARSE ? .35 : .5)), Math.max(1, hh * (COARSE ? .35 : .5))); camera.aspect = ww / hh;
275
+ }
276
+ addEventListener("resize", resize); addEventListener("orientationchange", () => setTimeout(resize, 200));
277
+ if (window.visualViewport) visualViewport.addEventListener("resize", resize);
278
+ resize();
279
+ 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); };
280
+
281
+ // ---------- the showroom pose (home and build) ----------
282
+ const showFr = frame(); W.track.sample(W.track.D[W.track.idxX(0)], showFr);
283
+ const intro = { t: -1 }; // the car drives in once the loader lifts
284
+ function showroom(dt, time) {
285
+ let introX = 0, introV = 0;
286
+ if (intro.t >= 0 && intro.t < 3.2) { intro.t += dt; const u = Math.min(1, intro.t / 3.2); introX = -46 * Math.pow(1 - u, 3); introV = 46 * 3 * Math.pow(1 - u, 2) / 3.2; }
287
+ else if (intro.t < 0) introX = -46;
288
+ if (!orbit.drag) { orbit.v *= .94; orbit.a += orbit.v + (REDUCED ? 0 : dt * (mode === "home" ? .16 : .1)); }
289
+ const hf = 2 * Math.atan(Math.tan(fovFor(38) * Math.PI / 360) * camera.aspect), fit = 3.1 / Math.tan(hf / 2); // fit the whole car across the screen
290
+ const scrolled = mode === "home" ? clamp(scrollY / innerHeight) : 0, dist = Math.max(mode === "build" ? 8.8 : 8.2, fit) + scrolled * 2;
291
+ const cam = { pos: new THREE.Vector3(Math.cos(orbit.a) * dist, 1.5 + scrolled * 4, Math.sin(orbit.a) * dist), look: new THREE.Vector3(0, .5 - scrolled * .4, 0), fov: 38 };
292
+ if (camera.aspect < 1) cam.look.y = mode === "build" ? -1.1 : -.2;
293
+ else if (mode === "build") { const right = new THREE.Vector3().subVectors(cam.look, cam.pos).cross(new THREE.Vector3(0, 1, 0)).normalize(); cam.look.addScaledVector(right, 1.7); }
294
+ const pos = showFr.p.clone(); pos.x += introX; const fr = { ...showFr, p: pos };
295
+ if (introX < -.01) cam.look.x = Math.max(introX * .35, -8);
296
+ return { pos, quat: new THREE.Quaternion(), fr, speed: introV * 3.6, g: 1, alt: 0, vs: 0, thrust: { power: 0, angle: 0, deploy: 0 }, wings: 0, air: false, cam, inside: false, screenOn: false, reveal: 1, rpm: 900, gear: 1, load: .1, engine: .6, mode: "PARK", zones: [] };
297
+ }
298
+
299
+ // ---------- per frame ----------
300
+ const clock = new THREE.Clock();
301
+ let lastGauge = 0, lastChips = 0;
302
+ // the speedometer: a dial with a glowing arc, the speed, the gear (or altitude when flying) and what you're in
303
+ const gauge = document.getElementById("gGauge"), gx = gauge.getContext("2d");
304
+ function drawGauge(S) {
305
+ const W2 = gauge.width, c = W2 / 2, r = c - 22, a0 = Math.PI * .75, a1 = Math.PI * 2.25, max = S.veh === "plane" ? 600 : S.veh === "boat" ? 140 : 360, f = Math.min(1, S.speed / max);
306
+ gx.clearRect(0, 0, W2, W2);
307
+ gx.beginPath(); gx.arc(c, c, r + 14, 0, 7); gx.fillStyle = "rgba(6,8,14,.62)"; gx.fill();
308
+ gx.lineCap = "round"; gx.lineWidth = 14; gx.beginPath(); gx.arc(c, c, r, a0, a1); gx.strokeStyle = "rgba(255,255,255,.12)"; gx.stroke();
309
+ const grd = gx.createLinearGradient(0, W2, W2, 0); grd.addColorStop(0, "#3df5ff"); grd.addColorStop(.6, "#ffffff"); grd.addColorStop(1, "#ff2a3a");
310
+ gx.beginPath(); gx.arc(c, c, r, a0, a0 + (a1 - a0) * f); gx.strokeStyle = grd; gx.shadowColor = "#3df5ff"; gx.shadowBlur = 16; gx.stroke(); gx.shadowBlur = 0;
311
+ gx.lineWidth = 2; gx.strokeStyle = "rgba(255,255,255,.5)"; for (let k = 0; k <= 12; k++) { const a = a0 + (a1 - a0) * k / 12, i = r - 18, o = r - (k % 3 ? 24 : 30); gx.beginPath(); gx.moveTo(c + Math.cos(a) * i, c + Math.sin(a) * i); gx.lineTo(c + Math.cos(a) * o, c + Math.sin(a) * o); gx.stroke(); }
312
+ gx.fillStyle = "#fff"; gx.textAlign = "center"; gx.font = `900 ${W2 * .22}px Unbounded, Inter, sans-serif`; gx.fillText(Math.round(S.speed), c, c + W2 * .06);
313
+ gx.font = `700 ${W2 * .055}px Inter, sans-serif`; gx.fillStyle = "rgba(255,255,255,.6)"; gx.fillText("KM/H", c, c + W2 * .14);
314
+ gx.font = `800 ${W2 * .07}px Unbounded, Inter, sans-serif`; gx.fillStyle = S.air ? "#3df5ff" : "#ffd23d";
315
+ gx.fillText(S.air || S.veh === "plane" || S.veh === "heli" ? `${Math.round(S.alt)} m` : S.veh === "boat" ? "BOAT" : `GEAR ${S.gear}`, c, c + W2 * .3);
316
+ if (S.nitro > .1) { gx.font = `800 ${W2 * .05}px Inter, sans-serif`; gx.fillStyle = "#3df5ff"; gx.fillText("NITRO", c, c - W2 * .16); }
317
+ }
318
+ let wasWrecked = false, wreckPose = null; const wrecks = [];
319
+ const perf = { t: 0, n: 0, pr: renderer.getPixelRatio(), max: renderer.getPixelRatio(), down: 0 };
320
+ applySettings();
321
+ let lastHud = 0, prevGear = 1, feedFlip = 0;
322
+ function frameLoop(fixed) {
323
+ const raw = fixed ? 0 : clock.getDelta(), dt = fixed || Math.min(.05, raw), time = clock.elapsedTime;
324
+ // adaptive resolution: drop the pixel count when frames run slow, bring it back when there is headroom
325
+ if (!fixed && raw < .5) {
326
+ perf.t += raw; perf.n++;
327
+ if (perf.t > 1.5) {
328
+ const avg = perf.t / perf.n, was = perf.pr;
329
+ if (avg > 1 / 45 && perf.pr > .6) perf.pr = Math.max(.6, perf.pr - (avg > 1 / 25 ? .25 : .12));
330
+ else if (avg > 1 / 40 && mode === "play" && ++perf.down >= 3 && SET.q !== "low") { // still slow at the lowest resolution: drop a quality level
331
+ perf.down = 0; SET.q = { ultra: "high", high: "medium", medium: "low" }[SET.q]; perf.pr = W.QUALITY[SET.q].pr; applySettings(false); toast(`Graphics set to ${SET.q} to keep it smooth`);
332
+ }
333
+ else if (avg < 1 / 58 && perf.pr < perf.max) perf.pr = Math.min(perf.max, perf.pr + .08);
334
+ if (perf.pr !== was) { renderer.setPixelRatio(perf.pr); composer.setPixelRatio(perf.pr); lastW = 0; resize(); }
335
+ perf.t = perf.n = 0;
336
+ }
337
+ }
338
+ let S;
339
+ if (mode === "watch") {
340
+ if (w.playing && !w.dragging) {
341
+ const st = sound.on ? sound.songTime() : null;
342
+ w.t = st != null ? st : w.t + dt;
343
+ if (w.t >= TOTAL) { w.t = TOTAL; pause(); $("wEnd").classList.add("on"); }
344
+ }
345
+ S = watch.at(w.t, time, camera.aspect, fovFor);
346
+ if (!w.dragging) scrub.value = w.t;
347
+ scrub.style.setProperty("--p", (w.t / TOTAL * 100) + "%");
348
+ $("wTime").textContent = `${fmt(w.t)} / ${fmt(TOTAL)}`;
349
+ const ch = S.chapter, cap = $("wCap");
350
+ if (cap.dataset.ch !== ch[1]) { cap.dataset.ch = ch[1]; cap.classList.add("hide"); setTimeout(() => { $("wNum").textContent = ch[1] ? String(CHAPTERS.indexOf(ch) + 1).padStart(2, "0") : ""; $("wTitle").textContent = ch[1]; $("wSub").textContent = ch[2]; cap.classList.toggle("hide", !ch[1]); }, 250); }
351
+ $("telem").style.opacity = S.bar > 2 && !S.inside && S.bar < 17.5 ? 1 : 0;
352
+ $("tSpd").textContent = Math.round(S.speed); $("tG").textContent = S.g.toFixed(1); $("tAlt").textContent = Math.round(S.alt);
353
+ $("dream").style.opacity = S.dream; $("fade").style.opacity = S.fade;
354
+ } else if (mode === "play") S = game.update(dt, time, camera.aspect);
355
+ else S = showroom(dt, time);
356
+ life.update(dt, time, mode === "play" ? S.player : null, camera.position);
357
+ if (mode === "play") {
358
+ police.update(dt, time); races.update(dt, time); nav.update();
359
+ const st = game.state, stars = police.stars;
360
+ document.getElementById("gStars").textContent = stars ? "★".repeat(stars) + "☆".repeat(5 - stars) : "";
361
+ document.getElementById("gStars").classList.toggle("bust", police.bust > .5);
362
+ const pr = races.progress();
363
+ net.tick(dt, { p: [st.x, st.y, st.z, st.yaw, st.pitch, st.roll].map(v => Math.round(v * 100) / 100), m: Object.keys(MODELS).indexOf(car.model), c: OPTS.paint.items[build.paint][1], v: Math.round(S.speed), a: S.air ? 1 : 0, r: pr ? pr.race : 0, rp: pr ? pr.rp : 0, rf: pr ? pr.rf : 0, w: stars });
364
+ for (const p of net.players()) {
365
+ const at = net.sample(p); const r = remoteFor(p); r.at = at;
366
+ if (!at || at.stale) { r.root.visible = false; continue; }
367
+ r.root.visible = true; r.root.position.set(at.x, at.y, at.z); r.root.rotation.set(0, -at.yaw, 0); r.car.group.rotation.set(at.roll, 0, at.pitch, "XYZ");
368
+ r.car.setThrust(at.air ? .6 : 0, at.air && at.v > 90 ? 1 : 0, at.air ? 1 : 0, time); r.car.setWings(at.air && at.v > 100 ? 1 : 0, time);
369
+ for (const wh of r.car.wheels) wh.rotation.z -= at.v / 3.6 * dt / .37;
370
+ if (r.emoT > 0 && (r.emoT -= dt) <= 0) r.emo.visible = false;
371
+ const dx = st.x - at.x, dz = st.z - at.z, d = Math.hypot(dx, dz);
372
+ net.setVolume(p.id, 1 - d / 260);
373
+ if (d < 3.4 && d > .01 && Math.abs(st.y - at.y) < 2) { st.x += dx / d * (3.4 - d) * .5; st.z += dz / d * (3.4 - d) * .5; st.vel.multiplyScalar(.9); }
374
+ }
375
+ } else { for (const r of remotes.values()) r.root.visible = false; net.tick(dt, null); } // the host keeps relaying from the menus
376
+ W.city.update(time, camera.position);
377
+ W.follow(S.pos);
378
+ if (mode !== "watch") $("fade").style.opacity = "";
379
+
380
+ // the car, or the plane / helicopter / boat
381
+ const veh = mode === "play" ? S.veh || "car" : "car";
382
+ carRoot.visible = veh === "car";
383
+ vehicles.show(veh === "car" ? null : veh, S.pos, S.quat, S, time, dt);
384
+ if (S.splash) for (let k = 0; k < 6; k++) { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(S.pos).add(new THREE.Vector3((Math.random() - .5) * 3, .3, (Math.random() - .5) * 3)); pf.life = 1; pf.s.visible = true; pf.grow = .8; pf.s.scale.setScalar(.8); pf.s.material.color.set(0xeaf6ff); pf.v.set((Math.random() - .5) * 6, 4 + Math.random() * 4, (Math.random() - .5) * 6); }
385
+ if (veh === "boat" && S.speed > 12 && Math.random() < .6) { const back = new THREE.Vector3(-3.6, .1, (Math.random() - .5) * 2).applyQuaternion(S.quat).add(S.pos); const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(back); pf.life = 1; pf.s.visible = true; pf.grow = .5; pf.s.scale.setScalar(.5); pf.s.material.color.set(0xf4fbff); pf.v.set((Math.random() - .5) * 2, .6, (Math.random() - .5) * 2); }
386
+ const night = W.timeOfDay !== "day";
387
+ heads.intensity = night && veh === "car" ? (W.timeOfDay === "night" ? 260 : 120) : 0;
388
+ W.sky.position.copy(camera.position); W.grade.uniforms.time.value = time % 100;
389
+ W.grade.uniforms.blur.value = mode === "play" ? Math.max(0, Math.min(1, (S.speed - 160) / 220)) * .9 + (S.nitro || 0) * .5 : 0;
390
+ atmos.update(dt, time, S, mode === "play" ? game.state : null);
391
+ W.city.poolMat.opacity = W.timeOfDay === "night" ? 1 : W.timeOfDay === "sunset" ? .45 : 0; W.city.poolMat.visible = W.timeOfDay !== "day";
392
+ if (mode === "play") { goals.update(dt, game.state, S.speed); if (time - lastChips > 2) { lastChips = time; chips(); } }
393
+ if (mode === "play" && time - lastGauge > .05) { lastGauge = time; drawGauge(S); const fu = document.getElementById("gFuel"); if (fu && S.fuel != null) { fu.style.setProperty("--v", S.fuel); fu.classList.toggle("low", S.fuel < .2); } }
394
+ car.group.position.copy(S.pos); car.group.quaternion.copy(S.quat);
395
+ car.setReveal(S.reveal);
396
+ car.setThrust(S.thrust.power, S.thrust.angle, S.thrust.deploy, time);
397
+ car.setFlame(S.nitro || 0, time);
398
+ // damage: dent the model where it was hit; a destroyed car is left where it crashed and a fresh one is built for you
399
+ const gs = mode === "play" ? game.state : null;
400
+ if (gs && gs.impacts && gs.impacts.length && car.damage) { carRoot.updateMatrixWorld(true); for (const im of gs.impacts.splice(0)) car.damage(carRoot.worldToLocal(new THREE.Vector3(im.x, im.y, im.z)), im.p); }
401
+ if (S.wrecked && !wasWrecked) { car.wreck && car.wreck(); wreckPose = { p: carRoot.position.clone(), q: carRoot.quaternion.clone() }; }
402
+ if (!S.wrecked && wasWrecked && wreckPose && inner) {
403
+ const wg = inner.group; carRoot.remove(wg); wg.position.copy(wreckPose.p); wg.quaternion.copy(wreckPose.q); scene.add(wg);
404
+ wrecks.push({ g: wg, t: 30 }); if (wrecks.length > 4) { const o = wrecks.shift(); scene.remove(o.g); }
405
+ useModel(car.model, true); applyBuild(); wreckPose = null;
406
+ }
407
+ wasWrecked = !!S.wrecked;
408
+ for (const w of wrecks) { w.t -= dt; if (Math.random() < .25 && w.t > 10) { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(w.g.position).add(new THREE.Vector3((Math.random() - .5) * 2, 1, (Math.random() - .5) * 2)); pf.life = 1; pf.s.visible = true; pf.grow = .6; pf.s.scale.setScalar(.6); pf.s.material.color.set(0x2a2a2a); pf.v.set(0, 2, 0); } }
409
+ for (let k = wrecks.length - 1; k >= 0; k--) if (wrecks[k].t <= 0) { scene.remove(wrecks[k].g); wrecks.splice(k, 1); }
410
+ car.setWings(S.wings, time);
411
+ const roll = S.speed / 3.6 * dt / .37;
412
+ for (const wh of car.wheels) wh.rotation.z -= S.air ? roll * .15 : roll;
413
+ car.group.updateMatrixWorld(true);
414
+
415
+ // shadow, underglow, exhaust, speed lines
416
+ const fr = S.fr, ground = clamp(1 - S.pos.y / 3) * (fr.u.y > .8 ? 1 : 0);
417
+ fx.shadow.position.set(S.pos.x, S.pos.y > .5 ? .07 : S.pos.y + .07, S.pos.z); fx.shadow.material.opacity = veh === "car" ? ground : 0; fx.shadow.rotation.z = Math.atan2(-fr.f.z, fr.f.x);
418
+ const glowOn = !!OPTS.glow.items[build.glow][1];
419
+ fx.glow.position.copy(fx.shadow.position).y += .01; fx.glow.rotation.z = fx.shadow.rotation.z; fx.glow.material.opacity = glowOn ? ground * .85 : 0;
420
+ if (S.thrust.power > .3) for (const n of nozzles) if (Math.random() < .18) {
421
+ const pf = fx.puffs[fx.puffI++ % fx.puffs.length], wp = n.clone().applyMatrix4(car.group.matrixWorld);
422
+ pf.s.position.copy(wp); pf.life = 1; pf.s.visible = true; pf.s.scale.setScalar(.4); pf.grow = .4; pf.s.material.color.set(0xc8d2e1);
423
+ pf.v.copy(fr.u).multiplyScalar(-(1 - S.thrust.angle) * 7).addScaledVector(fr.f, -S.thrust.angle * 9 - S.speed / 3.6 * .2).add(new THREE.Vector3(Math.random() - .5, Math.random() - .5, Math.random() - .5).multiplyScalar(1.5));
424
+ }
425
+ const emit = (p, v, col, size, life = 1) => { const pf = fx.puffs[fx.puffI++ % fx.puffs.length]; pf.s.position.copy(p); pf.life = life; pf.s.visible = true; pf.s.scale.setScalar(size); pf.s.material.color.set(col); pf.v.copy(v); pf.grow = size; };
426
+ if (mode === "play" && S.hp != null) {
427
+ const eng = new THREE.Vector3(-1.2, .9, 0).applyMatrix4(car.group.matrixWorld);
428
+ if (S.hp < 55 && !S.wrecked && Math.random() < (S.hp < 25 ? .5 : .2)) emit(eng, new THREE.Vector3((Math.random() - .5), 1.5 + Math.random(), (Math.random() - .5)), S.hp < 25 ? 0x222222 : 0x9a9a9a, .5);
429
+ if (S.hp < 20 && !S.wrecked && Math.random() < .35) emit(eng, new THREE.Vector3(0, 2.5, 0), 0xff7a1a, .35, .5);
430
+ if (S.sparks > .5) for (let k = 0; k < 4; k++) emit(S.pos.clone().add(new THREE.Vector3(0, .5, 0)), new THREE.Vector3((Math.random() - .5) * 12, Math.random() * 6, (Math.random() - .5) * 12), 0xffd27a, .12, .35);
431
+ if (S.boom > .95) for (let k = 0; k < 40; k++) emit(S.pos.clone().add(new THREE.Vector3(0, 1, 0)), new THREE.Vector3((Math.random() - .5) * 18, Math.random() * 14, (Math.random() - .5) * 18), k % 3 ? 0xff7a1a : 0x333333, 1.2, 1.4);
432
+ }
433
+ for (const pf of fx.puffs) if (pf.life > 0) {
434
+ pf.life -= dt * .9; pf.s.position.addScaledVector(pf.v, dt); pf.v.multiplyScalar(.97);
435
+ pf.s.scale.setScalar((pf.grow || .4) + (1 - pf.life) * 4.5); pf.s.material.opacity = Math.max(0, pf.life) * .5; if (pf.life <= 0) pf.s.visible = false;
436
+ }
437
+ const lineA = smooth(240, 320, S.speed) * (S.inside ? 0 : 1);
438
+ fx.lines.material.opacity = lineA * .08;
439
+ if (lineA > .01) {
440
+ const d = new THREE.Object3D();
441
+ for (let i = 0; i < fx.LINES; i++) {
442
+ const sd = fx.lineSeed[i]; sd[0] -= S.speed / 3.6 * dt * 1.6; if (sd[0] < -40) sd[0] += 80;
443
+ d.position.copy(fr.p).addScaledVector(fr.f, sd[0]).addScaledVector(fr.u, sd[1]).addScaledVector(fr.s, sd[2]); d.quaternion.copy(S.quat); d.scale.set(1.5 + S.speed * .018, 1, 1);
444
+ d.updateMatrix(); fx.lines.setMatrixAt(i, d.matrix);
445
+ }
446
+ fx.lines.instanceMatrix.needsUpdate = true;
447
+ }
448
+
449
+ // the front seat and its screens
450
+ if (S.screenOn) {
451
+ if (!COARSE || (feedFlip++ & 1)) interior.renderFeed(scene);
452
+ if (time - lastHud > .1) {
453
+ lastHud = time;
454
+ const hud = { time, speed: S.speed, alt: S.alt, vs: S.vs, mode: S.mode, zones: S.zones, gear: S.gear, rpm: S.rpm };
455
+ interior.drawHud(hud); interior.drawCluster(hud);
456
+ }
457
+ }
458
+ interior.wheel.rotation.x = 0;
459
+
460
+ // camera
461
+ const c = S.cam, shake = REDUCED ? 0 : (Math.max(0, S.g - 2) * .004 + S.thrust.power * .01 + (S.shake || 0) * .25 + (S.nitro || 0) * .02) * (S.inside ? .25 : 1);
462
+ camera.position.copy(c.pos).add(new THREE.Vector3(Math.sin(time * 53) * shake, Math.sin(time * 61 + 1) * shake, Math.sin(time * 47 + 2) * shake));
463
+ camera.up.copy(c.up || new THREE.Vector3(0, 1, 0)); camera.lookAt(c.look);
464
+ camera.fov = fovFor(c.fov); camera.near = S.inside || S.screenOn ? .02 : .05; camera.updateProjectionMatrix();
465
+ camLight.position.copy(camera.position).add(new THREE.Vector3(0, 3, 0)); camLight.target.position.copy(S.pos);
466
+ scene.fog.far = lerp(950, 2600, smooth(20, 160, S.alt)) * W.weatherFog; scene.fog.near = lerp(120, 400, smooth(20, 160, S.alt)) * W.weatherFog;
467
+ camera.far = scene.fog.far * 1.1 + 150; camera.updateProjectionMatrix(); // nothing past the fog gets drawn
468
+ // refresh the car's reflections every few frames (the car hides from its own camera)
469
+ // (no shadows, no crowds, a short view: it only has to look right in the paint)
470
+
471
+
472
+ // the car's own sound
473
+ if (S.gear > prevGear && !S.air && S.speed > 20) sound.pop();
474
+ prevGear = S.gear;
475
+ sound.car({ rpm: S.rpm, load: S.load, engine: mode === "build" || mode === "home" ? .4 : S.engine, thrust: S.thrust.power, speed: S.speed, inside: S.inside });
476
+
477
+ composer.render();
478
+ }
479
+
480
+ setLoad(95);
481
+ renderer.compile(scene, camera);
482
+ let raf;
483
+ let frameErr = 0;
484
+ const loop = () => { raf = requestAnimationFrame(loop); try { frameLoop(); } catch (e) { if (frameErr++ < 3) console.error(e); } }; // one bad frame never stops the game
485
+ loop();
486
+ setLoad(100);
487
+ setTimeout(() => { $("loader").classList.add("done"); setTimeout(() => { intro.t = 0; if (sound.on) sound.rev(); }, 450); }, 900);
488
+ document.addEventListener("visibilitychange", () => {
489
+ if (document.hidden) { cancelAnimationFrame(raf); if (mode === "watch" && w.playing) pause(); if (sound.ctx) sound.ctx.suspend(); }
490
+ else { clock.getDelta(); loop(); if (sound.on && sound.ctx) sound.ctx.resume(); }
491
+ });
492
+ window.VOID = { step: (n = 1, dt = 1 / 30) => { for (let i = 0; i < n; i++) frameLoop(dt); }, W, car, life, carCube, carEnv, get police() { return police; }, get races() { return races; }, get nav() { return nav; }, net, orbit, go, seek: t => { w.t = t; if (w.playing) playFrom(t); }, pause, get w() { return w; }, game }; // handy for testing in the console
static/sound.js CHANGED
@@ -124,7 +124,7 @@ export class Sound {
124
  if (this.mode === "song") {
125
  while (this.next < C.currentTime + .12) {
126
  const bar = Math.floor(this.songStep / 16);
127
- if (bar >= SONG_BARS) { this.mode = "idle"; break; }
128
  this.applyMix(songMix(bar + (this.songStep % 16) / 16), this.next);
129
  if (bar === 8 && this.songStep % 16 === 0) this.impact(this.next);
130
  if (bar === 7 && this.songStep % 16 === 12) this.chop(this.next, "ey", 1.2);
@@ -199,6 +199,8 @@ export class Sound {
199
  whoosh() { if (this.on) this.noiseHit(this.ctx.currentTime, "bandpass", 700, .8, .5, .9, this.sfx); }
200
  pop() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "bandpass", 300, 1, .9, .07, this.sfx); this.noiseHit(t + .06, "bandpass", 260, 1, .6, .06, this.sfx); } }
201
  crash() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "lowpass", 2500, .4, 1, 1.2, this.sfx); this.noiseHit(t, "bandpass", 180, 1, 1, .6, this.sfx); } }
 
 
202
  siren(level) { // a wailing two-tone siren whose volume follows how close the nearest cruiser is
203
  if (!this.ctx || !this.on) return; const c = this.ctx, t = c.currentTime;
204
  if (!this.sir) { const o = c.createOscillator(), lfo = c.createOscillator(), lg = c.createGain(), g = c.createGain(); o.type = "square"; o.frequency.value = 760; lfo.frequency.value = .45; lg.gain.value = 260; lfo.connect(lg); lg.connect(o.frequency); const lp = c.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 1800; o.connect(lp); lp.connect(g); g.gain.value = 0; g.connect(this.sfx); o.start(); lfo.start(); this.sir = g; }
 
124
  if (this.mode === "song") {
125
  while (this.next < C.currentTime + .12) {
126
  const bar = Math.floor(this.songStep / 16);
127
+ if (bar >= SONG_BARS) { if (this.radio === "voidfm") { this.startSong(0); continue; } this.mode = "idle"; break; }
128
  this.applyMix(songMix(bar + (this.songStep % 16) / 16), this.next);
129
  if (bar === 8 && this.songStep % 16 === 0) this.impact(this.next);
130
  if (bar === 7 && this.songStep % 16 === 12) this.chop(this.next, "ey", 1.2);
 
199
  whoosh() { if (this.on) this.noiseHit(this.ctx.currentTime, "bandpass", 700, .8, .5, .9, this.sfx); }
200
  pop() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "bandpass", 300, 1, .9, .07, this.sfx); this.noiseHit(t + .06, "bandpass", 260, 1, .6, .06, this.sfx); } }
201
  crash() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "lowpass", 2500, .4, 1, 1.2, this.sfx); this.noiseHit(t, "bandpass", 180, 1, 1, .6, this.sfx); } }
202
+ // the car radio: "score" is the game's own music, "voidfm" loops the film track, "off" is engine and city only
203
+ setRadio(st) { this.init(); this.radio = st; this.musicIn.gain.setTargetAtTime(st === "off" ? 0 : .5, this.ctx.currentTime, .3); if (st === "voidfm") this.startSong(0); else if (this.mode === "song") this.stopSong(); }
204
  siren(level) { // a wailing two-tone siren whose volume follows how close the nearest cruiser is
205
  if (!this.ctx || !this.on) return; const c = this.ctx, t = c.currentTime;
206
  if (!this.sir) { const o = c.createOscillator(), lfo = c.createOscillator(), lg = c.createGain(), g = c.createGain(); o.type = "square"; o.frequency.value = 760; lfo.frequency.value = .45; lg.gain.value = 260; lfo.connect(lg); lg.connect(o.frequency); const lp = c.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 1800; o.connect(lp); lp.connect(g); g.gain.value = 0; g.connect(this.sfx); o.start(); lfo.start(); this.sir = g; }
static/style.css CHANGED
@@ -360,3 +360,22 @@ body[data-mode="play"] .hub-chip { display: none !important; }
360
  }
361
  @media (max-width: 820px) { #playUI .hud-top { left: 10px; transform: none; width: calc(100% - 124px); top: calc(176px + var(--top)); justify-items: start; } }
362
  #playUI .g-bars .bar span { position: absolute; inset: 0; display: flex; align-items: center; line-height: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  }
361
  @media (max-width: 820px) { #playUI .hud-top { left: 10px; transform: none; width: calc(100% - 124px); top: calc(176px + var(--top)); justify-items: start; } }
362
  #playUI .g-bars .bar span { position: absolute; inset: 0; display: flex; align-items: center; line-height: 1; }
363
+
364
+ /* status chips, trophies, stats, challenges */
365
+ #playUI .hud-tr .chips { display: flex; gap: 6px; } #playUI .hud-tr .chips span { padding: 4px 9px; border-radius: 999px; background: rgba(8,10,16,.55); border: 1px solid rgba(255,255,255,.12); font: 600 11px var(--body); text-transform: capitalize; backdrop-filter: blur(8px); }
366
+ .g-trophy { position: absolute; left: 50%; top: calc(14px + var(--top)); z-index: 45; display: flex; align-items: center; gap: 12px; padding: 10px 18px 10px 12px; border-radius: 18px; background: linear-gradient(135deg, rgba(40,32,8,.92), rgba(12,12,16,.92)); border: 1px solid rgba(255,210,61,.6); box-shadow: 0 10px 40px rgba(255,190,40,.25); transform: translate(-50%, -140%); opacity: 0; pointer-events: none; }
367
+ .g-trophy.on { animation: trophy 4.2s cubic-bezier(.2,.8,.2,1) forwards; }
368
+ .g-trophy b { font-size: 30px; } .g-trophy span { display: block; font: 800 10px var(--wide); letter-spacing: .18em; text-transform: uppercase; color: #ffd23d; } .g-trophy div { font: 700 15px var(--body); } .g-trophy em { font-style: normal; color: #ffd23d; margin-left: 8px; }
369
+ @keyframes trophy { 0% { transform: translate(-50%, -140%); opacity: 0; } 10%, 85% { transform: translate(-50%, 0); opacity: 1; } 100% { transform: translate(-50%, -140%); opacity: 0; } }
370
+ .stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; }
371
+ .stats-row div { padding: 12px; border-radius: 16px; background: linear-gradient(160deg, rgba(255,255,255,.08), rgba(255,255,255,.02)); border: 1px solid rgba(255,255,255,.1); }
372
+ .stats-row b { display: block; font: 900 22px var(--wide); } .stats-row span { font: 600 11px var(--body); color: var(--mute); text-transform: uppercase; letter-spacing: .1em; }
373
+ .goals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
374
+ .goal { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 14px; background: rgba(0,0,0,.25); border: 1px solid rgba(255,255,255,.08); }
375
+ .goal b { font-size: 22px; } .goal span { display: block; font: 700 13px var(--body); } .goal small { color: var(--mute); font-size: 11.5px; }
376
+ .goal i { display: block; height: 5px; border-radius: 3px; margin-top: 5px; background: linear-gradient(90deg, #ffd23d calc(var(--v) * 100%), rgba(255,255,255,.12) 0); }
377
+ .goal em { font: 700 12px var(--body); font-style: normal; color: var(--mute); } .goal.ok { border-color: rgba(255,210,61,.45); background: rgba(255,210,61,.08); }
378
+ .g-menu .menu-grid section { transition: border-color .2s; } .g-menu .menu-grid section:hover { border-color: rgba(255,255,255,.2); }
379
+ .g-menu[hidden] + * {} .g-menu:not([hidden]) > * { animation: menuIn .28s cubic-bezier(.2,.8,.2,1) both; }
380
+ @keyframes menuIn { from { opacity: 0; transform: translateY(12px) scale(.98); } }
381
+ @media (max-width: 820px) { .stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .goals { grid-template-columns: minmax(0, 1fr); } #playUI .hud-tr .chips { display: none; } }
static/world.js CHANGED
@@ -72,11 +72,13 @@ export async function createWorld(canvas, COARSE) {
72
  const bloom = new UnrealBloomPass(new THREE.Vector2(256, 256), .42, .35, .9);
73
  composer.addPass(bloom); composer.addPass(new OutputPass());
74
  const grade = new ShaderPass({
75
- uniforms: { tDiffuse: { value: null }, time: { value: 0 }, vig: { value: .32 }, grain: { value: .035 }, sat: { value: 1.08 }, contrast: { value: 1.06 } },
76
  vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }",
77
- fragmentShader: `uniform sampler2D tDiffuse; uniform float time, vig, grain, sat, contrast; varying vec2 vUv;
78
  float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
79
- void main(){ vec4 c = texture2D(tDiffuse, vUv); vec3 col = c.rgb;
 
 
80
  float l = dot(col, vec3(.2126, .7152, .0722)); col = mix(vec3(l), col, sat); col = (col - .5) * contrast + .5;
81
  vec2 d = vUv - .5; col *= 1. - vig * smoothstep(.25, .85, dot(d, d) * 2.2);
82
  col += (h(vUv * 1000. + time) - .5) * grain;
@@ -121,15 +123,23 @@ export async function createWorld(canvas, COARSE) {
121
  for (const m of city.glassMats) { m.envMap = cityEnv; m.needsUpdate = true; }
122
  scene.environment = cityEnv;
123
  }
124
- let timeOfDay = "night";
125
  const sunDir = new THREE.Vector3(-300, 420, -500).normalize();
126
  function setTime(k) {
127
  const S = SKIES[k]; timeOfDay = k; paintSky(k);
128
- sky.visible = k !== "night";
 
 
 
 
 
 
129
  if (sky.visible) { skyU.turbidity.value = k === "day" ? 3 : 8; skyU.rayleigh.value = k === "day" ? 1.2 : 2.8; skyU.mieCoefficient.value = k === "day" ? .004 : .008; skyU.mieDirectionalG.value = .85; skyU.sunPosition.value.set(...S.sunPos).normalize(); }
130
  scene.fog.color.setHex(S.fog); hemi.color.setHex(S.hemi[0]); hemi.groundColor.setHex(S.hemi[1]); hemi.intensity = S.hemi[2];
131
  sun.color.setHex(S.sun[0]); sun.intensity = S.sun[1]; sunDir.set(...S.sunPos).normalize();
132
- renderer.toneMappingExposure = S.exp; stars.visible = S.stars; key.intensity = k === "day" ? .4 : 1.1;
 
 
133
  for (const m of city.emissive) {
134
  if (m.isMeshStandardMaterial) { if (m.userData.base0 == null) m.userData.base0 = m.emissiveIntensity; m.emissiveIntensity = m.userData.base0 * S.glow; }
135
  else { if (!m.userData.base0) m.userData.base0 = m.color.clone(); m.color.copy(m.userData.base0).multiplyScalar(Math.max(.55, S.glow)); }
@@ -149,6 +159,8 @@ export async function createWorld(canvas, COARSE) {
149
  bloom.enabled = Q.bloom; grade.enabled = Q.grade;
150
  return Math.min(devicePixelRatio || 1, Q.pr);
151
  }
 
 
152
  function follow(p) { sun.position.copy(p).addScaledVector(sunDir, 600); sun.target.position.copy(p); sun.target.updateMatrixWorld(); }
153
 
154
  // landing zones: a glowing ring, an H, and a tall beacon you can see from far away
@@ -239,7 +251,7 @@ export async function createWorld(canvas, COARSE) {
239
  for (let i = 0; i < 90; i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0 })); s.visible = false; scene.add(s); puffs.push({ s, life: 0, v: new THREE.Vector3() }); }
240
 
241
  return {
242
- renderer, scene, camera, composer, bloom, grade, sky, setQuality, get quality() { return quality; }, QUALITY, camLight, canvasTex, radial, buildings, city, setTime, follow, captureCity, get timeOfDay() { return timeOfDay; }, studioEnv,
243
  roads: city.roads, footprints: city.footprints, parks: city.parks, water: city.water, beaches: city.beaches, walkways: city.walkways, bounds: city.bounds, credit: city.credit,
244
  track: { P, F, S, U, D, N, loopIn, loopOut, rampIn, roadEnd, idxX, sample },
245
  fx: { shadow, glow, lines, LINES, lineSeed, puffs, puffI: 0 },
 
72
  const bloom = new UnrealBloomPass(new THREE.Vector2(256, 256), .42, .35, .9);
73
  composer.addPass(bloom); composer.addPass(new OutputPass());
74
  const grade = new ShaderPass({
75
+ uniforms: { tDiffuse: { value: null }, time: { value: 0 }, blur: { value: 0 }, vig: { value: .32 }, grain: { value: .035 }, sat: { value: 1.08 }, contrast: { value: 1.06 } },
76
  vertexShader: "varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }",
77
+ fragmentShader: `uniform sampler2D tDiffuse; uniform float time, vig, grain, sat, contrast, blur; varying vec2 vUv;
78
  float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
79
+ void main(){ vec4 c = texture2D(tDiffuse, vUv);
80
+ if (blur > .001) { vec2 d = vUv - .5; vec4 acc = c; for (int i = 1; i < 7; i++) acc += texture2D(tDiffuse, vUv - d * blur * float(i) * .011); c = acc / 7.; } // speed blur toward the edges
81
+ vec3 col = c.rgb;
82
  float l = dot(col, vec3(.2126, .7152, .0722)); col = mix(vec3(l), col, sat); col = (col - .5) * contrast + .5;
83
  vec2 d = vUv - .5; col *= 1. - vig * smoothstep(.25, .85, dot(d, d) * 2.2);
84
  col += (h(vUv * 1000. + time) - .5) * grain;
 
123
  for (const m of city.glassMats) { m.envMap = cityEnv; m.needsUpdate = true; }
124
  scene.environment = cityEnv;
125
  }
126
+ let timeOfDay = "night", wxDim = 1, wxOver = false, hemiBase = 1;
127
  const sunDir = new THREE.Vector3(-300, 420, -500).normalize();
128
  function setTime(k) {
129
  const S = SKIES[k]; timeOfDay = k; paintSky(k);
130
+ sky.visible = k !== "night" && !wxOver;
131
+ if (wxOver) { // overcast: a flat grey sky (darker at night)
132
+ const c = document.createElement("canvas"); c.width = 4; c.height = 512; const g2 = c.getContext("2d"), gr = g2.createLinearGradient(0, 0, 0, 512);
133
+ const top = k === "night" ? "#0d1016" : k === "sunset" ? "#4a4650" : "#7c838e", mid = k === "night" ? "#1d222b" : k === "sunset" ? "#7a6a6a" : "#a9b0b8";
134
+ gr.addColorStop(0, top); gr.addColorStop(.6, mid); gr.addColorStop(1, top); g2.fillStyle = gr; g2.fillRect(0, 0, 4, 512);
135
+ if (skyTex) skyTex.dispose(); skyTex = new THREE.CanvasTexture(c); skyTex.colorSpace = THREE.SRGBColorSpace; skyTex.mapping = THREE.EquirectangularReflectionMapping; scene.background = skyTex;
136
+ }
137
  if (sky.visible) { skyU.turbidity.value = k === "day" ? 3 : 8; skyU.rayleigh.value = k === "day" ? 1.2 : 2.8; skyU.mieCoefficient.value = k === "day" ? .004 : .008; skyU.mieDirectionalG.value = .85; skyU.sunPosition.value.set(...S.sunPos).normalize(); }
138
  scene.fog.color.setHex(S.fog); hemi.color.setHex(S.hemi[0]); hemi.groundColor.setHex(S.hemi[1]); hemi.intensity = S.hemi[2];
139
  sun.color.setHex(S.sun[0]); sun.intensity = S.sun[1]; sunDir.set(...S.sunPos).normalize();
140
+ hemi.intensity *= wxDim; sun.intensity *= wxDim * wxDim; hemiBase = hemi.intensity;
141
+ if (wxOver) scene.fog.color.lerp(new THREE.Color(k === "night" ? 0x151820 : 0x8a919a), .7);
142
+ renderer.toneMappingExposure = S.exp; stars.visible = S.stars && !wxOver; key.intensity = k === "day" ? .4 : 1.1;
143
  for (const m of city.emissive) {
144
  if (m.isMeshStandardMaterial) { if (m.userData.base0 == null) m.userData.base0 = m.emissiveIntensity; m.emissiveIntensity = m.userData.base0 * S.glow; }
145
  else { if (!m.userData.base0) m.userData.base0 = m.color.clone(); m.color.copy(m.userData.base0).multiplyScalar(Math.max(.55, S.glow)); }
 
159
  bloom.enabled = Q.bloom; grade.enabled = Q.grade;
160
  return Math.min(devicePixelRatio || 1, Q.pr);
161
  }
162
+ function setWeatherLook(dim, over) { wxDim = dim; wxOver = over; setTime(timeOfDay); }
163
+ function setFlash(f) { hemi.intensity = hemiBase + f * 5; }
164
  function follow(p) { sun.position.copy(p).addScaledVector(sunDir, 600); sun.target.position.copy(p); sun.target.updateMatrixWorld(); }
165
 
166
  // landing zones: a glowing ring, an H, and a tall beacon you can see from far away
 
251
  for (let i = 0; i < 90; i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0 })); s.visible = false; scene.add(s); puffs.push({ s, life: 0, v: new THREE.Vector3() }); }
252
 
253
  return {
254
+ renderer, scene, camera, composer, bloom, grade, sky, sunDir, setWeatherLook, setFlash, weatherFog: 1, setQuality, get quality() { return quality; }, QUALITY, camLight, canvasTex, radial, buildings, city, setTime, follow, captureCity, get timeOfDay() { return timeOfDay; }, studioEnv,
255
  roads: city.roads, footprints: city.footprints, parks: city.parks, water: city.water, beaches: city.beaches, walkways: city.walkways, bounds: city.bounds, credit: city.credit,
256
  track: { P, F, S, U, D, N, loopIn, loopOut, rampIn, roadEnd, idxX, sample },
257
  fx: { shadow, glow, lines, LINES, lineSeed, puffs, puffI: 0 },