Death242 commited on
Commit
b5684be
·
verified ·
1 Parent(s): 0e305b7

Static site

Browse files
Files changed (6) hide show
  1. index.html +2 -2
  2. static/extras.js +174 -174
  3. static/interior.js +145 -145
  4. static/main.js +0 -0
  5. static/style.css +1 -1
  6. static/vehicles.js +200 -200
index.html CHANGED
@@ -8,7 +8,7 @@
8
  <meta name="theme-color" content="#10131b">
9
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
11
- <link rel="stylesheet" href="/static/style.css?v=1793114272">
12
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
13
  <script type="importmap">
14
  { "imports": {
@@ -215,6 +215,6 @@
215
  <div id="toast" role="status"></div>
216
  <noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
217
  <script src="/static/hub.js" data-app="void" defer></script>
218
- <script type="module" src="/static/main.js?v=1793114272"></script>
219
  </body>
220
  </html>
 
8
  <meta name="theme-color" content="#10131b">
9
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/style.css?v=1793115242">
12
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
13
  <script type="importmap">
14
  { "imports": {
 
215
  <div id="toast" role="status"></div>
216
  <noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
217
  <script src="/static/hub.js" data-app="void" defer></script>
218
+ <script type="module" src="/static/main.js?v=1793115242"></script>
219
  </body>
220
  </html>
static/extras.js CHANGED
@@ -1,174 +1,174 @@
1
- // Taxi jobs and hidden coins.
2
- // Taxi: press J (or Menu → Taxi job). Passengers wave from the kerb near you; stop next to one and they hop in,
3
- // the GPS shows where they're going, and the fare drops the longer you take. Back-to-back fares build a streak.
4
- // Coins: 40 spinning VOID coins hidden on rooftops, piers, the bridge towers and round the park; found ones stay found.
5
- import * as THREE from "three";
6
- import { GRID } from "./city.js";
7
-
8
- 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 { } } };
9
- const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
10
- const ord = n => n + (n % 100 > 10 && n % 100 < 14 ? "th" : ["th", "st", "nd", "rd"][n % 10] || "th");
11
-
12
- export function createExtras(W, api, nav, sound) {
13
- const { scene } = W, $ = id => document.getElementById(id);
14
- let sd = 77; const rnd = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
15
-
16
- // ---- passengers: a little figure with a yellow beacon ----
17
- const personMat = new THREE.MeshStandardMaterial({ color: 0x3060c0, roughness: .7 }), skin = new THREE.MeshStandardMaterial({ color: 0xd9a07a, roughness: .7 });
18
- const beaconMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd23d).multiplyScalar(1.8), transparent: true, opacity: .35, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
19
- const fig = () => { const g = new THREE.Group(); const b = new THREE.Mesh(new THREE.CylinderGeometry(.25, .3, 1.2, 10).translate(0, .9, 0), personMat); const h = new THREE.Mesh(new THREE.SphereGeometry(.17, 12, 8).translate(0, 1.7, 0), skin); const arm = new THREE.Mesh(new THREE.BoxGeometry(.1, .6, .1).translate(0, .3, 0), personMat); arm.position.set(0, 1.35, .3); arm.rotation.x = -2.6; g.add(b, h, arm); const beam = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.4, 30, 16, 1, true).translate(0, 15, 0), beaconMat); g.add(beam); g.userData.arm = arm; scene.add(g); return g; };
20
- const waiting = [fig(), fig(), fig()], dropMark = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 40, 24, 1, true).translate(0, 20, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dff8a).multiplyScalar(1.6), transparent: true, opacity: .22, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }));
21
- scene.add(dropMark); dropMark.visible = false; waiting.forEach(w => w.visible = false);
22
- const kerb = (x0, z0, rMin, rMax) => { // a sidewalk spot rMin..rMax metres away
23
- for (let t = 0; t < 40; t++) {
24
- const a = rnd() * 6.28, r = rMin + rnd() * (rMax - rMin); let x = x0 + Math.cos(a) * r, z = z0 + Math.sin(a) * r;
25
- const i = Math.round((x - GRID.X0) / GRID.AV), j = Math.round((z - GRID.Z0) / GRID.ST);
26
- if (i < 0 || i >= GRID.NX || j < 0 || j >= GRID.NZ) continue;
27
- if (rnd() < .5) x = GRID.ax(i) + (GRID.avW(i) / 2 + 2) * (rnd() < .5 ? 1 : -1); else z = GRID.sz(j) + (GRID.stW(j) / 2 + 2) * (rnd() < .5 ? 1 : -1);
28
- if (api.hitBox(x, z, .5, 1.2) || api.isWater(x, z)) continue;
29
- return [x, z];
30
- }
31
- return null;
32
- };
33
- const J = { on: false, fare: null, streak: 0, t: 0, earned: 0 };
34
- function placeWaiting(p) { for (const w of waiting) { const s = kerb(p.x, p.z, 60, 260); if (s) { w.position.set(s[0], 0, s[1]); w.visible = true; } } }
35
- function toggle(on = !J.on) {
36
- const p = api.state; if (!p) return;
37
- J.on = on; J.fare = null; dropMark.visible = false;
38
- if (on) { placeWaiting(p); api.say("🚕 Taxi: pick up a passenger (yellow beams). J to stop", 4); }
39
- else { waiting.forEach(w => w.visible = false); if (nav.dest && nav.dest.taxi) nav.setDest(null); api.say(J.earned ? `🚕 Shift over: $${J.earned} earned` : "🚕 Taxi off", 3); J.earned = 0; J.streak = 0; }
40
- $("gJob").hidden = !on;
41
- }
42
- addEventListener("keydown", e => { if (e.code === "KeyJ" && api.active() && !e.repeat) toggle(); });
43
-
44
- // ---- coins ----
45
- const COINS = [], got = new Set(store.get("void.coins") || []);
46
- {
47
- const spots = [];
48
- for (const h of W.city.helipads.slice(0, 8)) spots.push([h.x + 4, h.y + 1.5, h.z]);
49
- const roofs = W.buildings.filter(b => b.h > 12 && b.h < 60 && b.hw > 4).sort(() => rnd() - .5).slice(0, 14);
50
- for (const b of roofs) spots.push([b.x, b.h + 1.5, b.z]);
51
- for (const [x, y, z] of [[1210, 112, 15], [1570, 112, -15], [-1080, 1.5, -700 + 140 * 2], [-1080, 1.5, -700 + 140 * 7], [W.city.lake.x, 1.5, W.city.lake.z + W.city.lake.rz + 8], [W.city.lawn.x, 1.5, W.city.lawn.z], [-2600, 1.5, 0], [1900, 1.5, 0], [134, 6, 4.2], [16, 18.5, 2]]) spots.push([x, y, z]);
52
- while (spots.length < 40) { const s = kerb(rnd() * 1800 - 900, rnd() * 1300 - 650, 0, 40); if (s) spots.push([s[0], 1.5, s[1]]); }
53
- const gold = new THREE.MeshStandardMaterial({ color: 0xffc23d, metalness: 1, roughness: .2, emissive: 0x6a4a00, emissiveIntensity: .6 });
54
- const geo = new THREE.CylinderGeometry(.8, .8, .14, 28).rotateX(Math.PI / 2);
55
- const vTex = (() => { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#ffc23d"; g.fillRect(0, 0, 64, 64); g.fillStyle = "#7a5200"; g.font = "900 44px sans-serif"; g.textAlign = "center"; g.fillText("V", 32, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
56
- const face = new THREE.MeshStandardMaterial({ map: vTex, metalness: .8, roughness: .3, emissive: 0x6a4a00, emissiveIntensity: .5 });
57
- spots.slice(0, 40).forEach(([x, y, z], k) => {
58
- const m = new THREE.Mesh(geo, [gold, face, face]); m.position.set(x, y, z); scene.add(m);
59
- const glow = new THREE.Mesh(new THREE.SphereGeometry(1.6, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffc23d).multiplyScalar(1.4), transparent: true, opacity: .12, depthWrite: false, toneMapped: false })); m.add(glow);
60
- COINS.push({ id: k, m, x, y, z }); if (got.has(k)) m.visible = false;
61
- });
62
- }
63
-
64
- // ---- boost pads: glowing chevrons on the avenues that kick you forward ----
65
- const PADS = [];
66
- {
67
- const tex = (() => { const c = document.createElement("canvas"); c.width = 64; c.height = 128; const g = c.getContext("2d"); g.clearRect(0, 0, 64, 128); g.strokeStyle = "#3df5ff"; g.lineWidth = 9; g.lineCap = "round"; for (const y of [30, 70, 110]) { g.beginPath(); g.moveTo(10, y); g.lineTo(32, y - 22); g.lineTo(54, y); g.stroke(); } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
68
- const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, color: new THREE.Color(1.8, 1.8, 1.8), polygonOffset: true, polygonOffsetFactor: -4 });
69
- for (let k = 0; k < 14; k++) {
70
- const axis = rnd() < .5, i = 1 + Math.floor(rnd() * (GRID.NX - 2)), j = 1 + Math.floor(rnd() * (GRID.NZ - 2));
71
- const x = axis ? GRID.ax(i) + 4.5 : GRID.ax(i) + 60 + rnd() * 40, z = axis ? GRID.sz(j) + 25 + rnd() * 20 : GRID.sz(j) + 3, yaw = axis ? (rnd() < .5 ? Math.PI / 2 : -Math.PI / 2) : (rnd() < .5 ? 0 : Math.PI);
72
- if (api.isWater(x, z) || api.hitBox(x, z, .5, 2)) continue;
73
- const m = new THREE.Mesh(new THREE.PlaneGeometry(4, 8).rotateX(-Math.PI / 2), mat); m.position.set(x, .07, z); m.rotation.y = -yaw + Math.PI / 2; scene.add(m);
74
- PADS.push({ x, z, yaw, t: 0 });
75
- }
76
- }
77
- // ---- speed cameras: drive past and your speed is recorded; beat your best for points ----
78
- const TRAPS = [], best = store.get("void.traps") || {};
79
- {
80
- const flashM = new THREE.MeshBasicMaterial({ color: new THREE.Color(1, 1, 1).multiplyScalar(4), toneMapped: false, transparent: true, opacity: 0 }), pole = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .5, roughness: .4 }), boxM = new THREE.MeshStandardMaterial({ color: 0xf7c414, roughness: .5 });
81
- const spots = [[GRID.ax(9), GRID.sz(3), "Midtown"], [GRID.ax(3), GRID.sz(15), "Park Ave"], [GRID.ax(11), GRID.sz(17), "Downtown"], [600, 0, "Broadway"], [GRID.ax(1), GRID.sz(6), "West Side"], [1400, 18, "Bridge"]];
82
- spots.forEach(([x, z, name]) => {
83
- const g = new THREE.Group(); g.position.set(x + 11, 0, z + 9); scene.add(g);
84
- g.add(new THREE.Mesh(new THREE.CylinderGeometry(.12, .15, 5, 8).translate(0, 2.5, 0), pole)); const b = new THREE.Mesh(new THREE.BoxGeometry(.8, .5, .5), boxM); b.position.y = 5; g.add(b);
85
- const f = new THREE.Mesh(new THREE.SphereGeometry(.18, 8, 6), flashM.clone()); f.position.set(-.45, 5, 0); g.add(f);
86
- TRAPS.push({ x, z, name, f, cool: 0 });
87
- });
88
- }
89
- // ---- sky traffic: an airliner crossing high up, a slow blimp with a glowing VOID sign ----
90
- const plane = new THREE.Group(), blimp = new THREE.Group();
91
- {
92
- const w = new THREE.MeshStandardMaterial({ color: 0xe8e8ea, roughness: .4, metalness: .3 });
93
- const body = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.4, 34, 14).rotateZ(Math.PI / 2), w); plane.add(body);
94
- const wing = new THREE.Mesh(new THREE.BoxGeometry(6, .3, 36), w); wing.position.x = 1; plane.add(wing);
95
- const tail = new THREE.Mesh(new THREE.BoxGeometry(4, 6, .3), w); tail.position.set(-15, 3, 0); plane.add(tail);
96
- for (const [z, c] of [[18, 0x22ff66], [-18, 0xff2233]]) { const l = new THREE.Mesh(new THREE.SphereGeometry(.4, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(3), toneMapped: false })); l.position.set(1, 0, z); plane.add(l); }
97
- scene.add(plane);
98
- const env = new THREE.Mesh(new THREE.SphereGeometry(8, 24, 16), new THREE.MeshStandardMaterial({ color: 0x1a1a20, roughness: .5, metalness: .2 })); env.scale.set(3.4, 1, 1); blimp.add(env);
99
- const signT = (() => { const c = document.createElement("canvas"); c.width = 512; c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#000"; g.fillRect(0, 0, 512, 128); g.fillStyle = "#ff2a3a"; g.shadowColor = "#ff2a3a"; g.shadowBlur = 20; g.font = "900 86px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("VOID", 256, 100); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
100
- for (const s of [-1, 1]) { const p = new THREE.Mesh(new THREE.PlaneGeometry(30, 7.5), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6) })); p.position.z = s * 8.1; p.rotation.y = s > 0 ? 0 : Math.PI; blimp.add(p); }
101
- const gond = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 3), new THREE.MeshStandardMaterial({ color: 0x3a3b40 })); gond.position.y = -8.5; blimp.add(gond);
102
- scene.add(blimp);
103
- }
104
- let skyT = 0;
105
-
106
- return {
107
- toggle, pads: PADS, get job() { return J; }, coins: () => ({ got: got.size, total: COINS.length }),
108
- sky(dt, time) {
109
- skyT += dt;
110
- const a = (skyT * .012) % (Math.PI * 2); plane.position.set(Math.cos(a) * 2600, 620, Math.sin(a) * 1600); plane.rotation.y = -Math.atan2(Math.cos(a) * 1600, -Math.sin(a) * 2600);
111
- const b = (skyT * .004) % (Math.PI * 2); blimp.position.set(-200 + Math.cos(b) * 700, 230, -300 + Math.sin(b) * 500); blimp.rotation.y = -Math.atan2(Math.cos(b) * 500, -Math.sin(b) * 700);
112
- },
113
- boosts(dt) {
114
- const p = api.state; if (!p || p.mode !== "drive" || p.y > 1) return;
115
- for (const b of PADS) {
116
- if (b.t > 0) { b.t -= dt; continue; }
117
- if (Math.abs(p.x - b.x) < 2.6 && Math.abs(p.z - b.z) < 4.6) {
118
- b.t = 1.5; const sp = Math.hypot(p.vel.x, p.vel.z) + 18; p.vel.set(Math.cos(p.yaw) * sp, 0, Math.sin(p.yaw) * sp); api.say("⚡ BOOST", 1.2); sound.whoosh && sound.whoosh();
119
- }
120
- }
121
- for (const t of TRAPS) {
122
- t.f.material.opacity = Math.max(0, t.f.material.opacity - dt * 3);
123
- if (t.cool > 0) { t.cool -= dt; continue; }
124
- if (Math.hypot(p.x - t.x, p.z - t.z) < 14) {
125
- const kmh = Math.round(Math.hypot(p.vel.x, p.vel.z) * 3.6); if (kmh < 60) continue;
126
- t.cool = 4; t.f.material.opacity = 1;
127
- if (kmh > (best[t.name] || 0)) { best[t.name] = kmh; store.set("void.traps", best); api.addScore(Math.round(kmh * 2), `📸 ${t.name} speed camera: ${kmh} km/h (new best)`); }
128
- else api.say(`📸 ${t.name}: ${kmh} km/h (best ${best[t.name]})`, 2.5);
129
- }
130
- }
131
- },
132
- update(dt, time) {
133
- const p = api.state; if (!p || !p.started) return;
134
- this.boosts(dt);
135
- // coins
136
- for (const c of COINS) {
137
- if (!c.m.visible) continue;
138
- c.m.rotation.y += dt * 2.4; c.m.position.y = c.y + Math.sin(time * 2 + c.id) * .2;
139
- if (Math.abs(p.x - c.x) < 3 && Math.abs(p.z - c.z) < 3 && Math.abs(p.y + .6 - c.y) < 3.5) {
140
- c.m.visible = false; got.add(c.id); store.set("void.coins", [...got]); api.addScore(250, `🪙 Coin ${got.size}/${COINS.length}`); sound.win();
141
- }
142
- }
143
- $("gCoins").textContent = `🪙 ${got.size}/${COINS.length}`;
144
- if (!J.on) return;
145
- const sp = Math.hypot(p.vel.x, p.vel.z);
146
- // waving passengers; the nearest one gets in when you stop beside it
147
- for (const w of waiting) {
148
- if (!w.visible) continue;
149
- w.userData.arm.rotation.z = Math.sin(time * 6) * .4;
150
- if (!J.fare && Math.hypot(p.x - w.position.x, p.z - w.position.z) < 9 && sp < 4 && p.mode === "drive") {
151
- const d = kerb(w.position.x, w.position.z, 350, 1100); if (!d) continue;
152
- const dist = Math.hypot(d[0] - w.position.x, d[1] - w.position.z);
153
- const j = Math.round((d[1] - GRID.Z0) / GRID.ST), street = j === 10 ? "Broadway" : `${ord(Math.abs(j - 10) * 7 + 14)} St`;
154
- J.fare = { x: d[0], z: d[1], t: dist / 11 + 25, max: 300 + Math.round(dist * .6), street };
155
- waiting.forEach(o => o.visible = false); dropMark.position.set(d[0], 0, d[1]); dropMark.visible = true;
156
- nav.setDest({ name: `🚕 ${street}`, x: d[0], z: d[1], taxi: true });
157
- api.say(`🚕 "Take me to ${street}, quick!"`, 3); sound.pop();
158
- }
159
- }
160
- if (J.fare) {
161
- const f = J.fare; f.t -= dt;
162
- const pay = Math.max(50, Math.round(f.max * Math.min(1, .35 + f.t / 40)));
163
- $("gJob").textContent = `🚕 To ${f.street} · ${fmt(Math.max(0, f.t))} · $${pay}${J.streak > 1 ? ` · streak ×${J.streak}` : ""}`;
164
- if (Math.hypot(p.x - f.x, p.z - f.z) < 12 && sp < 4) {
165
- J.streak = f.t > 0 ? J.streak + 1 : 0; const total = pay + (J.streak > 1 ? J.streak * 100 : 0); J.earned += total;
166
- api.addScore(total, `🚕 Fare $${total}${J.streak > 1 ? ` (streak ×${J.streak})` : ""}`); J.fare = null; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p);
167
- } else if (f.t < -20) { api.say("🚕 The passenger gave up and got out", 3); J.fare = null; J.streak = 0; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p); }
168
- } else {
169
- $("gJob").textContent = `🚕 Find a passenger (yellow beams)${J.earned ? ` · $${J.earned} so far` : ""}`;
170
- if (waiting.every(w => !w.visible || Math.hypot(p.x - w.position.x, p.z - w.position.z) > 600)) placeWaiting(p);
171
- }
172
- },
173
- };
174
- }
 
