Static site
Browse files- index.html +2 -2
- static/extras.js +174 -174
- static/interior.js +145 -145
- static/main.js +0 -0
- static/style.css +1 -1
- 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=
|
| 12 |
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
|
| 13 |
<script type="importmap">
|
| 14 |
{ "imports": {
|
|
@@ -215,6 +215,6 @@
|
|
| 215 |
<div id="toast" role="status"></div>
|
| 216 |
<noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
|
| 217 |
<script src="/static/hub.js" data-app="void" defer></script>
|
| 218 |
-
<script type="module" src="/static/main.js?v=
|
| 219 |
</body>
|
| 220 |
</html>
|
|
|
|
| 8 |
<meta name="theme-color" content="#10131b">
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="/static/style.css?v=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 |
+
}
|