Death242 commited on
Commit
5042fc2
·
verified ·
1 Parent(s): 46d5400

Static site

Browse files
Files changed (6) hide show
  1. index.html +2 -2
  2. static/car.js +3 -1
  3. static/city.js +69 -30
  4. static/dealer.js +36 -8
  5. static/main.js +3 -3
  6. static/world.js +3 -2
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=1793109323">
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": {
@@ -215,6 +215,6 @@
215
  <div id="toast" role="status"></div>
216
  <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>
217
  <script src="/static/hub.js" data-app="void" defer></script>
218
- <script type="module" src="/static/main.js?v=1793109323"></script>
219
  </body>
220
  </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=1793110117">
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": {
 
215
  <div id="toast" role="status"></div>
216
  <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>
217
  <script src="/static/hub.js" data-app="void" defer></script>
218
+ <script type="module" src="/static/main.js?v=1793110117"></script>
219
  </body>
220
  </html>
static/car.js CHANGED
@@ -285,6 +285,7 @@ export function buildCar(model = "hydra") {
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);
@@ -310,6 +311,7 @@ export function buildCar(model = "hydra") {
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; });
@@ -352,7 +354,7 @@ export function buildCar(model = "hydra") {
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);
 
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
+ mergeStatic(spoilers, new Set());
289
  }
290
  const liveryMat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: .3, clearcoat: 1, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2 });
291
  const livery = new THREE.Group(); solid.add(livery);
 
311
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
312
  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); }
313
  }
314
+ mergeStatic(livery, new Set());
315
  }
316
  const flameMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4fa8ff).multiplyScalar(3), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
317
  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; });
 
354
  // power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