1
+ // Taxi jobs and hidden coins.
2
+ // Taxi: press J (or Menu → Taxi job). Passengers wave from the kerb near you; stop next to one and they hop in,
3
+ // the GPS shows where they're going, and the fare drops the longer you take. Back-to-back fares build a streak.
4
+ // Coins: 40 spinning VOID coins hidden on rooftops, piers, the bridge towers and round the park; found ones stay found.
5
+ import * as THREE from "three";
6
+ import { GRID } from "./city.js";
7
+
8
+ 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 { } } };
9
+ const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
10
+ const ord = n => n + (n % 100 > 10 && n % 100 < 14 ? "th" : ["th", "st", "nd", "rd"][n % 10] || "th");
11
+
12
+ export function createExtras(W, api, nav, sound) {
13
+ const { scene } = W, $ = id => document.getElementById(id);
14
+ let sd = 77; const rnd = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
15
+
16
+ // ---- passengers: a little figure with a yellow beacon ----
17
+ const personMat = new THREE.MeshStandardMaterial({ color: 0x3060c0, roughness: .7 }), skin = new THREE.MeshStandardMaterial({ color: 0xd9a07a, roughness: .7 });
18
+ const beaconMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd23d).multiplyScalar(1.8), transparent: true, opacity: .35, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
19
+ const fig = () => { const g = new THREE.Group(); const b = new THREE.Mesh(new THREE.CylinderGeometry(.25, .3, 1.2, 10).translate(0, .9, 0), personMat); const h = new THREE.Mesh(new THREE.SphereGeometry(.17, 12, 8).translate(0, 1.7, 0), skin); const arm = new THREE.Mesh(new THREE.BoxGeometry(.1, .6, .1).translate(0, .3, 0), personMat); arm.position.set(0, 1.35, .3); arm.rotation.x = -2.6; g.add(b, h, arm); const beam = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.4, 30, 16, 1, true).translate(0, 15, 0), beaconMat); g.add(beam); g.userData.arm = arm; scene.add(g); return g; };
20
+ const waiting = [fig(), fig(), fig()], dropMark = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 40, 24, 1, true).translate(0, 20, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dff8a).multiplyScalar(1.6), transparent: true, opacity: .22, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }));
21
+ scene.add(dropMark); dropMark.visible = false; waiting.forEach(w => w.visible = false);
22
+ const kerb = (x0, z0, rMin, rMax) => { // a sidewalk spot rMin..rMax metres away
23
+ for (let t = 0; t < 40; t++) {
24
+ const a = rnd() * 6.28, r = rMin + rnd() * (rMax - rMin); let x = x0 + Math.cos(a) * r, z = z0 + Math.sin(a) * r;
25
+ const i = Math.round((x - GRID.X0) / GRID.AV), j = Math.round((z - GRID.Z0) / GRID.ST);
26
+ if (i < 0 || i >= GRID.NX || j < 0 || j >= GRID.NZ) continue;
27
+ if (rnd() < .5) x = GRID.ax(i) + (GRID.avW(i) / 2 + 2) * (rnd() < .5 ? 1 : -1); else z = GRID.sz(j) + (GRID.stW(j) / 2 + 2) * (rnd() < .5 ? 1 : -1);
28
+ if (api.hitBox(x, z, .5, 1.2) || api.isWater(x, z)) continue;
29
+ return [x, z];
30
+ }
31
+ return null;
32
+ };
33
+ const J = { on: false, fare: null, streak: 0, t: 0, earned: 0 };
34
+ function placeWaiting(p) { for (const w of waiting) { const s = kerb(p.x, p.z, 60, 260); if (s) { w.position.set(s[0], 0, s[1]); w.visible = true; } } }
35
+ function toggle(on = !J.on) {
36
+ const p = api.state; if (!p) return;
37
+ J.on = on; J.fare = null; dropMark.visible = false;
38
+ if (on) { placeWaiting(p); api.say("🚕 Taxi: pick up a passenger (yellow beams). J to stop", 4); }
39
+ else { waiting.forEach(w => w.visible = false); if (nav.dest && nav.dest.taxi) nav.setDest(null); api.say(J.earned ? `🚕 Shift over: $${J.earned} earned` : "🚕 Taxi off", 3); J.earned = 0; J.streak = 0; }
40
+ $("gJob").hidden = !on;
41
+ }
42
+ addEventListener("keydown", e => { if (e.code === "KeyJ" && api.active() && !e.repeat) toggle(); });
43
+
44
+ // ---- coins ----
45
+ const COINS = [], got = new Set(store.get("void.coins") || []);
46
+ {
47
+ const spots = [];
48
+ for (const h of W.city.helipads.slice(0, 8)) spots.push([h.x + 4, h.y + 1.5, h.z]);
49
+ const roofs = W.buildings.filter(b => b.h > 12 && b.h < 60 && b.hw > 4).sort(() => rnd() - .5).slice(0, 14);
50
+ for (const b of roofs) spots.push([b.x, b.h + 1.5, b.z]);
51
+ for (const [x, y, z] of [[1210, 112, 15], [1570, 112, -15], [-1080, 1.5, -700 + 140 * 2], [-1080, 1.5, -700 + 140 * 7], [W.city.lake.x, 1.5, W.city.lake.z + W.city.lake.rz + 8], [W.city.lawn.x, 1.5, W.city.lawn.z], [-2600, 1.5, 0], [1900, 1.5, 0], [134, 6, 4.2], [16, 18.5, 2]]) spots.push([x, y, z]);
52
+ while (spots.length < 40) { const s = kerb(rnd() * 1800 - 900, rnd() * 1300 - 650, 0, 40); if (s) spots.push([s[0], 1.5, s[1]]); }
53
+ const gold = new THREE.MeshStandardMaterial({ color: 0xffc23d, metalness: 1, roughness: .2, emissive: 0x6a4a00, emissiveIntensity: .6 });
54
+ const geo = new THREE.CylinderGeometry(.8, .8, .14, 28).rotateX(Math.PI / 2);
55
+ const vTex = (() => { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#ffc23d"; g.fillRect(0, 0, 64, 64); g.fillStyle = "#7a5200"; g.font = "900 44px sans-serif"; g.textAlign = "center"; g.fillText("V", 32, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
56
+ const face = new THREE.MeshStandardMaterial({ map: vTex, metalness: .8, roughness: .3, emissive: 0x6a4a00, emissiveIntensity: .5 });
57
+ spots.slice(0, 40).forEach(([x, y, z], k) => {
58
+ const m = new THREE.Mesh(geo, [gold, face, face]); m.position.set(x, y, z); scene.add(m);
59
+ const glow = new THREE.Mesh(new THREE.SphereGeometry(1.6, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffc23d).multiplyScalar(1.4), transparent: true, opacity: .12, depthWrite: false, toneMapped: false })); m.add(glow);
60
+ COINS.push({ id: k, m, x, y, z }); if (got.has(k)) m.visible = false;
61
+ });
62
+ }
63
+
64
+ // ---- boost pads: glowing chevrons on the avenues that kick you forward ----
65
+ const PADS = [];
66
+ {
67
+ const tex = (() => { const c = document.createElement("canvas"); c.width = 64; c.height = 128; const g = c.getContext("2d"); g.clearRect(0, 0, 64, 128); g.strokeStyle = "#3df5ff"; g.lineWidth = 9; g.lineCap = "round"; for (const y of [30, 70, 110]) { g.beginPath(); g.moveTo(10, y); g.lineTo(32, y - 22); g.lineTo(54, y); g.stroke(); } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
68
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, color: new THREE.Color(1.8, 1.8, 1.8), polygonOffset: true, polygonOffsetFactor: -4 });
69
+ for (let k = 0; k < 14; k++) {
70
+ const axis = rnd() < .5, i = 1 + Math.floor(rnd() * (GRID.NX - 2)), j = 1 + Math.floor(rnd() * (GRID.NZ - 2));
71
+ const x = axis ? GRID.ax(i) + 4.5 : GRID.ax(i) + 60 + rnd() * 40, z = axis ? GRID.sz(j) + 25 + rnd() * 20 : GRID.sz(j) + 3, yaw = axis ? (rnd() < .5 ? Math.PI / 2 : -Math.PI / 2) : (rnd() < .5 ? 0 : Math.PI);
72
+ if (api.isWater(x, z) || api.hitBox(x, z, .5, 2)) continue;
73
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(4, 8).rotateX(-Math.PI / 2), mat); m.position.set(x, .07, z); m.rotation.y = -yaw + Math.PI / 2; scene.add(m);
74
+ PADS.push({ x, z, yaw, t: 0 });
75
+ }
76
+ }
77
+ // ---- speed cameras: drive past and your speed is recorded; beat your best for points ----
78
+ const TRAPS = [], best = store.get("void.traps") || {};
79
+ {
80
+ const flashM = new THREE.MeshBasicMaterial({ color: new THREE.Color(1, 1, 1).multiplyScalar(4), toneMapped: false, transparent: true, opacity: 0 }), pole = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .5, roughness: .4 }), boxM = new THREE.MeshStandardMaterial({ color: 0xf7c414, roughness: .5 });
81
+ const spots = [[GRID.ax(9), GRID.sz(3), "Midtown"], [GRID.ax(3), GRID.sz(15), "Park Ave"], [GRID.ax(11), GRID.sz(17), "Downtown"], [600, 0, "Broadway"], [GRID.ax(1), GRID.sz(6), "West Side"], [1400, 18, "Bridge"]];
82
+ spots.forEach(([x, z, name]) => {
83
+ const g = new THREE.Group(); g.position.set(x + 11, 0, z + 9); scene.add(g);
84
+ g.add(new THREE.Mesh(new THREE.CylinderGeometry(.12, .15, 5, 8).translate(0, 2.5, 0), pole)); const b = new THREE.Mesh(new THREE.BoxGeometry(.8, .5, .5), boxM); b.position.y = 5; g.add(b);
85
+ const f = new THREE.Mesh(new THREE.SphereGeometry(.18, 8, 6), flashM.clone()); f.position.set(-.45, 5, 0); g.add(f);
86
+ TRAPS.push({ x, z, name, f, cool: 0 });
87
+ });
88
+ }
89
+ // ---- sky traffic: an airliner crossing high up, a slow blimp with a glowing VOID sign ----
90
+ const plane = new THREE.Group(), blimp = new THREE.Group();
91
+ {
92
+ const w = new THREE.MeshStandardMaterial({ color: 0xe8e8ea, roughness: .4, metalness: .3 });
93
+ const body = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.4, 34, 14).rotateZ(Math.PI / 2), w); plane.add(body);
94
+ const wing = new THREE.Mesh(new THREE.BoxGeometry(6, .3, 36), w); wing.position.x = 1; plane.add(wing);
95
+ const tail = new THREE.Mesh(new THREE.BoxGeometry(4, 6, .3), w); tail.position.set(-15, 3, 0); plane.add(tail);
96
+ for (const [z, c] of [[18, 0x22ff66], [-18, 0xff2233]]) { const l = new THREE.Mesh(new THREE.SphereGeometry(.4, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(3), toneMapped: false })); l.position.set(1, 0, z); plane.add(l); }
97
+ scene.add(plane);
98
+ const env = new THREE.Mesh(new THREE.SphereGeometry(8, 24, 16), new THREE.MeshStandardMaterial({ color: 0x1a1a20, roughness: .5, metalness: .2 })); env.scale.set(3.4, 1, 1); blimp.add(env);
99
+ const signT = (() => { const c = document.createElement("canvas"); c.width = 512; c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#000"; g.fillRect(0, 0, 512, 128); g.fillStyle = "#ff2a3a"; g.shadowColor = "#ff2a3a"; g.shadowBlur = 20; g.font = "900 86px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("VOID", 256, 100); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
100
+ for (const s of [-1, 1]) { const p = new THREE.Mesh(new THREE.PlaneGeometry(30, 7.5), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6) })); p.position.z = s * 8.1; p.rotation.y = s > 0 ? 0 : Math.PI; blimp.add(p); }
101
+ const gond = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 3), new THREE.MeshStandardMaterial({ color: 0x3a3b40 })); gond.position.y = -8.5; blimp.add(gond);
102
+ scene.add(blimp);
103
+ }
104
+ let skyT = 0;
105
+
106
+ return {
107
+ toggle, pads: PADS, get job() { return J; }, coins: () => ({ got: got.size, total: COINS.length }),
108
+ sky(dt, time) {
109
+ skyT += dt;
110
+ const a = (skyT * .012) % (Math.PI * 2); plane.position.set(Math.cos(a) * 2600, 620, Math.sin(a) * 1600); plane.rotation.y = -Math.atan2(Math.cos(a) * 1600, -Math.sin(a) * 2600);
111
+ const b = (skyT * .004) % (Math.PI * 2); blimp.position.set(-200 + Math.cos(b) * 700, 230, -300 + Math.sin(b) * 500); blimp.rotation.y = -Math.atan2(Math.cos(b) * 500, -Math.sin(b) * 700);
112
+ },
113
+ boosts(dt) {
114
+ const p = api.state; if (!p || p.mode !== "drive" || p.y > 1) return;
115
+ for (const b of PADS) {
116
+ if (b.t > 0) { b.t -= dt; continue; }
117
+ if (Math.abs(p.x - b.x) < 2.6 && Math.abs(p.z - b.z) < 4.6) {
118
+ b.t = 1.5; const sp = Math.hypot(p.vel.x, p.vel.z) + 18; p.vel.set(Math.cos(p.yaw) * sp, 0, Math.sin(p.yaw) * sp); api.say("⚡ BOOST", 1.2); sound.whoosh && sound.whoosh();
119
+ }
120
+ }
121
+ for (const t of TRAPS) {
122
+ t.f.material.opacity = Math.max(0, t.f.material.opacity - dt * 3);
123
+ if (t.cool > 0) { t.cool -= dt; continue; }
124
+ if (Math.hypot(p.x - t.x, p.z - t.z) < 14) {
125
+ const kmh = Math.round(Math.hypot(p.vel.x, p.vel.z) * 3.6); if (kmh < 60) continue;
126
+ t.cool = 4; t.f.material.opacity = 1;
127
+ if (kmh > (best[t.name] || 0)) { best[t.name] = kmh; store.set("void.traps", best); api.addScore(Math.round(kmh * 2), `📸 ${t.name} speed camera: ${kmh} km/h (new best)`); }
128
+ else api.say(`📸 ${t.name}: ${kmh} km/h (best ${best[t.name]})`, 2.5);
129
+ }
130
+ }
131
+ },
132
+ update(dt, time) {
133
+ const p = api.state; if (!p || !p.started) return;
134
+ this.boosts(dt);
135
+ // coins
136
+ for (const c of COINS) {
137
+ if (!c.m.visible) continue;
138
+ c.m.rotation.y += dt * 2.4; c.m.position.y = c.y + Math.sin(time * 2 + c.id) * .2;
139
+ if (Math.abs(p.x - c.x) < 3 && Math.abs(p.z - c.z) < 3 && Math.abs(p.y + .6 - c.y) < 3.5) {
140
+ c.m.visible = false; got.add(c.id); store.set("void.coins", [...got]); api.addScore(250, `🪙 Coin ${got.size}/${COINS.length}`); sound.win();
141
+ }
142
+ }
143
+ $("gCoins").textContent = `🪙 ${got.size}/${COINS.length}`;
144
+ if (!J.on) return;
145
+ const sp = Math.hypot(p.vel.x, p.vel.z);
146
+ // waving passengers; the nearest one gets in when you stop beside it
147
+ for (const w of waiting) {
148
+ if (!w.visible) continue;
149
+ w.userData.arm.rotation.z = Math.sin(time * 6) * .4;
150
+ if (!J.fare && Math.hypot(p.x - w.position.x, p.z - w.position.z) < 9 && sp < 4 && p.mode === "drive") {
151
+ const d = kerb(w.position.x, w.position.z, 350, 1100); if (!d) continue;
152
+ const dist = Math.hypot(d[0] - w.position.x, d[1] - w.position.z);
153
+ const j = Math.round((d[1] - GRID.Z0) / GRID.ST), street = j === 10 ? "Broadway" : `${ord(Math.abs(j - 10) * 7 + 14)} St`;
154
+ J.fare = { x: d[0], z: d[1], t: dist / 11 + 25, max: 300 + Math.round(dist * .6), street };
155
+ waiting.forEach(o => o.visible = false); dropMark.position.set(d[0], 0, d[1]); dropMark.visible = true;
156
+ nav.setDest({ name: `🚕 ${street}`, x: d[0], z: d[1], taxi: true });
157
+ api.say(`🚕 "Take me to ${street}, quick!"`, 3); sound.pop();
158
+ }
159
+ }
160
+ if (J.fare) {
161
+ const f = J.fare; f.t -= dt;
162
+ const pay = Math.max(50, Math.round(f.max * Math.min(1, .35 + f.t / 40)));
163
+ $("gJob").textContent = `🚕 To ${f.street} · ${fmt(Math.max(0, f.t))} · $${pay}${J.streak > 1 ? ` · streak ×${J.streak}` : ""}`;
164
+ if (Math.hypot(p.x - f.x, p.z - f.z) < 12 && sp < 4) {
165
+ J.streak = f.t > 0 ? J.streak + 1 : 0; const total = pay + (J.streak > 1 ? J.streak * 100 : 0); J.earned += total;
166
+ api.addScore(total, `🚕 Fare $${total}${J.streak > 1 ? ` (streak ×${J.streak})` : ""}`); J.fare = null; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p);
167
+ } else if (f.t < -20) { api.say("🚕 The passenger gave up and got out", 3); J.fare = null; J.streak = 0; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p); }
168
+ } else {
169
+ $("gJob").textContent = `🚕 Find a passenger (yellow beams)${J.earned ? ` · $${J.earned} so far` : ""}`;
170
+ if (waiting.every(w => !w.visible || Math.hypot(p.x - w.position.x, p.z - w.position.z) > 600)) placeWaiting(p);
171
+ }
172
+ },
173
+ };
174
+ }
static/interior.js CHANGED
@@ -1,145 +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
- }
 
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 CHANGED
The diff for this file is too large to render. See raw diff
 
