Static site
Browse files- index.html +7 -3
- static/atmos.js +122 -0
- static/car.js +411 -385
- static/city.js +7 -2
- static/game.js +1 -0
- static/goals.js +44 -0
- static/main.js +492 -473
- static/sound.js +3 -1
- static/style.css +19 -0
- 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=
|
| 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=
|
| 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
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
const
|
| 126 |
-
const
|
| 127 |
-
const
|
| 128 |
-
const
|
| 129 |
-
const
|
| 130 |
-
const
|
| 131 |
-
const
|
| 132 |
-
|
| 133 |
-
const
|
| 134 |
-
const
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
const
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
const
|
| 153 |
-
|
| 154 |
-
add(new THREE.BoxGeometry(
|
| 155 |
-
|
| 156 |
-
const
|
| 157 |
-
add(new THREE.BoxGeometry(.
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
add(new THREE.BoxGeometry(.
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
const
|
| 168 |
-
for (const
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
const
|
| 173 |
-
for (const
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
const
|
| 199 |
-
const
|
| 200 |
-
const
|
| 201 |
-
const
|
| 202 |
-
const
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
const
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
const
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
const
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
const
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
pivot.
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
const
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
const
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
},
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
for (const
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
}
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 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
|
|
|
|
| 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 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
if (
|
| 61 |
-
if (
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
const
|
| 68 |
-
|
| 69 |
-
const
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
document.getElementById("
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
document.body.classList.toggle("
|
| 86 |
-
|
| 87 |
-
if (
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
document.getElementById("
|
| 91 |
-
|
| 92 |
-
{
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
const
|
| 108 |
-
const
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
const
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
{
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
$("
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
$("
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
$
|
| 202 |
-
$
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
const
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
if (
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
//
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
function
|
| 270 |
-
|
| 271 |
-
if (
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
const
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
const
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
car.group.
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
const
|
| 400 |
-
|
| 401 |
-
if (S.
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
const
|
| 419 |
-
fx.
|
| 420 |
-
if (
|
| 421 |
-
const
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
//
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()} · ${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 ? "❚❚" : "▶"; $("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);
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 },
|