355
  setThrust(power, angle, deploy, time) {
356
  for (const t of thrusters) {
357
+ t.pivot.scale.setScalar(Math.max(.0001, deploy)); t.pivot.visible = deploy > .005;
358
  t.pivot.rotation.z = -angle * Math.PI / 2;
359
  const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
360
  t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
static/city.js CHANGED
@@ -43,7 +43,11 @@ export async function buildCity(scene, canvasTex, COARSE) {
43
  // ---- textures ----
44
  const noise = (g, w, h, n, a) => { for (let i = 0; i < n; i++) { g.fillStyle = `rgba(${rnd() > .5 ? 255 : 0},${rnd() > .5 ? 255 : 0},${rnd() > .5 ? 255 : 0},${rnd() * a})`; g.fillRect(rnd() * w, rnd() * h, 2, 2); } };
45
  const grassTex = canvasTex(256, 256, (g, w, h) => { g.fillStyle = "#3f6b2e"; g.fillRect(0, 0, w, h); for (let i = 0; i < 9000; i++) { g.fillStyle = `rgba(${40 + rnd() * 60 | 0},${90 + rnd() * 70 | 0},${30 + rnd() * 30 | 0},${.25 + rnd() * .5})`; g.fillRect(rnd() * w, rnd() * h, 1, 2 + rnd() * 3); } }, [1, 1]);
46
- const asphaltTex = canvasTex(256, 256, (g, w, h) => { g.fillStyle = "#26272b"; g.fillRect(0, 0, w, h); for (let i = 0; i < 6000; i++) { const c = 30 + rnd() * 40 | 0; g.fillStyle = `rgba(${c},${c},${c + 4},${rnd() * .6})`; g.fillRect(rnd() * w, rnd() * h, 1, 1); } }, [1, 1]);
 
 
 
 
47
  const walkTex = canvasTex(128, 128, (g, w, h) => { g.fillStyle = "#9c9a95"; g.fillRect(0, 0, w, h); noise(g, w, h, 900, .06); g.fillStyle = "rgba(60,60,60,.35)"; g.fillRect(0, 0, w, 2); g.fillRect(0, 0, 2, h); g.fillRect(0, h / 2, w, 1); g.fillRect(w / 2, 0, 1, h); }, [1, 1]);
48
  const GLASS = [["#1b3248", "#4b7898"], ["#0d1218", "#2a3440"], ["#3a2c1c", "#8a6a44"], ["#16302c", "#4f8a80"]];
49
  const glassTex = (st, lit) => canvasTex(256, 256, (g, w, h) => { // 16 m x 16 m: 4 bays x 4 floors
@@ -131,18 +135,39 @@ export async function buildCity(scene, canvasTex, COARSE) {
131
  const mat = (o) => new THREE.MeshStandardMaterial(o);
132
  const lit = (m, k = 1) => { m.userData.glow = k; emissive.push(m); return m; };
133
  const NS = new THREE.Vector2(1, 1);
134
- const glassM = [0, 1, 2, 3].map(s => { const m = lit(mat({ map: glassTex(s, false), emissive: 0xffffff, emissiveMap: glassTex(s, true), emissiveIntensity: .9, metalness: .9, roughness: .1, envMapIntensity: 1.6, normalMap: glassN, normalScale: NS })); glassMats.push(m); return m; });
135
- const stoneM = lit(mat({ map: stoneTex(false), emissive: 0xffffff, emissiveMap: stoneTex(true), emissiveIntensity: .75, roughness: .85, normalMap: stoneN, normalScale: NS }));
136
- const brickM = BRICK.map((c, i) => lit(mat({ map: brickTex(c, false, false), emissive: 0xffffff, emissiveMap: brickTex(c, true, false), emissiveIntensity: .75, roughness: .9, normalMap: brickN, normalScale: NS })));
137
- // glowing crown bands on the glass towers (cyan, violet, warm white, gold)
138
- const crownM = [0x3df5ff, 0xb38cff, 0xfff1d6, 0xffc23d].map(c => { const m = new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(2.2), toneMapped: false }); emissive.push(m); return m; });
139
- const shopM = AWN.map((_, k) => lit(mat({ map: shopTex(k, false), emissive: 0xffffff, emissiveMap: shopTex(k, true), emissiveIntensity: 1, roughness: .5, metalness: .1 }), 1.1));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  const roofM = mat({ color: 0x3b3c40, roughness: .9 }), trimM = mat({ color: 0xd9d2c4, roughness: .7 }), darkM = mat({ color: 0x1c1d21, roughness: .6, metalness: .4 });
141
- for (const m of [...glassM, stoneM, ...brickM, ...shopM]) for (const t of [m.map, m.emissiveMap]) t.wrapS = t.wrapT = THREE.RepeatWrapping;
142
 
143
  // ---- geometry buckets: every wall of a kind goes into one merged mesh ----
144
  const B = new Map();
145
- const bucket = (m, tw, th) => { if (!B.has(m)) B.set(m, { m, tw, th, pos: [], uv: [] }); return B.get(m); };
146
  const quad = (o, x1, z1, x2, z2, y0, y1) => {
147
  const L = Math.hypot(x2 - x1, z2 - z1), u1 = L / o.tw, v0 = y0 / o.th, v1 = y1 / o.th;
148
  o.pos.push(x1, y0, z1, x2, y0, z2, x2, y1, z2, x1, y0, z1, x2, y1, z2, x1, y1, z1);
@@ -151,7 +176,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
151
  const top = (o, x0, x1, z0, z1, y) => { o.pos.push(x0, y, z0, x0, y, z1, x1, y, z1, x0, y, z0, x1, y, z1, x1, y, z0); o.uv.push(0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0); };
152
  // walls go round counter-clockwise seen from above so they face out
153
  const walls = (o, x0, x1, z0, z1, y0, y1) => { quad(o, x0, z1, x1, z1, y0, y1); quad(o, x1, z1, x1, z0, y0, y1); quad(o, x1, z0, x0, z0, y0, y1); quad(o, x0, z0, x0, z1, y0, y1); };
154
- const roofB = bucket(roofM, 8, 8), trimB = bucket(trimM, 4, 4), darkB = bucket(darkM, 4, 4), crownB = crownM.map(m => bucket(m, 4, 4));
155
  const box = (o, x0, x1, z0, z1, y0, y1, roof = roofB) => { walls(o, x0, x1, z0, z1, y0, y1); top(roof, x0, x1, z0, z1, y1); };
156
 
157
  // collision boxes, in a spatial hash so lookups stay quick with thousands of buildings
@@ -183,9 +208,11 @@ export async function buildCity(scene, canvasTex, COARSE) {
183
  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);
184
  const m = new THREE.Mesh(g, grassMat); m.position.set((a + b) / 2, -.12, 0); scene.add(m);
185
  }
186
- const asphaltMat = mat({ map: asphaltTex, roughness: .82, envMapIntensity: .35 });
 
 
187
  const asphalt = new THREE.Mesh(new THREE.PlaneGeometry(XB - XA + 40, ZB - ZA + 40).rotateX(-Math.PI / 2), asphaltMat);
188
- asphaltTex.repeat.set((XB - XA) / 8, (ZB - ZA) / 8); asphalt.position.set((XA + XB) / 2, 0, (ZA + ZB) / 2); scene.add(asphalt);
189
  const walkG = [], parkG = [];
190
  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));
191
 
@@ -247,7 +274,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
247
  };
248
  // a storefront band round the ground floor (on the sides that face a road), with a sign over each shop
249
  const shops = (x0, x1, z0, z1, faces) => {
250
- const o = bucket(pick(shopM), 8, 4.5);
251
  for (const f of faces) {
252
  const [ax, az, bx, bz, nx, nz] = f === "s" ? [x0, z1, x1, z1, 0, 1] : f === "n" ? [x1, z0, x0, z0, 0, -1] : f === "e" ? [x1, z1, x1, z0, 1, 0] : [x0, z0, x0, z1, -1, 0];
253
  quad(o, ax, az, bx, bz, 0, 4.5);
@@ -260,7 +287,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
260
  }
261
  };
262
  const tower = (x0, x1, z0, z1, H, style, film) => {
263
- const glass = style < 4, o = glass ? bucket(glassM[style], 16, 16) : bucket(stoneM, 12, 12);
264
  let ax0 = x0, ax1 = x1, az0 = z0, az1 = z1, y = 0;
265
  const tiers = H > 120 ? 1 + Math.floor(rnd() * 3) : 1;
266
  shops(x0, x1, z0, z1, ["s", "n", "e", "w"]); const base = 4.5;
@@ -287,7 +314,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
287
  };
288
  const spires = [];
289
  const walkup = (x0, x1, z0, z1, floors, faces) => { // brick, shops below, a water tower on top
290
- const o = bucket(pick(brickM), 12, 9.6), h = 4.5 + floors * 3.2;
291
  shops(x0, x1, z0, z1, faces);
292
  const other = ["s", "n", "e", "w"].filter(f => !faces.includes(f));
293
  for (const f of other) { const [ax, az, bx, bz] = f === "s" ? [x0, z1, x1, z1] : f === "n" ? [x1, z0, x0, z0] : f === "e" ? [x1, z1, x1, z0] : [x0, z0, x0, z1]; quad(o, ax, az, bx, bz, 0, 4.5); }
@@ -307,7 +334,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
307
  while (x < x1 - 5) {
308
  const w = Math.min(x1 - x, range(6, 8.5)), d = range(13, 16), h = 3.2 * (3 + Math.floor(rnd() * 2)) + 1.2;
309
  const za = side > 0 ? zEdge - d : zEdge, zb = side > 0 ? zEdge : zEdge + d;
310
- const o = bucket(pick(brickM), 12, 9.6);
311
  walls(o, x, x + w, za, zb, 0, h); top(roofB, x, x + w, za, zb, h); walls(trimB, x - .2, x + w + .2, za - .2, zb + .2, h - .6, h + .3);
312
  solid(x, x + w, za, zb, h + .3);
313
  P.stoops.push([x + w / 2, side > 0 ? zEdge + 1.1 : zEdge - 1.1, side]);
@@ -402,7 +429,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
402
  { // the bridge: a deck at road level, two stone towers, cables
403
  const deck = new THREE.Mesh(new THREE.BoxGeometry(W2 + 700 - W2 + 120, 1.6, 30).translate((W2 + W2 + 700) / 2, -.75, 0), mat({ color: 0x55565c, roughness: .8 }));
404
  scene.add(deck);
405
- for (const tx of [W2 + 170, W2 + 530]) for (const s of [-1, 1]) { box(bucket(stoneM, 12, 12), tx - 6, tx + 6, s * 15 - 3, s * 15 + 3, 0, 110); solid(tx - 6, tx + 6, s * 15 - 3, s * 15 + 3, 110); }
406
  const cab = [];
407
  for (const s of [-1, 1]) { const curve = new THREE.QuadraticBezierCurve3(new THREE.Vector3(W2 + 170, 108, s * 15), new THREE.Vector3(W2 + 350, 12, s * 15), new THREE.Vector3(W2 + 530, 108, s * 15)); cab.push(new THREE.TubeGeometry(curve, 60, .6, 6)); for (let x = W2 + 190; x < W2 + 520; x += 14) { const y = curve.getPoint((x - W2 - 170) / 360).y; cab.push(new THREE.CylinderGeometry(.12, .12, y, 4).translate(x, y / 2, s * 15)); } }
408
  scene.add(new THREE.Mesh(mergeGeometries(cab), darkM));
@@ -455,17 +482,18 @@ export async function buildCity(scene, canvasTex, COARSE) {
455
 
456
  // ---- finish: one mesh per material per 320 m tile, so whatever is off screen (or out of the shadow box) is skipped ----
457
  const TILE = 640, tileOf = (x, z) => Math.floor(x / TILE) * 1000 + Math.floor(z / TILE);
458
- for (const o of B.values()) {
459
- if (!o.pos.length) continue;
 
460
  const tiles = new Map();
461
- for (let t = 0, u = 0; t < o.pos.length; t += 9, u += 6) {
462
  const k = tileOf((o.pos[t] + o.pos[t + 3] + o.pos[t + 6]) / 3, (o.pos[t + 2] + o.pos[t + 5] + o.pos[t + 8]) / 3);
463
- let a = tiles.get(k); if (!a) tiles.set(k, a = { pos: [], uv: [] });
464
- for (let i = 0; i < 9; i++) a.pos.push(o.pos[t + i]); for (let i = 0; i < 6; i++) a.uv.push(o.uv[u + i]);
465
  }
466
  for (const a of tiles.values()) {
467
- const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(a.pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(a.uv, 2)); g.computeVertexNormals();
468
- const m = new THREE.Mesh(g, o.m); m.userData.solid = true; scene.add(m);
469
  }
470
  }
471
  if (spires.length) scene.add(new THREE.Mesh(mergeGeometries(spires.map(g => g.toNonIndexed())), mat({ color: 0xb8bcc4, metalness: .9, roughness: .25 })));
@@ -481,7 +509,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
481
  // small props go in 200 m tiles and are only drawn near the camera (trees a little further)
482
  const propTiles = [];
483
  const instT = (geo, material, list, place, colorOf, far = 330) => {
484
- const groups = new Map(), PT = 200;
485
  list.forEach((it, i) => { place(it, i); const e = m4.elements, k = Math.floor(e[12] / PT) * 1000 + Math.floor(e[14] / PT); let gr = groups.get(k); if (!gr) groups.set(k, gr = []); gr.push([m4.clone(), colorOf ? colorOf(it, i) : null]); });
486
  for (const [k, arr] of groups) { const mesh = new THREE.InstancedMesh(geo, material, arr.length); arr.forEach(([m, c], j) => { mesh.setMatrixAt(j, m); if (c) mesh.setColorAt(j, c); }); mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); scene.add(mesh);
487
  propTiles.push({ mesh, x: (Math.floor(k / 1000) + .5) * PT, z: (((k % 1000) + 1000) % 1000 + .5) * PT, far }); }
@@ -534,7 +562,13 @@ export async function buildCity(scene, canvasTex, COARSE) {
534
  P.steam.forEach(([x, z]) => solid(x - .5, x + .5, z - .5, z + .5, 2.4));
535
  }
536
  const steamTex = canvasTex(64, 64, (g, w) => { const r = g.createRadialGradient(32, 32, 0, 32, 32, 32); r.addColorStop(0, "rgba(255,255,255,.55)"); r.addColorStop(1, "rgba(255,255,255,0)"); g.fillStyle = r; g.fillRect(0, 0, w, w); });
537
- const puffs = Array.from({ length: COARSE ? 40 : 90 }, () => { const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: steamTex, transparent: true, depthWrite: false, opacity: 0 })); sp.visible = false; scene.add(sp); return { sp, life: 0, vx: 0, vz: 0 }; });
 
 
 
 
 
 
538
  let puffI = 0, puffT = 0;
539
  // subway entrances: green railings, two green globes, a dark stair
540
  const globe = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dff6a).multiplyScalar(2), toneMapped: false }); globe.userData.glow = 2; globe.userData.base = new THREE.Color(0x3dff6a); emissive.push(globe);