static/style.css CHANGED
@@ -405,7 +405,7 @@ body[data-mode="play"] .hub-chip { display: none !important; }
405
  .up-row button:disabled { background: rgba(255,255,255,.1); color: var(--mute); cursor: not-allowed; }
406
  .up-row em { font: 800 11px var(--body); font-style: normal; color: #ffd23d; }
407
  #gCash { color: #9dffb0; }
408
- .up-note { margin: 10px 0 0; color: var(--mute); font-size: 11.5px; line-height: 1.4; }
409
 
410
  /* contracts, routes, the route builder, backups */
411
  .con-tiles { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
 
405
  .up-row button:disabled { background: rgba(255,255,255,.1); color: var(--mute); cursor: not-allowed; }
406
  .up-row em { font: 800 11px var(--body); font-style: normal; color: #ffd23d; }
407
  #gCash { color: #9dffb0; }
408
+ .up-note { margin: 10px 0 0; color: var(--mute); font-size: 11.5px; line-height: 1.4; }
409
 
410
  /* contracts, routes, the route builder, backups */
411
  .con-tiles { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
static/vehicles.js CHANGED
@@ -1,200 +1,200 @@
1
- // More things to drive: a propeller sport plane (takes off from the runway across the Hudson), a helicopter
2
- // (lifts off anywhere) and a speedboat (the rivers and the park lake). Each has its own model and physics;
3
- // the game hands them the same inputs as the car (steer, gas, brake, up, down, boost).
4
- import * as THREE from "three";
5
- import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
6
-
7
- const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)), lerp = (a, b, t) => a + (b - a) * t, G = 9.81;
8
- const wrap = a => { while (a > Math.PI) a -= 2 * Math.PI; while (a < -Math.PI) a += 2 * Math.PI; return a; };
9
- export const RUNWAY = { x0: -2620, x1: -1900, z: 0, w: 30 };
10
-
11
- function planeModel() {
12
- const g = new THREE.Group();
13
- const white = new THREE.MeshPhysicalMaterial({ color: 0xf2f2f2, metalness: .3, roughness: .3, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
14
- const dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .5 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, metalness: .2, roughness: .05, transparent: true, opacity: .7, clearcoat: 1 });
15
- const V2 = (x, y) => new THREE.Vector2(x, y);
16
- const body = new THREE.Mesh(new THREE.LatheGeometry([V2(0, -4.6), V2(.35, -4.3), V2(.55, -3), V2(.62, -1), V2(.64, 1), V2(.56, 2.6), V2(.42, 3.3), V2(.16, 3.7), V2(0, 3.75)], 24).rotateZ(-Math.PI / 2), white);
17
- g.add(body);
18
- const stripe = new THREE.Mesh(new THREE.CylinderGeometry(.645, .645, 6.2, 24, 1, true).rotateZ(Math.PI / 2), red); stripe.scale.set(1, 1, 1); stripe.position.x = -.4; stripe.scale.y = 1; g.add(stripe);
19
- const wingShape = new THREE.Shape([V2(1.2, 0), V2(-.6, 0), V2(-.3, 5.6), V2(.5, 5.6)]);
20
- for (const s of [-1, 1]) {
21
- const w = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape, { depth: .14, bevelEnabled: true, bevelSize: .05, bevelThickness: .04, bevelSegments: 2 }).rotateX(-Math.PI / 2), s > 0 ? white : white);
22
- w.position.set(0, -.15, 0); w.scale.z = -s; g.add(w);
23
- const tip = new THREE.Mesh(new THREE.BoxGeometry(.9, .08, .1), red); tip.position.set(.1, -.1, s * 5.6); g.add(tip);
24
- const nav = new THREE.Mesh(new THREE.SphereGeometry(.08, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false })); nav.position.set(.1, -.1, s * 5.68); g.add(nav);
25
- const ht = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.6, 0), V2(-.3, 0), V2(-.2, 2), V2(.3, 2)]), { depth: .08, bevelEnabled: false }).rotateX(-Math.PI / 2), white);
26
- ht.position.set(-4.2, .1, 0); ht.scale.z = -s; g.add(ht);
27
- }
28
- const fin = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.4, 0), V2(-.9, 0), V2(-.5, 1.7), V2(-.05, 1.7)]), { depth: .08, bevelEnabled: false }).translate(0, 0, -.04), red);
29
- fin.position.set(-3.7, .3, 0); g.add(fin);
30
- const canopy = new THREE.Mesh(new THREE.SphereGeometry(.6, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); canopy.scale.set(2.2, .9, .85); canopy.position.set(.6, .45, 0); g.add(canopy);
31
- const prop = new THREE.Group(); prop.position.x = 3.8; g.add(prop);
32
- prop.add(new THREE.Mesh(new THREE.ConeGeometry(.22, .5, 16).rotateZ(-Math.PI / 2), red));
33
- for (let k = 0; k < 3; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.06, 1.7, .16), dark); b.position.y = 0; b.rotation.x = k / 3 * Math.PI * 2; b.geometry.translate(0, .85, 0); prop.add(b); }
34
- const disc = new THREE.Mesh(new THREE.CircleGeometry(1.75, 32).rotateY(Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0, depthWrite: false })); prop.add(disc);
35
- for (const [x, z] of [[1.6, 0], [-.4, 1.3], [-.4, -1.3]]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); leg.position.set(x, -.85, z); g.add(leg); const wh = new THREE.Mesh(new THREE.CylinderGeometry(.24, .24, .16, 14).rotateX(Math.PI / 2), dark); wh.position.set(x, -1.3, z); g.add(wh); }
36
- g.traverse(o => { if (o.isMesh) o.castShadow = true; });
37
- g.position.y = 0; const root = new THREE.Group(); g.position.y = 1.54; root.add(g);
38
- return { root, update(st, time, dt) { prop.rotation.x += (8 + st.thr * 60) * dt; disc.material.opacity = st.thr * .25; } };
39
- }
40
- function heliModel() {
41
- const g = new THREE.Group();
42
- const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .4, roughness: .35, clearcoat: .8 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
43
- const dark = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .6, roughness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1420, roughness: .05, transparent: true, opacity: .65, clearcoat: 1 });
44
- const body = new THREE.Mesh(new THREE.SphereGeometry(1.5, 24, 16), paint); body.scale.set(1.7, 1, 1); body.position.y = 1.6; g.add(body);
45
- const nose = new THREE.Mesh(new THREE.SphereGeometry(1.2, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); nose.rotation.z = -Math.PI / 2; nose.scale.set(.9, 1.3, 1.05); nose.position.set(1.3, 1.8, 0); g.add(nose);
46
- const tail = new THREE.Mesh(new THREE.CylinderGeometry(.18, .38, 5.4, 12).rotateZ(Math.PI / 2), paint); tail.position.set(-4.4, 2.1, 0); g.add(tail);
47
- const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.2, .25, 3.05), red); stripe.position.set(-.3, 1.45, 0); g.add(stripe);
48
- const finV = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.4, .1), red); finV.position.set(-7, 2.6, 0); finV.rotation.z = -.3; g.add(finV);
49
- for (const z of [-1.15, 1.15]) { const sk = new THREE.Mesh(new THREE.CylinderGeometry(.07, .07, 3.8, 8).rotateZ(Math.PI / 2), dark); sk.position.set(0, .1, z); g.add(sk); for (const x of [-.8, .9]) { const st = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); st.position.set(x, .55, z * .85); st.rotation.x = z > 0 ? -.3 : .3; g.add(st); } }
50
- const mast = new THREE.Mesh(new THREE.CylinderGeometry(.12, .16, .5, 10), dark); mast.position.y = 3; g.add(mast);
51
- const rotor = new THREE.Group(); rotor.position.y = 3.25; g.add(rotor);
52
- for (let k = 0; k < 4; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(5.6, .05, .32).translate(2.8, 0, 0), dark); b.rotation.y = k * Math.PI / 2; rotor.add(b); }
53
- const blur = new THREE.Mesh(new THREE.CircleGeometry(5.6, 40).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide })); rotor.add(blur);
54
- const tr = new THREE.Group(); tr.position.set(-7.1, 2.3, .2); g.add(tr);
55
- for (let k = 0; k < 2; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.08, 1.4, .12), dark); b.rotation.z = k * Math.PI / 2; tr.add(b); }
56
- const beacon = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(3), toneMapped: false })); beacon.position.set(-1, 2.6, 0); g.add(beacon);
57
- g.traverse(o => { if (o.isMesh) o.castShadow = true; });
58
- const root = new THREE.Group(); root.add(g);
59
- return { root, update(st, time, dt) { const spin = 6 + st.thr * 34; rotor.rotation.y += spin * dt; tr.rotation.x += spin * 1.6 * dt; blur.material.opacity = clamp(st.thr) * .22; beacon.visible = Math.sin(time * 5) > 0; } };
60
- }
61
- function boatModel() {
62
- const g = new THREE.Group(), L = 7.2, W = 2.5;
63
- const hullM = new THREE.MeshPhysicalMaterial({ color: 0xf4f4f2, metalness: .1, roughness: .25, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, roughness: .3, clearcoat: 1 });
64
- const deckM = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: .7 }), dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, roughness: .05, transparent: true, opacity: .55 });
65
- // the hull: a side profile extruded across, then pinched to a point at the bow and given a V underneath
66
- const prof = new THREE.Shape([new THREE.Vector2(-L / 2, .25), new THREE.Vector2(L / 2 - 1.6, -.2), new THREE.Vector2(L / 2, .55), new THREE.Vector2(L / 2 - .2, 1.05), new THREE.Vector2(-L / 2, 1.05)]);
67
- const hg = new THREE.ExtrudeGeometry(prof, { depth: W, bevelEnabled: true, bevelSize: .12, bevelThickness: .12, bevelSegments: 3, curveSegments: 6, steps: 1 }).translate(0, 0, -W / 2);
68
- const ps = hg.attributes.position;
69
- for (let i = 0; i < ps.count; i++) { const x = ps.getX(i), y = ps.getY(i), z = ps.getZ(i), u = clamp((x + L / 2) / L); const pinch = u > .55 ? 1 - Math.pow((u - .55) / .45, 1.6) * .96 : 1; const v = y < .5 ? .6 + .4 * clamp((y + .2) / .7) : 1; ps.setZ(i, z * pinch * v); }
70
- hg.computeVertexNormals();
71
- const hull = new THREE.Mesh(hg, hullM); g.add(hull);
72
- const band = new THREE.Mesh(hg.clone(), red); band.scale.set(1.002, .12, 1.01); band.position.y = .72; g.add(band);
73
- const deck = new THREE.Mesh(new THREE.BoxGeometry(L * .62, .06, W * .82), deckM); deck.position.set(-.6, 1.08, 0); g.add(deck);
74
- const ws = new THREE.Mesh(new THREE.BoxGeometry(.06, .55, W * .8), glass); ws.position.set(.6, 1.38, 0); ws.rotation.z = -.5; g.add(ws);
75
- for (const z of [-.5, .5]) { const seat = new THREE.Mesh(new THREE.BoxGeometry(.7, .45, .6), red); seat.position.set(-.6, 1.3, z); g.add(seat); }
76
- const motor = new THREE.Mesh(new THREE.BoxGeometry(.6, .9, .55), dark); motor.position.set(-L / 2 - .2, .9, 0); g.add(motor);
77
- g.traverse(o => { if (o.isMesh) o.castShadow = true; });
78
- const root = new THREE.Group(); g.position.y = -.55; root.add(g);
79
- return { root, update() { } };
80
- }
81
-
82
- export function createVehicles(scene, api, W) {
83
- const models = { plane: planeModel(), heli: heliModel(), boat: boatModel() };
84
- for (const m of Object.values(models)) { m.root.visible = false; scene.add(m.root); }
85
- const WY = W.city.WATER_Y;
86
- // the runway: asphalt, edge lights and centre-line dashes
87
- {
88
- const r = RUNWAY, L = r.x1 - r.x0;
89
- const tex = W.canvasTex(512, 64, (g, w, h) => { g.fillStyle = "#2b2c30"; g.fillRect(0, 0, w, h); g.fillStyle = "#e8e8e8"; for (let x = 0; x < w; x += 64) g.fillRect(x, h / 2 - 2, 36, 4); g.fillRect(0, 3, w, 3); g.fillRect(0, h - 6, w, 3); }, [L / 120, 1]);
90
- const m = new THREE.Mesh(new THREE.PlaneGeometry(L, r.w).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));
91
- m.position.set((r.x0 + r.x1) / 2, .05, r.z); m.receiveShadow = true; scene.add(m);
92
- const lights = [], lm = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xfff0c0).multiplyScalar(2.2), toneMapped: false });
93
- for (let x = r.x0; x <= r.x1; x += 30) for (const s of [-1, 1]) lights.push([x, s * (r.w / 2 + 1)]);
94
- const im = new THREE.InstancedMesh(new THREE.SphereGeometry(.25, 8, 6), lm, lights.length), mm = new THREE.Matrix4();
95
- lights.forEach(([x, z], k) => im.setMatrixAt(k, mm.makeTranslation(x, .3, z))); scene.add(im);
96
- const sign = new THREE.Sprite(new THREE.SpriteMaterial({ map: W.canvasTex(512, 128, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.6)"; g.fillRect(0, 0, w, h); g.fillStyle = "#3dfcff"; g.font = "900 56px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("✈ VOID AIRFIELD", w / 2, 84); }), toneMapped: false }));
97
- sign.scale.set(24, 6, 1); sign.position.set(r.x0 + 40, 12, r.z + 40); scene.add(sign);
98
- }
99
-
100
- // ---- physics ----
101
- function plane(g, I, dt) {
102
- const V = g.vel.length(), ground = api.groundAt(g.x, g.z, g.y).h, onGround = g.y <= ground + .05;
103
- g.thr = clamp(g.thr + ((I.gas ? 1 : 0) - (I.brake && !onGround ? 1 : 0)) * dt * .6);
104
- if (onGround && I.brake) g.thr = Math.max(0, g.thr - dt);
105
- // smooth inputs: the plane answers like it has mass
106
- g.sRoll = lerp(g.sRoll || 0, I.turn, clamp(dt * 3)); g.sPitch = lerp(g.sPitch || 0, clamp(I.up - I.down + (I.stickY || 0), -1, 1), clamp(dt * 3));
107
- const vh = Math.hypot(g.vel.x, g.vel.z), gamma = Math.atan2(g.vel.y, Math.max(1, vh));
108
- const kL = .0115, INC = .05;
109
- if (onGround) { g.roll = lerp(g.roll, 0, clamp(dt * 5)); g.yaw += I.turn * clamp(vh / 12) * .55 * dt; g.pitch = lerp(g.pitch, g.sPitch > .2 && vh > 34 ? .14 : 0, clamp(dt * 3)); }
110
- else {
111
- g.roll = lerp(g.roll, g.sRoll * 1.05, clamp(dt * 2.4));
112
- g.yaw += G * Math.tan(g.roll) / Math.max(25, V) * dt;
113
- const wingK = kL * V * V * 2 * Math.PI * .9 * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll)));
114
- const needA = wingK > .5 ? clamp(G * Math.cos(gamma) / wingK - INC - g.vel.y * .006, -.25, .24) : 0;
115
- if (Math.abs(g.sPitch) > .05) g.pitch += g.sPitch * .55 * dt; else g.pitch += (gamma + needA / Math.max(.45, Math.cos(g.roll)) - g.pitch) * clamp(dt * 1.8);
116
- const cr = Math.max(.45, Math.cos(g.roll)); g.pitch = clamp(g.pitch, gamma - .3 / cr, gamma + .26 / cr);
117
- }
118
- 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));
119
- const s0 = new THREE.Vector3().crossVectors(f, new THREE.Vector3(0, 1, 0)).normalize(), u0 = new THREE.Vector3().crossVectors(s0, f), bu = u0.clone().applyAxisAngle(f, g.roll);
120
- const vhat = V > .1 ? g.vel.clone().divideScalar(V) : f.clone();
121
- const a = new THREE.Vector3(0, -G, 0);
122
- const alpha = Math.atan2(-g.vel.dot(bu), Math.max(.1, g.vel.dot(f))); let CL = 2 * Math.PI * .9 * (alpha + INC); const stalled = Math.abs(alpha) > .28 && V > 15; if (stalled) CL = Math.sign(alpha) * .5;
123
- g.stall = lerp(g.stall || 0, stalled ? 1 : 0, clamp(dt * 5));
124
- const liftDir = bu.clone().addScaledVector(vhat, -bu.dot(vhat)); if (liftDir.lengthSq() > 1e-6) liftDir.normalize();
125
- a.addScaledVector(liftDir, kL * V * V * CL * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll))));
126
- a.addScaledVector(vhat, -kL * V * V * (.05 + .06 * CL * CL + (I.brake && !onGround ? .3 : 0)));
127
- a.addScaledVector(s0, -g.vel.dot(s0) * 2);
128
- a.addScaledVector(f, g.thr * 7.5 + (I.nitro ? 5 : 0));
129
- if (onGround) { a.y = Math.max(a.y, 0); if (g.vel.y < 0) g.vel.y = 0; const roll = .02 * G + (I.brake ? 6 : 0); a.addScaledVector(vhat, -Math.min(roll, V / dt)); a.addScaledVector(s0, -g.vel.dot(s0) * 8); }
130
- g.vel.addScaledVector(a, dt);
131
- g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
132
- if (g.y < ground) {
133
- const down = -g.vel.y; g.y = ground; g.vel.y = 0;
134
- if (down > 9 || Math.abs(g.roll) > .5) { api.hurt(down * 5 + Math.abs(g.roll) * 40, "Crashed the plane"); api.impact(g.x, g.z, down * 2); }
135
- else if (down > 1 && g.airT > 3) api.say(down < 2.5 ? "Smooth landing" : "Firm landing", 2);
136
- g.airT = 0;
137
- } else if (g.y > ground + .3) g.airT = (g.airT || 0) + dt;
138
- g.mode = g.y > ground + .4 ? "air" : "drive";
139
- const hit = api.hitBox(g.x, g.z, g.y + .5, 2.5); if (hit) { api.hurt(V * 2, "Hit a building"); api.impact(g.x, g.z, V); g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.multiplyScalar(.2); }
140
- if (g.y < WY + .5 && api.isWater(g.x, g.z) && onGround) api.wreck("Ditched in the water");
141
- }
142
- function heli(g, I, dt) {
143
- const ground = api.groundAt(g.x, g.z, g.y).h;
144
- g.thr = lerp(g.thr || 0, I.up || I.gas || g.y > ground + .5 ? 1 : .35, clamp(dt * 1.5));
145
- const coll = I.up - I.down;
146
- g.vel.y += ((coll * 8 + (g.thr > .6 ? 0 : -3)) - g.vel.y) * clamp(dt * 1.6) ;
147
- if (g.y <= ground + .05 && coll <= 0) g.vel.y = Math.max(0, g.vel.y);
148
- // tilt to move: forward/back on gas/brake, turn on steer; slides a little like the real thing
149
- const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw), push = (I.gas ? 1 : 0) - (I.brake ? 1 : 0) + (I.stickY || 0) * 0;
150
- g.yaw += I.turn * 1.3 * dt * (g.y > ground + .5 ? 1 : .3);
151
- const top = I.nitro ? 1.6 : 1;
152
- if (g.y > ground + .5) { g.vel.x += fx * push * 15 * top * dt; g.vel.z += fz * push * 15 * top * dt; }
153
- const damp = Math.exp(-.55 * dt); g.vel.x *= damp; g.vel.z *= damp;
154
- const sp = g.vel.x * fx + g.vel.z * fz, side = -g.vel.x * fz + g.vel.z * fx;
155
- g.pitch = lerp(g.pitch, -clamp(push * .22 + sp * .002, -.3, .3), clamp(dt * 3)); g.roll = lerp(g.roll, clamp(I.turn * .25 + side * .01, -.4, .4), clamp(dt * 3));
156
- g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
157
- if (g.y < ground) { const down = -g.vel.y; g.y = ground; if (down > 8) { api.hurt(down * 4, "Hard landing"); api.impact(g.x, g.z, down * 2); } g.vel.y = 0; g.vel.x *= .8; g.vel.z *= .8; }
158
- g.mode = g.y > ground + .4 ? "air" : "drive";
159
- const hit = api.hitBox(g.x, g.z, g.y + 1, 3.5); if (hit) { const imp = Math.hypot(g.vel.x, g.vel.z); if (imp > 6) { api.hurt(imp * 2, "Rotor strike"); api.impact(g.x, g.z, imp); } g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.x *= -.3; g.vel.z *= -.3; }
160
- if (g.y <= WY + .2 && api.isWater(g.x, g.z)) api.wreck("Ditched in the water");
161
- }
162
- function boat(g, I, dt) {
163
- const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw);
164
- let u = g.vel.x * fx + g.vel.z * fz, w = -g.vel.x * fz + g.vel.z * fx;
165
- u += ((I.gas ? 13 : 0) + (I.nitro ? 9 : 0) - (I.brake ? (u > 1 ? 10 : 5) : 0)) * dt;
166
- u -= (u * .18 + Math.sign(u) * u * u * .0055) * dt; u = Math.max(-7, u);
167
- w *= Math.exp(-2.6 * dt);
168
- g.yaw += I.turn * (.35 + clamp(Math.abs(u) / 14)) * .8 * Math.sign(u || 1) * dt;
169
- const nfx = Math.cos(g.yaw), nfz = Math.sin(g.yaw);
170
- g.vel.set(nfx * u - nfz * w, 0, nfz * u + nfx * w);
171
- const nx = g.x + g.vel.x * dt, nz = g.z + g.vel.z * dt;
172
- if (api.isWater(nx, nz)) { g.x = nx; g.z = nz; } else { g.vel.multiplyScalar(-.25); if (Math.abs(u) > 9) { api.hurt(Math.abs(u) * 1.5, "Ran aground"); api.impact(g.x, g.z, Math.abs(u)); } }
173
- const t = performance.now() / 1000;
174
- g.y = WY + .02 + Math.sin(t * 1.7 + g.x * .1) * .08;
175
- g.pitch = lerp(g.pitch, clamp(u * .006, -.05, .12) + Math.sin(t * 1.3 + g.z * .1) * .015, clamp(dt * 3));
176
- g.roll = lerp(g.roll, -I.turn * clamp(Math.abs(u) / 20) * .18 + Math.sin(t * 1.1) * .02, clamp(dt * 3));
177
- g.mode = "drive"; g.thr = clamp(Math.abs(u) / 25);
178
- }
179
- // where each one starts when you pick it
180
- function spawn(g, type) {
181
- g.thr = 0; g.sRoll = g.sPitch = 0; g.pitch = g.roll = 0; g.airT = 0;
182
- if (type === "plane") { const fly = g.mode === "air" && g.y > 30; if (fly) { g.vel.set(Math.cos(g.yaw) * 55, 0, Math.sin(g.yaw) * 55); g.thr = .7; } else { Object.assign(g, { x: RUNWAY.x0 + 30, z: RUNWAY.z, y: 0, yaw: 0 }); g.vel.set(0, 0, 0); } }
183
- else if (type === "heli") { g.y += .5; g.vel.multiplyScalar(.3); }
184
- else if (type === "boat") {
185
- if (!api.isWater(g.x, g.z)) {
186
- const R = W.city.rivers, lake = W.city.lake, opts = [[(R.hudson[0] + R.hudson[1]) / 2, g.z], [(R.east[0] + R.east[1]) / 2, Math.abs(g.z) < 40 ? 80 : g.z], [lake.x, lake.z]];
187
- const [x, z] = opts.sort((a, b) => Math.hypot(a[0] - g.x, a[1] - g.z) - Math.hypot(b[0] - g.x, b[1] - g.z))[0];
188
- g.x = x; g.z = z; g.yaw = Math.abs(g.z) > 3000 ? 0 : Math.PI / 2;
189
- }
190
- g.vel.set(0, 0, 0); g.y = WY;
191
- }
192
- }
193
- return {
194
- models, spawn,
195
- step(g, I, dt) { if (g.veh === "plane") plane(g, I, dt); else if (g.veh === "heli") heli(g, I, dt); else if (g.veh === "boat") boat(g, I, dt); },
196
- show(type, pos, quat, st, time, dt) {
197
- for (const [k, m] of Object.entries(models)) { m.root.visible = k === type; if (k === type) { m.root.position.copy(pos); m.root.quaternion.copy(quat); m.update(st, time, dt); } }
198
- },
199
- };
200
- }
 
