Death242 commited on
Commit
b985a12
·
verified ·
1 Parent(s): 865aecb
Files changed (13) hide show
  1. README.md +10 -10
  2. index.html +154 -19
  3. static/car.js +248 -0
  4. static/city.js +226 -0
  5. static/game.js +232 -0
  6. static/interior.js +145 -0
  7. static/main.js +250 -0
  8. static/npc.js +118 -0
  9. static/sound.js +224 -0
  10. static/style.css +215 -0
  11. static/tubaishi.json +0 -0
  12. static/watch.js +122 -0
  13. static/world.js +186 -0
README.md CHANGED
@@ -1,10 +1,10 @@
1
- ---
2
- title: Void
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: pink
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ---
2
+ title: VOID
3
+ emoji: ⚫
4
+ colorFrom: gray
5
+ colorTo: red
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ VOID: the Hydra LTE242. Made by Awsaa. Map data (c) OpenStreetMap contributors, ODbL.
index.html CHANGED
@@ -1,19 +1,154 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>VOID</title>
7
+ <meta name="description" content="VOID: the Hydra LTE242, an black-and-red hypercar concept that drives a loop and then flies. Watch the film, play the game, build your own. Made by Awsaa.">
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=1792751865">
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": {
15
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
16
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
17
+ } }
18
+ </script>
19
+ </head>
20
+ <body data-mode="home">
21
+ <div id="loader" aria-hidden="true">
22
+ <div class="ld-word"><span>V</span><span>O</span><span>I</span><span>D</span></div>
23
+ <div class="ld-bar"><i id="ldbar"></i></div>
24
+ <div class="ld-pct"><b id="ldpct">0</b>%</div>
25
+ </div>
26
+
27
+ <canvas id="gl" aria-label="The VOID car in 3D"></canvas>
28
+ <div id="fade" aria-hidden="true"></div>
29
+
30
+ <nav id="nav">
31
+ <button class="logo" data-go="home" aria-label="VOID home">VOID</button>
32
+ <span class="credit">Made by Awsaa</span>
33
+ <div class="right">
34
+ <button id="snd" class="pill" aria-pressed="false" title="Music and engine sound">Sound off</button>
35
+ <button id="exit" class="pill" title="Back home (Esc)">&#10005;</button>
36
+ </div>
37
+ </nav>
38
+
39
+ <main id="page">
40
+ <section id="home">
41
+ <div class="home-copy">
42
+ <div class="kicker">Hydra LTE242 &middot; black-and-red hypercar concept</div>
43
+ <h1 class="mega">VOID</h1>
44
+ <p class="lede">It takes the loop. Then it flies.</p>
45
+ </div>
46
+ <div class="choices">
47
+ <button class="choice main" data-go="watch"><b>&#9654;&ensp;Watch</b><span>The 44-second film, with music</span></button>
48
+ <button class="choice" data-go="play"><b>Play</b><span>Free roam in al-Tubaishi, Dammam</span></button>
49
+ <button class="choice" data-go="build"><b>Build</b><span>Paint it your way</span></button>
50
+ </div>
51
+ <a class="more" href="#specs">Specs</a>
52
+ </section>
53
+
54
+ <section id="specs">
55
+ <div class="specs-wrap">
56
+ <div class="sec-label">The numbers</div>
57
+ <h2 class="specs-title">Nothing extra.<br>Nothing missing.</h2>
58
+ <div class="specs">
59
+ <div><b>790</b><span>horsepower</span></div>
60
+ <div><b>2.6<small>s</small></b><span>0&ndash;100 km/h</span></div>
61
+ <div><b>335</b><span>km/h top speed</span></div>
62
+ <div><b>1,240<small>kg</small></b><span>dry weight</span></div>
63
+ <div><b>4</b><span>lift thrusters</span></div>
64
+ <div><b>5<small>km</small></b><span>landing scan</span></div>
65
+ <div><b>130<small>m</small></b><span>glide height</span></div>
66
+ <div><b>9<small>m</small></b><span>loop radius</span></div>
67
+ </div>
68
+ </div>
69
+ <footer>
70
+ <div class="foot-logo">VOID</div>
71
+ <p><b>Made by Awsaa.</b> The Hydra LTE242 is the creator&rsquo;s dream car, if it existed. It&rsquo;s a concept, not a real car for sale. Its shape is inspired by the McLaren 765LT, and this site isn&rsquo;t made by or linked to McLaren. The music is original and made in code. The city is al-Tubaishi, Dammam, built from real map data: <span id="osmCredit"></span>.</p>
72
+ </footer>
73
+ </section>
74
+ </main>
75
+
76
+ <!-- Watch -->
77
+ <div id="watchUI" class="mode">
78
+ <div class="cap" id="wCap"><div class="num" id="wNum"></div><h2 id="wTitle"></h2><p id="wSub"></p></div>
79
+ <div class="telem" id="telem">
80
+ <div><span>Speed</span><b id="tSpd">0</b><i>km/h</i></div>
81
+ <div hidden><b id="tG"></b><b id="tAlt"></b></div>
82
+ </div>
83
+ <div class="dream" id="dream">
84
+ <div class="d0">HYDRA LTE242</div>
85
+ <div class="d1">This is the creator&rsquo;s dream car.</div>
86
+ <div class="d2">If it existed.</div>
87
+ <div class="d3">&mdash; Awsaa</div>
88
+ </div>
89
+ <div class="end" id="wEnd">
90
+ <h2>That&rsquo;s the Hydra LTE242.</h2>
91
+ <div class="end-btns"><button class="pill solid" id="wAgain">&#8634; Watch again</button><button class="pill" data-go="play">Play it</button><button class="pill" data-go="build">Build yours</button></div>
92
+ </div>
93
+ <div class="player">
94
+ <button id="wPlay" class="pbtn" aria-label="Pause">&#10074;&#10074;</button>
95
+ <div class="scrub"><input id="wScrub" type="range" min="0" max="48" step="0.01" value="0" aria-label="Film position"><div class="ticks" id="wTicks"></div></div>
96
+ <span class="wtime" id="wTime">0:00</span>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Play -->
101
+ <div id="playUI" class="mode">
102
+ <div class="g-hint" id="gHint"></div>
103
+ <div class="g-target"><i id="gArrow">&#11014;</i><span id="gTgt"></span></div>
104
+ <div class="g-stats two">
105
+ <div><span>Speed</span><b id="gSpd">0</b><i>km/h</i></div>
106
+ <div><span>Height</span><b id="gAlt">0</b><i>m &middot; <em id="gVs">0</em> m/s</i></div>
107
+ <div hidden><b id="gG"></b><b id="gMode"></b></div>
108
+ </div>
109
+ <div class="g-score"><span>Score</span><b id="gScore">0</b></div>
110
+ <canvas class="g-map" id="gMap" width="180" height="180" aria-label="Minimap"></canvas>
111
+ <div class="g-btns"><button class="pill solid" id="gFly">Fly</button><button class="pill" id="gGarage">Customize</button><button class="pill" id="gView">Front seat</button></div>
112
+ <div class="pads">
113
+ <div class="pad-l"><button data-pad="left" aria-label="Left">&#9664;</button><button data-pad="right" aria-label="Right">&#9654;</button></div>
114
+ <div class="pad-r"><button data-pad="brake">Brake</button><button data-pad="gas" class="big">Gas</button><button data-pad="thrust" class="big air">Lift</button></div>
115
+ </div>
116
+ <div class="g-card" id="gReady">
117
+ <h2>Drive al-Tubaishi</h2>
118
+ <ol>
119
+ <li><b>Drive anywhere:</b> <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> or the arrows. The real streets of al-Tubaishi, Dammam, with traffic and people.</li>
120
+ <li><b>Stunts:</b> the loop and the ramp are right in front of you. Too slow at the top of the loop and you fall.</li>
121
+ <li><b>Fly:</b> press <kbd>F</kbd> (or Fly). <kbd>Space</kbd> lifts, <kbd>W</kbd> boosts, <kbd>F</kbd> again to land. Land soft and keep driving.</li>
122
+ <li><b>Missions:</b> land on a <span class="ok">green</span> pad for points. <kbd>G</kbd> opens the garage.</li>
123
+ </ol>
124
+ <p class="small"><kbd>C</kbd> switches to the front seat. On a phone, use the buttons.</p>
125
+ <button class="pill solid" id="gGo">Start</button>
126
+ <div class="small" id="gBest"></div>
127
+ </div>
128
+ <div class="g-card" id="gOver" hidden>
129
+ <h2 id="gTitle"></h2>
130
+ <p id="gMsg"></p>
131
+ <div class="g-res" id="gStats"></div>
132
+ <div class="small" id="gOverBest"></div>
133
+ <div class="end-btns"><button class="pill solid" id="gRetry">&#8634; Try again</button><button class="pill" data-go="watch">Watch the film</button></div>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Build -->
138
+ <div id="buildUI" class="mode">
139
+ <div class="drag-hint">Drag to turn it</div>
140
+ <aside class="cfg" id="cfg">
141
+ <div class="cfg-head"><h2>Your Hydra LTE242</h2><button class="pill" id="rev">Start engine</button><button class="pill solid" id="gGarageClose">Drive</button></div>
142
+ <div id="cfgBody"></div>
143
+ <div class="cfg-foot">
144
+ <div class="sum" id="sum"></div>
145
+ <div class="cfg-btns"><button class="pill" id="reset">Black &amp; red</button><button class="pill solid" id="share">Copy my build</button></div>
146
+ </div>
147
+ </aside>
148
+ </div>
149
+
150
+ <div id="toast" role="status"></div>
151
+ <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>
152
+ <script type="module" src="/static/main.js?v=1792751865"></script>
153
+ </body>
154
+ </html>
static/car.js ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The VOID car (shape inspired by the McLaren 765LT), modelled in code: the body and canopy are lofted from side-profile curves
2
+ // (a rounded superellipse cross-section at every station), the wheel wells are carved out of
3
+ // the sides, and a blueprint of grid lines can be swept away by a clipping plane to "build" it.
4
+ import * as THREE from "three";
5
+
6
+ const X0 = -2.3, LEN = 4.6; // rear tip to nose, along +x
7
+ const WHEELS = [1.42, -1.38]; // front and rear axle x (a 2.8 m wheelbase)
8
+ const WR = 0.36, ARCH = 0.44, WELL = 0.62; // wheel radius, arch radius, how far in the wells are carved
9
+
10
+ // cosine interpolation through [u, value] keys (u from 0 at the rear to 1 at the nose)
11
+ function curve(keys, u) {
12
+ if (u <= keys[0][0]) return keys[0][1];
13
+ for (let i = 1; i < keys.length; i++) {
14
+ const [u1, v1] = keys[i], [u0, v0] = keys[i - 1];
15
+ if (u <= u1) { const s = (1 - Math.cos(Math.PI * (u - u0) / (u1 - u0))) / 2; return v0 + (v1 - v0) * s; }
16
+ }
17
+ return keys[keys.length - 1][1];
18
+ }
19
+ const spow = (v, p) => Math.sign(v) * Math.pow(Math.abs(v), p);
20
+
21
+ // long-tail proportions: a tall, blunt tail, wide rear haunches, a pinched waist where the door
22
+ // scoop is, wide front fenders and a low, narrow nose
23
+ const BODY = {
24
+ x0: X0, len: LEN, n: 6, tumble: .3,
25
+ top: [[0, .84], [.03, .93], [.12, .96], [.25, .96], [.4, .9], [.55, .84], [.66, .8], [.78, .68], [.9, .54], [.97, .44], [1, .36]],
26
+ bot: [[0, .3], [.04, .2], [.12, .16], [.88, .16], [.96, .2], [1, .28]],
27
+ half: [[0, .86], [.05, .95], [.18, 1.0], [.35, .96], [.5, .91], [.62, .93], [.8, .99], [.93, .93], [1, .62]],
28
+ ends: [.012, .06], fenders: true, scoop: true,
29
+ };
30
+ // a cab-forward teardrop: the screen starts far forward, the roof peaks over the doors and the
31
+ // glass runs back in a long taper over the engine
32
+ const CANOPY = {
33
+ x0: -1.35, len: 2.35, n: 4, tumble: .42,
34
+ top: [[0, .92], [.12, 1.0], [.3, 1.12], [.5, 1.19], [.65, 1.17], [.8, 1.06], [.92, .92], [1, .8]],
35
+ bot: [[0, .72], [1, .72]],
36
+ half: [[0, .42], [.2, .6], [.5, .72], [.75, .72], [.92, .64], [1, .5]],
37
+ ends: [.15, .06], fenders: false,
38
+ };
39
+
40
+ // a (nu+1) x nv grid of points on the lofted surface
41
+ function loft(spec, nu, nv) {
42
+ const P = new Float32Array((nu + 1) * nv * 3);
43
+ for (let i = 0; i <= nu; i++) {
44
+ const u = i / nu, x = spec.x0 + spec.len * u;
45
+ const t = curve(spec.top, u), b = curve(spec.bot, u), w = curve(spec.half, u);
46
+ const c = (t + b) / 2, h = (t - b) / 2;
47
+ const e = Math.pow(Math.max(0, Math.min(1, u / spec.ends[0], (1 - u) / spec.ends[1])), .35);
48
+ for (let j = 0; j < nv; j++) {
49
+ const th = (j / nv) * Math.PI * 2, cz = Math.cos(th), sy = Math.sin(th);
50
+ let z = w * spow(cz, 2 / spec.n), y = h * spow(sy, 2 / spec.n);
51
+ if (sy > 0) z *= 1 - spec.tumble * sy * sy;
52
+ if (spec.fenders && sy > 0) for (const xw of WHEELS) y += (xw > 0 ? .2 : .14) * Math.exp(-(((x - xw) / .55) ** 2)) * Math.abs(cz) ** 3;
53
+ z *= e; y = c + y * e;
54
+ if (spec.scoop && Math.abs(cz) > .3) { // the big intake carved into each door, deepest at its front
55
+ const xr = (x + .35) / .62, yr = (y - .6) / .17;
56
+ if (Math.abs(xr) < 1 && Math.abs(yr) < 1) z *= 1 - .17 * (1 - xr * xr) * Math.sqrt(1 - yr * yr) * (1.15 - .3 * xr);
57
+ }
58
+ if (spec.fenders) for (const xw of WHEELS) {
59
+ if ((x - xw) ** 2 + (y - WR) ** 2 < ARCH * ARCH && Math.abs(z) > WELL) z = Math.sign(z) * WELL;
60
+ }
61
+ const k = (i * nv + j) * 3; P[k] = x; P[k + 1] = y; P[k + 2] = z;
62
+ }
63
+ }
64
+ return { P, nu, nv };
65
+ }
66
+
67
+ function surface({ P, nu, nv }) {
68
+ const idx = [];
69
+ for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
70
+ const a = i * nv + j, b = i * nv + (j + 1) % nv, c = (i + 1) * nv + j, d = (i + 1) * nv + (j + 1) % nv;
71
+ idx.push(a, c, b, b, c, d);
72
+ }
73
+ const g = new THREE.BufferGeometry();
74
+ g.setAttribute("position", new THREE.BufferAttribute(P, 3));
75
+ g.setIndex(idx); g.computeVertexNormals();
76
+ return g;
77
+ }
78
+
79
+ // blueprint: every few rings across the car, and every few lines along it
80
+ function gridLines({ P, nu, nv }, ringStep, lineStep) {
81
+ const pts = [], at = (i, j) => { const k = (i * nv + j) * 3; return [P[k], P[k + 1], P[k + 2]]; };
82
+ for (let i = 0; i <= nu; i += ringStep) for (let j = 0; j < nv; j++) pts.push(...at(i, j), ...at(i, (j + 1) % nv));
83
+ for (let j = 0; j < nv; j += lineStep) for (let i = 0; i < nu; i++) pts.push(...at(i, j), ...at(i + 1, j));
84
+ const g = new THREE.BufferGeometry();
85
+ g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
86
+ return g;
87
+ }
88
+
89
+ export function buildCar() {
90
+ const car = new THREE.Group();
91
+ const solid = new THREE.Group(), wire = new THREE.Group();
92
+ car.add(solid, wire);
93
+
94
+ // the reveal: solid shows behind the scan plane, blueprint in front of it
95
+ const solidClip = new THREE.Plane(new THREE.Vector3(-1, 0, 0), -3);
96
+ const wireClip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 3);
97
+ const clip = [solidClip];
98
+
99
+ const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .35, roughness: .38, clearcoat: .6, clearcoatRoughness: .2, envMapIntensity: .9, clippingPlanes: clip });
100
+ const glass = new THREE.MeshPhysicalMaterial({ color: 0x0a0d12, metalness: .1, roughness: .03, clearcoat: 1, envMapIntensity: 1.0, transparent: true, opacity: .6, depthWrite: false, clippingPlanes: clip }); // tinted, so you can see the cabin
101
+ const carbon = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, metalness: .4, roughness: .55, clippingPlanes: clip });
102
+ const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: .9, clippingPlanes: clip });
103
+ const rim = new THREE.MeshStandardMaterial({ color: 0x121215, metalness: .9, roughness: .28, clippingPlanes: clip });
104
+ const caliper = new THREE.MeshStandardMaterial({ color: 0xc1121f, metalness: .3, roughness: .4, clippingPlanes: clip });
105
+ const front = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xe8fbff).multiplyScalar(3), toneMapped: false, clippingPlanes: clip });
106
+ const rear = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a3a).multiplyScalar(2.6), toneMapped: false, clippingPlanes: clip });
107
+ const trim = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .4, roughness: .3, clearcoat: 1, clippingPlanes: clip }); // the red accents
108
+ const lineMat = new THREE.LineBasicMaterial({ color: 0xeef0f5, transparent: true, opacity: .75, clippingPlanes: [wireClip] });
109
+
110
+ const bodyHi = loft(BODY, 220, 96), canHi = loft(CANOPY, 90, 64);
111
+ solid.add(new THREE.Mesh(surface(bodyHi), paint), new THREE.Mesh(surface(canHi), glass));
112
+ wire.add(new THREE.LineSegments(gridLines(loft(BODY, 110, 48), 5, 4), lineMat), new THREE.LineSegments(gridLines(loft(CANOPY, 40, 32), 5, 4), lineMat));
113
+
114
+ const add = (geo, mat, x, y, z, parent = solid) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parent.add(m); return m; };
115
+
116
+ // details: eye-socket headlights, hood vents, splitter, side blades and sills, flying
117
+ // buttresses, engine louvers, a full-width wing, a mesh tail with four high exhausts, a diffuser
118
+ const beam = (a, b, w, h, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, a.distanceTo(b), h), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); solid.add(m); return m; };
119
+ for (const s of [-1, 1]) {
120
+ const sock = add(new THREE.SphereGeometry(.2, 28, 14), carbon, 1.97, .52, s * .62);
121
+ sock.scale.set(.7, .42, 1.15); sock.rotation.y = s * .55; sock.rotation.x = s * .25;
122
+ const led = add(new THREE.BoxGeometry(.03, .022, .3), front, 2.04, .56, s * .64); led.rotation.y = s * .6; led.rotation.x = s * -.35;
123
+ add(new THREE.BoxGeometry(.03, .02, .12), front, 2.07, .49, s * .5).rotation.y = s * .25;
124
+ const vent = add(new THREE.BoxGeometry(.36, .012, .24), carbon, 1.5, .64, s * .3); vent.rotation.z = -.43;
125
+ const blade = add(new THREE.BoxGeometry(.62, .07, .02), carbon, .88, .4, s * .975); blade.rotation.z = .22;
126
+ add(new THREE.BoxGeometry(2.05, .07, .03), carbon, -.05, .21, s * .955);
127
+ add(new THREE.BoxGeometry(2.05, .012, .034), trim, -.05, .248, s * .956); // red pinstripe on the sill
128
+ beam(new THREE.Vector3(-.45, 1.1, s * .6), new THREE.Vector3(-2.05, .95, s * .86), .07, .11, paint);
129
+ const tl = add(new THREE.BoxGeometry(.03, .03, .56), rear, -2.29, .82, s * .52); tl.rotation.x = s * .05;
130
+ add(new THREE.BoxGeometry(.03, .2, .03), rear, -2.28, .72, s * .8);
131
+ }
132
+ for (let k = 0; k < 6; k++) add(new THREE.BoxGeometry(.06, .015, .92), carbon, -1.42 - k * .11, .975, 0);
133
+ add(new THREE.BoxGeometry(.3, .03, 1.62), carbon, 2.06, .175, 0);
134
+ add(new THREE.BoxGeometry(.02, .032, 1.62), trim, 2.2, .175, 0); // red splitter lip
135
+ const wing = add(new THREE.BoxGeometry(.38, .03, 1.94), carbon, -2.12, 1.02, 0); wing.rotation.z = .12;
136
+ for (const z of [-.97, .97]) add(new THREE.BoxGeometry(.42, .16, .02), trim, -2.1, .98, z);
137
+ add(new THREE.BoxGeometry(.04, .32, 1.5), carbon, -2.3, .58, 0);
138
+ for (let k = -3; k <= 3; k++) add(new THREE.BoxGeometry(.6, .2, .025), carbon, -2.06, .25, k * .2).rotation.z = -.42;
139
+ const ti = new THREE.MeshStandardMaterial({ color: 0x6d7079, metalness: 1, roughness: .3, side: THREE.DoubleSide, clippingPlanes: clip });
140
+ const pipe = new THREE.CylinderGeometry(.05, .05, .22, 24, 1, true).rotateZ(Math.PI / 2), soot = new THREE.CircleGeometry(.045, 20).rotateY(-Math.PI / 2);
141
+ for (const y of [.6, .72]) for (const z of [-.065, .065]) { add(pipe, ti, -2.28, y, z); add(soot, rubber, -2.37, y, z); }
142
+ add(new THREE.BoxGeometry(3.6, .03, 1.7), carbon, -.1, .16, 0); // flat floor
143
+
144
+ // wheels: a rounded tyre, a deep-dish rim with twin spokes and a centre lock, a brake disc
145
+ // (all spinning) and a fixed caliper
146
+ const wheels = [];
147
+ const V2 = (x, y) => new THREE.Vector2(x, y);
148
+ const tyreG = new THREE.LatheGeometry([V2(.265, -.15), V2(.32, -.156), V2(.35, -.145), V2(.362, -.115), V2(WR + .004, 0), V2(.362, .115), V2(.35, .145), V2(.32, .156), V2(.265, .15)], 72).rotateX(Math.PI / 2);
149
+ const barrelG = new THREE.LatheGeometry([V2(.262, .15), V2(.27, .13), V2(.268, -.13), V2(.24, -.14)], 64).rotateX(Math.PI / 2);
150
+ const lipG = new THREE.TorusGeometry(.266, .009, 8, 64);
151
+ const discG = new THREE.CylinderGeometry(.2, .2, .03, 48).rotateX(Math.PI / 2), hatG = new THREE.CylinderGeometry(.09, .09, .06, 32).rotateX(Math.PI / 2);
152
+ const nutG = new THREE.CylinderGeometry(.045, .045, .05, 6).rotateX(Math.PI / 2);
153
+ const spokeG = new THREE.BoxGeometry(.026, .2, .028).translate(0, .155, 0);
154
+ const disc = new THREE.MeshStandardMaterial({ color: 0x5a5c62, metalness: .8, roughness: .45, clippingPlanes: clip });
155
+ const wireWheel = new THREE.EdgesGeometry(new THREE.CylinderGeometry(WR, WR, .3, 28).rotateX(Math.PI / 2), 20);
156
+ for (const xw of WHEELS) for (const s of [-1, 1]) {
157
+ const spin = new THREE.Group(); spin.position.set(xw, WR, s * .83); solid.add(spin);
158
+ spin.add(new THREE.Mesh(tyreG, rubber), new THREE.Mesh(barrelG, carbon));
159
+ const lip = new THREE.Mesh(lipG, rim); lip.position.z = s * .14; spin.add(lip);
160
+ const d = new THREE.Mesh(discG, disc); d.position.z = -s * .04; spin.add(d);
161
+ const hat = new THREE.Mesh(hatG, rim); hat.position.z = s * .04; spin.add(hat);
162
+ const nut = new THREE.Mesh(nutG, caliper); nut.position.z = s * .12; spin.add(nut);
163
+ for (let k = 0; k < 5; k++) for (const off of [-.11, .11]) { // five twin spokes, dished in toward the hub
164
+ const sp = new THREE.Mesh(spokeG, rim); sp.position.z = s * .1;
165
+ sp.rotation.z = k / 5 * Math.PI * 2 + off; sp.rotation.x = s * .12; spin.add(sp);
166
+ }
167
+ const cal = add(new THREE.BoxGeometry(.24, .13, .08), caliper, xw - .16, WR + .13, s * .7); cal.rotation.z = .65;
168
+ const wl = new THREE.LineSegments(wireWheel, lineMat); wl.position.set(xw, WR, s * .83); wire.add(wl);
169
+ wheels.push(spin);
170
+ }
171
+
172
+ // the glowing scan plane that sweeps along the car during the build
173
+ const scanTex = (() => {
174
+ const c = document.createElement("canvas"); c.width = 4; c.height = 128;
175
+ const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 128);
176
+ gr.addColorStop(0, "rgba(255,255,255,0)"); gr.addColorStop(.5, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
177
+ g.fillStyle = gr; g.fillRect(0, 0, 4, 128); return new THREE.CanvasTexture(c);
178
+ })();
179
+ const scan = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 1.6), new THREE.MeshBasicMaterial({ map: scanTex, transparent: true, opacity: .0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
180
+ scan.rotation.y = Math.PI / 2; scan.rotation.z = Math.PI / 2; scan.position.y = .7; car.add(scan);
181
+
182
+ // flight kit: four thrusters that swing from straight down (lift-off) to straight back (cruise),
183
+ // and two swept wings that slide out of the sides, with nav lights on the tips
184
+ const housing = new THREE.MeshStandardMaterial({ color: 0x18181c, metalness: .85, roughness: .3 });
185
+ const core = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xdff6ff).multiplyScalar(4), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
186
+ const outer = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4f8dff).multiplyScalar(2.2), transparent: true, opacity: .55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
187
+ const nozG = new THREE.CylinderGeometry(.12, .16, .26, 24, 1, true), ringG = new THREE.TorusGeometry(.15, .025, 8, 28).rotateX(Math.PI / 2);
188
+ const flameG = new THREE.ConeGeometry(.14, 1, 20, 1, true).rotateX(Math.PI).translate(0, -.5, 0);
189
+ const thrusters = [];
190
+ for (const [x, y] of [[.9, .3], [-.95, .38]]) for (const s of [-1, 1]) {
191
+ const pivot = new THREE.Group(); pivot.position.set(x, y, s * 1.0); car.add(pivot);
192
+ const noz = new THREE.Mesh(nozG, housing); noz.position.y = -.05;
193
+ const ring = new THREE.Mesh(ringG, front); ring.position.y = -.18;
194
+ const fo = new THREE.Mesh(flameG, outer), fc = new THREE.Mesh(flameG, core);
195
+ fo.position.y = fc.position.y = -.18; fc.scale.set(.45, 1, .45);
196
+ pivot.add(noz, ring, fo, fc); pivot.scale.setScalar(0);
197
+ thrusters.push({ pivot, fo, fc, seed: Math.random() * 10 });
198
+ }
199
+ const glowLight = new THREE.PointLight(0x6aa8ff, 0, 9, 2); glowLight.position.set(0, -.4, 0); car.add(glowLight);
200
+
201
+ const wingShape = new THREE.Shape([[.7, 0], [-1.05, 0], [-1.35, 2.25], [-.75, 2.25]].map(([a, b]) => new THREE.Vector2(a, b)));
202
+ const wingG = new THREE.ExtrudeGeometry(wingShape, { depth: .05, bevelEnabled: true, bevelThickness: .015, bevelSize: .02, bevelSegments: 2 }).rotateX(-Math.PI / 2);
203
+ const wings = [];
204
+ for (const s of [-1, 1]) {
205
+ const w = new THREE.Mesh(wingG, paint); w.position.set(-.15, .6, s * .85); w.scale.set(1, 1, 0); car.add(w);
206
+ const nav = new THREE.Mesh(new THREE.SphereGeometry(.05, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false }));
207
+ nav.position.set(-1.1, .04, -2.25); w.add(nav);
208
+ const edge = new THREE.Mesh(new THREE.BoxGeometry(1.8, .02, .02), front); edge.position.set(-.18, .06, -.02); w.add(edge);
209
+ wings.push({ w, s, nav });
210
+ }
211
+
212
+ return {
213
+ group: car, wheels, clip, materials: { carbon, front },
214
+ // power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
215
+ setThrust(power, angle, deploy, time) {
216
+ for (const t of thrusters) {
217
+ t.pivot.scale.setScalar(Math.max(.0001, deploy));
218
+ t.pivot.rotation.z = -angle * Math.PI / 2;
219
+ const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
220
+ t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
221
+ t.fc.scale.set(.45, Math.max(.001, f * 1.5), .45);
222
+ t.fo.visible = t.fc.visible = power > .01;
223
+ }
224
+ glowLight.intensity = power * 30;
225
+ },
226
+ setWings(t, time) {
227
+ for (const { w, s, nav } of wings) {
228
+ w.scale.z = Math.max(.0001, t) * -s; w.rotation.x = s * (1 - t) * .4; w.visible = t > .01;
229
+ nav.visible = t > .6 && (Math.sin(time * 6 + s) > -.2);
230
+ }
231
+ },
232
+ setReveal(t) {
233
+ const x = t >= .999 ? 1e5 : -2.7 + 5.4 * t; // once built, never clip (the car drives far from the origin)
234
+ solidClip.constant = x; wireClip.constant = -x;
235
+ scan.position.x = x; scan.material.opacity = t > .01 && t < .99 ? .55 : 0;
236
+ wire.visible = t < .999;
237
+ },
238
+ setPaint(hex) { paint.color.set(hex); },
239
+ setFinish(f) {
240
+ const F = { gloss: [.35, .38, .6], satin: [.3, .55, .3], matte: [.15, .78, 0] }[f] || [.35, .38, .6];
241
+ paint.metalness = F[0]; paint.roughness = F[1]; paint.clearcoat = F[2];
242
+ },
243
+ setAccent(hex) { caliper.color.set(hex); },
244
+ setTrim(hex) { trim.color.set(hex); },
245
+ setRim(hex) { rim.color.set(hex); },
246
+ setLight(hex) { front.color.set(hex).multiplyScalar(3); },
247
+ };
248
+ }
static/city.js ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // al-Tubaishi, Dammam, built from real OpenStreetMap data (static/tubaishi.json, made by tools/osm_build.py):
2
+ // every building footprint extruded to its height with arched windows floor by floor, the real roads,
3
+ // parks, mosques, the Gulf from the real coastline with a beach and the corniche promenade, palms,
4
+ // street lamps, rooftop water tanks and glowing Arabic / English shop signs.
5
+ // Map data (c) OpenStreetMap contributors, ODbL.
6
+ import * as THREE from "three";
7
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
8
+
9
+ const SIGNS = [["الدمام", "DAMMAM"], ["الطبيشي", "TUBAISHI"], ["كورنيش", "CORNICHE"], ["سوق", "SOUQ"], ["قهوة", "COFFEE"], ["فندق", "HOTEL"], ["مطعم", "RESTAURANT"],
10
+ ["صيدلية", "PHARMACY"], ["مخبز", "BAKERY"], ["مرحبا", "WELCOME"], ["هايدرا", "HYDRA LTE242"], ["عطور", "PERFUME"], ["ذهب", "GOLD"],
11
+ ["شاورما", "SHAWARMA"], ["حلويات", "SWEETS"], ["ملابس", "FASHION"], ["بقالة", "GROCERY"], ["حلاق", "BARBER"]];
12
+ const NEON = ["#ff3b5c", "#3dfcff", "#ffd23d", "#7dff6a", "#ff8a3d", "#c07dff", "#ffffff"];
13
+ const SAND = [0xd8c3a0, 0xe6dccb, 0xc2a57f, 0xefe9df, 0xb89c78, 0xd1c7b4];
14
+
15
+ const centroid = p => [p.reduce((s, q) => s + q[0], 0) / p.length, p.reduce((s, q) => s + q[1], 0) / p.length];
16
+ export const pip = (x, z, p) => { let c = false; for (let i = 0, j = p.length - 1; i < p.length; j = i++) { const [x1, z1] = p[i], [x2, z2] = p[j]; if ((z1 > z) !== (z2 > z) && x < (x2 - x1) * (z - z1) / (z2 - z1) + x1) c = !c; } return c; };
17
+
18
+ // a flat polygon on the ground, triangulated
19
+ function flat(p, y) {
20
+ const v2 = p.map(([x, z]) => new THREE.Vector2(x, z)), tris = THREE.ShapeUtils.triangulateShape(v2, []);
21
+ const pos = [], uv = [];
22
+ for (const t of tris) for (const i of t) { pos.push(p[i][0], y, p[i][1]); uv.push(p[i][0] / 8, p[i][1] / 8); }
23
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
24
+ g.computeVertexNormals(); return g;
25
+ }
26
+ // a strip along a polyline, from offset a to offset b (metres to the right of the line)
27
+ function strip(pts, a, b, y, uScale = 8) {
28
+ const pos = [], uv = [], idx = []; let d = 0;
29
+ for (let i = 0; i < pts.length; i++) {
30
+ const p = pts[i], q = pts[Math.min(pts.length - 1, i + 1)], o = pts[Math.max(0, i - 1)];
31
+ let dx = q[0] - o[0], dz = q[1] - o[1]; const l = Math.hypot(dx, dz) || 1; dx /= l; dz /= l;
32
+ if (i) d += Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]);
33
+ const nx = -dz, nz = dx;
34
+ pos.push(p[0] + nx * a, y, p[1] + nz * a, p[0] + nx * b, y, p[1] + nz * b); uv.push(d / uScale, 0, d / uScale, 1);
35
+ if (i) { const k = (i - 1) * 2; idx.push(k, k + 2, k + 1, k + 1, k + 2, k + 3); }
36
+ }
37
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals();
38
+ return g;
39
+ }
40
+
41
+ export async function buildCity(scene, canvasTex, COARSE) {
42
+ let seed = 11; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
43
+ const pick = a => a[Math.floor(rnd() * a.length)];
44
+ const data = await (await fetch("/static/tubaishi.json")).json();
45
+ try { await Promise.race([document.fonts.load('700 64px "Noto Kufi Arabic"'), new Promise(r => setTimeout(r, 1500))]); } catch { }
46
+ const [bx0, bx1, bz0, bz1] = data.bounds;
47
+ const m4 = new THREE.Matrix4(), q0 = new THREE.Quaternion(), v = new THREE.Vector3(), sc = new THREE.Vector3();
48
+
49
+ // ---- ground: desert sand, the Gulf, the beach and the corniche ----
50
+ const sand = new THREE.Mesh(new THREE.PlaneGeometry(30000, 30000).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({
51
+ color: 0x9a8566, roughness: .95, map: canvasTex(256, 256, (g, w) => { g.fillStyle = "#b9a07c"; g.fillRect(0, 0, w, w); for (let i = 0; i < 3000; i++) { g.fillStyle = `rgba(${90 + rnd() * 60},${70 + rnd() * 40},40,${rnd() * .25})`; g.fillRect(rnd() * w, rnd() * w, 2, 2); } }, [900, 900]),
52
+ }));
53
+ sand.position.y = -.3; scene.add(sand);
54
+ const town = new THREE.Mesh(new THREE.PlaneGeometry(bx1 - bx0, bz1 - bz0).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x8c8378, roughness: .9 }));
55
+ town.position.set((bx0 + bx1) / 2, -.2, (bz0 + bz1) / 2); scene.add(town);
56
+ scene.add(new THREE.Mesh(flat(data.sea, -.14), new THREE.MeshStandardMaterial({ color: 0x0c2a3c, roughness: .1, metalness: .75, envMapIntensity: 1.2 })));
57
+ const coast = data.coast;
58
+ // land is on the left of the coastline as OSM draws it, so the beach goes from the water line inland
59
+ scene.add(new THREE.Mesh(strip(coast, -38, 0, -.1), new THREE.MeshStandardMaterial({ color: 0xd9c49c, roughness: 1, side: THREE.DoubleSide })));
60
+ scene.add(new THREE.Mesh(strip(coast, -52, -38, -.08, 4), new THREE.MeshStandardMaterial({ color: 0xcbb995, roughness: .75, map: canvasTex(64, 64, (g, w) => { g.fillStyle = "#d6c7a8"; g.fillRect(0, 0, w, w); g.fillStyle = "#b9a988"; g.fillRect(0, 0, w, 3); g.fillRect(0, 0, 3, w); }, [1, 4]) })));
61
+ const offsetLine = (pts, off) => pts.map((p, i) => { const q = pts[Math.min(pts.length - 1, i + 1)], o = pts[Math.max(0, i - 1)]; let dx = q[0] - o[0], dz = q[1] - o[1]; const l = Math.hypot(dx, dz) || 1; return [p[0] - dz / l * off, p[1] + dx / l * off]; });
62
+ const promenade = offsetLine(coast, -45).filter(([x, z]) => x > bx0 && x < bx1 && z > bz0 && z < bz1);
63
+
64
+ // ---- parks ----
65
+ const grass = new THREE.MeshStandardMaterial({ color: 0x3f6b35, roughness: .95 });
66
+ for (const p of data.parks) scene.add(new THREE.Mesh(flat(p.p, -.05), p.k === "stadium" ? new THREE.MeshStandardMaterial({ color: 0x8a8e96, roughness: .8 }) : grass));
67
+
68
+ // ---- roads: asphalt strips with a dashed centre line on the wide ones ----
69
+ const roadGeos = [], lineGeos = [];
70
+ for (const r of data.roads) { roadGeos.push(strip(r.p, -r.w / 2, r.w / 2, r.w >= 10 ? .03 : .015)); if (r.w >= 10) lineGeos.push(strip(r.p, -.12, .12, .05, 6)); }
71
+ scene.add(new THREE.Mesh(mergeGeometries(roadGeos), new THREE.MeshStandardMaterial({ color: 0x2d2e33, roughness: .85, envMapIntensity: .3, side: THREE.DoubleSide })));
72
+ if (lineGeos.length) scene.add(new THREE.Mesh(mergeGeometries(lineGeos), new THREE.MeshBasicMaterial({ map: canvasTex(64, 8, (g, w, h) => { g.fillStyle = "#000"; g.fillRect(0, 0, w, h); g.fillStyle = "#e9e4d2"; g.fillRect(0, 0, w / 2, h); }, [1, 1]), transparent: true, opacity: .85, alphaTest: .2, side: THREE.DoubleSide })));
73
+
74
+ // ---- buildings, closer to the real neighbourhood: three facade styles (framed rectangular windows,
75
+ // arches, wooden mashrabiya screens) with floor bands, lit shopfronts on the ground floor along the
76
+ // main streets, parapets on the flat roofs, boundary walls round the villas, AC units and water tanks ----
77
+ const facade = (style, lit) => canvasTex(256, 256, (g, w, h) => { // 8 windows across x 8 floors (32 m x 27 m) before it repeats
78
+ g.fillStyle = lit ? "#000" : "#fff"; g.fillRect(0, 0, w, h);
79
+ for (let r = 0; r < 8; r++) {
80
+ if (!lit) { g.fillStyle = "rgba(0,0,0,.10)"; g.fillRect(0, r * 32 + 28, w, 4); g.fillStyle = "rgba(255,255,255,.35)"; g.fillRect(0, r * 32 + 27, w, 1); }
81
+ for (let c = 0; c < 8; c++) {
82
+ const x = c * 32 + 9, y = r * 32 + 7, on = rnd() < .38, warm = `rgba(255,${185 + rnd() * 55 | 0},${110 + rnd() * 70 | 0},${.55 + rnd() * .45})`;
83
+ if (style === 0) { // framed rectangular windows with a sill, some with a little balcony rail
84
+ if (lit) { g.fillStyle = on ? warm : "#000"; g.fillRect(x + 1, y + 1, 12, 15); continue; }
85
+ g.fillStyle = "#5a5148"; g.fillRect(x - 1, y - 1, 16, 19); g.fillStyle = "#2c3238"; g.fillRect(x + 1, y + 1, 12, 15);
86
+ g.fillStyle = "rgba(255,255,255,.8)"; g.fillRect(x - 2, y + 17, 18, 2);
87
+ if (rnd() < .25) { g.fillStyle = "#3b3a38"; g.fillRect(x - 3, y + 11, 20, 1); for (let k = 0; k < 6; k++) g.fillRect(x - 3 + k * 4, y + 11, 1, 7); }
88
+ } else if (style === 1) { // arched windows
89
+ g.fillStyle = lit ? (on ? warm : "#000") : "#40392f";
90
+ g.beginPath(); g.moveTo(x, y + 18); g.lineTo(x, y + 6); g.arc(x + 6, y + 6, 6, Math.PI, 0); g.lineTo(x + 12, y + 18); g.closePath(); g.fill();
91
+ } else { // wooden mashrabiya screens on some windows
92
+ const screen = rnd() < .5;
93
+ if (lit) { g.fillStyle = on ? warm : "#000"; g.globalAlpha = screen ? .45 : 1; g.fillRect(x, y, 14, 17); g.globalAlpha = 1; continue; }
94
+ g.fillStyle = screen ? "#6b4a2c" : "#2c3238"; g.fillRect(x, y, 14, 17);
95
+ if (screen) { g.strokeStyle = "rgba(30,18,8,.8)"; g.lineWidth = 1; for (let k = 2; k < 14; k += 3) { g.beginPath(); g.moveTo(x + k, y); g.lineTo(x + k, y + 17); g.stroke(); } for (let k = 2; k < 17; k += 3) { g.beginPath(); g.moveTo(x, y + k); g.lineTo(x + 14, y + k); g.stroke(); } }
96
+ }
97
+ }
98
+ }
99
+ }, [1, 1]);
100
+ const shopTex = lit => canvasTex(256, 64, (g, w, h) => { // ground floor shops: 4 m tall, 32 m of glass fronts and pillars
101
+ g.fillStyle = lit ? "#000" : "#fff"; g.fillRect(0, 0, w, h);
102
+ for (let c = 0; c < 4; c++) {
103
+ const x = c * 64 + 4, on = rnd() < .8, tint = pick(["255,230,180", "200,230,255", "255,255,255"]);
104
+ g.fillStyle = lit ? (on ? `rgba(${tint},${.7 + rnd() * .3})` : "#000") : "#25313a"; g.fillRect(x, 14, 56, 46);
105
+ if (!lit) { g.fillStyle = "#4a4038"; g.fillRect(x - 4, 0, 4, h); g.fillStyle = pick(["#b22234", "#1f6f4a", "#204a8a", "#c8962a", "#333"]); g.fillRect(x, 2, 56, 10); }
106
+ else { g.fillStyle = on ? `rgba(${tint},1)` : "#000"; g.fillRect(x + 4, 4, 48, 5); }
107
+ }
108
+ }, [1, 1]);
109
+ const styles = [0, 1, 2].map(st => new THREE.MeshStandardMaterial({ map: facade(st, false), emissive: 0xffffff, emissiveMap: facade(st, true), emissiveIntensity: .7, roughness: .88, vertexColors: true, side: THREE.DoubleSide }));
110
+ const shopMat = new THREE.MeshStandardMaterial({ map: shopTex(false), emissive: 0xffffff, emissiveMap: shopTex(true), emissiveIntensity: .9, roughness: .5, vertexColors: true, side: THREE.DoubleSide });
111
+ for (const mtl of [...styles, shopMat]) for (const t of [mtl.map, mtl.emissiveMap]) t.wrapS = t.wrapT = THREE.RepeatWrapping;
112
+ const plainMat = new THREE.MeshStandardMaterial({ roughness: .95, vertexColors: true, side: THREE.DoubleSide });
113
+ const PALETTE = [0xf1ece2, 0xe8dfcf, 0xd9c7a7, 0xcbb89a, 0xe2e0db, 0xbfb7aa, 0xd8c3a0, 0xefe9df];
114
+ const G = () => ({ pos: [], uv: [], col: [] });
115
+ const wallG = [G(), G(), G()], shopG = G(), plainG = G(), roofs = { pos: [], col: [] }, buildings = [], footprints = [], tanks = [], acs = [], signSpots = [];
116
+ const quad = (o, x1, z1, x2, z2, y0, y1, u0, u1, v0, v1, c) => {
117
+ o.pos.push(x1, y0, z1, x2, y0, z2, x2, y1, z2, x1, y0, z1, x2, y1, z2, x1, y1, z1);
118
+ o.uv.push(u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1);
119
+ for (let k = 0; k < 6; k++) o.col.push(c.r, c.g, c.b);
120
+ };
121
+ const mainPts = []; for (const r of data.roads) if (r.w >= 10) for (const p of r.p) mainPts.push(p);
122
+ const distMain2 = (x, z) => { let best = 1e12; for (const [a, c] of mainPts) { const d = (a - x) ** 2 + (c - z) ** 2; if (d < best) best = d; } return best; };
123
+ const col = new THREE.Color(), pc = new THREE.Color(), wc = new THREE.Color(0xe7dcc8);
124
+ for (const b of data.buildings) {
125
+ let p = b.p; if (p.length < 3) continue;
126
+ let s2 = 0; for (let i = 0; i < p.length; i++) { const a2 = p[i], c2 = p[(i + 1) % p.length]; s2 += a2[0] * c2[1] - c2[0] * a2[1]; }
127
+ if (s2 < 0) p = p.slice().reverse();
128
+ const h = b.h, [cx, cz] = centroid(p), style = b.k === "mosque" ? 1 : (((b.c + Math.floor(cx * 7 + cz * 3)) % 3) + 3) % 3;
129
+ col.setHex(b.k === "mosque" ? 0xefe6d6 : PALETTE[(b.c * 3 + Math.abs(Math.floor(cx + cz))) % PALETTE.length]);
130
+ pc.copy(col).multiplyScalar(.9);
131
+ const shop = b.k !== "mosque" && h > 6 && distMain2(cx, cz) < 34 * 34, g0 = shop ? 4.2 : 0;
132
+ for (let i = 0; i < p.length; i++) {
133
+ const [x1, z1] = p[i], [x2, z2] = p[(i + 1) % p.length], L = Math.hypot(x2 - x1, z2 - z1);
134
+ if (shop) quad(shopG, x1, z1, x2, z2, 0, g0, 0, L / 32, 0, 1, col);
135
+ quad(wallG[style], x1, z1, x2, z2, g0, h, 0, L / 32, g0 / 27, h / 27, col);
136
+ quad(plainG, x1, z1, x2, z2, h, h + .9, 0, 1, 0, 1, pc); // parapet round the flat roof
137
+ if (L > 5 && h > 5 && rnd() < .35) { const t = .3 + rnd() * .4, fl = Math.floor(1 + rnd() * Math.max(1, (h - 3) / 3.4)); acs.push([x1 + (x2 - x1) * t + (z2 - z1) / L * .35, Math.min(h - 1, fl * 3.4 - 1.2), z1 + (z2 - z1) * t - (x2 - x1) / L * .35, Math.atan2(x2 - x1, z2 - z1)]); }
138
+ }
139
+ if (b.k === "house") { // a boundary wall round the villa's plot
140
+ const grow = 2.6, ring = p.map(([x, z]) => { const dx = x - cx, dz = z - cz, d = Math.hypot(dx, dz) || 1; return [x + dx / d * grow, z + dz / d * grow]; });
141
+ for (let i = 0; i < ring.length; i++) { const [x1, z1] = ring[i], [x2, z2] = ring[(i + 1) % ring.length]; quad(plainG, x1, z1, x2, z2, 0, 2.4, 0, 1, 0, 1, wc); }
142
+ }
143
+ const tris = THREE.ShapeUtils.triangulateShape(p.map(([x, z]) => new THREE.Vector2(x, z)), []);
144
+ const rc = col.clone().multiplyScalar(.78);
145
+ for (const t of tris) for (const i of [t[0], t[2], t[1]]) { roofs.pos.push(p[i][0], h, p[i][1]); roofs.col.push(rc.r, rc.g, rc.b); }
146
+ const xs = p.map(q => q[0]), zs = p.map(q => q[1]);
147
+ buildings.push({ x: (Math.min(...xs) + Math.max(...xs)) / 2, z: (Math.min(...zs) + Math.max(...zs)) / 2, hw: (Math.max(...xs) - Math.min(...xs)) / 2, hd: (Math.max(...zs) - Math.min(...zs)) / 2, h, poly: p });
148
+ footprints.push(p);
149
+ if (b.k !== "mosque" && rnd() < .85) for (let t = 1 + Math.floor(rnd() * 2); t > 0; t--) tanks.push([cx + (rnd() - .5) * 4, h, cz + (rnd() - .5) * 4]);
150
+ if (h > 6) signSpots.push({ p, h, cx, cz, shop });
151
+ }
152
+ const geo = o => { const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(o.pos, 3)); if (o.uv) g.setAttribute("uv", new THREE.Float32BufferAttribute(o.uv, 2)); g.setAttribute("color", new THREE.Float32BufferAttribute(o.col, 3)); g.computeVertexNormals(); return g; };
153
+ wallG.forEach((o, i) => o.pos.length && scene.add(new THREE.Mesh(geo(o), styles[i])));
154
+ if (shopG.pos.length) scene.add(new THREE.Mesh(geo(shopG), shopMat));
155
+ scene.add(new THREE.Mesh(geo(plainG), plainMat), new THREE.Mesh(geo(roofs), plainMat));
156
+ const inst = (geo2, mat, list, place) => { const mesh = new THREE.InstancedMesh(geo2, mat, Math.max(1, list.length)); list.forEach((it, i) => { place(it, i); mesh.setMatrixAt(i, m4); }); mesh.instanceMatrix.needsUpdate = true; if (list.length) scene.add(mesh); return mesh; };
157
+ inst(new THREE.CylinderGeometry(1, 1, 2, 10).translate(0, 1, 0), new THREE.MeshStandardMaterial({ color: 0xf2f2f2, roughness: .6 }), tanks, ([x, y, z]) => m4.compose(v.set(x, y, z), q0, sc.set(1, 1, 1)));
158
+ inst(new THREE.BoxGeometry(.9, .55, .45), new THREE.MeshStandardMaterial({ color: 0xd9d9d6, roughness: .6, metalness: .3 }), acs, ([x, y, z, ry]) => m4.compose(v.set(x, y, z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), ry), sc.set(1, 1, 1)));
159
+
160
+ // ---- mosques: a dome with a golden finial and a minaret with a lit balcony, on the real mosques ----
161
+ const white = new THREE.MeshStandardMaterial({ color: 0xefe6d6, roughness: .7 }), domeMat = new THREE.MeshStandardMaterial({ color: 0xdfe8e4, roughness: .35, metalness: .2 });
162
+ const gold = new THREE.MeshStandardMaterial({ color: 0xd8b24a, metalness: 1, roughness: .3 }), glowG = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x9dffb8).multiplyScalar(1.6), toneMapped: false });
163
+ const put = (geo, mat, x, y, z) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); scene.add(m); return m; };
164
+ for (const [x, z, size, h] of data.mosques) {
165
+ const r = Math.max(5, Math.min(16, size * .3)), top = h + r;
166
+ put(new THREE.SphereGeometry(r, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), domeMat, x, h, z);
167
+ put(new THREE.SphereGeometry(.7, 12, 8), gold, x, top + .5, z); put(new THREE.CylinderGeometry(.12, .12, 3, 6), gold, x, top + 2, z);
168
+ const mx = x + size * .45, mz = z - size * .45, mh = Math.max(28, h + 22);
169
+ put(new THREE.CylinderGeometry(1.4, 1.8, mh, 12).translate(0, mh / 2, 0), white, mx, 0, mz);
170
+ put(new THREE.CylinderGeometry(2.4, 2.4, .8, 16), white, mx, mh * .74, mz); put(new THREE.CylinderGeometry(1.5, 1.5, .5, 16), glowG, mx, mh * .74 + .7, mz);
171
+ put(new THREE.ConeGeometry(1.5, 5, 12).translate(0, 2.5, 0), domeMat, mx, mh, mz); put(new THREE.SphereGeometry(.4, 8, 6), gold, mx, mh + 5.3, mz);
172
+ buildings.push({ x: mx, z: mz, hw: 2.4, hd: 2.4, h: mh + 5 });
173
+ }
174
+
175
+ // ---- palms (parks, the corniche, along the main roads) and amber lamps ----
176
+ const palms = [], lamps = [];
177
+ for (const p of data.parks) { const xs = p.p.map(q => q[0]), zs = p.p.map(q => q[1]); for (let i = 0; i < 40; i++) { const x = Math.min(...xs) + rnd() * (Math.max(...xs) - Math.min(...xs)), z = Math.min(...zs) + rnd() * (Math.max(...zs) - Math.min(...zs)); if (pip(x, z, p.p)) palms.push([x, z]); } }
178
+ const along = (pts, every, off, list) => { let carry = 0; for (let i = 1; i < pts.length; i++) { const [x1, z1] = pts[i - 1], [x2, z2] = pts[i], L = Math.hypot(x2 - x1, z2 - z1); if (!L) continue; let d = carry; for (; d < L; d += every) { const t = d / L; list.push([x1 + (x2 - x1) * t - (z2 - z1) / L * off, z1 + (z2 - z1) * t + (x2 - x1) / L * off]); } carry = d - L; } };
179
+ along(promenade, 14, 0, palms); along(promenade, 26, 6, lamps);
180
+ for (const r of data.roads) if (r.w >= 10) { along(r.p, 30, r.w / 2 + 1.5, lamps); along(r.p, 30, -(r.w / 2 + 1.5), lamps); if (r.w >= 16) along(r.p, 22, 0, palms); }
181
+ for (let x = -60; x < 140; x += 12) for (const s of [-1, 1]) if (s < 0 || x < -2 || x > 34) { lamps.push([x, s * 12 + (x > 15 ? 4.2 : 0)]); if (x % 24 === 0) palms.push([x + 6, s * 15]); }
182
+ const trunkG = new THREE.CylinderGeometry(.22, .38, 1, 7).translate(0, .5, 0);
183
+ const frond = i => new THREE.PlaneGeometry(4.2, .9).translate(2.1, 0, 0).rotateX(Math.PI / 2).rotateZ(-.55).rotateY(i / 8 * Math.PI * 2);
184
+ const crownG = mergeGeometries(Array.from({ length: 8 }, (_, i) => frond(i)));
185
+ const ph = palms.map(() => 7 + rnd() * 6);
186
+ inst(trunkG, new THREE.MeshStandardMaterial({ color: 0x6b5236, roughness: .9 }), palms, ([x, z], i) => m4.compose(v.set(x, 0, z), q0, sc.set(1, ph[i], 1)));
187
+ inst(crownG, new THREE.MeshStandardMaterial({ color: 0x2f5a2a, roughness: .8, side: THREE.DoubleSide }), palms, ([x, z], i) => m4.compose(v.set(x, ph[i], z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), i), sc.set(1, 1, 1)));
188
+ inst(new THREE.CylinderGeometry(.09, .12, 1, 6).translate(0, .5, 0), new THREE.MeshStandardMaterial({ color: 0x55585e, metalness: .7, roughness: .4 }), lamps, ([x, z]) => m4.compose(v.set(x, 0, z), q0, sc.set(1, 8, 1)));
189
+ inst(new THREE.BoxGeometry(.9, .18, .4), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffb35c).multiplyScalar(2.2), toneMapped: false }), lamps, ([x, z]) => m4.compose(v.set(x, 8, z), q0, sc.set(1, 1, 1)));
190
+
191
+ // ---- signs: on the wall that faces the nearest main road ----
192
+ const signTex = new Map();
193
+ const signOf = (ar, en, color) => {
194
+ const key = ar + color; if (signTex.has(key)) return signTex.get(key);
195
+ const t = canvasTex(512, 192, (g, w, h) => {
196
+ g.fillStyle = "rgba(0,0,0,.65)"; g.fillRect(0, 0, w, h); g.shadowColor = color; g.shadowBlur = 18; g.fillStyle = color; g.textAlign = "center";
197
+ g.font = '700 92px "Noto Kufi Arabic", "Segoe UI", Tahoma, sans-serif'; g.direction = "rtl"; g.fillText(ar, w / 2, 112);
198
+ g.direction = "ltr"; g.font = "800 30px Unbounded, Inter, sans-serif"; g.fillText(en, w / 2, 168);
199
+ g.shadowBlur = 0; g.strokeStyle = color; g.lineWidth = 4; g.globalAlpha = .7; g.strokeRect(6, 6, w - 12, h - 12);
200
+ });
201
+ signTex.set(key, t); return t;
202
+ };
203
+ const sign = (ar, en, color, w, x, y, z, ry) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, w * 192 / 512), new THREE.MeshBasicMaterial({ map: signOf(ar, en, color), transparent: true, toneMapped: false, color: new THREE.Color(1.4, 1.4, 1.4) })); m.position.set(x, y, z); m.rotation.y = ry; scene.add(m); };
204
+ const nearMain = s => { let best = 1e9; for (const [x, z] of mainPts) { const d = (x - s.cx) ** 2 + (z - s.cz) ** 2; if (d < best) best = d; } return best; };
205
+ const chosen = signSpots.map(s => [nearMain(s) - (s.shop ? 1e4 : 0), s]).filter(([d]) => d < 45 * 45).sort((a, b) => a[0] - b[0]).slice(0, COARSE ? 40 : 80);
206
+ chosen.forEach(([, s], i) => {
207
+ let bestE = null, bd = 1e9;
208
+ for (let k = 0; k < s.p.length; k++) {
209
+ const [x1, z1] = s.p[k], [x2, z2] = s.p[(k + 1) % s.p.length], mx = (x1 + x2) / 2, mz = (z1 + z2) / 2, L = Math.hypot(x2 - x1, z2 - z1);
210
+ if (L < 6) continue;
211
+ let d = 1e9; for (const [x, z] of mainPts) d = Math.min(d, (x - mx) ** 2 + (z - mz) ** 2);
212
+ if (d < bd) { bd = d; bestE = { mx, mz, L, nx: (z2 - z1) / L, nz: -(x2 - x1) / L }; }
213
+ }
214
+ if (!bestE) return;
215
+ const [ar, en] = SIGNS[i % SIGNS.length];
216
+ sign(ar, en, NEON[i % NEON.length], Math.min(bestE.L * .7, 12), bestE.mx + bestE.nx * .08, s.shop ? 5.2 : Math.min(s.h - 1.6, 4.2), bestE.mz + bestE.nz * .08, Math.atan2(bestE.nx, bestE.nz));
217
+ });
218
+ const board = (ar, en, color, x, z, ry, y = 7) => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(.25, .3, y, 8).translate(0, y / 2, 0), new THREE.MeshStandardMaterial({ color: 0x3a3d44, metalness: .6, roughness: .4 })); pole.position.set(x, 0, z); scene.add(pole); sign(ar, en, color, 12, x, y + 2, z, ry); };
219
+ board("الطبيشي", "TUBAISHI", "#ffd23d", -30, -16, 0); board("حديقة الطبيشي", "TUBAISHI PARK", "#7dff6a", -168, 318, 0, 6); board("هايدرا", "HYDRA LTE242", "#ff3b5c", 4, -16, 0); board("فويد", "VOID", "#ffffff", 30, 20, Math.PI); board("مرحبا", "WELCOME", "#3dfcff", -50, 18, Math.PI);
220
+
221
+ // ---- where people walk: sidewalks beside the streets and the corniche ----
222
+ const walkways = [promenade];
223
+ for (const r of data.roads) if (r.w >= 7 && r.w <= 12 && r.p.length > 1) walkways.push(offsetLine(r.p, r.w / 2 + 1.3), offsetLine(r.p, -(r.w / 2 + 1.3)));
224
+
225
+ return { buildings, footprints, roads: data.roads, parks: data.parks.map(p => p.p), water: [data.sea], beaches: [], walkways, bounds: { x0: bx0, x1: bx1, z0: bz0, z1: bz1 }, credit: data.credit };
226
+ }
static/game.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Play: free roam. Drive anywhere (the loop and the ramp are stunts you can hit), press Fly to lift
2
+ // off on the thrusters, land anywhere soft to drive on. Green pads are missions worth points.
3
+ // Customize opens the garage without leaving the game. A minimap shows the roads, traffic and pads.
4
+ import * as THREE from "three";
5
+ import { clamp, lerp, G, R, LZ, HW, RAMP_X, PAD_R, ZONES, frame, loc, zonesFrom } from "./world.js";
6
+ import { HEAD } from "./interior.js";
7
+ import { pip } from "./city.js";
8
+
9
+ const $ = id => document.getElementById(id);
10
+ const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { } } };
11
+ const UP = new THREE.Vector3(0, 1, 0);
12
+ export const SPAWN = { x: -168, z: 339, yaw: -Math.PI / 2 }; // the middle of Tubaishi public park, facing the sea
13
+
14
+ export function createGame(W, car, sound, life, opts = {}) {
15
+ const T = W.track, fr = frame(), back = frame(), basis = new THREE.Matrix4(), q = new THREE.Quaternion();
16
+ const keys = new Set(), touch = new Set();
17
+ const held = k => keys.has(k) || touch.has(k);
18
+ const KEYMAP = { ArrowUp: "gas", KeyW: "gas", ArrowDown: "brake", KeyS: "brake", ArrowLeft: "left", KeyA: "left", ArrowRight: "right", KeyD: "right", Space: "thrust" };
19
+ let g = null, view = "chase", active = false, paused = false;
20
+ const camPos = new THREE.Vector3(), camLook = new THREE.Vector3();
21
+ const LOOP_X = T.P[T.loopIn].x, RAMP_END = RAMP_X + 14;
22
+
23
+ addEventListener("keydown", e => {
24
+ if (!active || paused) return;
25
+ if (KEYMAP[e.code]) { keys.add(KEYMAP[e.code]); e.preventDefault(); }
26
+ if (e.code === "KeyF") toggleFly();
27
+ if (e.code === "KeyC" || e.code === "KeyV") toggleView();
28
+ if (e.code === "KeyG") opts.garage && opts.garage();
29
+ });
30
+ addEventListener("keyup", e => { if (KEYMAP[e.code]) keys.delete(KEYMAP[e.code]); });
31
+ addEventListener("blur", () => { keys.clear(); touch.clear(); });
32
+ for (const b of document.querySelectorAll("[data-pad]")) {
33
+ const k = b.dataset.pad;
34
+ const on = e => { e.preventDefault(); touch.add(k); b.classList.add("on"); }, off = e => { e.preventDefault(); touch.delete(k); b.classList.remove("on"); };
35
+ b.addEventListener("pointerdown", on); b.addEventListener("pointerup", off); b.addEventListener("pointercancel", off); b.addEventListener("pointerleave", off);
36
+ b.addEventListener("contextmenu", e => e.preventDefault());
37
+ }
38
+ const toggleView = () => { view = view === "chase" ? "seat" : "chase"; $("gView").textContent = view === "chase" ? "Front seat" : "Chase cam"; };
39
+ $("gView").onclick = toggleView;
40
+ $("gFly").onclick = () => toggleFly();
41
+ $("gGarage").onclick = () => opts.garage && opts.garage();
42
+ $("gGo").onclick = () => { $("gReady").hidden = true; if (!sound.on) sound.setOn(true); g.started = true; };
43
+
44
+ function spawn(at) {
45
+ const s = at || SPAWN;
46
+ Object.assign(g, { mode: "drive", x: s.x, z: s.z, y: 0, yaw: s.yaw, v: 0, vel: new THREE.Vector3(), pitch: 0, roll: 0, wings: 0, thr: 0, ang: 0, gForce: 1, d: 0, hover: false, landing: false, wreck: 0 });
47
+ }
48
+ function start() {
49
+ g = { score: 0, started: false, last: { ...SPAWN }, msg: "", msgT: 0, done: new Set() };
50
+ spawn(); $("gReady").hidden = false; $("gScore").textContent = "0"; $("gBest").textContent = best() ? `Best score: ${best()}` : "";
51
+ sound.setGame("idle");
52
+ }
53
+ const best = () => store.get("void.best2") || 0;
54
+ const say = (m, t = 3) => { g.msg = m; g.msgT = t; };
55
+ function addScore(n, why) { g.score += n; $("gScore").textContent = g.score; if (g.score > best()) store.set("void.best2", g.score); say(`${why} +${n}`); sound.win(); }
56
+
57
+ function toggleFly() {
58
+ if (!g || g.wreck > 0 || !g.started) return;
59
+ if (g.mode === "drive") { g.mode = "air"; g.hover = true; g.landing = false; g.vel.set(Math.cos(g.yaw) * g.v, 2, Math.sin(g.yaw) * g.v); say("Thrusters on. Space lifts, W boosts, Land brings you down."); }
60
+ else if (g.mode === "air") { g.landing = !g.landing; if (!g.landing) g.hover = true; say(g.landing ? "Coming down gently" : "Flying"); }
61
+ }
62
+ function wreck(why) { g.wreck = 2.2; g.v = 0; g.vel.set(0, 0, 0); say(why + ". Respawning…", 2.2); sound.crash(); }
63
+ const rampH = (x, z) => (x >= RAMP_X && x <= RAMP_END && Math.abs(z - LZ) < HW + .3) ? 4.5 * ((x - RAMP_X) / 14) ** 2 : 0;
64
+ const hitBuilding = (x, z, y, pad) => W.buildings.find(b => Math.abs(x - b.x) < b.hw + pad && Math.abs(z - b.z) < b.hd + pad && y < b.h && (!b.poly || pip(x, z, b.poly) || [[pad, 0], [-pad, 0], [0, pad], [0, -pad]].some(([a, c]) => pip(x + a, z + c, b.poly))));
65
+
66
+ function step(dt) {
67
+ if (g.msgT > 0) g.msgT -= dt;
68
+ if (g.wreck > 0) { g.wreck -= dt; if (g.wreck <= 0) spawn(g.last); return; }
69
+ const turn = (held("right") ? 1 : 0) - (held("left") ? 1 : 0);
70
+
71
+ if (g.mode === "drive") {
72
+ const acc = (held("gas") ? (g.v < 0 ? 18 : 10 * (1 - g.v / 92)) : 0) - (held("brake") ? (g.v > .5 ? 16 : 6) : 0) - Math.sign(g.v) * (.35 + .0004 * g.v * g.v);
73
+ g.v = clamp(g.v + acc * dt, -12, 92);
74
+ if (!held("gas") && !held("brake") && Math.abs(g.v) < .3) g.v = 0;
75
+ const steer = turn * .5 / (1 + Math.abs(g.v) / 22);
76
+ g.yaw += g.v * Math.tan(steer) / 2.8 * dt;
77
+ const nx = g.x + Math.cos(g.yaw) * g.v * dt, nz = g.z + Math.sin(g.yaw) * g.v * dt, ny = rampH(nx, nz);
78
+ if (hitBuilding(nx, nz, ny + .5, 1.3)) { g.v *= -.25; sound.pop(); say("Bump!", 1); }
79
+ else {
80
+ // leave the ramp lip fast and you fly
81
+ if (rampH(g.x, g.z) > 2 && nx > RAMP_END - .01 && Math.cos(g.yaw) > .7 && g.v > 8) {
82
+ g.mode = "air"; g.hover = false; const th = Math.atan(2 * 4.5 / 14);
83
+ g.vel.set(Math.cos(g.yaw) * Math.cos(th) * g.v, Math.sin(th) * g.v, Math.sin(g.yaw) * Math.cos(th) * g.v); g.y = 4.5; g.x = nx; g.z = nz;
84
+ if (g.v > 30) addScore(250, "Ramp jump"); return;
85
+ }
86
+ g.x = nx; g.z = nz; g.y = ny;
87
+ }
88
+ // drive into the loop and the track takes you round
89
+ if (g.x > LOOP_X - 1.5 && g.x < LOOP_X + .5 && Math.abs(g.z) < HW && Math.cos(g.yaw) > .8 && g.v > 3) { g.mode = "rail"; g.d = T.D[T.loopIn]; return; }
90
+ for (const c of life ? life.cars : []) { const dx = g.x - c.x, dz = g.z - c.z, d = Math.hypot(dx, dz); if (d < 3 && d > 0) { g.x += dx / d * (3 - d); g.z += dz / d * (3 - d); g.v *= .4; if (c.bump <= 0) { c.bump = 1; sound.pop(); } } }
91
+ g.pitch = lerp(g.pitch, ny > 0 ? Math.atan(2 * 4.5 * ((g.x - RAMP_X) / 14) / 14) : 0, clamp(dt * 10)); g.roll = lerp(g.roll, -steer * g.v * .02, clamp(dt * 5));
92
+ if (g.y === 0 && Math.abs(g.v) < 30) g.last = { x: g.x, z: g.z, yaw: g.yaw };
93
+ g.gForce = 1 + Math.abs(g.v * g.v * Math.tan(steer) / 2.8) / G * .4;
94
+ return;
95
+ }
96
+
97
+ if (g.mode === "rail") {
98
+ T.sample(g.d, fr);
99
+ g.v = clamp(g.v + ((held("gas") ? 10 * (1 - g.v / 92) : 0) - (held("brake") ? 16 : 0) - .4 - G * fr.f.y) * dt, 0, 92);
100
+ g.d += g.v * dt;
101
+ const n = g.v * g.v / R + G * fr.u.y; g.gForce = n / G;
102
+ if (n < 0 || g.v < .5) { // too slow: you come off the loop and fall
103
+ g.mode = "air"; g.hover = false; g.vel.copy(fr.f).multiplyScalar(g.v); g.x = fr.p.x; g.y = fr.p.y; g.z = fr.p.z; g.yaw = 0; say("Too slow for the loop!", 2); return;
104
+ }
105
+ g.x = fr.p.x; g.y = fr.p.y; g.z = fr.p.z;
106
+ if (g.d >= T.D[T.loopOut]) { g.mode = "drive"; g.y = 0; g.yaw = 0; g.pitch = 0; g.x = T.P[T.loopOut].x; g.z = T.P[T.loopOut].z; addScore(500, "Full loop"); }
107
+ return;
108
+ }
109
+
110
+ // air
111
+ const lift = held("thrust"), boost = held("gas"), brake = held("brake");
112
+ g.yaw += turn * .85 * dt;
113
+ const vh = Math.hypot(g.vel.x, g.vel.z);
114
+ g.wings = clamp(g.wings + (vh > 34 ? 1 : -1) * dt * 1.4);
115
+ const dir = new THREE.Vector3(Math.cos(g.yaw), 0, Math.sin(g.yaw));
116
+ const h = new THREE.Vector3(g.vel.x, 0, g.vel.z), hl = h.length();
117
+ if (hl > .1) { h.lerp(dir.clone().multiplyScalar(hl), clamp(dt * (1.4 + g.wings))); g.vel.x = h.x; g.vel.z = h.z; }
118
+ const a = new THREE.Vector3(0, -G, 0), ceiling = clamp(1 - (g.y - 260) / 140);
119
+ a.y += g.wings * G * Math.min(.96, (vh / 80) ** 2) * (.4 + .6 * ceiling);
120
+ if (lift) a.y += 18 * ceiling;
121
+ if (g.hover && !lift && !g.landing) a.y += clamp(G - g.vel.y * 2.5, 0, 16); // hover assist: holds height when you let go
122
+ if (g.landing) a.y += clamp(G + ((g.y < 14 ? -3 : -9) - g.vel.y) * 3, 0, 22); // drop at 9 m/s, soft 3 m/s for the last few metres
123
+ if (boost) a.addScaledVector(dir, 7);
124
+ const k = .0008 + (brake ? .003 : 0) + (1 - g.wings) * .0004;
125
+ a.addScaledVector(g.vel, -(k * g.vel.length() + (brake || g.landing ? .35 : .01)));
126
+ g.vel.addScaledVector(a, dt);
127
+ g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
128
+ g.thr = lerp(g.thr, lift ? 1 : boost ? .85 : g.hover || g.landing ? .55 : .15, clamp(dt * 6)); g.ang = lerp(g.ang, lift || g.hover || g.landing ? .05 : 1, clamp(dt * 3));
129
+ g.roll = lerp(g.roll, -turn * .45, clamp(dt * 4)); g.pitch = lerp(g.pitch, clamp(Math.atan2(g.vel.y, Math.max(1, vh)), -.6, .6) * (g.hover ? .3 : 1), clamp(dt * 3));
130
+ g.gForce = 1 + (lift ? .9 : 0) + Math.abs(turn) * .3;
131
+ if (hitBuilding(g.x, g.z, g.y, .8)) return wreck(vh > 15 ? "Hit a building" : "Scraped a building");
132
+ const ground = rampH(g.x, g.z);
133
+ if (g.y <= ground) {
134
+ g.y = ground;
135
+ if (g.vel.y < -7.5 || vh > 34) return wreck(`Landed too hard (${Math.abs(g.vel.y).toFixed(1)} m/s)`);
136
+ const zone = ZONES.find(z => Math.hypot(g.x - z.x, g.z - z.z) < PAD_R);
137
+ if (zone && !g.done.has(zone.id)) { g.done.add(zone.id); if (zone.safe) addScore(1000 + Math.round(Math.max(0, 7 + g.vel.y) * 60), `Landed at LZ ${zone.id}`); else say(`LZ ${zone.id} is unsafe: ${zone.note}`); }
138
+ g.mode = "drive"; g.v = Math.cos(Math.atan2(g.vel.z, g.vel.x) - g.yaw) * vh; g.y = ground; g.wings = 0; g.hover = false; g.landing = false; g.pitch = 0;
139
+ }
140
+ }
141
+
142
+ // ---- minimap: the map drawn once, then turned with the car ----
143
+ const MM = 2.4, bounds = W.bounds, mapC = document.createElement("canvas");
144
+ mapC.width = Math.ceil((bounds.x1 - bounds.x0) / MM); mapC.height = Math.ceil((bounds.z1 - bounds.z0) / MM);
145
+ {
146
+ const x = mapC.getContext("2d"); x.fillStyle = "#2a2621"; x.fillRect(0, 0, mapC.width, mapC.height);
147
+ const P = ([px, pz]) => [(px - bounds.x0) / MM, (pz - bounds.z0) / MM];
148
+ const poly = (pts, fill) => { x.beginPath(); pts.forEach((p, i) => { const [a, b] = P(p); i ? x.lineTo(a, b) : x.moveTo(a, b); }); x.closePath(); x.fillStyle = fill; x.fill(); };
149
+ for (const w of W.water || []) poly(w, "#14374f");
150
+ for (const b of W.beaches || []) poly(b, "#8a7650");
151
+ for (const p of W.parks || []) poly(p, "#2c4a2e");
152
+ for (const b of W.footprints || []) poly(b, "#4a4642");
153
+ x.lineCap = "round"; x.lineJoin = "round";
154
+ for (const r of W.roads || []) { x.beginPath(); r.p.forEach((p, i) => { const [a, b] = P(p); i ? x.lineTo(a, b) : x.moveTo(a, b); }); x.strokeStyle = r.w >= 10 ? "#d6d3cc" : "#9a958c"; x.lineWidth = Math.max(1.5, r.w / MM); x.stroke(); }
155
+ }
156
+ const mm = $("gMap"), mx = mm.getContext("2d");
157
+ function drawMap() {
158
+ const S = mm.width, c = S / 2;
159
+ mx.save(); mx.clearRect(0, 0, S, S); mx.beginPath(); mx.arc(c, c, c - 2, 0, 7); mx.clip();
160
+ mx.fillStyle = "#1b1814"; mx.fillRect(0, 0, S, S);
161
+ mx.translate(c, c); mx.rotate(-g.yaw - Math.PI / 2);
162
+ mx.drawImage(mapC, -(g.x - bounds.x0) / MM, -(g.z - bounds.z0) / MM);
163
+ for (const z of ZONES) { const px = (z.x - g.x) / MM, pz = (z.z - g.z) / MM; mx.fillStyle = z.safe ? (g.done.has(z.id) ? "#888" : "#3dff8a") : "#ff4455"; mx.beginPath(); mx.arc(px, pz, 5, 0, 7); mx.fill(); }
164
+ if (life) { mx.fillStyle = "#ffd23d"; for (const n of life.cars) { const px = (n.x - g.x) / MM, pz = (n.z - g.z) / MM; if (Math.abs(px) < c && Math.abs(pz) < c) mx.fillRect(px - 1.5, pz - 1.5, 3, 3); } }
165
+ mx.restore();
166
+ mx.fillStyle = "#fff"; mx.beginPath(); mx.moveTo(c, c - 8); mx.lineTo(c - 6, c + 6); mx.lineTo(c + 6, c + 6); mx.closePath(); mx.fill();
167
+ mx.strokeStyle = "rgba(255,255,255,.35)"; mx.lineWidth = 2; mx.beginPath(); mx.arc(c, c, c - 2, 0, 7); mx.stroke();
168
+ }
169
+
170
+ let mapT = 0;
171
+ return {
172
+ get state() { return g; },
173
+ enter() { active = true; start(); },
174
+ exit() { active = false; keys.clear(); touch.clear(); sound.setGame("off"); },
175
+ pause(v) { paused = v; keys.clear(); touch.clear(); },
176
+ update(dt) {
177
+ if (!g) start();
178
+ if (g.started && !paused) step(dt);
179
+ const air = g.mode === "air", speed = air ? g.vel.length() * 3.6 : Math.abs(g.v) * 3.6;
180
+ if (g.mode === "rail") { basis.makeBasis(fr.f, fr.u, fr.s); q.setFromRotationMatrix(basis); }
181
+ else {
182
+ const f = new THREE.Vector3(Math.cos(g.yaw) * Math.cos(g.pitch), Math.sin(g.pitch), Math.sin(g.yaw) * Math.cos(g.pitch));
183
+ const s0 = new THREE.Vector3().crossVectors(f, UP).normalize(), u0 = new THREE.Vector3().crossVectors(s0, f);
184
+ fr.f.copy(f); fr.s.copy(s0.applyAxisAngle(f, g.roll)); fr.u.copy(u0.applyAxisAngle(f, g.roll));
185
+ basis.makeBasis(fr.f, fr.u, fr.s); q.setFromRotationMatrix(basis);
186
+ }
187
+ fr.p.set(g.x, g.y, g.z);
188
+ car.group.position.copy(fr.p); car.group.quaternion.copy(q); car.group.updateMatrixWorld(true);
189
+ let cam;
190
+ if (view === "seat") cam = { pos: HEAD.clone().applyMatrix4(car.group.matrixWorld), look: loc(fr, 30, .5, HEAD.z), up: fr.u.clone(), fov: 72 };
191
+ else {
192
+ let up = UP.clone();
193
+ if (g.mode === "rail") { T.sample(Math.max(T.D[T.loopIn] - 6, g.d - 7.5), back); camPos.copy(loc(back, 0, 2.4, 0)); camLook.copy(loc(fr, 5, .8, 0)); up = back.u.clone(); }
194
+ else {
195
+ const d = new THREE.Vector3(Math.cos(g.yaw), 0, Math.sin(g.yaw)), far = air ? 11 : 7 + speed * .012;
196
+ const wantPos = fr.p.clone().addScaledVector(d, -far).add(new THREE.Vector3(0, air ? 3.6 : 3.4, 0)), wantLook = fr.p.clone().addScaledVector(d, 6).add(new THREE.Vector3(0, .8, 0));
197
+ const k = clamp(dt * (air ? 4 : 6)); camPos.lerp(wantPos, k); camLook.lerp(wantLook, k);
198
+ // camera collision: if a building is between the car and the camera, bring the camera in front of it
199
+ const near = W.buildings.filter(b => Math.abs(b.x - g.x) < b.hw + 16 && Math.abs(b.z - g.z) < b.hd + 16);
200
+ if (near.length) {
201
+ const from = fr.p.clone().add(new THREE.Vector3(0, 1.6, 0)), to = camPos.clone(), seg = to.clone().sub(from), n = Math.ceil(seg.length() / .4);
202
+ for (let i = 1; i <= n; i++) {
203
+ const pnt = from.clone().addScaledVector(seg, i / n);
204
+ if (near.some(b => pnt.y < b.h + 1 && Math.abs(pnt.x - b.x) < b.hw + .6 && Math.abs(pnt.z - b.z) < b.hd + .6 && (!b.poly || pip(pnt.x, pnt.z, b.poly)))) { camPos.copy(from.addScaledVector(seg, Math.max(0, (i - 2) / n))); break; }
205
+ }
206
+ }
207
+ }
208
+ cam = { pos: camPos.clone(), look: camLook.clone(), up, fov: lerp(54, 64, clamp(speed / 330)) };
209
+ }
210
+ sound.setGame(!g.started ? "idle" : g.wreck > 0 ? "end" : air ? (g.y < 50 && g.vel.y < 0 ? "land" : "air") : "drive");
211
+ let gear = 1; const GEARS = [0, 60, 100, 140, 185, 235, 285, 340];
212
+ for (let i = 1; i < GEARS.length; i++) if (speed >= GEARS[i]) gear = i + 1;
213
+ gear = Math.min(7, gear);
214
+ const rpm = !air ? 1800 + clamp((speed - GEARS[gear - 1]) / ((GEARS[gear] || 340) - GEARS[gear - 1])) * 5800 : 900;
215
+ const zones = zonesFrom(fr.p, fr.f);
216
+ $("gSpd").textContent = Math.round(speed); $("gAlt").textContent = Math.round(Math.max(0, g.y)); $("gVs").textContent = (air ? g.vel.y : 0).toFixed(1);
217
+ $("gHint").textContent = g.msgT > 0 ? g.msg : "";
218
+ $("gFly").textContent = g.mode === "air" ? (g.landing ? "Hover" : "Land") : "Fly";
219
+ document.body.classList.toggle("g-air", air);
220
+ const tgt = zones.find(z => z.safe && z.dist <= 5 && !g.done.has(z.id)), arrow = $("gArrow");
221
+ if (tgt) { arrow.style.opacity = 1; arrow.style.transform = `rotate(${Math.atan2(tgt.side, tgt.fwd)}rad)`; $("gTgt").textContent = `Mission: land at LZ ${tgt.id} · ${tgt.dist.toFixed(2)} km`; }
222
+ else { arrow.style.opacity = 0; $("gTgt").textContent = ""; }
223
+ if ((mapT += dt) > .05) { mapT = 0; drawMap(); }
224
+ return {
225
+ pos: fr.p.clone(), quat: q, fr, speed, g: g.gForce, alt: Math.max(0, g.y), vs: air ? g.vel.y : 0, player: { x: g.x, y: g.y, z: g.z },
226
+ thrust: { power: air ? g.thr : 0, angle: g.ang, deploy: air ? 1 : 0 }, wings: g.wings, air, cam,
227
+ inside: view === "seat", screenOn: view === "seat", mode: !air ? "DRIVE" : g.wings > .9 ? "GLIDE" : g.hover ? "HOVER" : "THRUST",
228
+ rpm, gear, load: held("gas") ? 1 : .2, engine: !air ? 1 : .15, zones, reveal: 1,
229
+ };
230
+ },
231
+ };
232
+ }
static/interior.js ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The VOID's front seat: dashboard, steering wheel, a driver's cluster and a center screen.
2
+ // The center screen is two layers: a live dashcam feed (a second camera on the nose, rendered to
3
+ // a texture) and a HUD canvas on top (landing-zone scan within 5 km, speed, altitude).
4
+ import * as THREE from "three";
5
+
6
+ export const HEAD = new THREE.Vector3(-.2, 1.0, -.3); // driver's eyes, car-local (left-hand drive)
7
+
8
+ export function addInterior(car, renderer) {
9
+ const g = car.group, clip = car.clip;
10
+ const trim = new THREE.MeshStandardMaterial({ color: 0x0b0b0d, roughness: 1, metalness: 0, envMapIntensity: .15, clippingPlanes: clip });
11
+ const carbon = new THREE.MeshStandardMaterial({ color: 0x141418, roughness: .45, metalness: .5, clippingPlanes: clip });
12
+ const stitch = new THREE.MeshBasicMaterial({ color: 0x8a8a92, clippingPlanes: clip });
13
+ const parts = new THREE.Group(); g.add(parts);
14
+ const add = (geo, mat, x, y, z) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parts.add(m); return m; };
15
+ const beam = (a, b, r, mat) => { const len = a.distanceTo(b), m = new THREE.Mesh(new THREE.BoxGeometry(r, len, r * 1.6), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); parts.add(m); return m; };
16
+
17
+ // Everything is laid out from the driver's eyes (HEAD) forward, with clear space between parts:
18
+ // dash top at y .79, the cluster in a hooded binnacle above it, the wheel in front of the driver,
19
+ // the screen standing on the dash in the middle, the pillars and roof bar inside the glass.
20
+ const prof = new THREE.Shape([[.5, .64], [.55, .75], [.66, .79], [.82, .78], [.93, .72], [.93, .6], [.5, .6]].map(([x, y]) => new THREE.Vector2(x, y)));
21
+ const dash = new THREE.Mesh(new THREE.ExtrudeGeometry(prof, { depth: 1.1, bevelEnabled: true, bevelSize: .012, bevelThickness: .012, bevelSegments: 2, curveSegments: 6 }), trim);
22
+ dash.position.z = -.55; parts.add(dash);
23
+ add(new THREE.BoxGeometry(.34, .003, 1.06), stitch, .73, .797, 0); // a stitch line along the top
24
+ add(new THREE.BoxGeometry(.46, .12, .22), carbon, .2, .56, 0); // center console
25
+ add(new THREE.BoxGeometry(2.0, .02, 1.3), trim, .0, .44, 0); // floor
26
+ for (const s of [-1, 1]) {
27
+ add(new THREE.BoxGeometry(1.4, .26, .02), trim, .05, .64, s * .66); // door panels
28
+ beam(new THREE.Vector3(.9, .8, s * .4), new THREE.Vector3(.28, 1.08, s * .47), .035, carbon); // A-pillars
29
+ }
30
+ add(new THREE.BoxGeometry(.05, .03, .86), carbon, .26, 1.08, 0); // roof bar
31
+ const seatB = add(new THREE.BoxGeometry(.1, .5, .42), trim, -.4, .74, .3); seatB.rotation.z = .22; // passenger seat
32
+ add(new THREE.BoxGeometry(.46, .09, .42), trim, -.16, .52, .3);
33
+
34
+ // steering wheel: in front of the driver, below the line of sight to the cluster
35
+ const wheel = new THREE.Group(); wheel.position.set(.3, .79, HEAD.z); wheel.rotation.z = -.35; parts.add(wheel);
36
+ const rim = new THREE.Mesh(new THREE.TorusGeometry(.13, .018, 12, 40), trim); rim.rotation.y = Math.PI / 2; wheel.add(rim);
37
+ const hub = new THREE.Mesh(new THREE.CylinderGeometry(.045, .045, .035, 24).rotateZ(Math.PI / 2), carbon); wheel.add(hub);
38
+ for (const a of [Math.PI / 2, -Math.PI / 6, Math.PI + Math.PI / 6]) {
39
+ const sp = new THREE.Mesh(new THREE.BoxGeometry(.012, .085, .022), carbon); sp.position.set(0, Math.sin(a) * .085, Math.cos(a) * .085); sp.rotation.x = -a + Math.PI / 2; wheel.add(sp);
40
+ }
41
+ const mark = new THREE.Mesh(new THREE.BoxGeometry(.012, .028, .018), new THREE.MeshBasicMaterial({ color: 0xc1121f, clippingPlanes: clip })); mark.position.set(0, .13, 0); wheel.add(mark);
42
+ const column = new THREE.Mesh(new THREE.CylinderGeometry(.03, .035, .24, 16).rotateZ(Math.PI / 2 - .35), carbon); column.position.set(.42, .74, HEAD.z); parts.add(column);
43
+
44
+ // the driver's cluster, in a little hood on top of the dash
45
+ const clusterC = document.createElement("canvas"); clusterC.width = 512; clusterC.height = 192;
46
+ const clusterT = new THREE.CanvasTexture(clusterC); clusterT.colorSpace = THREE.SRGBColorSpace;
47
+ const cluster = add(new THREE.PlaneGeometry(.2, .075), new THREE.MeshBasicMaterial({ map: clusterT, toneMapped: false, clippingPlanes: clip }), .6, .84, HEAD.z);
48
+ cluster.lookAt(HEAD);
49
+ const hood = add(new THREE.BoxGeometry(.12, .012, .24), carbon, .63, .885, HEAD.z); hood.rotation.z = -.12;
50
+
51
+ // the center screen, standing on the dash between the seats
52
+ const SCR_W = .32, SCR_H = .18;
53
+ const screen = new THREE.Group(); screen.position.set(.64, .9, .06); parts.add(screen);
54
+ screen.lookAt(HEAD.clone().setZ(-.1));
55
+ const rt = new THREE.WebGLRenderTarget(640, 360, { colorSpace: THREE.SRGBColorSpace });
56
+ const feed = new THREE.Mesh(new THREE.PlaneGeometry(SCR_W, SCR_H), new THREE.MeshBasicMaterial({ map: rt.texture, toneMapped: false, clippingPlanes: clip }));
57
+ screen.add(feed);
58
+ const hudC = document.createElement("canvas"); hudC.width = 1280; hudC.height = 720;
59
+ const hudT = new THREE.CanvasTexture(hudC); hudT.colorSpace = THREE.SRGBColorSpace; hudT.anisotropy = renderer.capabilities.getMaxAnisotropy();
60
+ const hud = new THREE.Mesh(new THREE.PlaneGeometry(SCR_W, SCR_H), new THREE.MeshBasicMaterial({ map: hudT, transparent: true, toneMapped: false, clippingPlanes: clip }));
61
+ hud.position.z = .002; screen.add(hud);
62
+ const bezel = new THREE.Mesh(new THREE.BoxGeometry(SCR_W + .02, SCR_H + .02, .012), carbon); bezel.position.z = -.008; screen.add(bezel);
63
+ const stem = new THREE.Mesh(new THREE.BoxGeometry(.05, .03, .02), carbon); stem.position.set(0, -SCR_H / 2 - .015, -.012); screen.add(stem);
64
+
65
+ const feedCam = new THREE.PerspectiveCamera(62, 16 / 9, .1, 3000);
66
+ feedCam.position.set(2.15, .58, 0); feedCam.rotation.y = -Math.PI / 2; g.add(feedCam);
67
+
68
+ return {
69
+ parts, screen, SCR_W, SCR_H, feedCam, rt, wheel,
70
+ // where the camera must sit for the screen to fill a view of this aspect and vertical fov
71
+ screenView(aspect, fovDeg) {
72
+ const t = Math.tan(fovDeg * Math.PI / 360), d = Math.max(SCR_H / 2 / t, SCR_W / 2 / (t * aspect)) * 1.02;
73
+ const n = new THREE.Vector3(0, 0, 1).applyQuaternion(screen.getWorldQuaternion(new THREE.Quaternion()));
74
+ const c = screen.getWorldPosition(new THREE.Vector3());
75
+ return { pos: c.clone().addScaledVector(n, d), look: c, up: new THREE.Vector3(0, 1, 0).applyQuaternion(screen.getWorldQuaternion(new THREE.Quaternion())) };
76
+ },
77
+ renderFeed(scene) {
78
+ renderer.setRenderTarget(rt); renderer.render(scene, feedCam); renderer.setRenderTarget(null);
79
+ },
80
+ drawCluster(s) {
81
+ const x = clusterC.getContext("2d"), W = 512, H = 192;
82
+ x.fillStyle = "#050507"; x.fillRect(0, 0, W, H);
83
+ x.fillStyle = "#fff"; x.font = "800 92px Unbounded, Inter, sans-serif"; x.textAlign = "center"; x.fillText(Math.round(s.speed), W / 2, 112);
84
+ x.fillStyle = "#8c8c96"; x.font = "600 20px Inter, sans-serif"; x.fillText("KM/H", W / 2, 142);
85
+ x.textAlign = "left"; x.fillStyle = "#fff"; x.font = "800 44px Unbounded, Inter, sans-serif"; x.fillText(s.gear, 28, 112);
86
+ x.fillStyle = "rgba(255,255,255,.12)"; x.fillRect(28, 166, W - 56, 8);
87
+ x.fillStyle = s.rpm > 7200 ? "#ff3344" : "#fff"; x.fillRect(28, 166, (W - 56) * Math.min(1, s.rpm / 8200), 8);
88
+ clusterT.needsUpdate = true;
89
+ },
90
+ drawHud(s) {
91
+ const x = hudC.getContext("2d"), W = 1280, H = 720;
92
+ x.clearRect(0, 0, W, H);
93
+ const grad = x.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, "rgba(0,0,0,.45)"); grad.addColorStop(.35, "rgba(0,0,0,0)"); grad.addColorStop(.7, "rgba(0,0,0,0)"); grad.addColorStop(1, "rgba(0,0,0,.6)");
94
+ x.fillStyle = grad; x.fillRect(0, 0, W, H);
95
+ for (let y = 0; y < H; y += 4) { x.fillStyle = "rgba(255,255,255,.025)"; x.fillRect(0, y, W, 1); }
96
+ x.font = "700 26px Inter, sans-serif"; x.textAlign = "left"; x.fillStyle = "#ff3344";
97
+ if (Math.floor(s.time * 2) % 2) { x.beginPath(); x.arc(46, 46, 9, 0, 7); x.fill(); }
98
+ x.fillText("REC", 66, 55); x.fillStyle = "#e6e6ea"; x.fillText("DASHCAM · FRONT", 150, 55);
99
+ x.textAlign = "right"; x.fillText(new Date().toLocaleTimeString([], { hour12: false }), W - 36, 55);
100
+
101
+ // radar (heading up)
102
+ const cx = 200, cy = 330, R = 150;
103
+ x.fillStyle = "rgba(0,0,0,.55)"; x.beginPath(); x.arc(cx, cy, R + 10, 0, 7); x.fill();
104
+ x.strokeStyle = "rgba(255,255,255,.18)"; x.lineWidth = 2;
105
+ for (let r = 1; r <= 5; r++) { x.beginPath(); x.arc(cx, cy, R * r / 5, 0, 7); x.stroke(); }
106
+ const sw = s.time * 1.8;
107
+ x.fillStyle = "rgba(61,255,138,.18)"; x.beginPath(); x.moveTo(cx, cy); x.arc(cx, cy, R, sw - .5 - Math.PI / 2, sw - Math.PI / 2); x.closePath(); x.fill();
108
+ x.fillStyle = "rgba(255,255,255,.55)"; x.font = "600 18px Inter, sans-serif"; x.textAlign = "left"; x.fillText("5 km", cx + 6, cy - R + 20);
109
+ for (const z of s.zones) {
110
+ const inR = z.dist <= 5, k = inR ? 1 : 5 / z.dist, px = cx + z.side * k * R / 5, py = cy - z.fwd * k * R / 5;
111
+ x.fillStyle = !inR ? "rgba(255,255,255,.35)" : z.safe ? "#3dff8a" : "#ff4455"; x.beginPath(); x.arc(px, py, inR ? 9 : 5, 0, 7); x.fill();
112
+ if (inR) { x.fillStyle = "#fff"; x.font = "700 18px Inter, sans-serif"; x.fillText(z.id[0], px + 12, py + 6); }
113
+ }
114
+ x.fillStyle = "#fff"; x.beginPath(); x.moveTo(cx, cy - 14); x.lineTo(cx - 10, cy + 10); x.lineTo(cx + 10, cy + 10); x.closePath(); x.fill();
115
+ x.fillStyle = "#8c8c96"; x.font = "700 18px Inter, sans-serif"; x.textAlign = "center"; x.fillText("SAFE LANDING SCAN · 5 KM", cx, cy + R + 44);
116
+
117
+ // landing zone list
118
+ const lx = 820, ly = 110; x.fillStyle = "rgba(0,0,0,.55)"; x.fillRect(lx, ly, 424, 360);
119
+ x.textAlign = "left"; x.fillStyle = "#8c8c96"; x.font = "700 18px Inter, sans-serif"; x.fillText("LANDING ZONES", lx + 22, ly + 38);
120
+ s.zones.slice(0, 5).forEach((z, i) => {
121
+ const y = ly + 84 + i * 56, far = z.dist > 5;
122
+ x.globalAlpha = far ? .45 : 1;
123
+ x.fillStyle = "#fff"; x.font = "800 24px Unbounded, Inter, sans-serif"; x.fillText("LZ " + z.id, lx + 22, y);
124
+ x.font = "500 17px Inter, sans-serif"; x.fillStyle = "#a9a9b2"; x.fillText(z.dist.toFixed(1) + " km · " + z.note, lx + 22, y + 24);
125
+ x.textAlign = "right"; x.font = "800 18px Inter, sans-serif"; x.fillStyle = far ? "#8c8c96" : z.safe ? "#3dff8a" : "#ff4455";
126
+ x.fillText(far ? "OUT OF RANGE" : z.safe ? "SAFE" : "UNSAFE", lx + 402, y); x.textAlign = "left"; x.globalAlpha = 1;
127
+ });
128
+ const best = s.zones.find(z => z.safe && z.dist <= 5);
129
+ x.fillStyle = best ? "#3dff8a" : "#ffb347"; x.font = "700 19px Inter, sans-serif";
130
+ x.fillText(best ? `BEST: LZ ${best.id} · ${best.dist.toFixed(1)} KM · ${Math.round(best.dist * 1000 / Math.max(1, s.speed / 3.6))} S` : "NO SAFE ZONE IN RANGE", lx + 22, ly + 346);
131
+
132
+ // bottom strip
133
+ const cells = [["SPEED", Math.round(s.speed), "km/h"], ["ALTITUDE", Math.round(s.alt), "m"], ["VERTICAL", (s.vs >= 0 ? "+" : "") + s.vs.toFixed(1), "m/s"], ["MODE", s.mode, ""]];
134
+ const cw = (W - 72 - 3 * 14) / 4;
135
+ cells.forEach(([k, v, u], i) => {
136
+ const bx = 36 + i * (cw + 14), by = H - 150;
137
+ x.fillStyle = "rgba(0,0,0,.6)"; x.fillRect(bx, by, cw, 118);
138
+ x.fillStyle = "#8c8c96"; x.font = "700 16px Inter, sans-serif"; x.fillText(k, bx + 18, by + 32);
139
+ x.fillStyle = "#fff"; x.font = "800 40px Unbounded, Inter, sans-serif"; x.fillText(v, bx + 18, by + 84);
140
+ const tw = x.measureText(String(v)).width; x.fillStyle = "#8c8c96"; x.font = "600 18px Inter, sans-serif"; x.fillText(u, bx + 26 + tw, by + 84);
141
+ });
142
+ hudT.needsUpdate = true;
143
+ },
144
+ };
145
+ }
static/main.js ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // VOID: one WebGL canvas, three modes on top of a showroom home screen.
2
+ // Watch: a 48 s film whose clock is the music. Play: drive it, fly it, land it. Build: paint it.
3
+ import * as THREE from "three";
4
+
5
+ const V = new URL(import.meta.url).search;
6
+ const $ = id => document.getElementById(id);
7
+ const setLoad = p => { $("ldbar").style.width = p + "%"; $("ldpct").textContent = Math.round(p); };
8
+ setLoad(15);
9
+ const [{ createWorld, clamp, lerp, smooth, frame, PAD_R }, { buildCar }, { addInterior, HEAD }, { Sound, BAR }, { createWatch, TOTAL, CHAPTERS }, { createGame }, { createLife }] = await Promise.all(
10
+ ["world", "car", "interior", "sound", "watch", "game", "npc"].map(n => import(`./${n}.js${V}`)));
11
+ setLoad(40);
12
+
13
+ const COARSE = matchMedia("(pointer: coarse)").matches, REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
14
+ const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { } } };
15
+ const W = await createWorld($("gl"), COARSE), { renderer, scene, camera, composer, bloom, camLight, fx } = W;
16
+ setLoad(55);
17
+
18
+ const car = buildCar();
19
+ scene.add(car.group);
20
+ setLoad(80);
21
+ const interior = addInterior(car, renderer);
22
+ const sound = new Sound();
23
+ const watch = createWatch(W, car, interior);
24
+ const life = createLife(scene, W.roads, W.walkways, COARSE);
25
+ const game = createGame(W, car, sound, life, { garage: () => openGarage(true) });
26
+ $("osmCredit").textContent = W.credit;
27
+ // the garage: the Build panel over the game, the game paused underneath
28
+ function openGarage(on) { document.body.classList.toggle("garage", on); game.pause(on); }
29
+ $("gGarageClose").onclick = () => openGarage(false);
30
+ const nozzles = (car.nozzles || [[.9, .3, 1], [.9, .3, -1], [-.95, .38, 1], [-.95, .38, -1]]).map(([x, y, s]) => new THREE.Vector3(x, y - .5, s));
31
+
32
+ // ---------- configurator ----------
33
+ const OPTS = {
34
+ paint: { label: "Paint", items: [["Void Black", "#0a0a0c"], ["Stealth Grey", "#2b2d32"], ["Liquid Silver", "#9fa3aa"], ["Ghost White", "#e9e9ea"], ["Blood Red", "#6f0a12"], ["Ember Orange", "#c9480c"], ["Ion Blue", "#123a8c"], ["Venom Green", "#2d6a1e"]] },
35
+ finish: { label: "Finish", seg: true, items: [["Gloss", "gloss"], ["Satin", "satin"], ["Matte", "matte"]] },
36
+ rim: { label: "Wheels", items: [["Black", "#121215"], ["Gunmetal", "#3a3d44"], ["Silver", "#b8bcc4"], ["Bronze", "#7a5a32"]] },
37
+ cal: { label: "Calipers", items: [["Red", "#c1121f"], ["Black", "#2a2a30"], ["Yellow", "#e0b000"], ["White", "#e8e8e8"], ["Ion", "#3df5ff"]] },
38
+ trim: { label: "Accents", items: [["Red", "#c1121f"], ["Black", "#16161a"], ["White", "#e8e8e8"], ["Gold", "#b8912e"], ["Ion", "#3df5ff"]] },
39
+ light: { label: "Lights", items: [["Ice", "#e8fbff"], ["Amber", "#ffb347"], ["Ion", "#6ad8ff"], ["Violet", "#b38cff"]] },
40
+ glow: { label: "Underglow", items: [["Off", ""], ["White", "#ffffff"], ["Red", "#ff2233"], ["Ion", "#3df5ff"], ["Violet", "#9b5cff"]] },
41
+ };
42
+ const DEFAULT = { paint: 0, finish: 0, rim: 0, cal: 0, trim: 0, light: 0, glow: 0 };
43
+ let build = { ...DEFAULT, ...(store.get("void.build2") || {}) };
44
+ try { const h = new URLSearchParams(location.hash.slice(1)).get("b"); if (h) { const v = h.split(".").map(Number); Object.keys(DEFAULT).forEach((k, i) => { if (OPTS[k].items[v[i]]) build[k] = v[i]; }); } } catch { }
45
+ function applyBuild() {
46
+ car.setPaint(OPTS.paint.items[build.paint][1]); car.setFinish(OPTS.finish.items[build.finish][1]);
47
+ car.setRim(OPTS.rim.items[build.rim][1]); car.setAccent(OPTS.cal.items[build.cal][1]); car.setTrim(OPTS.trim.items[build.trim][1]); car.setLight(OPTS.light.items[build.light][1]);
48
+ const gl = OPTS.glow.items[build.glow][1]; if (gl) fx.glow.material.color.set(gl).multiplyScalar(1.4);
49
+ store.set("void.build2", build);
50
+ $("sum").innerHTML = `<b>HYDRA LTE242 &middot; ${OPTS.paint.items[build.paint][0]}</b>${OPTS.finish.items[build.finish][0]} paint, ${OPTS.rim.items[build.rim][0].toLowerCase()} wheels, ${OPTS.cal.items[build.cal][0].toLowerCase()} calipers, ${OPTS.trim.items[build.trim][0].toLowerCase()} accents, ${OPTS.light.items[build.light][0].toLowerCase()} lights${gl ? ", " + OPTS.glow.items[build.glow][0].toLowerCase() + " underglow" : ""}`;
51
+ for (const b of document.querySelectorAll("#cfgBody button")) b.setAttribute("aria-pressed", String(build[b.dataset.k] === +b.dataset.i));
52
+ for (const k in OPTS) { const n = document.querySelector(`[data-name="${k}"]`); if (n) n.textContent = OPTS[k].items[build[k]][0]; }
53
+ }
54
+ $("cfgBody").innerHTML = Object.entries(OPTS).map(([k, o]) => `<div class="grp"><div class="grp-h">${o.label}<b data-name="${k}"></b></div><div class="${o.seg ? "seg" : "sw"}">${o.items.map(([n, c], i) => o.seg
55
+ ? `<button data-k="${k}" data-i="${i}">${n}</button>`
56
+ : `<button data-k="${k}" data-i="${i}" title="${n}" aria-label="${o.label}: ${n}" style="background:${c || "repeating-linear-gradient(45deg,#222 0 4px,#111 4px 8px)"}"></button>`).join("")}</div></div>`).join("");
57
+ $("cfgBody").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; build[b.dataset.k] = +b.dataset.i; applyBuild(); });
58
+ $("reset").onclick = () => { build = { ...DEFAULT }; applyBuild(); toast("Back to black and red"); };
59
+ $("share").onclick = async () => {
60
+ const url = location.origin + location.pathname + "#b=" + Object.keys(DEFAULT).map(k => build[k]).join(".");
61
+ try { await navigator.clipboard.writeText(url); toast("Link to your build copied"); } catch { prompt("Copy your build link", url); }
62
+ };
63
+ applyBuild();
64
+ let toastT;
65
+ function toast(t) { const el = $("toast"); el.textContent = t; el.classList.add("on"); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove("on"), 2200); }
66
+
67
+ // ---------- sound ----------
68
+ function setSound(on) { sound.setOn(on); $("snd").setAttribute("aria-pressed", String(on)); $("snd").textContent = on ? "Sound on" : "Sound off"; if (on && mode === "watch" && w.playing) sound.startSong(w.t); }
69
+ $("snd").onclick = () => setSound(!sound.on);
70
+ $("rev").onclick = () => { if (!sound.on) setSound(true); sound.rev(); };
71
+
72
+ // ---------- modes ----------
73
+ let mode = "home";
74
+ const w = { t: 0, playing: false, dragging: false };
75
+ function go(next) {
76
+ if (next === mode) return;
77
+ $("fade").classList.add("on");
78
+ setTimeout(() => {
79
+ if (mode === "play") { game.exit(); openGarage(false); }
80
+ if (mode === "watch") { sound.stopSong(); w.playing = false; }
81
+ mode = next; document.body.dataset.mode = next;
82
+ if (next === "home") scrollTo(0, 0);
83
+ if (next === "watch") { if (!sound.on) setSound(true); playFrom(0); }
84
+ if (next === "play") game.enter();
85
+ $("fade").classList.remove("on");
86
+ }, 320);
87
+ }
88
+ document.addEventListener("click", e => { const b = e.target.closest("[data-go]"); if (b) { e.preventDefault(); go(b.dataset.go); } });
89
+ $("exit").onclick = () => go("home");
90
+ addEventListener("keydown", e => {
91
+ if (e.code === "Escape" && mode !== "home") go("home");
92
+ if (mode === "watch") {
93
+ if (e.code === "Space") { e.preventDefault(); togglePlay(); }
94
+ if (e.code === "ArrowRight") seek(w.t + 4); if (e.code === "ArrowLeft") seek(w.t - 4);
95
+ }
96
+ });
97
+
98
+ // watch player
99
+ function playFrom(t) { w.t = clamp(t, 0, TOTAL); w.playing = true; $("wEnd").classList.remove("on"); if (sound.on) sound.startSong(w.t); setPlayIcon(); }
100
+ function pause() { w.playing = false; sound.stopSong(); setPlayIcon(); }
101
+ function togglePlay() { if (w.playing) pause(); else playFrom(w.t >= TOTAL - .05 ? 0 : w.t); }
102
+ function seek(t) { w.t = clamp(t, 0, TOTAL); if (w.playing) playFrom(w.t); }
103
+ function setPlayIcon() { $("wPlay").innerHTML = w.playing ? "&#10074;&#10074;" : "&#9654;"; $("wPlay").setAttribute("aria-label", w.playing ? "Pause" : "Play"); }
104
+ $("wPlay").onclick = togglePlay;
105
+ $("wAgain").onclick = () => playFrom(0);
106
+ const scrub = $("wScrub"); scrub.max = TOTAL;
107
+ scrub.addEventListener("input", () => { w.dragging = true; w.t = +scrub.value; if (w.playing) { sound.stopSong(); } });
108
+ scrub.addEventListener("change", () => { w.dragging = false; if (w.playing) playFrom(+scrub.value); });
109
+ $("wTicks").innerHTML = CHAPTERS.filter(c => c[1]).map(c => `<button style="left:${c[0] * BAR / TOTAL * 100}%" data-t="${c[0] * BAR}">${c[1]}</button>`).join("");
110
+ $("wTicks").addEventListener("click", e => { const b = e.target.closest("button"); if (b) { seek(+b.dataset.t); if (!w.playing) playFrom(+b.dataset.t); } });
111
+ const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
112
+
113
+ // drag to turn the car on the home and build screens
114
+ const orbit = { a: .7, v: 0, drag: false, x: 0 };
115
+ addEventListener("pointerdown", e => { if ((mode !== "build" && mode !== "home") || e.target !== $("gl") && !e.target.closest(".drag-hint, #buildUI") || e.target.closest(".cfg, button")) return; orbit.drag = true; orbit.x = e.clientX; });
116
+ addEventListener("pointermove", e => { if (!orbit.drag) return; orbit.v = (e.clientX - orbit.x) * -.006; orbit.a += orbit.v; orbit.x = e.clientX; });
117
+ addEventListener("pointerup", () => { orbit.drag = false; });
118
+
119
+ // ---------- resize: ignore the phone address bar sliding, follow rotation and folding ----------
120
+ let lastW = 0, lastH = 0;
121
+ function resize() {
122
+ const ww = innerWidth, hh = Math.max(innerHeight, document.documentElement.clientHeight);
123
+ if (!ww || !hh) return;
124
+ if (ww === lastW && Math.abs(hh - lastH) < 140 && lastW) return;
125
+ lastW = ww; lastH = hh; renderer.setSize(ww, hh, false); composer.setSize(ww, hh);
126
+ bloom.resolution.set(Math.max(1, ww * (COARSE ? .35 : .5)), Math.max(1, hh * (COARSE ? .35 : .5))); camera.aspect = ww / hh;
127
+ }
128
+ addEventListener("resize", resize); addEventListener("orientationchange", () => setTimeout(resize, 200));
129
+ if (window.visualViewport) visualViewport.addEventListener("resize", resize);
130
+ resize();
131
+ const fovFor = f => { const a = camera.aspect; if (a >= 1.5) return f; const h = 2 * Math.atan(Math.tan(f * Math.PI / 360) * 1.5); return Math.min(88, 2 * Math.atan(Math.tan(h / 2) / a) * 180 / Math.PI); };
132
+
133
+ // ---------- the showroom pose (home and build) ----------
134
+ const showFr = frame(); W.track.sample(W.track.D[W.track.idxX(0)], showFr);
135
+ const intro = { t: -1 }; // the car drives in once the loader lifts
136
+ function showroom(dt, time) {
137
+ let introX = 0, introV = 0;
138
+ if (intro.t >= 0 && intro.t < 3.2) { intro.t += dt; const u = Math.min(1, intro.t / 3.2); introX = -46 * Math.pow(1 - u, 3); introV = 46 * 3 * Math.pow(1 - u, 2) / 3.2; }
139
+ else if (intro.t < 0) introX = -46;
140
+ if (!orbit.drag) { orbit.v *= .94; orbit.a += orbit.v + (REDUCED ? 0 : dt * (mode === "home" ? .16 : .1)); }
141
+ const hf = 2 * Math.atan(Math.tan(fovFor(38) * Math.PI / 360) * camera.aspect), fit = 3.1 / Math.tan(hf / 2); // fit the whole car across the screen
142
+ const scrolled = mode === "home" ? clamp(scrollY / innerHeight) : 0, dist = Math.max(mode === "build" ? 8.8 : 8.2, fit) + scrolled * 2;
143
+ const cam = { pos: new THREE.Vector3(Math.cos(orbit.a) * dist, 1.5 + scrolled * 4, Math.sin(orbit.a) * dist), look: new THREE.Vector3(0, .5 - scrolled * .4, 0), fov: 38 };
144
+ if (camera.aspect < 1) cam.look.y = mode === "build" ? -1.1 : -.2;
145
+ else if (mode === "build") { const right = new THREE.Vector3().subVectors(cam.look, cam.pos).cross(new THREE.Vector3(0, 1, 0)).normalize(); cam.look.addScaledVector(right, 1.7); }
146
+ const pos = showFr.p.clone(); pos.x += introX; const fr = { ...showFr, p: pos };
147
+ if (introX < -.01) cam.look.x = Math.max(introX * .35, -8);
148
+ return { pos, quat: new THREE.Quaternion(), fr, speed: introV * 3.6, g: 1, alt: 0, vs: 0, thrust: { power: 0, angle: 0, deploy: 0 }, wings: 0, air: false, cam, inside: false, screenOn: false, reveal: 1, rpm: 900, gear: 1, load: .1, engine: .6, mode: "PARK", zones: [] };
149
+ }
150
+
151
+ // ---------- per frame ----------
152
+ const clock = new THREE.Clock();
153
+ let lastHud = 0, prevGear = 1, feedFlip = 0;
154
+ function frameLoop(fixed) {
155
+ const dt = fixed || Math.min(.05, clock.getDelta()), time = clock.elapsedTime;
156
+ let S;
157
+ if (mode === "watch") {
158
+ if (w.playing && !w.dragging) {
159
+ const st = sound.on ? sound.songTime() : null;
160
+ w.t = st != null ? st : w.t + dt;
161
+ if (w.t >= TOTAL) { w.t = TOTAL; pause(); $("wEnd").classList.add("on"); }
162
+ }
163
+ S = watch.at(w.t, time, camera.aspect, fovFor);
164
+ if (!w.dragging) scrub.value = w.t;
165
+ scrub.style.setProperty("--p", (w.t / TOTAL * 100) + "%");
166
+ $("wTime").textContent = `${fmt(w.t)} / ${fmt(TOTAL)}`;
167
+ const ch = S.chapter, cap = $("wCap");
168
+ if (cap.dataset.ch !== ch[1]) { cap.dataset.ch = ch[1]; cap.classList.add("hide"); setTimeout(() => { $("wNum").textContent = ch[1] ? String(CHAPTERS.indexOf(ch) + 1).padStart(2, "0") : ""; $("wTitle").textContent = ch[1]; $("wSub").textContent = ch[2]; cap.classList.toggle("hide", !ch[1]); }, 250); }
169
+ $("telem").style.opacity = S.bar > 2 && !S.inside && S.bar < 17.5 ? 1 : 0;
170
+ $("tSpd").textContent = Math.round(S.speed); $("tG").textContent = S.g.toFixed(1); $("tAlt").textContent = Math.round(S.alt);
171
+ $("dream").style.opacity = S.dream; $("fade").style.opacity = S.fade;
172
+ } else if (mode === "play") S = game.update(dt, time, camera.aspect);
173
+ else S = showroom(dt, time);
174
+ life.update(dt, time, mode === "play" ? S.player : null);
175
+ if (mode !== "watch") $("fade").style.opacity = "";
176
+
177
+ // the car
178
+ car.group.position.copy(S.pos); car.group.quaternion.copy(S.quat);
179
+ car.setReveal(S.reveal);
180
+ car.setThrust(S.thrust.power, S.thrust.angle, S.thrust.deploy, time);
181
+ car.setWings(S.wings, time);
182
+ const roll = S.speed / 3.6 * dt / .37;
183
+ for (const wh of car.wheels) wh.rotation.z -= S.air ? roll * .15 : roll;
184
+ car.group.updateMatrixWorld(true);
185
+
186
+ // shadow, underglow, exhaust, speed lines
187
+ const fr = S.fr, ground = clamp(1 - S.pos.y / 3) * (fr.u.y > .8 ? 1 : 0);
188
+ fx.shadow.position.set(S.pos.x, S.pos.y > .5 ? .07 : S.pos.y + .07, S.pos.z); fx.shadow.material.opacity = ground; fx.shadow.rotation.z = Math.atan2(-fr.f.z, fr.f.x);
189
+ const glowOn = !!OPTS.glow.items[build.glow][1];
190
+ fx.glow.position.copy(fx.shadow.position).y += .01; fx.glow.rotation.z = fx.shadow.rotation.z; fx.glow.material.opacity = glowOn ? ground * .85 : 0;
191
+ if (S.thrust.power > .3) for (const n of nozzles) if (Math.random() < .18) {
192
+ const pf = fx.puffs[fx.puffI++ % fx.puffs.length], wp = n.clone().applyMatrix4(car.group.matrixWorld);
193
+ pf.s.position.copy(wp); pf.life = 1; pf.s.visible = true; pf.s.scale.setScalar(.4);
194
+ pf.v.copy(fr.u).multiplyScalar(-(1 - S.thrust.angle) * 7).addScaledVector(fr.f, -S.thrust.angle * 9 - S.speed / 3.6 * .2).add(new THREE.Vector3(Math.random() - .5, Math.random() - .5, Math.random() - .5).multiplyScalar(1.5));
195
+ }
196
+ for (const pf of fx.puffs) if (pf.life > 0) {
197
+ pf.life -= dt * .9; pf.s.position.addScaledVector(pf.v, dt); pf.v.multiplyScalar(.97);
198
+ pf.s.scale.setScalar(.4 + (1 - pf.life) * 4.5); pf.s.material.opacity = Math.max(0, pf.life) * .5; if (pf.life <= 0) pf.s.visible = false;
199
+ }
200
+ const lineA = smooth(240, 320, S.speed) * (S.inside ? 0 : 1);
201
+ fx.lines.material.opacity = lineA * .08;
202
+ if (lineA > .01) {
203
+ const d = new THREE.Object3D();
204
+ for (let i = 0; i < fx.LINES; i++) {
205
+ const sd = fx.lineSeed[i]; sd[0] -= S.speed / 3.6 * dt * 1.6; if (sd[0] < -40) sd[0] += 80;
206
+ d.position.copy(fr.p).addScaledVector(fr.f, sd[0]).addScaledVector(fr.u, sd[1]).addScaledVector(fr.s, sd[2]); d.quaternion.copy(S.quat); d.scale.set(1.5 + S.speed * .018, 1, 1);
207
+ d.updateMatrix(); fx.lines.setMatrixAt(i, d.matrix);
208
+ }
209
+ fx.lines.instanceMatrix.needsUpdate = true;
210
+ }
211
+
212
+ // the front seat and its screens
213
+ if (S.screenOn) {
214
+ if (!COARSE || (feedFlip++ & 1)) interior.renderFeed(scene);
215
+ if (time - lastHud > .1) {
216
+ lastHud = time;
217
+ const hud = { time, speed: S.speed, alt: S.alt, vs: S.vs, mode: S.mode, zones: S.zones, gear: S.gear, rpm: S.rpm };
218
+ interior.drawHud(hud); interior.drawCluster(hud);
219
+ }
220
+ }
221
+ interior.wheel.rotation.x = 0;
222
+
223
+ // camera
224
+ const c = S.cam, shake = REDUCED ? 0 : (Math.max(0, S.g - 2) * .004 + S.thrust.power * .01) * (S.inside ? .25 : 1);
225
+ camera.position.copy(c.pos).add(new THREE.Vector3(Math.sin(time * 53) * shake, Math.sin(time * 61 + 1) * shake, Math.sin(time * 47 + 2) * shake));
226
+ camera.up.copy(c.up || new THREE.Vector3(0, 1, 0)); camera.lookAt(c.look);
227
+ camera.fov = fovFor(c.fov); camera.near = S.inside || S.screenOn ? .02 : .05; camera.updateProjectionMatrix();
228
+ camLight.position.copy(camera.position).add(new THREE.Vector3(0, 3, 0)); camLight.target.position.copy(S.pos);
229
+ scene.fog.far = lerp(480, 1600, smooth(20, 110, S.alt)); scene.fog.near = lerp(70, 200, smooth(20, 110, S.alt));
230
+
231
+ // the car's own sound
232
+ if (S.gear > prevGear && !S.air && S.speed > 20) sound.pop();
233
+ prevGear = S.gear;
234
+ sound.car({ rpm: S.rpm, load: S.load, engine: mode === "build" || mode === "home" ? .4 : S.engine, thrust: S.thrust.power, speed: S.speed, inside: S.inside });
235
+
236
+ composer.render();
237
+ }
238
+
239
+ setLoad(95);
240
+ renderer.compile(scene, camera);
241
+ let raf;
242
+ const loop = () => { raf = requestAnimationFrame(loop); frameLoop(); };
243
+ loop();
244
+ setLoad(100);
245
+ setTimeout(() => { $("loader").classList.add("done"); setTimeout(() => { intro.t = 0; if (sound.on) sound.rev(); }, 450); }, 900);
246
+ document.addEventListener("visibilitychange", () => {
247
+ if (document.hidden) { cancelAnimationFrame(raf); if (mode === "watch" && w.playing) pause(); if (sound.ctx) sound.ctx.suspend(); }
248
+ else { clock.getDelta(); loop(); if (sound.on && sound.ctx) sound.ctx.resume(); }
249
+ });
250
+ window.VOID = { step: (n = 1, dt = 1 / 30) => { for (let i = 0; i < n; i++) frameLoop(dt); }, W, car, orbit, go, seek: t => { w.t = t; if (w.playing) playFrom(t); }, pause, get w() { return w; }, game }; // handy for testing in the console
static/npc.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Life in the streets: NPC cars that follow the real road lines, and people walking the sidewalks
2
+ // and the corniche. Cars are mostly white and silver SUVs, sedans and pickups; men in white thobes
3
+ // with red-and-white shemaghs, women in black abayas. Nobody gets hurt: people step aside when the
4
+ // player comes close, and cars brake when something is in front of them.
5
+ import * as THREE from "three";
6
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
7
+
8
+ const CAR_COLS = [0xf4f4f2, 0xf4f4f2, 0xf4f4f2, 0xc8ccd2, 0xc8ccd2, 0x1b1c20, 0x8a8f96, 0x6b1a1a, 0x24364f, 0xd8cfb8];
9
+
10
+ function carGeometry() {
11
+ const parts = [
12
+ new THREE.BoxGeometry(4.4, .7, 1.85).translate(0, .62, 0), // body
13
+ new THREE.BoxGeometry(2.6, .6, 1.7).translate(-.25, 1.25, 0), // cabin
14
+ ];
15
+ for (const x of [1.35, -1.35]) for (const z of [.85, -.85]) parts.push(new THREE.CylinderGeometry(.36, .36, .26, 12).rotateX(Math.PI / 2).translate(x, .36, z));
16
+ return mergeGeometries(parts.map(g => g.toNonIndexed()));
17
+ }
18
+ function personGeometry(abaya) {
19
+ const parts = [
20
+ new THREE.CylinderGeometry(abaya ? .24 : .2, abaya ? .3 : .26, 1.45, 10).translate(0, .73, 0), // thobe / abaya
21
+ new THREE.SphereGeometry(.13, 10, 8).translate(0, 1.6, 0), // head
22
+ ];
23
+ if (!abaya) parts.push(new THREE.ConeGeometry(.19, .32, 10).translate(0, 1.66, 0)); // shemagh
24
+ return mergeGeometries(parts.map(g => g.toNonIndexed()));
25
+ }
26
+
27
+ export function createLife(scene, roads, walkways, COARSE) {
28
+ let seed = 3; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
29
+ // road polylines into segments with lengths, for following
30
+ const lanes = roads.filter(r => r.p.length > 1 && r.w >= 6).map(r => {
31
+ const pts = r.p.map(([x, z]) => new THREE.Vector2(x, z)), len = [0];
32
+ for (let i = 1; i < pts.length; i++) len.push(len[i - 1] + pts[i].distanceTo(pts[i - 1]));
33
+ return { pts, len, total: len[len.length - 1], w: r.w };
34
+ }).filter(l => l.total > 40);
35
+ const at = (lane, d, out) => {
36
+ d = Math.max(0, Math.min(lane.total, d));
37
+ let i = 1; while (i < lane.len.length - 1 && lane.len[i] < d) i++;
38
+ const t = (d - lane.len[i - 1]) / Math.max(1e-6, lane.len[i] - lane.len[i - 1]);
39
+ out.x = lane.pts[i - 1].x + (lane.pts[i].x - lane.pts[i - 1].x) * t; out.z = lane.pts[i - 1].y + (lane.pts[i].y - lane.pts[i - 1].y) * t;
40
+ out.dx = lane.pts[i].x - lane.pts[i - 1].x; out.dz = lane.pts[i].y - lane.pts[i - 1].y;
41
+ return out;
42
+ };
43
+
44
+ // cars
45
+ const NC = lanes.length ? (COARSE ? 40 : 80) : 0;
46
+ const carMat = new THREE.MeshStandardMaterial({ roughness: .35, metalness: .5 });
47
+ const cars = new THREE.InstancedMesh(carGeometry(), carMat, Math.max(1, NC));
48
+ const glass = new THREE.InstancedMesh(new THREE.BoxGeometry(2.62, .4, 1.72).translate(-.25, 1.3, 0), new THREE.MeshStandardMaterial({ color: 0x0b0e14, roughness: .1, metalness: .6 }), Math.max(1, NC));
49
+ const heads = new THREE.InstancedMesh(new THREE.BoxGeometry(.06, .14, 1.5).translate(2.21, .72, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xfff2d0).multiplyScalar(2), toneMapped: false }), Math.max(1, NC));
50
+ const tails = new THREE.InstancedMesh(new THREE.BoxGeometry(.06, .12, 1.5).translate(-2.21, .78, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2030).multiplyScalar(1.8), toneMapped: false }), Math.max(1, NC));
51
+ const npcCars = [];
52
+ const col = new THREE.Color();
53
+ for (let i = 0; i < NC; i++) {
54
+ const lane = lanes[Math.floor(rnd() * lanes.length)];
55
+ npcCars.push({ lane, d: rnd() * lane.total, dir: rnd() < .5 ? 1 : -1, v: 9 + rnd() * 7, want: 9 + rnd() * 7, x: 0, z: 0, yaw: 0, bump: 0 });
56
+ cars.setColorAt(i, col.setHex(CAR_COLS[Math.floor(rnd() * CAR_COLS.length)]));
57
+ }
58
+ if (NC) { cars.instanceColor.needsUpdate = true; scene.add(cars, glass, heads, tails); }
59
+
60
+ // people
61
+ const paths = walkways.filter(w => w.length > 1).map(p => {
62
+ const pts = p.map(([x, z]) => new THREE.Vector2(x, z)), len = [0];
63
+ for (let i = 1; i < pts.length; i++) len.push(len[i - 1] + pts[i].distanceTo(pts[i - 1]));
64
+ return { pts, len, total: len[len.length - 1] };
65
+ }).filter(l => l.total > 20);
66
+ const NP = paths.length ? (COARSE ? 70 : 140) : 0;
67
+ const men = new THREE.InstancedMesh(personGeometry(false), new THREE.MeshStandardMaterial({ color: 0xf6f6f2, roughness: .8 }), Math.max(1, NP));
68
+ const women = new THREE.InstancedMesh(personGeometry(true), new THREE.MeshStandardMaterial({ color: 0x141416, roughness: .9 }), Math.max(1, NP));
69
+ const shemagh = new THREE.InstancedMesh(new THREE.ConeGeometry(.2, .34, 10).translate(0, 1.66, 0), new THREE.MeshStandardMaterial({ color: 0xc8202a, roughness: .8 }), Math.max(1, NP));
70
+ const people = [];
71
+ for (let i = 0; i < NP; i++) {
72
+ const path = paths[Math.floor(rnd() * paths.length)];
73
+ people.push({ path, d: rnd() * path.total, dir: rnd() < .5 ? 1 : -1, v: .9 + rnd() * .7, side: (rnd() - .5) * 2, woman: rnd() < .4, x: 0, z: 0, push: new THREE.Vector2(), phase: rnd() * 6 });
74
+ }
75
+ if (NP) scene.add(men, women, shemagh);
76
+
77
+ const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), Y = new THREE.Vector3(0, 1, 0), pos = new THREE.Vector3(), one = new THREE.Vector3(1, 1, 1), zero = new THREE.Vector3(0, 0, 0), tmp = {};
78
+ return {
79
+ cars: npcCars,
80
+ update(dt, time, player) {
81
+ // cars: follow the lane, brake for the player or a car just ahead, turn round at the end
82
+ for (let i = 0; i < npcCars.length; i++) {
83
+ const c = npcCars[i];
84
+ let want = c.want;
85
+ if (player) { const dx = player.x - c.x, dz = player.z - c.z, ahead = (dx * Math.cos(c.yaw) + dz * Math.sin(c.yaw)); if (ahead > 0 && ahead < 18 && Math.abs(-dx * Math.sin(c.yaw) + dz * Math.cos(c.yaw)) < 3 && player.y < 3) want = 0; }
86
+ for (const o of npcCars) if (o !== c && o.lane === c.lane && o.dir === c.dir) { const gap = (o.d - c.d) * c.dir; if (gap > 0 && gap < 11) want = Math.min(want, gap < 6 ? 0 : o.v * .9); }
87
+ c.v += Math.max(-10 * dt, Math.min(3 * dt, want - c.v));
88
+ c.d += c.v * c.dir * dt;
89
+ if (c.d < 0 || c.d > c.lane.total) { c.dir *= -1; c.d = Math.max(0, Math.min(c.lane.total, c.d)); }
90
+ at(c.lane, c.d, tmp);
91
+ const yaw = Math.atan2(tmp.dz * c.dir, tmp.dx * c.dir), nx = -Math.sin(yaw), nz = Math.cos(yaw), off = Math.min(c.lane.w / 4, 3);
92
+ c.x = tmp.x + nx * off; c.z = tmp.z + nz * off; // keep to the right of the line
93
+ let dy = yaw - c.yaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI; c.yaw += dy * Math.min(1, dt * 6);
94
+ if (c.bump > 0) c.bump -= dt;
95
+ q.setFromAxisAngle(Y, -c.yaw); m4.compose(pos.set(c.x, 0, c.z), q, one);
96
+ cars.setMatrixAt(i, m4); glass.setMatrixAt(i, m4); heads.setMatrixAt(i, m4); tails.setMatrixAt(i, m4);
97
+ }
98
+ if (NC) for (const im of [cars, glass, heads, tails]) im.instanceMatrix.needsUpdate = true;
99
+ // people: walk along, step aside from the player, a little bob as they walk
100
+ for (let i = 0; i < people.length; i++) {
101
+ const p = people[i];
102
+ p.d += p.v * p.dir * dt;
103
+ if (p.d < 0 || p.d > p.path.total) { p.dir *= -1; p.d = Math.max(0, Math.min(p.path.total, p.d)); }
104
+ at(p.path, p.d, tmp);
105
+ const yaw = Math.atan2(tmp.dz * p.dir, tmp.dx * p.dir), l = Math.hypot(tmp.dx, tmp.dz) || 1;
106
+ let x = tmp.x - tmp.dz / l * p.side, z = tmp.z + tmp.dx / l * p.side;
107
+ if (player && player.y < 3) { const dx = x + p.push.x - player.x, dz = z + p.push.y - player.z, d = Math.hypot(dx, dz); if (d < 6) { p.push.x += dx / d * (6 - d) * dt * 4; p.push.y += dz / d * (6 - d) * dt * 4; } }
108
+ p.push.multiplyScalar(1 - Math.min(1, dt * .4));
109
+ x += p.push.x; z += p.push.y; p.x = x; p.z = z;
110
+ q.setFromAxisAngle(Y, -yaw);
111
+ m4.compose(pos.set(x, Math.abs(Math.sin(time * 6 * p.v + p.phase)) * .05, z), q, one);
112
+ const hide = new THREE.Matrix4().compose(pos, q, zero);
113
+ men.setMatrixAt(i, p.woman ? hide : m4); shemagh.setMatrixAt(i, p.woman ? hide : m4); women.setMatrixAt(i, p.woman ? m4 : hide);
114
+ }
115
+ if (NP) for (const im of [men, women, shemagh]) im.instanceMatrix.needsUpdate = true;
116
+ },
117
+ };
118
+ }
static/sound.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // VOID's sound, all synthesized live with Web Audio (no samples, no licensed music).
2
+ // The music is an original 130 bpm Brazilian-phonk style track: a tamborzão-style kick pattern,
3
+ // a crunchy distorted cowbell riff, a gliding 808, vocal-style "ey"/"ah" chops, sidechain pump.
4
+ // In Watch mode the song's clock IS the animation's clock, so picture and beat never drift.
5
+
6
+ export const BPM = 130, STEP = 60 / BPM / 4, BAR = STEP * 16;
7
+ const F0 = 739.99; // F#5, the riff's home note
8
+ const ROOTS = [92.5, 92.5, 73.42, 69.3]; // F#2 F#2 D2 C#2, one per bar
9
+ const PAD = [[185, 220, 277.2], [185, 220, 277.2], [146.8, 185, 220], [138.6, 174.6, 207.7]];
10
+ const KICK = [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0];
11
+ const KICK_HALF = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0];
12
+ const RIFF_A = [[0, 0], [2, 0], [3, 3], [6, 0], [8, 7], [10, 5], [11, 3], [14, 0]];
13
+ const RIFF_B = [[0, 0], [2, 0], [3, 3], [6, 0], [8, 10], [10, 8], [11, 7], [13, 5], [14, 3]];
14
+ const RIFF_C = [[0, 12], [1, 12], [3, 10], [4, 7], [6, 8], [8, 7], [9, 5], [11, 3], [12, 5], [14, 0]];
15
+
16
+ // the Watch arrangement, bar by bar (24 bars, 44 s)
17
+ export const SONG_BARS = 24;
18
+ function songMix(bar) {
19
+ const m = { pad: .45, hat: 0, kick: 0, clap: 0, bass: 0, bell: 0, chop: 0, rise: 0, half: false, riff: 0, open: 900 };
20
+ if (bar < 2) Object.assign(m, { pad: .55, chop: .6, open: 500 + bar * 400 });
21
+ else if (bar < 4.5) Object.assign(m, { hat: .8, bass: .6, kick: .8, half: true, chop: .5, open: 1500 });
22
+ else if (bar < 6) Object.assign(m, { hat: 1, kick: 1, clap: .9, bass: 1, chop: .5, open: 2400 });
23
+ else if (bar < 8) Object.assign(m, { pad: .7, hat: .35, rise: (bar - 6) / 2, open: 1200 + (bar - 6) * 2000 });
24
+ else if (bar < 14) Object.assign(m, { pad: .5, hat: 1, kick: 1, clap: 1, bass: 1, bell: 1, chop: .7, riff: Math.floor(bar) % 2, open: 4200 });
25
+ else if (bar < 17.5) Object.assign(m, { pad: .55, hat: .7, kick: .9, clap: .8, bass: .9, bell: .9, chop: .4, riff: 2, half: true, open: 3000 });
26
+ else if (bar < 22) Object.assign(m, { pad: .7, hat: .3, kick: .6, bass: .6, bell: .35, riff: Math.floor(bar) % 2, half: true, open: 800 });
27
+ else Object.assign(m, { pad: .8 * Math.max(0, 1 - (bar - 22) / 2), open: 1000 });
28
+ return m;
29
+ }
30
+ // the Play (game) arrangement, by what the player is doing
31
+ function gameMix(state) {
32
+ const base = { pad: .45, hat: 0, kick: 0, clap: 0, bass: 0, bell: 0, chop: 0, rise: 0, half: false, riff: 0, open: 1200 };
33
+ if (state === "drive") return { ...base, hat: .9, kick: 1, clap: .8, bass: .9, chop: .3, open: 2400 };
34
+ if (state === "air") return { ...base, hat: 1, kick: 1, clap: 1, bass: 1, bell: 1, chop: .5, open: 4200 };
35
+ if (state === "land") return { ...base, pad: .6, hat: .5, kick: .7, bass: .7, bell: .5, half: true, riff: 2, open: 1800 };
36
+ if (state === "end") return { ...base, pad: .6, open: 700 };
37
+ return { ...base, pad: .5, hat: .4, chop: .3, open: 900 };
38
+ }
39
+
40
+ export class Sound {
41
+ constructor() { this.on = false; this.ctx = null; this.songStart = null; this.liveStep = 0; this.gameState = "idle"; this.mode = "idle"; }
42
+
43
+ init() {
44
+ if (this.ctx) return;
45
+ const C = this.ctx = new (window.AudioContext || window.webkitAudioContext)();
46
+ this.master = C.createGain(); this.master.gain.value = 0;
47
+ const comp = C.createDynamicsCompressor(); comp.threshold.value = -12; comp.ratio.value = 5; comp.attack.value = .003;
48
+ this.master.connect(comp).connect(C.destination);
49
+
50
+ // music: everything goes through a pump (sidechain duck on kicks) and a soft clipper
51
+ this.musicIn = C.createGain(); this.musicIn.gain.value = .5;
52
+ this.pump = C.createGain();
53
+ const clip = C.createWaveShaper(); clip.curve = this.curve(1.6); clip.oversample = "2x";
54
+ this.musicIn.connect(clip).connect(this.master);
55
+ this.pump.connect(this.musicIn);
56
+ const len = C.sampleRate * 2, buf = C.createBuffer(1, len, C.sampleRate), d = buf.getChannelData(0);
57
+ for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
58
+ this.noise = buf;
59
+ const rev = C.createConvolver(), ir = C.createBuffer(2, C.sampleRate * 1.8, C.sampleRate);
60
+ for (let ch = 0; ch < 2; ch++) { const x = ir.getChannelData(ch); for (let i = 0; i < x.length; i++) x[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / x.length, 3); }
61
+ rev.buffer = ir; this.revBus = C.createGain(); this.revBus.gain.value = .28; this.revBus.connect(rev).connect(this.musicIn);
62
+
63
+ this.bus = {};
64
+ for (const k of ["pad", "hat", "kick", "clap", "bass", "bell", "chop"]) {
65
+ const g = C.createGain(); g.gain.value = 0; this.bus[k] = g;
66
+ g.connect(k === "kick" || k === "clap" || k === "hat" ? this.musicIn : this.pump);
67
+ }
68
+ // the cowbell gets crushed through heavy drive, then a touch of room
69
+ this.bellIn = C.createGain(); const bd = C.createWaveShaper(); bd.curve = this.curve(9); bd.oversample = "4x";
70
+ const bellTone = C.createBiquadFilter(); bellTone.type = "peaking"; bellTone.frequency.value = 2200; bellTone.gain.value = 5;
71
+ this.bellIn.connect(bd).connect(bellTone).connect(this.bus.bell); bellTone.connect(this.revBus);
72
+ this.bus.chop.connect(this.revBus);
73
+
74
+ // pad: detuned saws through a lowpass that opens with the section
75
+ this.padF = C.createBiquadFilter(); this.padF.type = "lowpass"; this.padF.frequency.value = 900; this.padF.Q.value = .8;
76
+ this.padF.connect(this.bus.pad);
77
+ this.padOsc = [];
78
+ for (let i = 0; i < 6; i++) { const o = C.createOscillator(); o.type = "sawtooth"; o.detune.value = i % 2 ? 11 : -11; const g = C.createGain(); g.gain.value = .04; o.connect(g).connect(this.padF); o.start(); this.padOsc.push(o); }
79
+ // riser
80
+ this.riseF = C.createBiquadFilter(); this.riseF.type = "bandpass"; this.riseF.Q.value = 4; this.riseG = C.createGain(); this.riseG.gain.value = 0;
81
+ this.loopNoise().connect(this.riseF).connect(this.riseG).connect(this.musicIn);
82
+
83
+ // the car: engine (saw + square through a clipper), thrusters, wind
84
+ this.sfx = C.createGain(); this.sfx.gain.value = .45; this.sfxLP = C.createBiquadFilter(); this.sfxLP.type = "lowpass"; this.sfxLP.frequency.value = 16000;
85
+ this.sfx.connect(this.sfxLP).connect(this.master);
86
+ this.eng1 = C.createOscillator(); this.eng1.type = "sawtooth"; this.eng2 = C.createOscillator(); this.eng2.type = "square";
87
+ const es = C.createWaveShaper(); es.curve = this.curve(3.2);
88
+ this.engF = C.createBiquadFilter(); this.engF.type = "lowpass"; this.engF.frequency.value = 600; this.engF.Q.value = 2;
89
+ this.engG = C.createGain(); this.engG.gain.value = 0; const e2 = C.createGain(); e2.gain.value = .5;
90
+ this.eng1.connect(es); this.eng2.connect(e2).connect(es); es.connect(this.engF).connect(this.engG).connect(this.sfx);
91
+ this.eng1.start(); this.eng2.start();
92
+ this.thrF = C.createBiquadFilter(); this.thrF.type = "lowpass"; this.thrF.frequency.value = 700; this.thrG = C.createGain(); this.thrG.gain.value = 0;
93
+ this.loopNoise().connect(this.thrF).connect(this.thrG).connect(this.sfx);
94
+ this.rumble = C.createOscillator(); this.rumble.frequency.value = 42; this.rumbleG = C.createGain(); this.rumbleG.gain.value = 0; this.rumble.connect(this.rumbleG).connect(this.sfx); this.rumble.start();
95
+ this.windF = C.createBiquadFilter(); this.windF.type = "bandpass"; this.windF.frequency.value = 900; this.windF.Q.value = .6; this.windG = C.createGain(); this.windG.gain.value = 0;
96
+ this.loopNoise().connect(this.windF).connect(this.windG).connect(this.sfx);
97
+
98
+ this.next = C.currentTime + .05;
99
+ setInterval(() => this.schedule(), 25);
100
+ }
101
+ curve(k) { const c = new Float32Array(1024); for (let i = 0; i < 1024; i++) { const x = i / 512 - 1; c[i] = Math.tanh(x * k) / Math.tanh(k); } return c; }
102
+ loopNoise() { const s = this.ctx.createBufferSource(); s.buffer = this.noise; s.loop = true; s.start(); return s; }
103
+
104
+ setOn(v) {
105
+ this.init(); this.on = v;
106
+ if (v && this.ctx.state !== "running") this.ctx.resume();
107
+ this.master.gain.setTargetAtTime(v ? .85 : 0, this.ctx.currentTime, .12);
108
+ }
109
+
110
+ // ---- song clock (Watch) ----
111
+ startSong(atSeconds) {
112
+ this.init(); const C = this.ctx;
113
+ this.mode = "song"; this.songStart = C.currentTime + .06 - atSeconds;
114
+ const n = Math.max(0, Math.ceil((C.currentTime + .06 - this.songStart) / STEP));
115
+ this.songStep = n; this.next = this.songStart + n * STEP; this.dropped = atSeconds > 8 * BAR;
116
+ }
117
+ stopSong() { this.mode = "idle"; this.songStart = null; }
118
+ songTime() { return this.songStart == null ? null : this.ctx.currentTime - this.songStart; }
119
+ setGame(state) { if (this.mode !== "song") { this.mode = state === "off" ? "idle" : "game"; this.gameState = state; } }
120
+
121
+ schedule() {
122
+ if (!this.on || this.ctx.state !== "running") return;
123
+ const C = this.ctx;
124
+ if (this.mode === "song") {
125
+ while (this.next < C.currentTime + .12) {
126
+ const bar = Math.floor(this.songStep / 16);
127
+ if (bar >= SONG_BARS) { this.mode = "idle"; break; }
128
+ this.applyMix(songMix(bar + (this.songStep % 16) / 16), this.next);
129
+ if (bar === 8 && this.songStep % 16 === 0) this.impact(this.next);
130
+ if (bar === 7 && this.songStep % 16 === 12) this.chop(this.next, "ey", 1.2);
131
+ if (bar !== 7 || this.songStep % 16 < 12) this.playStep(this.songStep, this.next, songMix(bar + (this.songStep % 16) / 16));
132
+ this.songStep++; this.next += STEP;
133
+ }
134
+ } else {
135
+ const mix = this.mode === "game" ? gameMix(this.gameState) : { pad: .4, hat: 0, kick: 0, clap: 0, bass: 0, bell: 0, chop: 0, rise: 0, half: true, riff: 0, open: 700 };
136
+ while (this.next < C.currentTime + .12) { this.applyMix(mix, this.next); this.playStep(this.liveStep, this.next, mix); this.liveStep++; this.next += STEP; }
137
+ }
138
+ }
139
+
140
+ applyMix(m, t) {
141
+ for (const k in this.bus) this.bus[k].gain.setTargetAtTime(m[k] || 0, t, .2);
142
+ this.padF.frequency.setTargetAtTime(m.open, t, .3);
143
+ this.riseF.frequency.setTargetAtTime(200 + (m.rise || 0) ** 2 * 7000, t, .05);
144
+ this.riseG.gain.setTargetAtTime((m.rise || 0) * .3, t, .05);
145
+ }
146
+
147
+ playStep(n, t, m) {
148
+ const s = n % 16, bar = Math.floor(n / 16) % 4;
149
+ if (s === 0) PAD[bar].forEach((fr, i) => { this.padOsc[i * 2].frequency.setTargetAtTime(fr, t, .06); this.padOsc[i * 2 + 1].frequency.setTargetAtTime(fr * 1.003, t, .06); });
150
+ const kicks = m.half ? KICK_HALF : KICK;
151
+ if (kicks[s] && m.kick > .01) { this.kick(t); this.duck(t); }
152
+ if (kicks[s] && m.bass > .01) this.bass808(t, ROOTS[bar] * (s === 12 ? 1.5 : 1));
153
+ if ((s === 4 || s === 12) && m.clap > .01 && !(m.half && s === 4)) this.clap(t);
154
+ if (m.hat > .01) { if (s % 2 === 1 || (bar === 3 && s >= 12)) this.hat(t, s % 4 === 3 ? 1 : .6); if (s === 6 || s === 14) this.hat(t, .8, true); }
155
+ if (m.bell > .01) {
156
+ const riff = m.riff === 2 ? RIFF_C : (Math.floor(n / 16) % 2 ? RIFF_B : RIFF_A);
157
+ for (const [st, semi] of riff) if (st === s) this.bell(t, F0 * Math.pow(2, semi / 12));
158
+ }
159
+ if (m.chop > .01 && ((s === 0 && bar % 2 === 0) || (s === 10 && bar === 3))) this.chop(t, s === 0 ? "ah" : "ey", 1);
160
+ }
161
+
162
+ env(g, t, a, peak, dec) { g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(.0001, t + a + dec); }
163
+ duck(t) { const g = this.pump.gain; g.cancelScheduledValues(t); g.setValueAtTime(1, t); g.linearRampToValueAtTime(.3, t + .01); g.linearRampToValueAtTime(1, t + .22); }
164
+ kick(t) {
165
+ const C = this.ctx, o = C.createOscillator(), g = C.createGain(), sh = C.createWaveShaper(); sh.curve = this.curve(4);
166
+ o.frequency.setValueAtTime(170, t); o.frequency.exponentialRampToValueAtTime(45, t + .1);
167
+ this.env(g, t, .002, 1, .32); o.connect(sh).connect(g).connect(this.bus.kick); o.start(t); o.stop(t + .4);
168
+ }
169
+ bass808(t, fr) {
170
+ const C = this.ctx, o = C.createOscillator(), g = C.createGain(), sh = C.createWaveShaper(); sh.curve = this.curve(3);
171
+ o.frequency.setValueAtTime(fr * 2, t); o.frequency.exponentialRampToValueAtTime(fr, t + .05);
172
+ this.env(g, t, .004, .8, .55); o.connect(sh).connect(g).connect(this.bus.bass); o.start(t); o.stop(t + .65);
173
+ }
174
+ noiseHit(t, type, freq, q, peak, dec, bus) {
175
+ const C = this.ctx, s = C.createBufferSource(), f = C.createBiquadFilter(), g = C.createGain();
176
+ s.buffer = this.noise; f.type = type; f.frequency.value = freq; f.Q.value = q;
177
+ this.env(g, t, .001, peak, dec); s.connect(f).connect(g).connect(bus); s.start(t, Math.random()); s.stop(t + dec + .05);
178
+ }
179
+ clap(t) { for (const d of [0, .01, .022]) this.noiseHit(t + d, "bandpass", 1700, 1.4, .6, .14, this.bus.clap); this.noiseHit(t, "highpass", 3000, .5, .2, .2, this.revBus); }
180
+ hat(t, v, open) { this.noiseHit(t, "highpass", 8500, .8, .16 * v, open ? .16 : .035, this.bus.hat); }
181
+ bell(t, fr) {
182
+ const C = this.ctx, f = C.createBiquadFilter(), g = C.createGain();
183
+ f.type = "bandpass"; f.frequency.value = fr * 1.3; f.Q.value = 2.2;
184
+ this.env(g, t, .002, .3, .22); f.connect(g).connect(this.bellIn);
185
+ for (const m of [1, 1.504]) { const o = C.createOscillator(); o.type = "square"; o.frequency.value = fr * m; o.connect(f); o.start(t); o.stop(t + .28); }
186
+ }
187
+ chop(t, vowel, amt) { // a vocal-ish "ah" / "ey": a buzzy source through two formant filters with a pitch fall
188
+ const C = this.ctx, o = C.createOscillator(), g = C.createGain(), [f1, f2] = vowel === "ah" ? [800, 1200] : [530, 1850];
189
+ o.type = "sawtooth"; o.frequency.setValueAtTime(vowel === "ah" ? 196 : 247, t); o.frequency.exponentialRampToValueAtTime(vowel === "ah" ? 150 : 185, t + .3);
190
+ const a = C.createBiquadFilter(), b = C.createBiquadFilter(); a.type = b.type = "bandpass"; a.frequency.value = f1; b.frequency.value = f2; a.Q.value = 8; b.Q.value = 10;
191
+ this.env(g, t, .01, .9 * amt, .32); o.connect(a).connect(g); o.connect(b).connect(g); g.connect(this.bus.chop); o.start(t); o.stop(t + .4);
192
+ }
193
+ impact(t = this.ctx.currentTime) {
194
+ if (!this.on) return; const C = this.ctx, o = C.createOscillator(), g = C.createGain();
195
+ o.frequency.setValueAtTime(95, t); o.frequency.exponentialRampToValueAtTime(28, t + 1.4);
196
+ this.env(g, t, .004, 1.1, 1.6); o.connect(g).connect(this.musicIn); o.start(t); o.stop(t + 1.7);
197
+ this.noiseHit(t, "lowpass", 6000, .5, .55, 1.5, this.musicIn);
198
+ }
199
+ whoosh() { if (this.on) this.noiseHit(this.ctx.currentTime, "bandpass", 700, .8, .5, .9, this.sfx); }
200
+ pop() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "bandpass", 300, 1, .9, .07, this.sfx); this.noiseHit(t + .06, "bandpass", 260, 1, .6, .06, this.sfx); } }
201
+ crash() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "lowpass", 2500, .4, 1, 1.2, this.sfx); this.noiseHit(t, "bandpass", 180, 1, 1, .6, this.sfx); } }
202
+ win() { if (this.on) { const t = this.ctx.currentTime; [0, 4, 7, 12].forEach((s, i) => this.bell(t + i * STEP * 2, F0 * Math.pow(2, s / 12))); } }
203
+ rev() {
204
+ if (!this.on) return; const t = this.ctx.currentTime;
205
+ for (const [o, k] of [[this.eng1, 1], [this.eng2, .5]]) { o.frequency.cancelScheduledValues(t); o.frequency.setValueAtTime(30 * k, t); o.frequency.linearRampToValueAtTime(240 * k, t + .35); o.frequency.linearRampToValueAtTime(32 * k, t + 1.3); }
206
+ this.engG.gain.cancelScheduledValues(t); this.engG.gain.setValueAtTime(.06, t); this.engG.gain.linearRampToValueAtTime(.2, t + .35); this.engG.gain.linearRampToValueAtTime(.05, t + 1.3);
207
+ this.engF.frequency.setValueAtTime(2600, t); this.engF.frequency.linearRampToValueAtTime(500, t + 1.3);
208
+ this.revUntil = performance.now() + 1400;
209
+ }
210
+ // the car, every frame
211
+ car(s) {
212
+ if (!this.ctx || !this.on) return; const t = this.ctx.currentTime;
213
+ if (!(performance.now() < this.revUntil)) {
214
+ const f = Math.max(28, s.rpm / 30);
215
+ this.eng1.frequency.setTargetAtTime(f, t, .03); this.eng2.frequency.setTargetAtTime(f * .5, t, .03);
216
+ this.engF.frequency.setTargetAtTime(300 + s.load * 2600, t, .05);
217
+ this.engG.gain.setTargetAtTime(s.engine * (.04 + s.load * .1), t, .06);
218
+ }
219
+ this.thrG.gain.setTargetAtTime(s.thrust * .45, t, .08); this.thrF.frequency.setTargetAtTime(500 + s.thrust * 1500, t, .1);
220
+ this.rumbleG.gain.setTargetAtTime(s.thrust * .3, t, .1);
221
+ this.windG.gain.setTargetAtTime(Math.min(1, s.speed / 320) * .16, t, .2);
222
+ this.sfxLP.frequency.setTargetAtTime(s.inside ? 1100 : 16000, t, .3);
223
+ }
224
+ }
static/style.css ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* VOID: matte black, white type, one clear thing per screen */
2
+ :root {
3
+ --bg: #10131b; --ink: #f6f7fa; --mute: #a3a8b6; --dim: #5a6070; --line: rgba(255,255,255,.18);
4
+ --panel: rgba(40,46,62,.55); --ok: #3dff8a; --bad: #ff4455;
5
+ --wide: "Unbounded", "Inter", system-ui, sans-serif; --body: "Inter", system-ui, sans-serif;
6
+ --pad: clamp(16px, 4vw, 40px); --top: env(safe-area-inset-top); --bot: env(safe-area-inset-bottom);
7
+ }
8
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
9
+ html { background: var(--bg); }
10
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.55 var(--body); -webkit-font-smoothing: antialiased; overflow-x: hidden; }
11
+ button { font: inherit; color: inherit; cursor: pointer; }
12
+ a { color: inherit; text-decoration: none; }
13
+ kbd { font: 600 12px var(--body); border: 1px solid rgba(255,255,255,.3); border-bottom-width: 2px; border-radius: 6px; padding: 1px 6px; }
14
+ [hidden] { display: none !important; }
15
+
16
+ #gl { position: fixed; left: 0; top: 0; width: 100%; height: 100vh; height: 100lvh; display: block; z-index: 0; touch-action: pan-y; }
17
+ body:not([data-mode="home"]) #gl { touch-action: none; }
18
+ #grain { position: fixed; inset: -50%; z-index: 1; pointer-events: none; opacity: .05;
19
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
20
+ animation: grain 1.2s steps(6) infinite; }
21
+ @keyframes grain { 0% { transform: translate(0,0) } 33% { transform: translate(-3%,2%) } 66% { transform: translate(2%,-3%) } 100% { transform: translate(0,0) } }
22
+ #fade { position: fixed; inset: 0; background: #000; opacity: 0; pointer-events: none; z-index: 6; transition: opacity .35s; }
23
+ #fade.on { opacity: 1; }
24
+
25
+ /* loader */
26
+ #loader { position: fixed; inset: 0; z-index: 50; background: var(--bg); display: grid; place-content: center; justify-items: center; gap: 22px; transition: opacity .8s ease, visibility .8s; }
27
+ #loader.done { opacity: 0; visibility: hidden; }
28
+ .ld-word { font: 900 clamp(64px, 16vw, 180px)/1 var(--wide); letter-spacing: .04em; display: flex; }
29
+ .ld-word span { display: inline-block; color: transparent; -webkit-text-stroke: 1.5px rgba(255,255,255,.55); animation: ldIn .9s cubic-bezier(.2,.8,.2,1) both; }
30
+ .ld-word span:nth-child(2) { animation-delay: .08s } .ld-word span:nth-child(3) { animation-delay: .16s } .ld-word span:nth-child(4) { animation-delay: .24s }
31
+ @keyframes ldIn { from { opacity: 0; transform: translateY(40%); } }
32
+ .ld-bar { width: min(260px, 60vw); height: 2px; background: var(--line); overflow: hidden; }
33
+ .ld-bar i { display: block; height: 100%; width: 0; background: var(--ink); transition: width .25s ease; }
34
+ .ld-pct { font: 500 12px var(--wide); color: var(--mute); letter-spacing: .2em; }
35
+
36
+ /* nav: the same three things everywhere */
37
+ #nav { position: fixed; top: 0; left: 0; right: 0; z-index: 20; display: flex; align-items: center; gap: 16px; padding: calc(14px + var(--top)) var(--pad) 14px; }
38
+ .logo { font: 900 20px var(--wide); letter-spacing: .12em; background: none; border: 0; padding: 0; }
39
+ .credit { font-size: 11px; letter-spacing: .16em; text-transform: uppercase; font-weight: 600; color: #d6d6dc; }
40
+ .right { margin-left: auto; display: flex; gap: 10px; }
41
+ #exit { display: none; padding: 8px 14px; }
42
+ body:not([data-mode="home"]) #exit { display: inline-block; }
43
+ .pill { border: 1px solid rgba(255,255,255,.28); background: rgba(0,0,0,.35); border-radius: 999px; padding: 9px 16px; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; transition: background .2s, color .2s, border-color .2s; white-space: nowrap; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
44
+ .pill:hover { border-color: #fff; }
45
+ .pill.solid, .pill[aria-pressed="true"] { background: #fff; color: #000; border-color: #fff; }
46
+
47
+ /* home */
48
+ #page { position: relative; z-index: 2; }
49
+ body:not([data-mode="home"]) #page { display: none; }
50
+ body:not([data-mode="home"]) { overflow: hidden; }
51
+ #home { min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; justify-content: space-between; padding: calc(84px + var(--top)) var(--pad) calc(28px + var(--bot)); }
52
+ .home-copy { text-align: center; }
53
+ .kicker { font: 500 11px var(--wide); letter-spacing: .36em; color: var(--mute); text-transform: uppercase; }
54
+ .mega { font: 900 clamp(84px, 20vw, 280px)/.86 var(--wide); letter-spacing: -.02em; margin: 12px 0 8px;
55
+ background: linear-gradient(180deg, #fff 0%, #c9c9cf 55%, rgba(255,255,255,.08) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
56
+ .lede { color: #b9b9c1; font-size: clamp(15px, 1.6vw, 19px); margin: 0; }
57
+ .choices { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 12px; width: min(860px, 100%); margin: 0 auto; }
58
+ .choice { text-align: left; padding: 18px 20px; border-radius: 18px; border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); transition: border-color .2s, transform .15s; }
59
+ .choice:hover { border-color: rgba(255,255,255,.5); transform: translateY(-2px); }
60
+ .choice b { display: block; font: 800 clamp(18px, 2.2vw, 24px) var(--wide); }
61
+ .choice span { display: block; font-size: 13px; color: var(--mute); margin-top: 4px; }
62
+ .choice.main { background: #fff; color: #000; border-color: #fff; }
63
+ .choice.main span { color: #444; }
64
+ .more { align-self: center; margin-top: 16px; font-size: 11px; letter-spacing: .26em; text-transform: uppercase; color: var(--mute); }
65
+ .more::after { content: " \2193"; }
66
+
67
+ #specs { background: linear-gradient(180deg, transparent, rgba(5,5,6,.8) 18%, var(--bg) 45%); }
68
+ .specs-wrap { padding: 18vh var(--pad) 8vh; max-width: 1200px; margin: 0 auto; }
69
+ .sec-label { font: 500 11px var(--wide); letter-spacing: .28em; text-transform: uppercase; color: var(--mute); margin-bottom: 16px; }
70
+ h2 { font: 800 clamp(28px, 4.6vw, 60px)/1.04 var(--wide); letter-spacing: -.01em; margin: 0 0 14px; }
71
+ .specs-title { margin-bottom: 40px; }
72
+ .specs { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); }
73
+ .specs div { padding: 26px 16px 26px 0; border-bottom: 1px solid var(--line); }
74
+ .specs b { display: block; font: 800 clamp(32px, 4.2vw, 56px)/1 var(--wide); letter-spacing: -.02em; }
75
+ .specs small { font-size: .42em; color: var(--mute); margin-left: 4px; }
76
+ .specs span { display: block; margin-top: 10px; font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--mute); }
77
+ footer { padding: 36px var(--pad) calc(36px + var(--bot)); max-width: 1200px; margin: 0 auto; border-top: 1px solid var(--line); display: flex; gap: 32px; align-items: flex-start; }
78
+ .foot-logo { font: 900 44px/1 var(--wide); letter-spacing: .06em; color: transparent; -webkit-text-stroke: 1px rgba(255,255,255,.5); }
79
+ footer p { margin: 0; color: var(--mute); font-size: 13px; max-width: 70ch; }
80
+ footer b, footer a { color: var(--ink); }
81
+
82
+ /* modes */
83
+ .mode { position: fixed; inset: 0; z-index: 10; pointer-events: none; display: none; }
84
+ body[data-mode="watch"] #watchUI, body[data-mode="play"] #playUI, body[data-mode="build"] #buildUI { display: block; }
85
+ .mode button, .mode input, .mode aside, .mode .g-card { pointer-events: auto; }
86
+
87
+ /* watch */
88
+ .cap { position: absolute; left: var(--pad); bottom: calc(96px + var(--bot)); width: min(440px, calc(100% - 2 * var(--pad))); transition: opacity .4s ease, transform .4s ease; }
89
+ .cap.hide { opacity: 0; transform: translateY(12px); }
90
+ .cap .num { font: 500 11px var(--wide); letter-spacing: .3em; color: var(--mute); margin-bottom: 6px; }
91
+ .cap h2 { font-size: clamp(28px, 4vw, 52px); margin: 0 0 6px; }
92
+ .cap p { margin: 0; color: #b6b6bf; text-shadow: 0 1px 12px #000; }
93
+ .telem { position: absolute; right: var(--pad); bottom: calc(96px + var(--bot)); display: flex; gap: 22px; transition: opacity .4s; }
94
+ .telem div { display: grid; justify-items: end; }
95
+ .telem span, .g-stats span { font: 500 10px var(--wide); letter-spacing: .22em; text-transform: uppercase; color: var(--mute); }
96
+ .telem b { font: 800 clamp(24px, 3.4vw, 42px)/1.05 var(--wide); font-variant-numeric: tabular-nums; }
97
+ .telem i, .g-stats i { font-style: normal; font-size: 11px; color: var(--mute); }
98
+ .dream { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; padding: 0 20px; opacity: 0; background: radial-gradient(closest-side, rgba(0,0,0,.55), rgba(0,0,0,.85)); }
99
+ .dream .d0 { font: 600 12px var(--wide); letter-spacing: .4em; color: var(--mute); margin-bottom: 18px; }
100
+ .dream .d1 { font: 800 clamp(26px, 4.4vw, 62px)/1.08 var(--wide); }
101
+ .dream .d2 { font: 500 clamp(18px, 2.4vw, 28px) var(--wide); color: #b9b9c2; margin-top: 14px; letter-spacing: .06em; }
102
+ .dream .d3 { margin-top: 24px; font: 600 12px var(--wide); letter-spacing: .3em; text-transform: uppercase; color: var(--mute); }
103
+ .end { position: absolute; inset: 0; display: none; place-content: center; justify-items: center; text-align: center; gap: 22px; background: rgba(0,0,0,.75); padding: 0 20px; }
104
+ .end.on { display: grid; }
105
+ .end-btns { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
106
+ .player { position: absolute; left: var(--pad); right: var(--pad); bottom: calc(22px + var(--bot)); display: flex; align-items: center; gap: 14px; padding: 10px 14px; border-radius: 16px; background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); pointer-events: auto; }
107
+ .pbtn { width: 40px; height: 40px; flex: none; border-radius: 50%; border: 0; background: #fff; color: #000; font-size: 13px; letter-spacing: -2px; }
108
+ .scrub { position: relative; flex: 1; height: 28px; display: flex; align-items: center; }
109
+ .scrub input { width: 100%; margin: 0; -webkit-appearance: none; appearance: none; background: transparent; height: 28px; position: relative; z-index: 1; }
110
+ .scrub input::-webkit-slider-runnable-track { height: 3px; background: linear-gradient(90deg, #fff var(--p, 0%), rgba(255,255,255,.18) var(--p, 0%)); border-radius: 2px; }
111
+ .scrub input::-moz-range-track { height: 3px; background: rgba(255,255,255,.18); border-radius: 2px; }
112
+ .scrub input::-moz-range-progress { height: 3px; background: #fff; }
113
+ .scrub input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; margin-top: -6.5px; }
114
+ .scrub input::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: #fff; border: 0; }
115
+ .ticks { position: absolute; left: 0; right: 0; top: 0; bottom: 0; pointer-events: none; }
116
+ .ticks button { position: absolute; top: -20px; transform: translateX(-50%); background: none; border: 0; padding: 0 2px; font-size: 9px; letter-spacing: .12em; text-transform: uppercase; color: var(--mute); pointer-events: auto; white-space: nowrap; }
117
+ .ticks button::after { content: ""; position: absolute; left: 50%; top: 17px; width: 1px; height: 8px; background: rgba(255,255,255,.35); }
118
+ .wtime { font: 600 12px var(--body); color: var(--mute); font-variant-numeric: tabular-nums; min-width: 72px; text-align: right; }
119
+
120
+ /* play */
121
+ .g-hint { position: absolute; top: calc(70px + var(--top)); left: 50%; transform: translateX(-50%); max-width: calc(100% - 32px); text-align: center; font: 600 14px var(--body); letter-spacing: .02em; padding: 8px 16px; border-radius: 999px; background: var(--panel); border: 1px solid var(--line); }
122
+ .g-hint:empty { display: none; }
123
+ .g-target { position: absolute; top: calc(118px + var(--top)); left: 50%; transform: translateX(-50%); display: grid; justify-items: center; gap: 4px; }
124
+ .g-target i { font-style: normal; font-size: 30px; color: var(--ok); transition: opacity .3s; display: inline-block; text-shadow: 0 0 14px rgba(61,255,138,.6); }
125
+ .g-target span { font: 700 12px var(--wide); letter-spacing: .1em; color: var(--ok); }
126
+ .g-stats { position: absolute; left: var(--pad); bottom: calc(22px + var(--bot)); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; width: min(480px, calc(100% - 2 * var(--pad))); }
127
+ .g-stats em { font-style: normal; }
128
+ .g-stats.two { grid-template-columns: repeat(2, minmax(0, 1fr)); width: min(340px, calc(100% - 2 * var(--pad))); }
129
+ .g-stats > div { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 8px 12px; }
130
+ .g-stats b { display: block; font: 800 clamp(18px, 2.4vw, 26px)/1.2 var(--wide); font-variant-numeric: tabular-nums; }
131
+ .fbar { height: 3px; background: rgba(255,255,255,.12); border-radius: 2px; margin-top: 4px; overflow: hidden; }
132
+ .fbar i { display: block; height: 100%; width: 100%; background: var(--ok); }
133
+ .g-btns { position: absolute; right: var(--pad); top: calc(64px + var(--top)); display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
134
+ .g-score { position: absolute; left: var(--pad); top: calc(64px + var(--top)); display: grid; }
135
+ .g-score span { font: 500 10px var(--wide); letter-spacing: .22em; text-transform: uppercase; color: var(--mute); }
136
+ .g-score b { font: 800 26px var(--wide); }
137
+ .g-map { position: absolute; right: var(--pad); bottom: calc(22px + var(--bot)); width: 180px; height: 180px; border-radius: 50%; box-shadow: 0 6px 30px rgba(0,0,0,.5); }
138
+ #gGarageClose { display: none; }
139
+ body.garage[data-mode="play"] #buildUI { display: block; z-index: 15; }
140
+ body.garage[data-mode="play"] #gGarageClose { display: inline-block; }
141
+ body.garage[data-mode="play"] #playUI > *:not(.g-card) { opacity: .25; pointer-events: none; }
142
+ .pads { display: none; }
143
+ .g-card { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(460px, calc(100% - 32px)); max-height: calc(100% - 140px); overflow: auto; padding: 26px; border-radius: 22px; background: rgba(10,10,13,.88); border: 1px solid var(--line); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); }
144
+ .g-card h2 { font-size: clamp(24px, 3.6vw, 34px); }
145
+ .g-card h2.win { color: var(--ok); } .g-card h2.crash, .g-card h2.lose { color: var(--bad); }
146
+ .g-card ol { margin: 0 0 12px; padding-left: 20px; color: #c4c4cc; font-size: 14px; display: grid; gap: 8px; }
147
+ .g-card .small { font-size: 12px; color: var(--mute); margin: 10px 0; }
148
+ .g-card .ok { color: var(--ok); font-weight: 700; }
149
+ .g-card p { color: #c4c4cc; }
150
+ .g-res { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; margin: 14px 0; }
151
+ .g-res div { border: 1px solid var(--line); border-radius: 12px; padding: 10px; }
152
+ .g-res b { display: block; font: 800 22px var(--wide); }
153
+ .g-res span { font-size: 11px; color: var(--mute); letter-spacing: .1em; text-transform: uppercase; }
154
+
155
+ /* build */
156
+ .drag-hint { position: absolute; left: var(--pad); bottom: calc(26px + var(--bot)); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; color: var(--mute); }
157
+ .cfg { position: absolute; right: clamp(16px, 3vw, 32px); top: calc(72px + var(--top)); bottom: 24px; width: min(360px, calc(100% - 32px)); display: flex; flex-direction: column;
158
+ background: var(--panel); border: 1px solid var(--line); border-radius: 20px; backdrop-filter: blur(18px) saturate(1.2); -webkit-backdrop-filter: blur(18px); overflow: hidden; }
159
+ .cfg-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 6px; }
160
+ .cfg-head h2 { font-size: 20px; margin: 0; }
161
+ #cfgBody { flex: 1; overflow-y: auto; padding: 4px 20px 10px; scrollbar-width: thin; }
162
+ .grp { padding: 13px 0; border-bottom: 1px solid var(--line); }
163
+ .grp:last-child { border-bottom: 0; }
164
+ .grp-h { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: .2em; text-transform: uppercase; color: var(--mute); margin-bottom: 10px; }
165
+ .grp-h b { color: var(--ink); font-weight: 600; letter-spacing: .08em; text-transform: none; font-size: 13px; }
166
+ .sw { display: flex; flex-wrap: wrap; gap: 10px; }
167
+ .sw button { width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(255,255,255,.18); position: relative; padding: 0; transition: transform .15s; }
168
+ .sw button:hover { transform: scale(1.08); }
169
+ .sw button[aria-pressed="true"]::after { content: ""; position: absolute; inset: -5px; border-radius: 50%; border: 1.5px solid #fff; }
170
+ .seg { display: flex; gap: 6px; }
171
+ .seg button { flex: 1; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--line); background: transparent; font-size: 12px; letter-spacing: .06em; }
172
+ .seg button[aria-pressed="true"] { background: #fff; color: #000; border-color: #fff; }
173
+ .cfg-foot { border-top: 1px solid var(--line); padding: 14px 20px 18px; display: grid; gap: 12px; }
174
+ .sum { font-size: 12px; color: var(--mute); }
175
+ .sum b { color: var(--ink); font: 800 13px var(--wide); letter-spacing: .08em; display: block; margin-bottom: 2px; }
176
+ .cfg-btns { display: flex; gap: 8px; }
177
+ .cfg-btns .pill { flex: 1; }
178
+
179
+ #toast { position: fixed; left: 50%; bottom: 100px; transform: translate(-50%, 20px); z-index: 30; background: #fff; color: #000; padding: 10px 18px; border-radius: 999px; font-size: 13px; font-weight: 600; opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s; }
180
+ #toast.on { opacity: 1; transform: translate(-50%, 0); }
181
+
182
+ /* touch controls (phones, tablets, anything without a mouse) */
183
+ @media (pointer: coarse) {
184
+ .pads { display: flex; justify-content: space-between; align-items: flex-end; position: absolute; left: 12px; right: 12px; bottom: calc(92px + var(--bot)); pointer-events: none; }
185
+ .pads button { pointer-events: auto; touch-action: none; user-select: none; -webkit-user-select: none; width: 64px; height: 64px; border-radius: 50%; border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.45); font: 700 11px var(--body); letter-spacing: .08em; text-transform: uppercase; backdrop-filter: blur(8px); }
186
+ .pads button.on { background: #fff; color: #000; }
187
+ .pads .big { width: 78px; height: 78px; }
188
+ .pad-l, .pad-r { display: flex; gap: 10px; align-items: flex-end; }
189
+ .pad-l button { font-size: 20px; }
190
+ body:not(.g-air) .pads [data-pad="thrust"], body:not(.g-air) .pads .pad-l { opacity: .35; }
191
+ }
192
+ @media (max-width: 820px) {
193
+ .credit { font-size: 9px; letter-spacing: .1em; }
194
+ .choices { grid-template-columns: 1fr 1fr; }
195
+ .choice.main { grid-column: 1 / -1; }
196
+ .choice { padding: 14px 16px; }
197
+ .specs { grid-template-columns: repeat(2, 1fr); }
198
+ footer { flex-direction: column; gap: 16px; }
199
+ .cap { bottom: auto; top: calc(70px + var(--top)); }
200
+ .telem { bottom: calc(92px + var(--bot)); gap: 14px; }
201
+ .g-stats > div { padding: 6px 9px; }
202
+ .g-btns { top: calc(112px + var(--top)); right: 12px; }
203
+ .g-map { width: 120px; height: 120px; bottom: auto; top: calc(160px + var(--top)); right: 12px; }
204
+ .pads { bottom: calc(150px + var(--bot)); }
205
+ .cfg { top: auto; bottom: calc(12px + var(--bot)); left: 12px; right: 12px; width: auto; height: 50vh; }
206
+ .drag-hint { bottom: auto; top: calc(70px + var(--top)); }
207
+ .ticks button { font-size: 0; } .ticks button::after { top: 17px; }
208
+ }
209
+ @media (max-height: 520px) and (orientation: landscape) {
210
+ .cap p { display: none; }
211
+ .mega { font-size: 64px; }
212
+ #home { padding-top: calc(64px + var(--top)); }
213
+ .pads { bottom: calc(84px + var(--bot)); }
214
+ }
215
+ @media (prefers-reduced-motion: reduce) { #grain, .ld-word span { animation: none; } }
static/tubaishi.json ADDED
The diff for this file is too large to render. See raw diff
 
static/watch.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Watch: the 48 s film. Its clock is the song's clock (26 bars at 130 bpm) whenever sound is on,
2
+ // so the loop lands on the beat, the ramp gets the build-up and lift-off gets the drop.
3
+ import * as THREE from "three";
4
+ import { clamp, lerp, smooth, keyed, G, R, LZ, frame, loc, zonesFrom } from "./world.js";
5
+ import { BAR, SONG_BARS } from "./sound.js";
6
+ import { HEAD } from "./interior.js";
7
+
8
+ export const TOTAL = SONG_BARS * BAR;
9
+ export const CHAPTERS = [
10
+ [0, "Build", "Blueprint to carbon."], [2, "Roll out", "Flat out from a standstill."], [4.5, "The loop", "A full 360°. Real loop physics."],
11
+ [6, "The ramp", "Foot down. 33° kicker."], [8, "Lift-off", "Four thrusters fire straight down."], [11, "Wings out", "Thrusters swing back. It glides over Dammam."],
12
+ [17.5, "Front seat", "Inside the Hydra LTE242."], [20.2, "The screen", "Dashcam and a landing scan within 5 km."], [22, "", ""],
13
+ ];
14
+
15
+ export function createWatch(W, car, interior) {
16
+ const T = W.track, fr = frame(), basis = new THREE.Matrix4(), q = new THREE.Quaternion();
17
+ const dStart = T.D[T.idxX(0)], dX = x => T.D[T.idxX(x, T.roadEnd)];
18
+ const RIDE = [[0, dStart], [.065, T.D[T.loopIn]], [.245, T.D[T.loopOut]], [.33, T.D[T.rampIn]], [.385, T.D[T.roadEnd]], [.5, dX(77)], [.64, dX(180)], [.78, dX(420)], [1, dX(880)]];
19
+ const rideP = b => b < 2 ? 0 : keyed([[2, 0], [4.5, .065], [6, .245], [7.2, .33], [8, .385], [11, .5], [14, .64], [17.5, .735], [24, 1]], b);
20
+ const speedMS = (p, y) => {
21
+ if (p < .065) return 30 * Math.pow(smooth(0, .065, p), .6);
22
+ if (p < .245) return Math.sqrt(Math.max(30 * 30 - 2 * G * y, 9));
23
+ if (p < .33) return lerp(30, 48, smooth(.245, .33, p));
24
+ if (p < .385) return Math.sqrt(Math.max(48 * 48 - 2 * G * y, 9));
25
+ return keyed([[.385, 47], [.5, 72], [.64, 88], [.78, 84], [1, 81]], p);
26
+ };
27
+ const thrustDeploy = p => smooth(.355, .395, p), thrustPower = p => keyed([[.37, 0], [.392, 1], [.5, 1], [.56, .5], [.66, .35], [1, .3]], p);
28
+ const thrustAngle = p => keyed([[.39, 0], [.47, .12], [.56, .85], [.62, 1]], p), wingsOut = p => smooth(.5, .585, p);
29
+
30
+ // where the car is at each bar, with real speed changes inside each section: it pulls away from
31
+ // rest, slows over the top of the loop and whips through the bottom, builds up the run-up and
32
+ // ramp, surges on the thrusters, then glides. w(s) is the speed shape over the section.
33
+ const tmp = frame(), V0 = 30, dFilm = T.D[T.idxX(-55)];
34
+ const SECTIONS = [
35
+ [2, 4.5, dFilm, T.D[T.loopIn], s => Math.sqrt(s + .02)],
36
+ [4.5, 6, T.D[T.loopIn], T.D[T.loopOut], s => Math.sqrt(Math.max(.05, 1 - 2 * G * T.sample(lerp(T.D[T.loopIn], T.D[T.loopOut], s), tmp).p.y / (V0 * V0)))],
37
+ [6, 8, T.D[T.loopOut], T.D[T.roadEnd], s => .75 + .35 * s],
38
+ [8, 11, T.D[T.roadEnd], dX(T.P[T.roadEnd].x + 110), s => Math.sqrt(.25 + 1.4 * s)],
39
+ [11, 17.5, dX(T.P[T.roadEnd].x + 110), dX(T.P[T.roadEnd].x + 1150), () => 1],
40
+ [17.5, 24, dX(T.P[T.roadEnd].x + 1150), dX(T.P[T.roadEnd].x + 2100), () => 1],
41
+ ].map(([b0, b1, d0, d1, w]) => {
42
+ const N = 240, tau = new Float32Array(N + 1);
43
+ for (let i = 1; i <= N; i++) tau[i] = tau[i - 1] + 2 / (w((i - 1) / N) + w(i / N)) / N;
44
+ for (let i = 1; i <= N; i++) tau[i] /= tau[N];
45
+ return { b0, b1, d0, d1, tau, N };
46
+ });
47
+ const distAt = b => {
48
+ if (b <= 2) return dFilm;
49
+ const S = SECTIONS.find(x => b <= x.b1) || SECTIONS[SECTIONS.length - 1];
50
+ const u = clamp((b - S.b0) / (S.b1 - S.b0));
51
+ let lo = 0, hi = S.N; while (hi - lo > 1) { const m = (lo + hi) >> 1; if (S.tau[m] <= u) lo = m; else hi = m; }
52
+ const s = (lo + clamp((u - S.tau[lo]) / Math.max(1e-6, S.tau[hi] - S.tau[lo]))) / S.N;
53
+ return lerp(S.d0, S.d1, s);
54
+ };
55
+ const FILM = 1; // real time
56
+ const UP = new THREE.Vector3(0, 1, 0);
57
+ const SHOTS = [
58
+ [0, .05, f => ({ pos: loc(f, -1, 1.3, 8), look: loc(f, 0, .6, 0), fov: 40 })],
59
+ [.06, .065, f => ({ pos: loc(f, -7.5, 2.3, 4.5), look: loc(f, 4, .6, 0), fov: 42 })],
60
+ [.085, .23, f => ({ pos: new THREE.Vector3(16, 9, LZ + 25), look: new THREE.Vector3(16, 9, 2).lerp(f.p, .3), fov: 44 })],
61
+ [.255, .37, f => ({ pos: f.p.clone().add(new THREE.Vector3(-7, 2, 9)), look: f.p.clone().add(new THREE.Vector3(5, 1.4, 0)), fov: 46 })],
62
+ [.395, .48, f => ({ pos: loc(f, -9, -1.8, 7), look: loc(f, 2, 1, 0), fov: 50 })],
63
+ [.52, .62, f => ({ pos: loc(f, -4, 2.2, 12), look: loc(f, 1.5, .5, 0), fov: 42 })],
64
+ [.665, 1, f => ({ pos: loc(f, -11, 3.2, 2), look: loc(f, 8, 1, 0), fov: 48 })],
65
+ ];
66
+ const shotAt = (p, f) => {
67
+ for (let i = 0; i < SHOTS.length; i++) {
68
+ const [a, b, fn] = SHOTS[i];
69
+ if (p <= b || i === SHOTS.length - 1) {
70
+ if (p >= a || i === 0) return fn(f);
71
+ const prev = SHOTS[i - 1], w = smooth(prev[1], a, p), A = prev[2](f), B = fn(f);
72
+ return { pos: A.pos.lerp(B.pos, w), look: A.look.lerp(B.look, w), fov: lerp(A.fov, B.fov, w) };
73
+ }
74
+ }
75
+ };
76
+ const blend = (A, B, w) => ({ pos: A.pos.clone().lerp(B.pos, w), look: A.look.clone().lerp(B.look, w), fov: lerp(A.fov, B.fov, w), up: (A.up || UP).clone().lerp(B.up || UP, w).normalize() });
77
+
78
+ let prevB = 0;
79
+ return {
80
+ // everything about the film at time t (seconds)
81
+ at(t, time, aspect, fovFor = f => f) {
82
+ const b = clamp(t / BAR, 0, SONG_BARS), p = rideP(b);
83
+ T.sample(distAt(b), fr);
84
+ basis.makeBasis(fr.f, fr.u, fr.s); q.setFromRotationMatrix(basis);
85
+ const v = b < 2 ? 0 : Math.min(93, (distAt(b + .02) - distAt(b)) / (.02 * BAR) * FILM), onLoop = fr.i >= T.loopIn && fr.i <= T.loopOut;
86
+ const g = onLoop ? v * v / (R * G) + fr.u.y : (p > .385 ? 1 + thrustPower(p) * .25 : 1);
87
+ const speed = v * 3.6, dep = thrustDeploy(p), power = thrustPower(p);
88
+ // camera
89
+ let cam;
90
+ if (b < 2) {
91
+ const h = b / 2, a = lerp(.75, 1.45, smooth(0, 1, h));
92
+ cam = { pos: fr.p.clone().add(new THREE.Vector3(Math.cos(a) * 7.4, lerp(2.2, 1.3, h), Math.sin(a) * 8.2)), look: fr.p.clone().add(new THREE.Vector3(0, .55, 0)), fov: 40 };
93
+ cam = blend(cam, shotAt(0, fr), smooth(.8, 1, h));
94
+ } else {
95
+ cam = shotAt(p, fr);
96
+ if (b > 17.2) {
97
+ car.group.position.copy(fr.p); car.group.quaternion.copy(q); car.group.updateMatrixWorld(true);
98
+ const head = { pos: HEAD.clone().applyMatrix4(car.group.matrixWorld), look: loc(fr, 30, .5, HEAD.z), up: fr.u.clone(), fov: 72 };
99
+ const glance = { pos: head.pos, look: interior.screen.getWorldPosition(new THREE.Vector3()), up: fr.u.clone(), fov: 66 };
100
+ const sv = interior.screenView(aspect, fovFor(46)), scr = { pos: sv.pos, look: sv.look, up: sv.up, fov: 46 };
101
+ if (b < 18.4) cam = blend(cam, head, smooth(17.5, 18.4, b));
102
+ else if (b < 20.2) cam = blend(head, glance, smooth(19.4, 20.2, b));
103
+ else cam = blend(glance, scr, smooth(20.2, 21.4, b));
104
+ }
105
+ }
106
+ if (b >= 8 && prevB < 8) this.onDrop && this.onDrop();
107
+ prevB = b;
108
+ let gear = 1; const GEARS = [0, 60, 100, 140, 185, 235, 285, 340];
109
+ for (let i = 1; i < GEARS.length; i++) if (speed >= GEARS[i]) gear = i + 1;
110
+ gear = Math.min(7, gear);
111
+ const rpm = b < 8 && b >= 2 ? 2400 + clamp((speed - GEARS[gear - 1]) / ((GEARS[gear] || 340) - GEARS[gear - 1])) * 5200 : 900;
112
+ return {
113
+ bar: b, p, reveal: smooth(0, 1.8, b), pos: fr.p.clone(), quat: q.clone(), fr, speed, g, alt: Math.max(0, fr.p.y), vs: v * fr.f.y,
114
+ thrust: { power: power * dep, angle: thrustAngle(p), deploy: dep }, wings: wingsOut(p), air: p > .385, onLoop,
115
+ cam, inside: b > 18.1, screenOn: b > 18.2, mode: thrustAngle(p) > .9 ? "GLIDE" : p > .385 ? "THRUST" : "DRIVE",
116
+ rpm, gear, load: b < 8 ? .8 : .15, engine: b < 8 ? 1 : .15,
117
+ zones: zonesFrom(fr.p, fr.f), dream: smooth(22, 22.6, b), fade: smooth(23.4, 24, b),
118
+ chapter: CHAPTERS.reduce((c, ch) => (b >= ch[0] ? ch : c), CHAPTERS[0]),
119
+ };
120
+ },
121
+ };
122
+ }
static/world.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The shared world for Watch and Play: renderer, lights, the night city, the track (straight,
2
+ // a 9 m 360 loop shifted sideways so it can exit, a run-up, a 33° ramp), the flight path after
3
+ // the ramp, the landing zones with beacons, and the effects (shadow, underglow, speed lines, exhaust).
4
+ import * as THREE from "three";
5
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
6
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
7
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
8
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
9
+ import { buildCity } from "./city.js";
10
+
11
+ export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)), lerp = (a, b, t) => a + (b - a) * t;
12
+ export const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a)); return t * t * (3 - 2 * t); };
13
+ export const keyed = (K, p) => {
14
+ if (p <= K[0][0]) return K[0][1];
15
+ for (let i = 1; i < K.length; i++) if (p <= K[i][0]) return lerp(K[i - 1][1], K[i][1], (p - K[i - 1][0]) / (K[i][0] - K[i - 1][0]));
16
+ return K[K.length - 1][1];
17
+ };
18
+ export const G = 9.81, R = 9, LZ = 4.2, HW = 2.3, RAMP_X = 120;
19
+
20
+ // landing zones, metres in world space (x forward from the start line, z to the right)
21
+ export const ZONES = [
22
+ { id: "ALPHA", x: -1646, z: 1015, safe: true, note: "West park, open lawn" },
23
+ { id: "BRAVO", x: -1321, z: 1267, safe: true, note: "Big park, open lawn" },
24
+ { id: "CHARLIE", x: -206, z: -900, safe: false, note: "Over the Gulf, water" },
25
+ { id: "DELTA", x: -1870, z: 590, safe: true, note: "Stadium car park" },
26
+ { id: "ECHO", x: 1150, z: 640, safe: false, note: "Power lines" },
27
+ { id: "FOXTROT", x: 5200, z: 2600, safe: true, note: "Airport apron" },
28
+ ];
29
+ export const PAD_R = 28;
30
+
31
+ export async function createWorld(canvas, COARSE) {
32
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance", logarithmicDepthBuffer: true });
33
+ renderer.localClippingEnabled = true;
34
+ renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2;
35
+ renderer.setPixelRatio(Math.min(devicePixelRatio || 1, COARSE ? 1.5 : 1.75));
36
+ const scene = new THREE.Scene();
37
+ // a blue-hour sky, and a studio of long light strips for the paint to reflect
38
+ const sky = document.createElement("canvas"); sky.width = 4; sky.height = 512;
39
+ { const g = sky.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 512);
40
+ gr.addColorStop(0, "#0b1222"); gr.addColorStop(.42, "#1c2a4a"); gr.addColorStop(.56, "#3d4d73"); gr.addColorStop(.64, "#7a6f86"); gr.addColorStop(.7, "#3a3d4c"); gr.addColorStop(1, "#22252e");
41
+ g.fillStyle = gr; g.fillRect(0, 0, 4, 512); }
42
+ const skyTex = new THREE.CanvasTexture(sky); skyTex.colorSpace = THREE.SRGBColorSpace; skyTex.mapping = THREE.EquirectangularReflectionMapping;
43
+ scene.background = skyTex;
44
+ scene.fog = new THREE.Fog(0x3a4562, 70, 480);
45
+ const studio = new THREE.Scene();
46
+ studio.add(new THREE.Mesh(new THREE.SphereGeometry(50, 32, 16), new THREE.MeshBasicMaterial({ color: 0x232838, side: THREE.BackSide })));
47
+ const strip = (w, h, x, y, z, ry, rx, k) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(k), side: THREE.DoubleSide })); m.position.set(x, y, z); m.rotation.set(rx, ry, 0); studio.add(m); };
48
+ strip(20, 8, 0, 12, 0, 0, Math.PI / 2, 1.1); // one big soft panel overhead
49
+ for (const s of [-1, 1]) strip(16, 4, 0, 4, s * 14, 0, 0, .7); // a soft panel each side
50
+ studio.add(new THREE.Mesh(new THREE.PlaneGeometry(100, 100).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x3a4050 })));
51
+ scene.environment = new THREE.PMREMGenerator(renderer).fromScene(studio, .02).texture;
52
+ const camera = new THREE.PerspectiveCamera(40, 1, .03, 9000);
53
+ scene.add(new THREE.HemisphereLight(0xb8c2de, 0x2a2a32, 1.3));
54
+ const key = new THREE.DirectionalLight(0xffffff, 1.1); key.position.set(-6, 10, 8); scene.add(key);
55
+ const rimL = new THREE.DirectionalLight(0xbfd4ff, .6); rimL.position.set(8, 4, -10); scene.add(rimL);
56
+ const camLight = new THREE.DirectionalLight(0xffffff, .35); scene.add(camLight, camLight.target); // follows the camera so black paint always catches light
57
+
58
+ const composer = new EffectComposer(renderer);
59
+ composer.addPass(new RenderPass(scene, camera));
60
+ const bloom = new UnrealBloomPass(new THREE.Vector2(256, 256), .28, .25, .97);
61
+ composer.addPass(bloom); composer.addPass(new OutputPass());
62
+
63
+ const canvasTex = (w, h, draw, repeat) => {
64
+ const c = document.createElement("canvas"); c.width = w; c.height = h; draw(c.getContext("2d"), w, h);
65
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = renderer.capabilities.getMaxAnisotropy();
66
+ if (repeat) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(...repeat); }
67
+ return t;
68
+ };
69
+ const radial = (inner, outer) => canvasTex(128, 128, (g, w, h) => { const r = g.createRadialGradient(64, 64, 0, 64, 64, 64); r.addColorStop(0, inner); r.addColorStop(1, outer); g.fillStyle = r; g.fillRect(0, 0, w, h); });
70
+
71
+ // stars over the Gulf, then the city itself (city.js)
72
+ {
73
+ const n = 1400, pos = new Float32Array(n * 3);
74
+ for (let i = 0; i < n; i++) { const th = Math.random() * Math.PI * 2, ph = Math.acos(Math.random() * .9), r = 6000; pos.set([Math.cos(th) * Math.sin(ph) * r, Math.cos(ph) * r * .8 + 80, Math.sin(th) * Math.sin(ph) * r], i * 3); }
75
+ const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
76
+ scene.add(new THREE.Points(g, new THREE.PointsMaterial({ color: 0xffffff, size: 2.2, sizeAttenuation: false, fog: false, transparent: true, opacity: .7 })));
77
+ }
78
+ const city = await buildCity(scene, canvasTex, COARSE), buildings = city.buildings;
79
+
80
+ // landing zones: a glowing ring, an H, and a tall beacon you can see from far away
81
+ for (const z of ZONES) {
82
+ const col = z.safe ? 0x3dff8a : 0xff4455, c3 = new THREE.Color(col).multiplyScalar(2);
83
+ const ring = new THREE.Mesh(new THREE.RingGeometry(PAD_R - 1.4, PAD_R, 64).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: c3, toneMapped: false }));
84
+ ring.position.set(z.x, .05, z.z); scene.add(ring);
85
+ const h = new THREE.Mesh(new THREE.PlaneGeometry(PAD_R, PAD_R).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({
86
+ map: canvasTex(128, 128, (g) => { g.fillStyle = "#fff"; g.font = "900 96px sans-serif"; g.textAlign = "center"; g.fillText("H", 64, 100); }), transparent: true, opacity: .55, color: col,
87
+ }));
88
+ h.position.set(z.x, .06, z.z); scene.add(h);
89
+ const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.8, .8, 260, 8, 1, true).translate(0, 130, 0), new THREE.MeshBasicMaterial({ color: c3, transparent: true, opacity: .18, blending: THREE.AdditiveBlending, depthWrite: false, fog: false, toneMapped: false }));
90
+ beacon.position.set(z.x, 0, z.z); scene.add(beacon);
91
+ }
92
+
93
+ // the track points, with a moving frame (forward, side, up) at every one
94
+ const P = [];
95
+ for (let x = -60; x < 16; x += .25) P.push(new THREE.Vector3(x, 0, 0));
96
+ const loopIn = P.length, LOOPN = 420;
97
+ for (let i = 0; i <= LOOPN; i++) { const t = i / LOOPN, th = -Math.PI / 2 + t * Math.PI * 2; P.push(new THREE.Vector3(16 + R * Math.cos(th), R + R * Math.sin(th), LZ * smooth(0, 1, t))); }
98
+ const loopOut = P.length - 1;
99
+ for (let x = 16.25; x <= RAMP_X; x += .25) P.push(new THREE.Vector3(x, 0, LZ));
100
+ const rampIn = P.length - 1;
101
+ for (let x = RAMP_X + .25; x <= RAMP_X + 14.001; x += .25) { const u = (x - RAMP_X) / 14; P.push(new THREE.Vector3(x, 4.5 * u * u, LZ)); }
102
+ const roadEnd = P.length - 1;
103
+ const X1 = RAMP_X + 14; // the ramp lip; then up on the thrusters and out over the city, the corniche and downtown
104
+ const air = new THREE.CatmullRomCurve3([[X1, 4.5, LZ], [X1 + 5, 8.2, LZ], [X1 + 10, 13, LZ + .1], [X1 + 15, 20, LZ + .4], [X1 + 21, 30, LZ + 1], [X1 + 29, 43, LZ + 2], [X1 + 42, 62, 8], [X1 + 66, 90, 14],
105
+ [X1 + 110, 125, 26], [X1 + 180, 160, 48], [X1 + 300, 190, 90], [X1 + 480, 205, 150], [X1 + 720, 210, 210], [X1 + 1000, 208, 250], [X1 + 1300, 205, 255], [X1 + 1650, 200, 230], [X1 + 2050, 196, 190], [X1 + 2500, 192, 150]].map(a => new THREE.Vector3(...a)), false, "centripetal").getSpacedPoints(3200);
106
+ for (let i = 1; i < air.length; i++) P.push(air[i]);
107
+ const N = P.length, D = new Float32Array(N), F = [], S = [], U = [], Z = new THREE.Vector3(0, 0, 1);
108
+ for (let i = 1; i < N; i++) D[i] = D[i - 1] + P[i].distanceTo(P[i - 1]);
109
+ for (let i = 0; i < N; i++) {
110
+ const f = P[Math.min(N - 1, i + 1)].clone().sub(P[Math.max(0, i - 1)]).normalize();
111
+ const s = Z.clone().addScaledVector(f, -f.dot(Z)).normalize();
112
+ F.push(f); S.push(s); U.push(new THREE.Vector3().crossVectors(s, f));
113
+ }
114
+ const idxX = (x, from = 0) => { for (let i = from; i < N; i++) if (P[i].x >= x) return i; return N - 1; };
115
+ const sample = (d, out) => {
116
+ let lo = 0, hi = N - 1;
117
+ while (hi - lo > 1) { const m = (lo + hi) >> 1; if (D[m] <= d) lo = m; else hi = m; }
118
+ const t = clamp((d - D[lo]) / Math.max(1e-6, D[hi] - D[lo]));
119
+ out.p.lerpVectors(P[lo], P[hi], t); out.f.lerpVectors(F[lo], F[hi], t).normalize();
120
+ out.s.lerpVectors(S[lo], S[hi], t).normalize(); out.u.lerpVectors(U[lo], U[hi], t).normalize();
121
+ out.i = lo; return out;
122
+ };
123
+
124
+ function ribbon(i0, i1, a, b, mat) {
125
+ const pos = [], uv = [], idx = [];
126
+ for (let i = i0; i <= i1; i++) {
127
+ for (const [sOff, uOff, v] of [[a[0], a[1], 0], [b[0], b[1], 1]]) { const q = P[i].clone().addScaledVector(S[i], sOff).addScaledVector(U[i], uOff); pos.push(q.x, q.y, q.z); uv.push(D[i] / 8, v); }
128
+ if (i < i1) { const k = (i - i0) * 2; idx.push(k, k + 1, k + 2, k + 1, k + 3, k + 2); }
129
+ }
130
+ const g = new THREE.BufferGeometry();
131
+ g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
132
+ g.setIndex(idx); g.computeVertexNormals();
133
+ const m = new THREE.Mesh(g, mat); scene.add(m); return m;
134
+ }
135
+ const roadTex = canvasTex(256, 64, (g, w, h) => {
136
+ g.fillStyle = "#0e0e12"; g.fillRect(0, 0, w, h);
137
+ for (let i = 0; i < 900; i++) { g.fillStyle = `rgba(255,255,255,${Math.random() * .05})`; g.fillRect(Math.random() * w, Math.random() * h, 1, 1); }
138
+ g.fillStyle = "rgba(255,255,255,.3)"; g.fillRect(0, 3, w, 2); g.fillRect(0, h - 5, w, 2); g.fillRect(0, h / 2 - 1, w / 2, 2);
139
+ });
140
+ roadTex.wrapS = THREE.RepeatWrapping;
141
+ const roadMat = new THREE.MeshStandardMaterial({ map: roadTex, color: 0xb8bcc6, roughness: .85, metalness: 0, envMapIntensity: .25, side: THREE.DoubleSide });
142
+ const sideMat = new THREE.MeshStandardMaterial({ color: 0x111115, roughness: .5, metalness: .6, side: THREE.DoubleSide });
143
+ const edgeMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(1.05), toneMapped: false, side: THREE.DoubleSide });
144
+ ribbon(0, roadEnd, [-HW, 0], [HW, 0], roadMat);
145
+ ribbon(loopIn - 4, roadEnd, [-HW, -.4], [HW, -.4], sideMat);
146
+ for (const s of [-1, 1]) {
147
+ ribbon(loopIn - 4, roadEnd, [s * HW, 0], [s * HW, -.4], sideMat);
148
+ ribbon(0, roadEnd, [s * HW, .06], [s * HW, .02], edgeMat);
149
+ ribbon(loopIn, loopOut, [s * HW, .1], [s * HW, .75], new THREE.MeshPhysicalMaterial({ color: 0x9fb0c8, transparent: true, opacity: .14, roughness: .05, metalness: .2, side: THREE.DoubleSide, depthWrite: false }));
150
+ }
151
+ {
152
+ const pm = new THREE.MeshStandardMaterial({ color: 0x141418, metalness: .7, roughness: .4 });
153
+ for (let x = RAMP_X + 2; x <= RAMP_X + 13; x += 2.75) { const y = 4.5 * ((x - RAMP_X) / 14) ** 2 - .4; for (const s of [-1, 1]) { const c = new THREE.Mesh(new THREE.CylinderGeometry(.14, .18, y, 12).translate(0, y / 2, 0), pm); c.position.set(x, 0, LZ + s * 1.8); scene.add(c); } }
154
+ for (const dx of [-4.5, 4.5]) { const foot = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.6, LZ + HW * 2 + .6).translate(0, .8, 0), pm); foot.position.set(16 + dx, -.4, LZ / 2); scene.add(foot); }
155
+ }
156
+
157
+ // effects
158
+ const shadow = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 3.4), new THREE.MeshBasicMaterial({ map: radial("rgba(0,0,0,.92)", "rgba(0,0,0,0)"), transparent: true, depthWrite: false }));
159
+ shadow.rotation.x = -Math.PI / 2; shadow.position.y = .01; scene.add(shadow);
160
+ const glow = new THREE.Mesh(new THREE.PlaneGeometry(6.2, 3.4), new THREE.MeshBasicMaterial({ map: radial("rgba(255,255,255,1)", "rgba(255,255,255,0)"), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0, toneMapped: false }));
161
+ glow.rotation.x = -Math.PI / 2; glow.position.y = .02; scene.add(glow);
162
+ const LINES = 110, lines = new THREE.InstancedMesh(new THREE.BoxGeometry(1, .012, .012), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }), LINES);
163
+ const lineSeed = Array.from({ length: LINES }, () => [Math.random() * 80 - 40, Math.random() * 7 - 1.5, (Math.random() < .5 ? -1 : 1) * (2.2 + Math.random() * 9)]);
164
+ lines.frustumCulled = false; scene.add(lines);
165
+ const smokeTex = radial("rgba(200,210,225,.55)", "rgba(200,210,225,0)"), puffs = [];
166
+ for (let i = 0; i < 90; i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false, opacity: 0 })); s.visible = false; scene.add(s); puffs.push({ s, life: 0, v: new THREE.Vector3() }); }
167
+
168
+ return {
169
+ renderer, scene, camera, composer, bloom, camLight, canvasTex, radial, buildings,
170
+ roads: city.roads, footprints: city.footprints, parks: city.parks, water: city.water, beaches: city.beaches, walkways: city.walkways, bounds: city.bounds, credit: city.credit,
171
+ track: { P, F, S, U, D, N, loopIn, loopOut, rampIn, roadEnd, idxX, sample },
172
+ fx: { shadow, glow, lines, LINES, lineSeed, puffs, puffI: 0 },
173
+ };
174
+ }
175
+
176
+ // a reusable moving frame, and "car-local to world" along it
177
+ export const frame = () => ({ p: new THREE.Vector3(), f: new THREE.Vector3(), s: new THREE.Vector3(), u: new THREE.Vector3(), i: 0 });
178
+ export const loc = (fr, x, y, z) => fr.p.clone().addScaledVector(fr.f, x).addScaledVector(fr.u, y).addScaledVector(fr.s, z);
179
+ export const local = (car, v) => v.clone().applyMatrix4(car.group.matrixWorld);
180
+
181
+ // where each zone is relative to a position and heading (km, forward/side), nearest first
182
+ export function zonesFrom(pos, fwd) {
183
+ const fh = new THREE.Vector2(fwd.x, fwd.z); if (fh.lengthSq() < 1e-6) fh.set(1, 0); fh.normalize();
184
+ const sh = new THREE.Vector2(-fh.y, fh.x);
185
+ return ZONES.map(z => { const rel = new THREE.Vector2(z.x - pos.x, z.z - pos.z); return { ...z, dist: rel.length() / 1000, fwd: rel.dot(fh) / 1000, side: rel.dot(sh) / 1000 }; }).sort((a, b) => a.dist - b.dist);
186
+ }