@@ -564,7 +598,7 @@ export async function buildCity(scene, canvasTex, COARSE) {
564
  for (const [m, list] of food) scene.add(new THREE.Mesh(mergeGeometries(list.map(g => { g.deleteAttribute("uv"); return g; })), m));
565
  // helipads and red aviation lights
566
  const padTex = canvasTex(256, 256, (g, w) => { g.fillStyle = "#2a2c30"; g.fillRect(0, 0, w, w); g.strokeStyle = "#ffd23d"; g.lineWidth = 10; g.beginPath(); g.arc(128, 128, 110, 0, 7); g.stroke(); g.fillStyle = "#fff"; g.font = "900 150px sans-serif"; g.textAlign = "center"; g.fillText("H", 128, 180); });
567
- for (const h of P.helipads) { const m = new THREE.Mesh(new THREE.CircleGeometry(h.r, 32).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: padTex, roughness: .8 })); m.position.set(h.x, h.y + .05, h.z); scene.add(m); }
568
  const avMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(3), toneMapped: false, transparent: true });
569
  inst(new THREE.SphereGeometry(.6, 8, 6), avMat, P.aviation, ([x, y, z]) => at(x, y, z));
570
 
@@ -609,13 +643,18 @@ export async function buildCity(scene, canvasTex, COARSE) {
609
  },
610
  update(t, cam) {
611
  setLights(t);
612
- if (cam && (++cullN % 10) === 0) { const far = (1 + Math.max(0, cam.y - 20) / 60) * (this.detail || 1); for (const pt of propTiles) pt.mesh.visible = Math.hypot(pt.x - cam.x, pt.z - cam.z) < pt.far * far + 141; }
613
  waveTex.offset.set((t * .004) % 1, (t * .011) % 1);
614
  avMat.opacity = Math.sin(t * 3) > 0 ? 1 : .15;
615
  // steam from the stacks near the camera
616
  const dt = Math.min(.1, Math.max(0, t - (this._t || t))); this._t = t;
617
- if (cam && (puffT += dt) > .09) { puffT = 0; for (const [x, z] of P.steam) if (Math.abs(x - cam.x) < 160 && Math.abs(z - cam.z) < 160) { const pf = puffs[puffI++ % puffs.length]; pf.life = 1; pf.sp.position.set(x + (Math.random() - .5) * .3, 2.5, z + (Math.random() - .5) * .3); pf.vx = (Math.random() - .5) * .6; pf.vz = (Math.random() - .5) * .6; pf.sp.visible = true; } }
618
- for (const pf of puffs) if (pf.life > 0) { pf.life -= dt * .32; pf.sp.position.x += pf.vx * dt; pf.sp.position.z += pf.vz * dt; pf.sp.position.y += dt * 1.6; const k = 1 - pf.life; pf.sp.scale.setScalar(1 + k * 4.5); pf.sp.material.opacity = Math.max(0, pf.life) * .6; if (pf.life <= 0) pf.sp.visible = false; }
 
 
 
 
 
619
  billboards.forEach((b, k) => { b.material.color.setScalar(1.15 + .25 * Math.sin(t * 2 + k)); });
620
  },
621
  };
 
43
  // ---- textures ----
44
  const noise = (g, w, h, n, a) => { for (let i = 0; i < n; i++) { g.fillStyle = `rgba(${rnd() > .5 ? 255 : 0},${rnd() > .5 ? 255 : 0},${rnd() > .5 ? 255 : 0},${rnd() * a})`; g.fillRect(rnd() * w, rnd() * h, 2, 2); } };
45
  const grassTex = canvasTex(256, 256, (g, w, h) => { g.fillStyle = "#3f6b2e"; g.fillRect(0, 0, w, h); for (let i = 0; i < 9000; i++) { g.fillStyle = `rgba(${40 + rnd() * 60 | 0},${90 + rnd() * 70 | 0},${30 + rnd() * 30 | 0},${.25 + rnd() * .5})`; g.fillRect(rnd() * w, rnd() * h, 1, 2 + rnd() * 3); } }, [1, 1]);
46
+ const asphaltTex = canvasTex(512, 512, (g, w, h) => { // 5 m of road: dark binder, light and dark stones, a few old patches
47
+ g.fillStyle = "#28292d"; g.fillRect(0, 0, w, h);
48
+ for (let i = 0; i < 6; i++) { g.fillStyle = `rgba(${rnd() < .5 ? "0,0,0" : "70,70,74"},${.06 + rnd() * .08})`; g.fillRect(rnd() * w, rnd() * h, 60 + rnd() * 160, 40 + rnd() * 120); }
49
+ for (let i = 0; i < 26000; i++) { const c = 20 + rnd() * 70 | 0; g.fillStyle = `rgba(${c},${c},${c + 4},${.3 + rnd() * .6})`; const sz = rnd() < .15 ? 2 : 1; g.fillRect(rnd() * w | 0, rnd() * h | 0, sz, sz); }
50
+ }, [1, 1]);
51
  const walkTex = canvasTex(128, 128, (g, w, h) => { g.fillStyle = "#9c9a95"; g.fillRect(0, 0, w, h); noise(g, w, h, 900, .06); g.fillStyle = "rgba(60,60,60,.35)"; g.fillRect(0, 0, w, 2); g.fillRect(0, 0, 2, h); g.fillRect(0, h / 2, w, 1); g.fillRect(w / 2, 0, 1, h); }, [1, 1]);
52
  const GLASS = [["#1b3248", "#4b7898"], ["#0d1218", "#2a3440"], ["#3a2c1c", "#8a6a44"], ["#16302c", "#4f8a80"]];