1
+ // More things to drive: a propeller sport plane (takes off from the runway across the Hudson), a helicopter
2
+ // (lifts off anywhere) and a speedboat (the rivers and the park lake). Each has its own model and physics;
3
+ // the game hands them the same inputs as the car (steer, gas, brake, up, down, boost).
4
+ import * as THREE from "three";
5
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
6
+
7
+ const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)), lerp = (a, b, t) => a + (b - a) * t, G = 9.81;
8
+ const wrap = a => { while (a > Math.PI) a -= 2 * Math.PI; while (a < -Math.PI) a += 2 * Math.PI; return a; };
9
+ export const RUNWAY = { x0: -2620, x1: -1900, z: 0, w: 30 };
10
+
11
+ function planeModel() {
12
+ const g = new THREE.Group();
13
+ const white = new THREE.MeshPhysicalMaterial({ color: 0xf2f2f2, metalness: .3, roughness: .3, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
14
+ const dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .5 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, metalness: .2, roughness: .05, transparent: true, opacity: .7, clearcoat: 1 });
15
+ const V2 = (x, y) => new THREE.Vector2(x, y);
16
+ const body = new THREE.Mesh(new THREE.LatheGeometry([V2(0, -4.6), V2(.35, -4.3), V2(.55, -3), V2(.62, -1), V2(.64, 1), V2(.56, 2.6), V2(.42, 3.3), V2(.16, 3.7), V2(0, 3.75)], 24).rotateZ(-Math.PI / 2), white);
17
+ g.add(body);
18
+ const stripe = new THREE.Mesh(new THREE.CylinderGeometry(.645, .645, 6.2, 24, 1, true).rotateZ(Math.PI / 2), red); stripe.scale.set(1, 1, 1); stripe.position.x = -.4; stripe.scale.y = 1; g.add(stripe);
19
+ const wingShape = new THREE.Shape([V2(1.2, 0), V2(-.6, 0), V2(-.3, 5.6), V2(.5, 5.6)]);
20
+ for (const s of [-1, 1]) {
21
+ const w = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape, { depth: .14, bevelEnabled: true, bevelSize: .05, bevelThickness: .04, bevelSegments: 2 }).rotateX(-Math.PI / 2), s > 0 ? white : white);
22
+ w.position.set(0, -.15, 0); w.scale.z = -s; g.add(w);
23
+ const tip = new THREE.Mesh(new THREE.BoxGeometry(.9, .08, .1), red); tip.position.set(.1, -.1, s * 5.6); g.add(tip);
24
+ const nav = new THREE.Mesh(new THREE.SphereGeometry(.08, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false })); nav.position.set(.1, -.1, s * 5.68); g.add(nav);
25
+ const ht = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.6, 0), V2(-.3, 0), V2(-.2, 2), V2(.3, 2)]), { depth: .08, bevelEnabled: false }).rotateX(-Math.PI / 2), white);
26
+ ht.position.set(-4.2, .1, 0); ht.scale.z = -s; g.add(ht);
27
+ }
28
+ const fin = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.4, 0), V2(-.9, 0), V2(-.5, 1.7), V2(-.05, 1.7)]), { depth: .08, bevelEnabled: false }).translate(0, 0, -.04), red);
29
+ fin.position.set(-3.7, .3, 0); g.add(fin);
30
+ const canopy = new THREE.Mesh(new THREE.SphereGeometry(.6, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); canopy.scale.set(2.2, .9, .85); canopy.position.set(.6, .45, 0); g.add(canopy);
31
+ const prop = new THREE.Group(); prop.position.x = 3.8; g.add(prop);
32
+ prop.add(new THREE.Mesh(new THREE.ConeGeometry(.22, .5, 16).rotateZ(-Math.PI / 2), red));
33
+ for (let k = 0; k < 3; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.06, 1.7, .16), dark); b.position.y = 0; b.rotation.x = k / 3 * Math.PI * 2; b.geometry.translate(0, .85, 0); prop.add(b); }
34
+ const disc = new THREE.Mesh(new THREE.CircleGeometry(1.75, 32).rotateY(Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0, depthWrite: false })); prop.add(disc);
35
+ for (const [x, z] of [[1.6, 0], [-.4, 1.3], [-.4, -1.3]]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); leg.position.set(x, -.85, z); g.add(leg); const wh = new THREE.Mesh(new THREE.CylinderGeometry(.24, .24, .16, 14).rotateX(Math.PI / 2), dark); wh.position.set(x, -1.3, z); g.add(wh); }
36
+ g.traverse(o => { if (o.isMesh) o.castShadow = true; });
37
+ g.position.y = 0; const root = new THREE.Group(); g.position.y = 1.54; root.add(g);
38
+ return { root, update(st, time, dt) { prop.rotation.x += (8 + st.thr * 60) * dt; disc.material.opacity = st.thr * .25; } };
39
+ }
40
+ function heliModel() {
41
+ const g = new THREE.Group();
42
+ const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .4, roughness: .35, clearcoat: .8 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
43
+ const dark = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .6, roughness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1420, roughness: .05, transparent: true, opacity: .65, clearcoat: 1 });
44
+ const body = new THREE.Mesh(new THREE.SphereGeometry(1.5, 24, 16), paint); body.scale.set(1.7, 1, 1); body.position.y = 1.6; g.add(body);
45
+ const nose = new THREE.Mesh(new THREE.SphereGeometry(1.2, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); nose.rotation.z = -Math.PI / 2; nose.scale.set(.9, 1.3, 1.05); nose.position.set(1.3, 1.8, 0); g.add(nose);
46
+ const tail = new THREE.Mesh(new THREE.CylinderGeometry(.18, .38, 5.4, 12).rotateZ(Math.PI / 2), paint); tail.position.set(-4.4, 2.1, 0); g.add(tail);
47
+ const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.2, .25, 3.05), red); stripe.position.set(-.3, 1.45, 0); g.add(stripe);
48
+ const finV = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.4, .1), red); finV.position.set(-7, 2.6, 0); finV.rotation.z = -.3; g.add(finV);
49
+ for (const z of [-1.15, 1.15]) { const sk = new THREE.Mesh(new THREE.CylinderGeometry(.07, .07, 3.8, 8).rotateZ(Math.PI / 2), dark); sk.position.set(0, .1, z); g.add(sk); for (const x of [-.8, .9]) { const st = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); st.position.set(x, .55, z * .85); st.rotation.x = z > 0 ? -.3 : .3; g.add(st); } }
50
+ const mast = new THREE.Mesh(new THREE.CylinderGeometry(.12, .16, .5, 10), dark); mast.position.y = 3; g.add(mast);
51
+ const rotor = new THREE.Group(); rotor.position.y = 3.25; g.add(rotor);
52
+ for (let k = 0; k < 4; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(5.6, .05, .32).translate(2.8, 0, 0), dark); b.rotation.y = k * Math.PI / 2; rotor.add(b); }
53
+ const blur = new THREE.Mesh(new THREE.CircleGeometry(5.6, 40).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide })); rotor.add(blur);
54
+ const tr = new THREE.Group(); tr.position.set(-7.1, 2.3, .2); g.add(tr);
55
+ for (let k = 0; k < 2; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.08, 1.4, .12), dark); b.rotation.z = k * Math.PI / 2; tr.add(b); }
56
+ const beacon = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(3), toneMapped: false })); beacon.position.set(-1, 2.6, 0); g.add(beacon);
57
+ g.traverse(o => { if (o.isMesh) o.castShadow = true; });
58
+ const root = new THREE.Group(); root.add(g);
59
+ return { root, update(st, time, dt) { const spin = 6 + st.thr * 34; rotor.rotation.y += spin * dt; tr.rotation.x += spin * 1.6 * dt; blur.material.opacity = clamp(st.thr) * .22; beacon.visible = Math.sin(time * 5) > 0; } };
60
+ }
61
+ function boatModel() {
62
+ const g = new THREE.Group(), L = 7.2, W = 2.5;
63
+ const hullM = new THREE.MeshPhysicalMaterial({ color: 0xf4f4f2, metalness: .1, roughness: .25, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, roughness: .3, clearcoat: 1 });
64
+ const deckM = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: .7 }), dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, roughness: .05, transparent: true, opacity: .55 });
65
+ // the hull: a side profile extruded across, then pinched to a point at the bow and given a V underneath
66
+ const prof = new THREE.Shape([new THREE.Vector2(-L / 2, .25), new THREE.Vector2(L / 2 - 1.6, -.2), new THREE.Vector2(L / 2, .55), new THREE.Vector2(L / 2 - .2, 1.05), new THREE.Vector2(-L / 2, 1.05)]);
67
+ const hg = new THREE.ExtrudeGeometry(prof, { depth: W, bevelEnabled: true, bevelSize: .12, bevelThickness: .12, bevelSegments: 3, curveSegments: 6, steps: 1 }).translate(0, 0, -W / 2);
68
+ const ps = hg.attributes.position;
69
+ for (let i = 0; i < ps.count; i++) { const x = ps.getX(i), y = ps.getY(i), z = ps.getZ(i), u = clamp((x + L / 2) / L); const pinch = u > .55 ? 1 - Math.pow((u - .55) / .45, 1.6) * .96 : 1; const v = y < .5 ? .6 + .4 * clamp((y + .2) / .7) : 1; ps.setZ(i, z * pinch * v); }
70
+ hg.computeVertexNormals();
71
+ const hull = new THREE.Mesh(hg, hullM); g.add(hull);
72
+ const band = new THREE.Mesh(hg.clone(), red); band.scale.set(1.002, .12, 1.01); band.position.y = .72; g.add(band);
73
+ const deck = new THREE.Mesh(new THREE.BoxGeometry(L * .62, .06, W * .82), deckM); deck.position.set(-.6, 1.08, 0); g.add(deck);
74
+ const ws = new THREE.Mesh(new THREE.BoxGeometry(.06, .55, W * .8), glass); ws.position.set(.6, 1.38, 0); ws.rotation.z = -.5; g.add(ws);
75
+ for (const z of [-.5, .5]) { const seat = new THREE.Mesh(new THREE.BoxGeometry(.7, .45, .6), red); seat.position.set(-.6, 1.3, z); g.add(seat); }
76
+ const motor = new THREE.Mesh(new THREE.BoxGeometry(.6, .9, .55), dark); motor.position.set(-L / 2 - .2, .9, 0); g.add(motor);
77
+ g.traverse(o => { if (o.isMesh) o.castShadow = true; });
78
+ const root = new THREE.Group(); g.position.y = -.55; root.add(g);
79
+ return { root, update() { } };
80
+ }
81
+
82
+ export function createVehicles(scene, api, W) {
83
+ const models = { plane: planeModel(), heli: heliModel(), boat: boatModel() };
84
+ for (const m of Object.values(models)) { m.root.visible = false; scene.add(m.root); }
85
+ const WY = W.city.WATER_Y;
86
+ // the runway: asphalt, edge lights and centre-line dashes
87
+ {
88
+ const r = RUNWAY, L = r.x1 - r.x0;
89
+ const tex = W.canvasTex(512, 64, (g, w, h) => { g.fillStyle = "#2b2c30"; g.fillRect(0, 0, w, h); g.fillStyle = "#e8e8e8"; for (let x = 0; x < w; x += 64) g.fillRect(x, h / 2 - 2, 36, 4); g.fillRect(0, 3, w, 3); g.fillRect(0, h - 6, w, 3); }, [L / 120, 1]);
90
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(L, r.w).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));
91
+ m.position.set((r.x0 + r.x1) / 2, .05, r.z); m.receiveShadow = true; scene.add(m);
92
+ const lights = [], lm = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xfff0c0).multiplyScalar(2.2), toneMapped: false });
93
+ for (let x = r.x0; x <= r.x1; x += 30) for (const s of [-1, 1]) lights.push([x, s * (r.w / 2 + 1)]);
94
+ const im = new THREE.InstancedMesh(new THREE.SphereGeometry(.25, 8, 6), lm, lights.length), mm = new THREE.Matrix4();
95
+ lights.forEach(([x, z], k) => im.setMatrixAt(k, mm.makeTranslation(x, .3, z))); scene.add(im);
96
+ const sign = new THREE.Sprite(new THREE.SpriteMaterial({ map: W.canvasTex(512, 128, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.6)"; g.fillRect(0, 0, w, h); g.fillStyle = "#3dfcff"; g.font = "900 56px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("✈ VOID AIRFIELD", w / 2, 84); }), toneMapped: false }));
97
+ sign.scale.set(24, 6, 1); sign.position.set(r.x0 + 40, 12, r.z + 40); scene.add(sign);
98
+ }
99
+
100
+ // ---- physics ----
101
+ function plane(g, I, dt) {
102
+ const V = g.vel.length(), ground = api.groundAt(g.x, g.z, g.y).h, onGround = g.y <= ground + .05;
103
+ g.thr = clamp(g.thr + ((I.gas ? 1 : 0) - (I.brake && !onGround ? 1 : 0)) * dt * .6);
104
+ if (onGround && I.brake) g.thr = Math.max(0, g.thr - dt);
105
+ // smooth inputs: the plane answers like it has mass
106
+ g.sRoll = lerp(g.sRoll || 0, I.turn, clamp(dt * 3)); g.sPitch = lerp(g.sPitch || 0, clamp(I.up - I.down + (I.stickY || 0), -1, 1), clamp(dt * 3));
107
+ const vh = Math.hypot(g.vel.x, g.vel.z), gamma = Math.atan2(g.vel.y, Math.max(1, vh));
108
+ const kL = .0115, INC = .05;
109
+ if (onGround) { g.roll = lerp(g.roll, 0, clamp(dt * 5)); g.yaw += I.turn * clamp(vh / 12) * .55 * dt; g.pitch = lerp(g.pitch, g.sPitch > .2 && vh > 34 ? .14 : 0, clamp(dt * 3)); }
110
+ else {
111
+ g.roll = lerp(g.roll, g.sRoll * 1.05, clamp(dt * 2.4));
112
+ g.yaw += G * Math.tan(g.roll) / Math.max(25, V) * dt;
113
+ const wingK = kL * V * V * 2 * Math.PI * .9 * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll)));
114
+ const needA = wingK > .5 ? clamp(G * Math.cos(gamma) / wingK - INC - g.vel.y * .006, -.25, .24) : 0;
115
+ if (Math.abs(g.sPitch) > .05) g.pitch += g.sPitch * .55 * dt; else g.pitch += (gamma + needA / Math.max(.45, Math.cos(g.roll)) - g.pitch) * clamp(dt * 1.8);
116
+ const cr = Math.max(.45, Math.cos(g.roll)); g.pitch = clamp(g.pitch, gamma - .3 / cr, gamma + .26 / cr);
117
+ }
118
+ 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));
119
+ const s0 = new THREE.Vector3().crossVectors(f, new THREE.Vector3(0, 1, 0)).normalize(), u0 = new THREE.Vector3().crossVectors(s0, f), bu = u0.clone().applyAxisAngle(f, g.roll);
120
+ const vhat = V > .1 ? g.vel.clone().divideScalar(V) : f.clone();
121
+ const a = new THREE.Vector3(0, -G, 0);
122
+ const alpha = Math.atan2(-g.vel.dot(bu), Math.max(.1, g.vel.dot(f))); let CL = 2 * Math.PI * .9 * (alpha + INC); const stalled = Math.abs(alpha) > .28 && V > 15; if (stalled) CL = Math.sign(alpha) * .5;
123
+ g.stall = lerp(g.stall || 0, stalled ? 1 : 0, clamp(dt * 5));
124
+ const liftDir = bu.clone().addScaledVector(vhat, -bu.dot(vhat)); if (liftDir.lengthSq() > 1e-6) liftDir.normalize();
125
+ a.addScaledVector(liftDir, kL * V * V * CL * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll))));
126
+ a.addScaledVector(vhat, -kL * V * V * (.05 + .06 * CL * CL + (I.brake && !onGround ? .3 : 0)));
127
+ a.addScaledVector(s0, -g.vel.dot(s0) * 2);
128
+ a.addScaledVector(f, g.thr * 7.5 + (I.nitro ? 5 : 0));
129
+ if (onGround) { a.y = Math.max(a.y, 0); if (g.vel.y < 0) g.vel.y = 0; const roll = .02 * G + (I.brake ? 6 : 0); a.addScaledVector(vhat, -Math.min(roll, V / dt)); a.addScaledVector(s0, -g.vel.dot(s0) * 8); }
130
+ g.vel.addScaledVector(a, dt);
131
+ g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
132
+ if (g.y < ground) {
133
+ const down = -g.vel.y; g.y = ground; g.vel.y = 0;
134
+ if (down > 9 || Math.abs(g.roll) > .5) { api.hurt(down * 5 + Math.abs(g.roll) * 40, "Crashed the plane"); api.impact(g.x, g.z, down * 2); }
135
+ else if (down > 1 && g.airT > 3) api.say(down < 2.5 ? "Smooth landing" : "Firm landing", 2);
136
+ g.airT = 0;
137
+ } else if (g.y > ground + .3) g.airT = (g.airT || 0) + dt;
138
+ g.mode = g.y > ground + .4 ? "air" : "drive";
139
+ const hit = api.hitBox(g.x, g.z, g.y + .5, 2.5); if (hit) { api.hurt(V * 2, "Hit a building"); api.impact(g.x, g.z, V); g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.multiplyScalar(.2); }
140
+ if (g.y < WY + .5 && api.isWater(g.x, g.z) && onGround) api.wreck("Ditched in the water");
141
+ }
142
+ function heli(g, I, dt) {
143
+ const ground = api.groundAt(g.x, g.z, g.y).h;
144
+ g.thr = lerp(g.thr || 0, I.up || I.gas || g.y > ground + .5 ? 1 : .35, clamp(dt * 1.5));
145
+ const coll = I.up - I.down;
146
+ g.vel.y += ((coll * 8 + (g.thr > .6 ? 0 : -3)) - g.vel.y) * clamp(dt * 1.6) ;
147
+ if (g.y <= ground + .05 && coll <= 0) g.vel.y = Math.max(0, g.vel.y);
148
+ // tilt to move: forward/back on gas/brake, turn on steer; slides a little like the real thing
149
+ const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw), push = (I.gas ? 1 : 0) - (I.brake ? 1 : 0) + (I.stickY || 0) * 0;
150
+ g.yaw += I.turn * 1.3 * dt * (g.y > ground + .5 ? 1 : .3);
151
+ const top = I.nitro ? 1.6 : 1;
152
+ if (g.y > ground + .5) { g.vel.x += fx * push * 15 * top * dt; g.vel.z += fz * push * 15 * top * dt; }
153
+ const damp = Math.exp(-.55 * dt); g.vel.x *= damp; g.vel.z *= damp;
154
+ const sp = g.vel.x * fx + g.vel.z * fz, side = -g.vel.x * fz + g.vel.z * fx;
155
+ g.pitch = lerp(g.pitch, -clamp(push * .22 + sp * .002, -.3, .3), clamp(dt * 3)); g.roll = lerp(g.roll, clamp(I.turn * .25 + side * .01, -.4, .4), clamp(dt * 3));
156
+ g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
157
+ if (g.y < ground) { const down = -g.vel.y; g.y = ground; if (down > 8) { api.hurt(down * 4, "Hard landing"); api.impact(g.x, g.z, down * 2); } g.vel.y = 0; g.vel.x *= .8; g.vel.z *= .8; }
158
+ g.mode = g.y > ground + .4 ? "air" : "drive";
159
+ const hit = api.hitBox(g.x, g.z, g.y + 1, 3.5); if (hit) { const imp = Math.hypot(g.vel.x, g.vel.z); if (imp > 6) { api.hurt(imp * 2, "Rotor strike"); api.impact(g.x, g.z, imp); } g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.x *= -.3; g.vel.z *= -.3; }
160
+ if (g.y <= WY + .2 && api.isWater(g.x, g.z)) api.wreck("Ditched in the water");
161
+ }
162
+ function boat(g, I, dt) {
163
+ const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw);
164
+ let u = g.vel.x * fx + g.vel.z * fz, w = -g.vel.x * fz + g.vel.z * fx;
165
+ u += ((I.gas ? 13 : 0) + (I.nitro ? 9 : 0) - (I.brake ? (u > 1 ? 10 : 5) : 0)) * dt;
166
+ u -= (u * .18 + Math.sign(u) * u * u * .0055) * dt; u = Math.max(-7, u);
167
+ w *= Math.exp(-2.6 * dt);
168
+ g.yaw += I.turn * (.35 + clamp(Math.abs(u) / 14)) * .8 * Math.sign(u || 1) * dt;
169
+ const nfx = Math.cos(g.yaw), nfz = Math.sin(g.yaw);
170
+ g.vel.set(nfx * u - nfz * w, 0, nfz * u + nfx * w);
171
+ const nx = g.x + g.vel.x * dt, nz = g.z + g.vel.z * dt;
172
+ if (api.isWater(nx, nz)) { g.x = nx; g.z = nz; } else { g.vel.multiplyScalar(-.25); if (Math.abs(u) > 9) { api.hurt(Math.abs(u) * 1.5, "Ran aground"); api.impact(g.x, g.z, Math.abs(u)); } }
173
+ const t = performance.now() / 1000;
174
+ g.y = WY + .02 + Math.sin(t * 1.7 + g.x * .1) * .08;
175
+ g.pitch = lerp(g.pitch, clamp(u * .006, -.05, .12) + Math.sin(t * 1.3 + g.z * .1) * .015, clamp(dt * 3));
176
+ g.roll = lerp(g.roll, -I.turn * clamp(Math.abs(u) / 20) * .18 + Math.sin(t * 1.1) * .02, clamp(dt * 3));
177
+ g.mode = "drive"; g.thr = clamp(Math.abs(u) / 25);
178
+ }
179
+ // where each one starts when you pick it
180
+ function spawn(g, type) {
181
+ g.thr = 0; g.sRoll = g.sPitch = 0; g.pitch = g.roll = 0; g.airT = 0;
182
+ if (type === "plane") { const fly = g.mode === "air" && g.y > 30; if (fly) { g.vel.set(Math.cos(g.yaw) * 55, 0, Math.sin(g.yaw) * 55); g.thr = .7; } else { Object.assign(g, { x: RUNWAY.x0 + 30, z: RUNWAY.z, y: 0, yaw: 0 }); g.vel.set(0, 0, 0); } }
183
+ else if (type === "heli") { g.y += .5; g.vel.multiplyScalar(.3); }
184
+ else if (type === "boat") {
185
+ if (!api.isWater(g.x, g.z)) {
186
+ const R = W.city.rivers, lake = W.city.lake, opts = [[(R.hudson[0] + R.hudson[1]) / 2, g.z], [(R.east[0] + R.east[1]) / 2, Math.abs(g.z) < 40 ? 80 : g.z], [lake.x, lake.z]];
187
+ const [x, z] = opts.sort((a, b) => Math.hypot(a[0] - g.x, a[1] - g.z) - Math.hypot(b[0] - g.x, b[1] - g.z))[0];
188
+ g.x = x; g.z = z; g.yaw = Math.abs(g.z) > 3000 ? 0 : Math.PI / 2;
189
+ }
190
+ g.vel.set(0, 0, 0); g.y = WY;
191
+ }
192
+ }
193
+ return {
194
+ models, spawn,
195
+ step(g, I, dt) { if (g.veh === "plane") plane(g, I, dt); else if (g.veh === "heli") heli(g, I, dt); else if (g.veh === "boat") boat(g, I, dt); },
196
+ show(type, pos, quat, st, time, dt) {
197
+ for (const [k, m] of Object.entries(models)) { m.root.visible = k === type; if (k === type) { m.root.position.copy(pos); m.root.quaternion.copy(quat); m.update(st, time, dt); } }
198
+ },
199
+ };
200
+ }