Static site
Browse files- index.html +2 -2
- static/car.js +3 -1
- static/city.js +69 -30
- static/dealer.js +36 -8
- static/main.js +3 -3
- 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=
|
| 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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
const
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 =
|
| 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
|
|
|
|
|
|
|
| 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) /
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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 |
-
|
| 459 |
-
|
|
|
|
| 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,
|
| 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 =
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 +
|
| 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) > .
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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)",
|
| 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.
|
| 47 |
const s = sel || list[0]; sel = s;
|
| 48 |
-
const cls = ["all", "owned", ...Object.keys(
|
| 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})` :
|
| 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 > .
|
| 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: .
|
| 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;
|