53
  const glassTex = (st, lit) => canvasTex(256, 256, (g, w, h) => { // 16 m x 16 m: 4 bays x 4 floors
 
135
  const mat = (o) => new THREE.MeshStandardMaterial(o);
136
  const lit = (m, k = 1) => { m.userData.glow = k; emissive.push(m); return m; };
137
  const NS = new THREE.Vector2(1, 1);
138
+ // a texture atlas: several facades side by side; each wall says which one it uses (aTile) and the shader wraps inside it.
139
+ // One material per family means one draw per city tile instead of one per facade (that was ~80 draw calls a frame).
140
+ const atlasOf = (texs, srgb = true) => {
141
+ const w = texs[0].image.width, h = texs[0].image.height, c = document.createElement("canvas"); c.width = w * texs.length; c.height = h;
142
+ const g = c.getContext("2d"); texs.forEach((t, i) => { g.drawImage(t.image, i * w, 0); t.dispose(); });
143
+ const t = new THREE.CanvasTexture(c); if (srgb) t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.anisotropy = 8; return t;
144
+ };
145
+ const atlasify = (m, n, normalToo) => {
146
+ m.onBeforeCompile = sh => {
147
+ sh.vertexShader = sh.vertexShader.replace("#include <common>", "#include <common>\nattribute float aTile; varying float vTile;").replace("#include <begin_vertex>", "#include <begin_vertex>\nvTile = aTile;");
148
+ const fn = `varying float vTile;\nvec4 atlas(sampler2D t, vec2 uv) { vec2 k = vec2(1.0 / ${n}.0, 1.0); return textureGrad(t, vec2((clamp(fract(uv.x), .004, .996) + vTile) / ${n}.0, uv.y), dFdx(uv) * k, dFdy(uv) * k); }\n`;
149
+ let f = sh.fragmentShader.replace("void main() {", fn + "void main() {")
150
+ .replace("#include <map_fragment>", THREE.ShaderChunk.map_fragment.replace("texture2D( map, vMapUv )", "atlas( map, vMapUv )"))
151
+ .replace("#include <emissivemap_fragment>", THREE.ShaderChunk.emissivemap_fragment.replace("texture2D( emissiveMap, vEmissiveMapUv )", "atlas( emissiveMap, vEmissiveMapUv )"));
152
+ if (normalToo) f = f.replace("#include <normal_fragment_maps>", THREE.ShaderChunk.normal_fragment_maps.split("texture2D( normalMap, vNormalMapUv )").join("atlas( normalMap, vNormalMapUv )"));
153
+ sh.fragmentShader = f;
154
+ };
155
+ m.customProgramCacheKey = () => "atlas" + n + (normalToo ? "n" : ""); return m;
156
+ };
157
+ const glassA = atlasify(lit(mat({ map: atlasOf([0, 1, 2, 3].map(k => glassTex(k, false))), emissive: 0xffffff, emissiveMap: atlasOf([0, 1, 2, 3].map(k => glassTex(k, true))), emissiveIntensity: .9, metalness: .9, roughness: .1, envMapIntensity: 1.6, normalMap: glassN, normalScale: NS })), 4, false);
158
+ glassMats.push(glassA);
159
+ // brick in five colours plus the limestone, in one atlas (tile 5 is stone)
160
+ const wallA = atlasify(lit(mat({ map: atlasOf([...BRICK.map(c => brickTex(c, false, false)), stoneTex(false)]), emissive: 0xffffff, emissiveMap: atlasOf([...BRICK.map(c => brickTex(c, true, false)), stoneTex(true)]),
161
+ emissiveIntensity: .75, roughness: .88, normalMap: (() => { const ts = [...BRICK.map(() => brickN.clone()), stoneN]; return atlasOf(ts.map(t => ({ image: t.image, dispose() { } })), false); })(), normalScale: NS })), 6, true);
162
+ const STONE = 5;
163
+ const crownA = (() => { const cols = ["#3df5ff", "#b38cff", "#fff1d6", "#ffc23d"]; const t = canvasTex(4, 1, g => cols.forEach((c, i) => { g.fillStyle = c; g.fillRect(i, 0, 1, 1); })); t.magFilter = t.minFilter = THREE.NearestFilter; t.generateMipmaps = false;
164
+ const m = atlasify(new THREE.MeshBasicMaterial({ map: t, color: new THREE.Color(2.2, 2.2, 2.2), toneMapped: false }), 4, false); emissive.push(m); return m; })();
165
+ const shopA = atlasify(lit(mat({ map: atlasOf(AWN.map((_, k) => shopTex(k, false))), emissive: 0xffffff, emissiveMap: atlasOf(AWN.map((_, k) => shopTex(k, true))), emissiveIntensity: 1, roughness: .5, metalness: .1 }), 1.1), AWN.length, false);
166
  const roofM = mat({ color: 0x3b3c40, roughness: .9 }), trimM = mat({ color: 0xd9d2c4, roughness: .7 }), darkM = mat({ color: 0x1c1d21, roughness: .6, metalness: .4 });
 
167
 
168
  // ---- geometry buckets: every wall of a kind goes into one merged mesh ----
169
  const B = new Map();
170
+ const bucket = (m, tw, th, tile = 0) => { const k = m.uuid + ":" + tile + ":" + tw + ":" + th; if (!B.has(k)) B.set(k, { m, tw, th, tile, pos: [], uv: [] }); return B.get(k); };
171
  const quad = (o, x1, z1, x2, z2, y0, y1) => {
172
  const L = Math.hypot(x2 - x1, z2 - z1), u1 = L / o.tw, v0 = y0 / o.th, v1 = y1 / o.th;
173
  o.pos.push(x1, y0, z1, x2, y0, z2, x2, y1, z2, x1, y0, z1, x2, y1, z2, x1, y1, z1);
 
176
  const top = (o, x0, x1, z0, z1, y) => { o.pos.push(x0, y, z0, x0, y, z1, x1, y, z1, x0, y, z0, x1, y, z1, x1, y, z0); o.uv.push(0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0); };
177
  // walls go round counter-clockwise seen from above so they face out
178
  const walls = (o, x0, x1, z0, z1, y0, y1) => { quad(o, x0, z1, x1, z1, y0, y1); quad(o, x1, z1, x1, z0, y0, y1); quad(o, x1, z0, x0, z0, y0, y1); quad(o, x0, z0, x0, z1, y0, y1); };
179
+ const roofB = bucket(roofM, 8, 8), trimB = bucket(trimM, 4, 4), darkB = bucket(darkM, 4, 4), crownB = [0, 1, 2, 3].map(k => bucket(crownA, 4, 4, k));
180
  const box = (o, x0, x1, z0, z1, y0, y1, roof = roofB) => { walls(o, x0, x1, z0, z1, y0, y1); top(roof, x0, x1, z0, z1, y1); };
181
 
182
  // collision boxes, in a spatial hash so lookups stay quick with thousands of buildings
 
208
  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);
209
  const m = new THREE.Mesh(g, grassMat); m.position.set((a + b) / 2, -.12, 0); scene.add(m);
210
  }
211
+ const asphaltN = normalTex(256, 256, (g, w, h) => { g.fillStyle = "#808080"; g.fillRect(0, 0, w, h); for (let i = 0; i < 9000; i++) { const v = 90 + rnd() * 120 | 0; g.fillStyle = `rgb(${v},${v},${v})`; g.fillRect(rnd() * w | 0, rnd() * h | 0, 1 + (rnd() < .2), 1 + (rnd() < .2)); } }, 1.6);
212
+ asphaltN.repeat.copy(asphaltTex.repeat);
213
+ const asphaltMat = mat({ map: asphaltTex, roughness: .82, envMapIntensity: .35, normalMap: asphaltN, normalScale: new THREE.Vector2(.6, .6) });
214
  const asphalt = new THREE.Mesh(new THREE.PlaneGeometry(XB - XA + 40, ZB - ZA + 40).rotateX(-Math.PI / 2), asphaltMat);
215
+ asphaltTex.repeat.set((XB - XA) / 5, (ZB - ZA) / 5); asphaltN.repeat.copy(asphaltTex.repeat); asphalt.position.set((XA + XB) / 2, 0, (ZA + ZB) / 2); scene.add(asphalt);
216
  const walkG = [], parkG = [];
217
  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));
218
 
 
274
  };
275
  // a storefront band round the ground floor (on the sides that face a road), with a sign over each shop
276
  const shops = (x0, x1, z0, z1, faces) => {
277
+ const o = bucket(shopA, 8, 4.5, Math.floor(rnd() * AWN.length));
278
  for (const f of faces) {
279
  const [ax, az, bx, bz, nx, nz] = f === "s" ? [x0, z1, x1, z1, 0, 1] : f === "n" ? [x1, z0, x0, z0, 0, -1] : f === "e" ? [x1, z1, x1, z0, 1, 0] : [x0, z0, x0, z1, -1, 0];
280
  quad(o, ax, az, bx, bz, 0, 4.5);
 
287
  }
288
  };
289
  const tower = (x0, x1, z0, z1, H, style, film) => {
290
+ const glass = style < 4, o = glass ? bucket(glassA, 16, 16, style) : bucket(wallA, 12, 12, STONE);
291
  let ax0 = x0, ax1 = x1, az0 = z0, az1 = z1, y = 0;
292
  const tiers = H > 120 ? 1 + Math.floor(rnd() * 3) : 1;
293
  shops(x0, x1, z0, z1, ["s", "n", "e", "w"]); const base = 4.5;
 
314
  };
315
  const spires = [];
