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