316
  const walkup = (x0, x1, z0, z1, floors, faces) => { // brick, shops below, a water tower on top
317
+ const o = bucket(wallA, 12, 9.6, Math.floor(rnd() * BRICK.length)), h = 4.5 + floors * 3.2;
318
  shops(x0, x1, z0, z1, faces);
319
  const other = ["s", "n", "e", "w"].filter(f => !faces.includes(f));
320
  for (const f of other) { const [ax, az, bx, bz] = f === "s" ? [x0, z1, x1, z1] : f === "n" ? [x1, z0, x0, z0] : f === "e" ? [x1, z1, x1, z0] : [x0, z0, x0, z1]; quad(o, ax, az, bx, bz, 0, 4.5); }
 
334
  while (x < x1 - 5) {
335
  const w = Math.min(x1 - x, range(6, 8.5)), d = range(13, 16), h = 3.2 * (3 + Math.floor(rnd() * 2)) + 1.2;
336
  const za = side > 0 ? zEdge - d : zEdge, zb = side > 0 ? zEdge : zEdge + d;
337
+ const o = bucket(wallA, 12, 9.6, Math.floor(rnd() * BRICK.length));
338
  walls(o, x, x + w, za, zb, 0, h); top(roofB, x, x + w, za, zb, h); walls(trimB, x - .2, x + w + .2, za - .2, zb + .2, h - .6, h + .3);
339
  solid(x, x + w, za, zb, h + .3);
340
  P.stoops.push([x + w / 2, side > 0 ? zEdge + 1.1 : zEdge - 1.1, side]);
 
429
  { // the bridge: a deck at road level, two stone towers, cables
430
  const deck = new THREE.Mesh(new THREE.BoxGeometry(W2 + 700 - W2 + 120, 1.6, 30).translate((W2 + W2 + 700) / 2, -.75, 0), mat({ color: 0x55565c, roughness: .8 }));
431
  scene.add(deck);
432
+ for (const tx of [W2 + 170, W2 + 530]) for (const s of [-1, 1]) { box(bucket(wallA, 12, 12, STONE), tx - 6, tx + 6, s * 15 - 3, s * 15 + 3, 0, 110); solid(tx - 6, tx + 6, s * 15 - 3, s * 15 + 3, 110); }
433
  const cab = [];
434
  for (const s of [-1, 1]) { const curve = new THREE.QuadraticBezierCurve3(new THREE.Vector3(W2 + 170, 108, s * 15), new THREE.Vector3(W2 + 350, 12, s * 15), new THREE.Vector3(W2 + 530, 108, s * 15)); cab.push(new THREE.TubeGeometry(curve, 60, .6, 6)); for (let x = W2 + 190; x < W2 + 520; x += 14) { const y = curve.getPoint((x - W2 - 170) / 360).y; cab.push(new THREE.CylinderGeometry(.12, .12, y, 4).translate(x, y / 2, s * 15)); } }
435
  scene.add(new THREE.Mesh(mergeGeometries(cab), darkM));
 
482
 
483
  // ---- finish: one mesh per material per 320 m tile, so whatever is off screen (or out of the shadow box) is skipped ----
484
  const TILE = 640, tileOf = (x, z) => Math.floor(x / TILE) * 1000 + Math.floor(z / TILE);
485
+ const byMat = new Map();
486
+ for (const o of B.values()) { if (!o.pos.length) continue; if (!byMat.has(o.m)) byMat.set(o.m, []); byMat.get(o.m).push(o); }
487
+ for (const [mtl, list] of byMat) {
488
  const tiles = new Map();
489
+ for (const o of list) for (let t = 0, u = 0; t < o.pos.length; t += 9, u += 6) {
490
  const k = tileOf((o.pos[t] + o.pos[t + 3] + o.pos[t + 6]) / 3, (o.pos[t + 2] + o.pos[t + 5] + o.pos[t + 8]) / 3);
491
+ let a = tiles.get(k); if (!a) tiles.set(k, a = { pos: [], uv: [], tile: [] });
492
+ for (let i = 0; i < 9; i++) a.pos.push(o.pos[t + i]); for (let i = 0; i < 6; i++) a.uv.push(o.uv[u + i]); a.tile.push(o.tile, o.tile, o.tile);
493
  }
494
  for (const a of tiles.values()) {
495
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(a.pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(a.uv, 2)); g.setAttribute("aTile", new THREE.Float32BufferAttribute(a.tile, 1)); g.computeVertexNormals();
496
+ const m = new THREE.Mesh(g, mtl); m.userData.solid = true; scene.add(m);
497
  }
498
  }
499
  if (spires.length) scene.add(new THREE.Mesh(mergeGeometries(spires.map(g => g.toNonIndexed())), mat({ color: 0xb8bcc4, metalness: .9, roughness: .25 })));
 
509
  // small props go in 200 m tiles and are only drawn near the camera (trees a little further)
510
  const propTiles = [];
511
  const instT = (geo, material, list, place, colorOf, far = 330) => {
512
+ const groups = new Map(), PT = 400;
513
  list.forEach((it, i) => { place(it, i); const e = m4.elements, k = Math.floor(e[12] / PT) * 1000 + Math.floor(e[14] / PT); let gr = groups.get(k); if (!gr) groups.set(k, gr = []); gr.push([m4.clone(), colorOf ? colorOf(it, i) : null]); });
514
  for (const [k, arr] of groups) { const mesh = new THREE.InstancedMesh(geo, material, arr.length); arr.forEach(([m, c], j) => { mesh.setMatrixAt(j, m); if (c) mesh.setColorAt(j, c); }); mesh.instanceMatrix.needsUpdate = true; mesh.computeBoundingSphere(); scene.add(mesh);
515
  propTiles.push({ mesh, x: (Math.floor(k / 1000) + .5) * PT, z: (((k % 1000) + 1000) % 1000 + .5) * PT, far }); }
 
562
  P.steam.forEach(([x, z]) => solid(x - .5, x + .5, z - .5, z + .5, 2.4));
563
  }
564
  const steamTex = canvasTex(64, 64, (g, w) => { const r = g.createRadialGradient(32, 32, 0, 32, 32, 32); r.addColorStop(0, "rgba(255,255,255,.55)"); r.addColorStop(1, "rgba(255,255,255,0)"); g.fillStyle = r; g.fillRect(0, 0, w, w); });
565
+ const NPUFF = COARSE ? 40 : 90, steamMat = new THREE.MeshBasicMaterial({ map: steamTex, transparent: true, depthWrite: false });
566
+ steamMat.onBeforeCompile = sh => { // each puff fades on its own: its instance colour's red is its opacity
567
+ sh.vertexShader = sh.vertexShader.replace("#include <common>", "#include <common>\nvarying float vFade;").replace("#include <begin_vertex>", "#include <begin_vertex>\nvFade = instanceColor.r;");
568
+ sh.fragmentShader = sh.fragmentShader.replace("#include <common>", "#include <common>\nvarying float vFade;").replace("#include <color_fragment>", "diffuseColor.a *= vFade;");
569
+ };
570
+ const steamMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), steamMat, NPUFF); steamMesh.frustumCulled = false; steamMesh.count = 0; steamMesh.setColorAt(0, new THREE.Color(1, 1, 1)); scene.add(steamMesh);
571
+ const puffs = Array.from({ length: NPUFF }, () => ({ p: new THREE.Vector3(), life: 0, vx: 0, vz: 0 })), fadeC = new THREE.Color(), puffS = new THREE.Vector3();
572
  let puffI = 0, puffT = 0;
573
  // subway entrances: green railings, two green globes, a dark stair
574
  const globe = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dff6a).multiplyScalar(2), toneMapped: false }); globe.userData.glow = 2; globe.userData.base = new THREE.Color(0x3dff6a); emissive.push(globe);
 
598
  for (const [m, list] of food) scene.add(new THREE.Mesh(mergeGeometries(list.map(g => { g.deleteAttribute("uv"); return g; })), m));
599
  // helipads and red aviation lights
600
  const padTex = canvasTex(256, 256, (g, w) => { g.fillStyle = "#2a2c30"; g.fillRect(0, 0, w, w); g.strokeStyle = "#ffd23d"; g.lineWidth = 10; g.beginPath(); g.arc(128, 128, 110, 0, 7); g.stroke(); g.fillStyle = "#fff"; g.font = "900 150px sans-serif"; g.textAlign = "center"; g.fillText("H", 128, 180); });
601
+ if (P.helipads.length) scene.add(new THREE.Mesh(mergeGeometries(P.helipads.map(h => new THREE.CircleGeometry(h.r, 32).rotateX(-Math.PI / 2).translate(h.x, h.y + .05, h.z))), new THREE.MeshStandardMaterial({ map: padTex, roughness: .8 })));
602
  const avMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(3), toneMapped: false, transparent: true });
603
  inst(new THREE.SphereGeometry(.6, 8, 6), avMat, P.aviation, ([x, y, z]) => at(x, y, z));
604
 
 
643
  },
644
  update(t, cam) {
645
  setLights(t);
646
+ if (cam && (++cullN % 10) === 0) { const far = (1 + Math.max(0, cam.y - 20) / 60) * (this.detail || 1); for (const pt of propTiles) pt.mesh.visible = Math.hypot(pt.x - cam.x, pt.z - cam.z) < pt.far * far + 283; }
647
  waveTex.offset.set((t * .004) % 1, (t * .011) % 1);
648
  avMat.opacity = Math.sin(t * 3) > 0 ? 1 : .15;
649
  // steam from the stacks near the camera
650
  const dt = Math.min(.1, Math.max(0, t - (this._t || t))); this._t = t;
651
+ if (cam && (puffT += dt) > .14) { puffT = 0; for (const [x, z] of P.steam) if (Math.abs(x - cam.x) < 150 && Math.abs(z - cam.z) < 150) { const pf = puffs[puffI++ % puffs.length]; pf.life = 1; pf.p.set(x + (Math.random() - .5) * .3, 2.5, z + (Math.random() - .5) * .3); pf.vx = (Math.random() - .5) * .6; pf.vz = (Math.random() - .5) * .6; } }
652
+ let pn = 0;
653
+ if (cam && this.camQ) for (const pf of puffs) if (pf.life > 0) {
654
+ pf.life -= dt * .3; pf.p.x += pf.vx * dt; pf.p.z += pf.vz * dt; pf.p.y += dt * 1.6; if (pf.life <= 0) continue;
655
+ const k = 1 - pf.life; m4.compose(pf.p, this.camQ, puffS.setScalar(1.2 + k * 5)); steamMesh.setMatrixAt(pn, m4); steamMesh.setColorAt(pn++, fadeC.setRGB(pf.life * .6, 1, 1));
656
+ }
657
+ steamMesh.count = pn; steamMesh.visible = pn > 0; steamMesh.instanceMatrix.needsUpdate = true; if (steamMesh.instanceColor) steamMesh.instanceColor.needsUpdate = true;
658
  billboards.forEach((b, k) => { b.material.color.setScalar(1.15 + .25 * Math.sin(t * 2 + k)); });
659
  },
660
  };
static/dealer.js CHANGED
@@ -1,11 +1,12 @@
1
- // VOID Motors: a dealership on the boulevard with 50 cars for sale, from city runabouts to hover cars and hypercars.
 
2
  // Walk or drive up to the showroom (or open it from the Menu once you have been there) and buy with your cash.
3
  // Bought cars are yours for good: pick one in the showroom and it is delivered next to you, wherever you are.
4
  import * as THREE from "three";
5
  import { GRID } from "./city.js";
6
 
7
  const BASE = { compact: 6000, hatch: 8000, sedan: 10000, van: 9000, wagon: 11000, pickup: 14000, suv: 16000, classic: 22000, coupe: 25000, limo: 40000, muscle: 45000, sports: 60000, hover: 80000, supercar: 110000 };
8
- const CLASS = { void: "VOID car (flies, 330 km/h)", compact: "City car", hatch: "Hatchback", sedan: "Sedan", van: "Van", wagon: "Estate", pickup: "Pickup", suv: "SUV", classic: "Classic", coupe: "Coupe", limo: "Limousine", muscle: "Muscle", sports: "Sports", hover: "Hover car (flies)", supercar: "Hypercar" };
9
 
10
  export function createDealer(W, game, life, nav, sound, store, toast, wallet) {
11
  const api = game.api, $ = id => document.getElementById(id);
@@ -17,9 +18,36 @@ export function createDealer(W, game, life, nav, sound, store, toast, wallet) {
17
  for (const m of ms.slice(0, per[cls] || 3)) STOCK.push({ m, price: Math.round(BASE[cls] * (m.top / ms[ms.length - 1].top) * (.9 + m.acc * .1) / 500) * 500 });
18
  }
19
  STOCK.length = Math.min(50, STOCK.length);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  // and the one everybody wants: your VOID car, the fastest thing in the city and the only car with wings
21
  const HERO = { id: "void", name: "VOID Hydra", kind: { id: "void", fly: true }, cols: [0x0a0a0c], top: 92, acc: 1.7, grip: 1.25, sl: 1, sh: 1, sw: 1 };
22
- STOCK.unshift({ m: HERO, price: 250000, hero: true });
23
  const owned = store.get("void.owned") || {}; // id -> colour
24
  if (wallet.heroOwned()) owned.void = 0x0a0a0c;
25
  // the showroom: a gold column, a sign, and three cars on turntables, on a quiet crossing near the start
@@ -43,13 +71,13 @@ export function createDealer(W, game, life, nav, sound, store, toast, wallet) {
43
  const kmh = m => Math.round(m.top * 3.6 * .82);
44
  const bar = (v, max) => `<i style="--v:${Math.min(1, v / max)}"></i>`;
45
  function render() {
46
- const list = STOCK.filter(s => filter === "all" || (filter === "owned" ? owned[s.m.id] : s.m.kind.id === filter));
47
  const s = sel || list[0]; sel = s;
48
- const cls = ["all", "owned", ...Object.keys(BASE)];
49
  panel.innerHTML = `<div class="deal-head"><h2>VOID Motors</h2><span class="deal-cash">$${wallet.cash().toLocaleString()}</span><button class="pill solid" id="gDealX">Close</button></div>
50
- <div class="deal-filters">${cls.map(c => `<button class="${c === filter ? "on" : ""}" data-f="${c}">${c === "all" ? `All ${STOCK.length}` : c === "owned" ? `Yours (${Object.keys(owned).length})` : CLASS[c].replace(" (flies)", "")}</button>`).join("")}</div>
51
- <div class="deal-body"><div class="deal-list">${list.map(x => `<button class="deal-car${x === s ? " on" : ""}${owned[x.m.id] ? " own" : ""}" data-id="${x.m.id}"><span style="background:#${new THREE.Color(owned[x.m.id] || x.m.cols[0]).getHexString()}"></span><b>${x.m.name}</b><small>${CLASS[x.m.kind.id]} · ${kmh(x.m)} km/h</small><em>${owned[x.m.id] ? "OWNED" : "$" + x.price.toLocaleString()}</em></button>`).join("") || `<p class="small">Nothing here yet.</p>`}</div>
52
- ${s ? `<div class="deal-info"><h3>${s.m.name}</h3><p class="small">${CLASS[s.m.kind.id]}${s.m.kind.fly ? " · flies with F" : ""}</p>
53
  <div class="deal-stats"><label>Top speed<span>${kmh(s.m)} km/h</span>${bar(s.m.top, 92)}</label><label>Acceleration${bar(s.m.acc, 1.7)}</label><label>Grip${bar(s.m.grip, 1.25)}</label></div>
54
  <div class="deal-cols">${s.m.cols.map(c => `<button data-col="${c}" style="background:#${new THREE.Color(c).getHexString()}" class="${(owned[s.m.id] || s.m.cols[0]) === c ? "on" : ""}"></button>`).join("")}</div>
55
  ${owned[s.m.id] ? `<button class="pill solid deal-go" id="gDealDrive">Drive it</button>` : `<button class="pill solid deal-go" id="gDealBuy" ${wallet.cash() < s.price ? "disabled" : ""}>Buy for $${s.price.toLocaleString()}</button>${wallet.cash() < s.price ? `<p class="small">You need $${(s.price - wallet.cash()).toLocaleString()} more. Points from driving, races and contracts are cash.</p>` : ""}`}
 
1
+ // VOID Motors: a dealership in the park with over 100 cars for sale in 14 categories, from microcars and work vans to
2
+ // rally cars, drift cars, electric cars, lowriders, off-roaders, track cars, hover cars and hypercars.
3
  // Walk or drive up to the showroom (or open it from the Menu once you have been there) and buy with your cash.
4
  // Bought cars are yours for good: pick one in the showroom and it is delivered next to you, wherever you are.
5
  import * as THREE from "three";
6
  import { GRID } from "./city.js";
7
 
8
  const BASE = { compact: 6000, hatch: 8000, sedan: 10000, van: 9000, wagon: 11000, pickup: 14000, suv: 16000, classic: 22000, coupe: 25000, limo: 40000, muscle: 45000, sports: 60000, hover: 80000, supercar: 110000 };
9
+ const CLASS = { void: "VOID car (flies, 330 km/h)", taxi: "Taxi", police: "Police interceptor", bus: "Bus", truck: "Box truck", compact: "City car", hatch: "Hatchback", sedan: "Sedan", van: "Van", wagon: "Estate", pickup: "Pickup", suv: "SUV", classic: "Classic", coupe: "Coupe", limo: "Limousine", muscle: "Muscle", sports: "Sports", hover: "Hover car (flies)", supercar: "Hypercar" };
10
 
11
  export function createDealer(W, game, life, nav, sound, store, toast, wallet) {
12
  const api = game.api, $ = id => document.getElementById(id);
 
18
  for (const m of ms.slice(0, per[cls] || 3)) STOCK.push({ m, price: Math.round(BASE[cls] * (m.top / ms[ms.length - 1].top) * (.9 + m.acc * .1) / 500) * 500 });
19
  }
20
  STOCK.length = Math.min(50, STOCK.length);
21
+ // the categories you shop by; the regular models go into one by body type
22
+ const CAT_OF = { compact: "city", hatch: "city", sedan: "city", wagon: "city", van: "work", pickup: "suv", suv: "suv", classic: "classic", limo: "luxury", coupe: "sports", sports: "sports", muscle: "muscle", supercar: "hyper", hover: "flying" };
23
+ for (const st of STOCK) st.cat = CAT_OF[st.m.kind.id];
24
+ // and specials built for the showroom: same bodies, their own stance, power, grip and paint
25
+ const kindOf = id => (life.models.find(m => m.kind.id === id) || life.models[0]).kind;
26
+ const SPECIALS = [
27
+ ["offroad", [["suv", "Trailcat"], ["pickup", "Mudslinger"], ["suv", "Basecamp XR"], ["pickup", "Raptor Dirt"], ["van", "Overland"], ["suv", "Boulder 4x4"]], { sh: 1.18, sw: 1.06, grip: 1.15, acc: 1.05, top: 46 }, [0x6b5a44, 0x2f4a3a, 0xc8962a, 0x8a1a14, 0x3a3d44], 26000],
28
+ ["rally", [["hatch", "Gravel R"], ["compact", "Stage One"], ["hatch", "Group B"], ["wagon", "Rallye Avant"], ["coupe", "Special Stage"], ["hatch", "Snow Tyre"]], { sh: 1.05, grip: 1.35, acc: 1.25, top: 58 }, [0x2e5aa8, 0xf2f2f0, 0xf7c414, 0xd21f2b, 0x111111], 34000],
29
+ ["drift", [["coupe", "Sideways"], ["sports", "Tandem"], ["coupe", "Smoke Show"], ["muscle", "Burnout"], ["sports", "Clutch Kick"], ["coupe", "Handbrake"]], { sh: .93, grip: .72, acc: 1.4, top: 64 }, [0xff4fd8, 0x3df5ff, 0x8acb1a, 0xff6a00, 0xf2f2f0, 0x111111], 38000],
30
+ ["electric", [["sedan", "Volt S"], ["hatch", "Spark EV"], ["suv", "Current X"], ["supercar", "Ampere"], ["compact", "Zappy"], ["coupe", "Ion GT"], ["sedan", "Silent Line"]], { acc: 1.75, grip: 1.1, top: 62 }, [0xf2f2f0, 0x1f8a8a, 0x9fa3aa, 0x123a8c, 0x111111], 42000],
31
+ ["lowrider", [["classic", "Hopper"], ["classic", "Candy Cruiser"], ["limo", "Boulevard"], ["sedan", "Low & Slow"], ["classic", "Hydraulics"], ["coupe", "Sunday Drive"]], { sh: .8, sl: 1.06, grip: .95, acc: .95, top: 44 }, [0x7a2a8a, 0xd2163c, 0x1f8a8a, 0xb8862a, 0x123a8c, 0xe8a6c0], 30000],
32
+ ["track", [["supercar", "Circuit RS"], ["sports", "Apex Cup"], ["supercar", "Pole Position"], ["coupe", "Time Attack"], ["sports", "Slipstream"]], { sh: .92, grip: 1.3, acc: 1.55, top: 86 }, [0xf2f2f0, 0xd21f2b, 0xf7c414, 0x111111, 0x3df5ff], 95000],
33
+ ["tiny", [["compact", "Bubble"], ["compact", "Pocket"], ["hatch", "Smart Dot"], ["compact", "Mini Moke"], ["compact", "Thimble"]], { sl: .78, sw: .9, sh: .95, acc: 1.1, top: 38, grip: 1.1 }, [0xf2a0b4, 0x9fd3e8, 0xc0d84a, 0xf7c414, 0xe8e6e0], 4500],
34
+ ["work", [["taxi", "Yellow Cab"], ["police", "Interceptor"], ["bus", "City Bus"], ["truck", "Box Truck"], ["van", "Ice Cream Van"], ["pickup", "Tow Truck"]], {}, [], 18000],
35
+ ["luxury", [["sedan", "Ambassador LX"], ["limo", "Royale Stretch"], ["suv", "Estate Black"], ["coupe", "Grand Tourer"], ["sedan", "Chauffeur"]], { sl: 1.05, acc: 1.15, grip: 1.05, top: 64 }, [0x0a0a0c, 0xf2f2f0, 0x2b2d32, 0x0d1838, 0x4a1270], 65000],
36
+ ["flying", [["hover", "Skyrunner"], ["hover", "Cloud Nine"], ["hover", "Jetstream"]], { acc: 1.3, top: 76 }, [0x3df5ff, 0x9b5cff, 0xe9e9ea, 0xff4fd8], 120000],
37
+ ];
38
+ const BRAND = ["Aurex", "Kova", "Bellmont", "Zentri", "Harlow", "Vanta", "Ostrel", "Mirage", "Dunmore", "Caldera", "Nexo", "Ryden", "Solace", "Torvik"];
39
+ let sd = 77; const rr = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
40
+ for (const [cat, list, mod, cols, base] of SPECIALS) list.forEach(([kid, word], i) => {
41
+ const kind = kindOf(kid), ref = life.models.find(m => m.kind === kind && !m.rust) || life.models.find(m => m.kind === kind);
42
+ const m = { id: `${cat}-${i}`, name: `${BRAND[(i * 3 + cat.length) % BRAND.length]} ${word}`, kind, sl: (mod.sl || 1) * (.97 + rr() * .06), sh: (mod.sh || 1) * (.98 + rr() * .04), sw: (mod.sw || 1),
43
+ cols: cols.length ? cols : ref.cols, rust: 0, top: (mod.top || ref.top) * (.95 + rr() * .1), acc: (mod.acc || ref.acc) * (.95 + rr() * .1), grip: (mod.grip || ref.grip) * (.97 + rr() * .06) };
44
+ STOCK.push({ m, cat, price: Math.round(base * (.8 + rr() * .5) * (m.top / 60) / 500) * 500 });
45
+ });
46
+ const CATS = { flying: "✈️ Flying", hyper: "🚀 Hypercars", track: "🏁 Track", sports: "🏎️ Sports", muscle: "💪 Muscle", drift: "🌀 Drift", rally: "🌲 Rally", electric: "⚡ Electric",
47
+ luxury: "💎 Luxury", classic: "🕰️ Classic", lowrider: "🌴 Lowriders", offroad: "⛰️ Off-road", suv: "🚙 SUVs & pickups", city: "🏙️ Everyday", tiny: "🐞 Microcars", work: "🚕 Work & service" };
48
  // and the one everybody wants: your VOID car, the fastest thing in the city and the only car with wings
49
  const HERO = { id: "void", name: "VOID Hydra", kind: { id: "void", fly: true }, cols: [0x0a0a0c], top: 92, acc: 1.7, grip: 1.25, sl: 1, sh: 1, sw: 1 };
50
+ STOCK.unshift({ m: HERO, price: 250000, hero: true, cat: "flying" });
51
  const owned = store.get("void.owned") || {}; // id -> colour
52
  if (wallet.heroOwned()) owned.void = 0x0a0a0c;
53
  // the showroom: a gold column, a sign, and three cars on turntables, on a quiet crossing near the start
 
71
  const kmh = m => Math.round(m.top * 3.6 * .82);
72
  const bar = (v, max) => `<i style="--v:${Math.min(1, v / max)}"></i>`;
73
  function render() {
74
+ const list = STOCK.filter(s => filter === "all" || (filter === "owned" ? owned[s.m.id] : s.cat === filter));
75
  const s = sel || list[0]; sel = s;
76
+ const cls = ["all", "owned", ...Object.keys(CATS)];
77
  panel.innerHTML = `<div class="deal-head"><h2>VOID Motors</h2><span class="deal-cash">$${wallet.cash().toLocaleString()}</span><button class="pill solid" id="gDealX">Close</button></div>
78
+ <div class="deal-filters">${cls.map(c => `<button class="${c === filter ? "on" : ""}" data-f="${c}">${c === "all" ? `All ${STOCK.length}` : c === "owned" ? `Yours (${Object.keys(owned).length})` : `${CATS[c]} ${STOCK.filter(x => x.cat === c).length}`}</button>`).join("")}</div>
79
+ <div class="deal-body"><div class="deal-list">${list.map(x => `<button class="deal-car${x === s ? " on" : ""}${owned[x.m.id] ? " own" : ""}" data-id="${x.m.id}"><span style="background:#${new THREE.Color(owned[x.m.id] || x.m.cols[0]).getHexString()}"></span><b>${x.m.name}</b><small>${(CATS[x.cat] || "").replace(/^\S+ /, "")} · ${CLASS[x.m.kind.id] || ""} · ${kmh(x.m)} km/h</small><em>${owned[x.m.id] ? "OWNED" : "$" + x.price.toLocaleString()}</em></button>`).join("") || `<p class="small">Nothing here yet.</p>`}</div>
80
+ ${s ? `<div class="deal-info"><h3>${s.m.name}</h3><p class="small">${CATS[s.cat] || ""} · ${CLASS[s.m.kind.id] || ""}${s.m.kind.fly ? " · flies with F" : ""}</p>
81
  <div class="deal-stats"><label>Top speed<span>${kmh(s.m)} km/h</span>${bar(s.m.top, 92)}</label><label>Acceleration${bar(s.m.acc, 1.7)}</label><label>Grip${bar(s.m.grip, 1.25)}</label></div>
82
  <div class="deal-cols">${s.m.cols.map(c => `<button data-col="${c}" style="background:#${new THREE.Color(c).getHexString()}" class="${(owned[s.m.id] || s.m.cols[0]) === c ? "on" : ""}"></button>`).join("")}</div>
83
  ${owned[s.m.id] ? `<button class="pill solid deal-go" id="gDealDrive">Drive it</button>` : `<button class="pill solid deal-go" id="gDealBuy" ${wallet.cash() < s.price ? "disabled" : ""}>Buy for $${s.price.toLocaleString()}</button>${wallet.cash() < s.price ? `<p class="small">You need $${(s.price - wallet.cash()).toLocaleString()} more. Points from driving, races and contracts are cash.</p>` : ""}`}
static/main.js CHANGED
@@ -462,7 +462,7 @@ function frameLoop(fixed) {
462
  if (perf.t > 1.5) {
463
  const avg = perf.t / perf.n, was = perf.pr; fpsNow = Math.round(1 / avg); const fe = document.getElementById("gFps"); if (fe) fe.textContent = fpsNow + " fps";
464
  if (avg > 1 / 25 && mode === "play" && SET.q !== "low") perf.down = 3; // very slow: skip straight to a lower level
465
- if (avg > 1 / 45 && perf.pr > .6 && perf.down < 3) perf.pr = Math.max(.6, perf.pr - .12);
466
  else if (avg > 1 / 40 && mode === "play" && ++perf.down >= 3 && SET.q !== "low") { // still slow at the lowest resolution: drop a quality level
467
  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`);
468
  }
@@ -509,7 +509,7 @@ function frameLoop(fixed) {
509
  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); }
510
  }
511
  } else { for (const r of remotes.values()) r.root.visible = false; net.tick(dt, null); } // the host keeps relaying from the menus
512
- W.city.update(time, camera.position);
513
  W.follow(S.pos);
514
  if (mode !== "watch") $("fade").style.opacity = "";
515
 
@@ -526,7 +526,7 @@ function frameLoop(fixed) {
526
  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); }
527
  const night = W.timeOfDay !== "day";
528
  heads.intensity = night && veh === "car" ? (W.timeOfDay === "night" ? 260 : 120) : 0;
529
- W.sky.position.copy(camera.position); W.grade.uniforms.time.value = time % 100;
530
  W.grade.uniforms.blur.value = mode === "play" && motionOK() ? Math.max(0, Math.min(1, (S.speed - 160) / 220)) * .9 + (S.nitro || 0) * .5 : 0;
531
  atmos.update(dt, time, S, mode === "play" ? game.state : null);
532
  W.city.poolMat.opacity = W.timeOfDay === "night" ? 1 : W.timeOfDay === "sunset" ? .45 : 0; W.city.poolMat.visible = W.timeOfDay !== "day";
 
462
  if (perf.t > 1.5) {
463
  const avg = perf.t / perf.n, was = perf.pr; fpsNow = Math.round(1 / avg); const fe = document.getElementById("gFps"); if (fe) fe.textContent = fpsNow + " fps";
464
  if (avg > 1 / 25 && mode === "play" && SET.q !== "low") perf.down = 3; // very slow: skip straight to a lower level
465
+ if (avg > 1 / 45 && perf.pr > .75 && perf.down < 3) perf.pr = Math.max(.75, perf.pr - .12); // below 75% everything turns to mush: drop a quality level instead
466
  else if (avg > 1 / 40 && mode === "play" && ++perf.down >= 3 && SET.q !== "low") { // still slow at the lowest resolution: drop a quality level
467
  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`);
468
  }
 
509
  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); }
510
  }
511
  } else { for (const r of remotes.values()) r.root.visible = false; net.tick(dt, null); } // the host keeps relaying from the menus
512
+ W.city.camQ = camera.quaternion; W.city.update(time, camera.position);
513
  W.follow(S.pos);
514
  if (mode !== "watch") $("fade").style.opacity = "";
515
 
 
526
  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); }
527
  const night = W.timeOfDay !== "day";
528
  heads.intensity = night && veh === "car" ? (W.timeOfDay === "night" ? 260 : 120) : 0;
529
+ W.sky.position.copy(camera.position); W.grade.uniforms.time.value = time % 100; W.grade.uniforms.sharp.value = renderer.getPixelRatio() < (devicePixelRatio || 1) * .95 ? .45 : .22;
530
  W.grade.uniforms.blur.value = mode === "play" && motionOK() ? Math.max(0, Math.min(1, (S.speed - 160) / 220)) * .9 + (S.nitro || 0) * .5 : 0;
531
  atmos.update(dt, time, S, mode === "play" ? game.state : null);
532
  W.city.poolMat.opacity = W.timeOfDay === "night" ? 1 : W.timeOfDay === "sunset" ? .45 : 0; W.city.poolMat.visible = W.timeOfDay !== "day";
static/world.js CHANGED
@@ -72,13 +72,14 @@ 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 }, 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;
 
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: .012 }, sat: { value: 1.08 }, contrast: { value: 1.06 }, sharp: { value: .25 } },
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, sharp; 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
+ if (sharp > .001 && blur < .001) { vec2 px = 1. / vec2(textureSize(tDiffuse, 0)); vec3 n = texture2D(tDiffuse, vUv + vec2(px.x, 0.)).rgb + texture2D(tDiffuse, vUv - vec2(px.x, 0.)).rgb + texture2D(tDiffuse, vUv + vec2(0., px.y)).rgb + texture2D(tDiffuse, vUv - vec2(0., px.y)).rgb; col = max(col + (col - n * .25) * sharp, 0.); } // unsharp mask: crisper edges and road texture
83
  float l = dot(col, vec3(.2126, .7152, .0722)); col = mix(vec3(l), col, sat); col = (col - .5) * contrast + .5;
84
  vec2 d = vUv - .5; col *= 1. - vig * smoothstep(.25, .85, dot(d, d) * 2.2);
85
  col += (h(vUv * 1000. + time) - .5) * grain;