File size: 31,964 Bytes
b5684be
413ff82
 
 
b5684be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413ff82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5684be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413ff82
b5684be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413ff82
 
 
 
 
 
 
 
 
b5684be
413ff82
b5684be
 
413ff82
 
 
 
 
 
 
b5684be
413ff82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5684be
 
413ff82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5684be
 
 
 
 
 
413ff82
b5684be
 
 
 
413ff82
b5684be
 
 
413ff82
b5684be
 
413ff82
b5684be
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// More things to drive: a propeller sport plane (takes off from the runway across the Hudson), a helicopter
// (lifts off anywhere), and five boats: speedboat, jet ski, yacht, tugboat and a hovercraft that also runs on land.
// Boats ride real waves (buoyancy, the bow lifts at speed, they bank into turns, a jet ski hops off the crests),
// and the rivers have their own traffic: ferries, tugs, sailboats, yachts and speedboats that keep clear of you. Each has its own model and physics;
// the game hands them the same inputs as the car (steer, gas, brake, up, down, boost).
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";

const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)), lerp = (a, b, t) => a + (b - a) * t, G = 9.81;
const wrap = a => { while (a > Math.PI) a -= 2 * Math.PI; while (a < -Math.PI) a += 2 * Math.PI; return a; };
export const RUNWAY = { x0: -2620, x1: -1900, z: 0, w: 30 };

function planeModel() {
  const g = new THREE.Group();
  const white = new THREE.MeshPhysicalMaterial({ color: 0xf2f2f2, metalness: .3, roughness: .3, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
  const dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .5 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, metalness: .2, roughness: .05, transparent: true, opacity: .7, clearcoat: 1 });
  const V2 = (x, y) => new THREE.Vector2(x, y);
  const body = new THREE.Mesh(new THREE.LatheGeometry([V2(0, -4.6), V2(.35, -4.3), V2(.55, -3), V2(.62, -1), V2(.64, 1), V2(.56, 2.6), V2(.42, 3.3), V2(.16, 3.7), V2(0, 3.75)], 24).rotateZ(-Math.PI / 2), white);
  g.add(body);
  const stripe = new THREE.Mesh(new THREE.CylinderGeometry(.645, .645, 6.2, 24, 1, true).rotateZ(Math.PI / 2), red); stripe.scale.set(1, 1, 1); stripe.position.x = -.4; stripe.scale.y = 1; g.add(stripe);
  const wingShape = new THREE.Shape([V2(1.2, 0), V2(-.6, 0), V2(-.3, 5.6), V2(.5, 5.6)]);
  for (const s of [-1, 1]) {
    const w = new THREE.Mesh(new THREE.ExtrudeGeometry(wingShape, { depth: .14, bevelEnabled: true, bevelSize: .05, bevelThickness: .04, bevelSegments: 2 }).rotateX(-Math.PI / 2), s > 0 ? white : white);
    w.position.set(0, -.15, 0); w.scale.z = -s; g.add(w);
    const tip = new THREE.Mesh(new THREE.BoxGeometry(.9, .08, .1), red); tip.position.set(.1, -.1, s * 5.6); g.add(tip);
    const nav = new THREE.Mesh(new THREE.SphereGeometry(.08, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false })); nav.position.set(.1, -.1, s * 5.68); g.add(nav);
    const ht = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.6, 0), V2(-.3, 0), V2(-.2, 2), V2(.3, 2)]), { depth: .08, bevelEnabled: false }).rotateX(-Math.PI / 2), white);
    ht.position.set(-4.2, .1, 0); ht.scale.z = -s; g.add(ht);
  }
  const fin = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape([V2(.4, 0), V2(-.9, 0), V2(-.5, 1.7), V2(-.05, 1.7)]), { depth: .08, bevelEnabled: false }).translate(0, 0, -.04), red);
  fin.position.set(-3.7, .3, 0); g.add(fin);
  const canopy = new THREE.Mesh(new THREE.SphereGeometry(.6, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); canopy.scale.set(2.2, .9, .85); canopy.position.set(.6, .45, 0); g.add(canopy);
  const prop = new THREE.Group(); prop.position.x = 3.8; g.add(prop);
  prop.add(new THREE.Mesh(new THREE.ConeGeometry(.22, .5, 16).rotateZ(-Math.PI / 2), red));
  for (let k = 0; k < 3; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.06, 1.7, .16), dark); b.position.y = 0; b.rotation.x = k / 3 * Math.PI * 2; b.geometry.translate(0, .85, 0); prop.add(b); }
  const disc = new THREE.Mesh(new THREE.CircleGeometry(1.75, 32).rotateY(Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0, depthWrite: false })); prop.add(disc);
  for (const [x, z] of [[1.6, 0], [-.4, 1.3], [-.4, -1.3]]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); leg.position.set(x, -.85, z); g.add(leg); const wh = new THREE.Mesh(new THREE.CylinderGeometry(.24, .24, .16, 14).rotateX(Math.PI / 2), dark); wh.position.set(x, -1.3, z); g.add(wh); }
  g.traverse(o => { if (o.isMesh) o.castShadow = true; });
  g.position.y = 0; const root = new THREE.Group(); g.position.y = 1.54; root.add(g);
  return { root, update(st, time, dt) { prop.rotation.x += (8 + st.thr * 60) * dt; disc.material.opacity = st.thr * .25; } };
}
function heliModel() {
  const g = new THREE.Group();
  const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .4, roughness: .35, clearcoat: .8 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .3, roughness: .3, clearcoat: 1 });
  const dark = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .6, roughness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1420, roughness: .05, transparent: true, opacity: .65, clearcoat: 1 });
  const body = new THREE.Mesh(new THREE.SphereGeometry(1.5, 24, 16), paint); body.scale.set(1.7, 1, 1); body.position.y = 1.6; g.add(body);
  const nose = new THREE.Mesh(new THREE.SphereGeometry(1.2, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), glass); nose.rotation.z = -Math.PI / 2; nose.scale.set(.9, 1.3, 1.05); nose.position.set(1.3, 1.8, 0); g.add(nose);
  const tail = new THREE.Mesh(new THREE.CylinderGeometry(.18, .38, 5.4, 12).rotateZ(Math.PI / 2), paint); tail.position.set(-4.4, 2.1, 0); g.add(tail);
  const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.2, .25, 3.05), red); stripe.position.set(-.3, 1.45, 0); g.add(stripe);
  const finV = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.4, .1), red); finV.position.set(-7, 2.6, 0); finV.rotation.z = -.3; g.add(finV);
  for (const z of [-1.15, 1.15]) { const sk = new THREE.Mesh(new THREE.CylinderGeometry(.07, .07, 3.8, 8).rotateZ(Math.PI / 2), dark); sk.position.set(0, .1, z); g.add(sk); for (const x of [-.8, .9]) { const st = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .9, 6), dark); st.position.set(x, .55, z * .85); st.rotation.x = z > 0 ? -.3 : .3; g.add(st); } }
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(.12, .16, .5, 10), dark); mast.position.y = 3; g.add(mast);
  const rotor = new THREE.Group(); rotor.position.y = 3.25; g.add(rotor);
  for (let k = 0; k < 4; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(5.6, .05, .32).translate(2.8, 0, 0), dark); b.rotation.y = k * Math.PI / 2; rotor.add(b); }
  const blur = new THREE.Mesh(new THREE.CircleGeometry(5.6, 40).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: 0x111111, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide })); rotor.add(blur);
  const tr = new THREE.Group(); tr.position.set(-7.1, 2.3, .2); g.add(tr);
  for (let k = 0; k < 2; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.08, 1.4, .12), dark); b.rotation.z = k * Math.PI / 2; tr.add(b); }
  const beacon = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(3), toneMapped: false })); beacon.position.set(-1, 2.6, 0); g.add(beacon);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; });
  const root = new THREE.Group(); root.add(g);
  return { root, update(st, time, dt) { const spin = 6 + st.thr * 34; rotor.rotation.y += spin * dt; tr.rotation.x += spin * 1.6 * dt; blur.material.opacity = clamp(st.thr) * .22; beacon.visible = Math.sin(time * 5) > 0; } };
}
// a hull: side profile extruded across, pinched to a bow, with a V underneath
function hullGeo(L, W, H) {
  const prof = new THREE.Shape([new THREE.Vector2(-L / 2, H * .24), new THREE.Vector2(L / 2 - L * .22, -H * .2), new THREE.Vector2(L / 2, H * .52), new THREE.Vector2(L / 2 - L * .03, H), new THREE.Vector2(-L / 2, H)]);
  const hg = new THREE.ExtrudeGeometry(prof, { depth: W, bevelEnabled: true, bevelSize: .1, bevelThickness: .1, bevelSegments: 2, curveSegments: 6, steps: 1 }).translate(0, 0, -W / 2);
  const ps = hg.attributes.position;
  for (let i = 0; i < ps.count; i++) { const x = ps.getX(i), y = ps.getY(i), z = ps.getZ(i), u = clamp((x + L / 2) / L), pinch = u > .55 ? 1 - Math.pow((u - .55) / .45, 1.6) * .94 : 1, v = y < H * .45 ? .6 + .4 * clamp(y / (H * .45)) : 1; ps.setZ(i, z * pinch * v); }
  hg.computeVertexNormals(); return hg;
}
const pm = (c, o = {}) => new THREE.MeshPhysicalMaterial({ color: c, roughness: .3, clearcoat: 1, ...o }), sm = (c, o = {}) => new THREE.MeshStandardMaterial({ color: c, roughness: .55, ...o });
const box = (g, w, h, d, m, x, y, z) => { const o = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); o.position.set(x, y, z); g.add(o); return o; };
function jetskiModel() {
  const g = new THREE.Group(), hull = new THREE.Mesh(hullGeo(3.2, 1.1, .75), pm(0x1f6fe0)); g.add(hull);
  box(g, 1.2, .25, .5, sm(0x111111), -.3, .9, 0); box(g, .5, .35, .9, pm(0xf2f2f2), .55, .85, 0);
  box(g, .06, .06, .8, sm(0x222222, { metalness: .6 }), .45, 1.15, 0);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; }); const root = new THREE.Group(); g.position.y = -.35; root.add(g); return { root, update() { } };
}
function yachtModel() {
  const g = new THREE.Group(), L = 15, W = 4.2, white = pm(0xf4f4f2), dark = pm(0x0b1520, { roughness: .1 }), teak = sm(0x9a7a52);
  g.add(new THREE.Mesh(hullGeo(L, W, 1.9), white)); box(g, L * .55, .08, W * .8, teak, -1.5, 1.95, 0);
  box(g, L * .42, 1.2, W * .72, white, -1.2, 2.6, 0); box(g, L * .43, .55, W * .74, dark, -1.2, 2.75, 0);
  box(g, L * .26, .9, W * .6, white, -1.8, 3.65, 0); box(g, L * .27, .4, W * .62, dark, -1.8, 3.75, 0);
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(.06, .08, 2.2, 8), sm(0xdddddd, { metalness: .6 })); mast.position.set(-2.2, 5.1, 0); g.add(mast);
  for (const z of [-W * .42, W * .42]) box(g, L * .6, .05, .05, sm(0xcccccc, { metalness: .8 }), .3, 2.55, z);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; }); const root = new THREE.Group(); g.position.y = -.9; root.add(g); return { root, update() { } };
}
function tugModel() {
  const g = new THREE.Group(), L = 9, W = 3.4, red = pm(0xb3121c), black = sm(0x15161a), white = pm(0xf2f2f2), dark = pm(0x0b1520, { roughness: .1 });
  g.add(new THREE.Mesh(hullGeo(L, W, 1.7), red)); box(g, L * .9, .3, W * 1.02, black, 0, 1.6, 0);
  box(g, 3.2, 1.8, W * .7, white, -.4, 2.6, 0); box(g, 2.2, .9, W * .62, white, -.2, 3.9, 0); box(g, 2.25, .45, W * .64, dark, -.2, 4, 0);
  const stack = new THREE.Mesh(new THREE.CylinderGeometry(.35, .4, 1.6, 12), black); stack.position.set(-2.1, 4, 0); g.add(stack);
  const band = new THREE.Mesh(new THREE.CylinderGeometry(.41, .41, .3, 12), red); band.position.set(-2.1, 4.4, 0); g.add(band);
  for (let k = -3; k <= 3; k++) for (const z of [-W / 2 - .05, W / 2 + .05]) { const t = new THREE.Mesh(new THREE.TorusGeometry(.3, .12, 6, 12), black); t.position.set(k * 1.1, 1.1, z); g.add(t); }
  g.traverse(o => { if (o.isMesh) o.castShadow = true; }); const root = new THREE.Group(); g.position.y = -.8; root.add(g); return { root, update() { } };
}
function hoverModel() {
  const g = new THREE.Group(), skirt = new THREE.Mesh(new THREE.CylinderGeometry(2.1, 2.3, .7, 24), sm(0x111111, { roughness: .9 })); skirt.scale.set(1.7, 1, 1); skirt.position.y = .35; g.add(skirt);
  const deck = new THREE.Mesh(new THREE.CylinderGeometry(2, 2, .2, 24), pm(0xf7c414)); deck.scale.set(1.65, 1, 1); deck.position.y = .8; g.add(deck);
  box(g, 2.6, 1.1, 2.4, pm(0xf7c414), .4, 1.45, 0); box(g, 2.62, .45, 2.42, pm(0x0b1520, { roughness: .1 }), .5, 1.6, 0);
  const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, .12, 8, 24), sm(0x222222, { metalness: .5 })); ring.position.set(-2.4, 2, 0); ring.rotation.y = Math.PI / 2; g.add(ring);
  const fan = new THREE.Group(); fan.position.set(-2.4, 2, 0); g.add(fan); for (let k = 0; k < 4; k++) { const b = new THREE.Mesh(new THREE.BoxGeometry(.06, 1.9, .2), sm(0x333333)); b.rotation.x = k * Math.PI / 4; fan.add(b); }
  box(g, .1, 1.3, .1, sm(0x222222), -2.4, 1.3, 0);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; }); const root = new THREE.Group(); root.add(g);
  return { root, update(st, time, dt) { fan.rotation.x += dt * (6 + (st.thr || 0) * 40); } };
}
function boatModel() {
  const g = new THREE.Group(), L = 7.2, W = 2.5;
  const hullM = new THREE.MeshPhysicalMaterial({ color: 0xf4f4f2, metalness: .1, roughness: .25, clearcoat: 1 }), red = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, roughness: .3, clearcoat: 1 });
  const deckM = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: .7 }), dark = new THREE.MeshStandardMaterial({ color: 0x15161a, roughness: .5, metalness: .4 }), glass = new THREE.MeshPhysicalMaterial({ color: 0x0a1018, roughness: .05, transparent: true, opacity: .55 });
  // the hull: a side profile extruded across, then pinched to a point at the bow and given a V underneath
  const prof = new THREE.Shape([new THREE.Vector2(-L / 2, .25), new THREE.Vector2(L / 2 - 1.6, -.2), new THREE.Vector2(L / 2, .55), new THREE.Vector2(L / 2 - .2, 1.05), new THREE.Vector2(-L / 2, 1.05)]);
  const hg = new THREE.ExtrudeGeometry(prof, { depth: W, bevelEnabled: true, bevelSize: .12, bevelThickness: .12, bevelSegments: 3, curveSegments: 6, steps: 1 }).translate(0, 0, -W / 2);
  const ps = hg.attributes.position;
  for (let i = 0; i < ps.count; i++) { const x = ps.getX(i), y = ps.getY(i), z = ps.getZ(i), u = clamp((x + L / 2) / L); const pinch = u > .55 ? 1 - Math.pow((u - .55) / .45, 1.6) * .96 : 1; const v = y < .5 ? .6 + .4 * clamp((y + .2) / .7) : 1; ps.setZ(i, z * pinch * v); }
  hg.computeVertexNormals();
  const hull = new THREE.Mesh(hg, hullM); g.add(hull);
  const band = new THREE.Mesh(hg.clone(), red); band.scale.set(1.002, .12, 1.01); band.position.y = .72; g.add(band);
  const deck = new THREE.Mesh(new THREE.BoxGeometry(L * .62, .06, W * .82), deckM); deck.position.set(-.6, 1.08, 0); g.add(deck);
  const ws = new THREE.Mesh(new THREE.BoxGeometry(.06, .55, W * .8), glass); ws.position.set(.6, 1.38, 0); ws.rotation.z = -.5; g.add(ws);
  for (const z of [-.5, .5]) { const seat = new THREE.Mesh(new THREE.BoxGeometry(.7, .45, .6), red); seat.position.set(-.6, 1.3, z); g.add(seat); }
  const motor = new THREE.Mesh(new THREE.BoxGeometry(.6, .9, .55), dark); motor.position.set(-L / 2 - .2, .9, 0); g.add(motor);
  g.traverse(o => { if (o.isMesh) o.castShadow = true; });
  const root = new THREE.Group(); g.position.y = -.55; root.add(g);
  return { root, update() { } };
}

export function createVehicles(scene, api, W) {
  const models = { plane: planeModel(), heli: heliModel(), boat: boatModel(), boat_jetski: jetskiModel(), boat_yacht: yachtModel(), boat_tug: tugModel(), boat_hover: hoverModel() };
  for (const m of Object.values(models)) { m.root.visible = false; scene.add(m.root); }
  const WY = W.city.WATER_Y;
  // the runway: asphalt, edge lights and centre-line dashes
  {
    const r = RUNWAY, L = r.x1 - r.x0;
    const tex = W.canvasTex(512, 64, (g, w, h) => { g.fillStyle = "#2b2c30"; g.fillRect(0, 0, w, h); g.fillStyle = "#e8e8e8"; for (let x = 0; x < w; x += 64) g.fillRect(x, h / 2 - 2, 36, 4); g.fillRect(0, 3, w, 3); g.fillRect(0, h - 6, w, 3); }, [L / 120, 1]);
    const m = new THREE.Mesh(new THREE.PlaneGeometry(L, r.w).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));
    m.position.set((r.x0 + r.x1) / 2, .05, r.z); m.receiveShadow = true; scene.add(m);
    const lights = [], lm = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xfff0c0).multiplyScalar(2.2), toneMapped: false });
    for (let x = r.x0; x <= r.x1; x += 30) for (const s of [-1, 1]) lights.push([x, s * (r.w / 2 + 1)]);
    const im = new THREE.InstancedMesh(new THREE.SphereGeometry(.25, 8, 6), lm, lights.length), mm = new THREE.Matrix4();
    lights.forEach(([x, z], k) => im.setMatrixAt(k, mm.makeTranslation(x, .3, z))); scene.add(im);
    const sign = new THREE.Sprite(new THREE.SpriteMaterial({ map: W.canvasTex(512, 128, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.6)"; g.fillRect(0, 0, w, h); g.fillStyle = "#3dfcff"; g.font = "900 56px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("✈ VOID AIRFIELD", w / 2, 84); }), toneMapped: false }));
    sign.scale.set(24, 6, 1); sign.position.set(r.x0 + 40, 12, r.z + 40); scene.add(sign);
  }

  // ---- physics ----
  function plane(g, I, dt) {
    const V = g.vel.length(), ground = api.groundAt(g.x, g.z, g.y).h, onGround = g.y <= ground + .05;
    g.thr = clamp(g.thr + ((I.gas ? 1 : 0) - (I.brake && !onGround ? 1 : 0)) * dt * .6);
    if (onGround && I.brake) g.thr = Math.max(0, g.thr - dt);
    // smooth inputs: the plane answers like it has mass
    g.sRoll = lerp(g.sRoll || 0, I.turn, clamp(dt * 3)); g.sPitch = lerp(g.sPitch || 0, clamp(I.up - I.down + (I.stickY || 0), -1, 1), clamp(dt * 3));
    const vh = Math.hypot(g.vel.x, g.vel.z), gamma = Math.atan2(g.vel.y, Math.max(1, vh));
    const kL = .0115, INC = .05;
    if (onGround) { g.roll = lerp(g.roll, 0, clamp(dt * 5)); g.yaw += I.turn * clamp(vh / 12) * .55 * dt; g.pitch = lerp(g.pitch, g.sPitch > .2 && vh > 34 ? .14 : 0, clamp(dt * 3)); }
    else {
      g.roll = lerp(g.roll, g.sRoll * 1.05, clamp(dt * 2.4));
      g.yaw += G * Math.tan(g.roll) / Math.max(25, V) * dt;
      const wingK = kL * V * V * 2 * Math.PI * .9 * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll)));
      const needA = wingK > .5 ? clamp(G * Math.cos(gamma) / wingK - INC - g.vel.y * .006, -.25, .24) : 0;
      if (Math.abs(g.sPitch) > .05) g.pitch += g.sPitch * .55 * dt; else g.pitch += (gamma + needA / Math.max(.45, Math.cos(g.roll)) - g.pitch) * clamp(dt * 1.8);
      const cr = Math.max(.45, Math.cos(g.roll)); g.pitch = clamp(g.pitch, gamma - .3 / cr, gamma + .26 / cr);
    }
    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));
    const s0 = new THREE.Vector3().crossVectors(f, new THREE.Vector3(0, 1, 0)).normalize(), u0 = new THREE.Vector3().crossVectors(s0, f), bu = u0.clone().applyAxisAngle(f, g.roll);
    const vhat = V > .1 ? g.vel.clone().divideScalar(V) : f.clone();
    const a = new THREE.Vector3(0, -G, 0);
    const alpha = Math.atan2(-g.vel.dot(bu), Math.max(.1, g.vel.dot(f))); let CL = 2 * Math.PI * .9 * (alpha + INC); const stalled = Math.abs(alpha) > .28 && V > 15; if (stalled) CL = Math.sign(alpha) * .5;
    g.stall = lerp(g.stall || 0, stalled ? 1 : 0, clamp(dt * 5));
    const liftDir = bu.clone().addScaledVector(vhat, -bu.dot(vhat)); if (liftDir.lengthSq() > 1e-6) liftDir.normalize();
    a.addScaledVector(liftDir, kL * V * V * CL * Math.min(2, 1 / Math.max(.5, Math.cos(g.roll))));
    a.addScaledVector(vhat, -kL * V * V * (.05 + .06 * CL * CL + (I.brake && !onGround ? .3 : 0)));
    a.addScaledVector(s0, -g.vel.dot(s0) * 2);
    a.addScaledVector(f, g.thr * 7.5 + (I.nitro ? 5 : 0));
    if (onGround) { a.y = Math.max(a.y, 0); if (g.vel.y < 0) g.vel.y = 0; const roll = .02 * G + (I.brake ? 6 : 0); a.addScaledVector(vhat, -Math.min(roll, V / dt)); a.addScaledVector(s0, -g.vel.dot(s0) * 8); }
    g.vel.addScaledVector(a, dt);
    g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
    if (g.y < ground) {
      const down = -g.vel.y; g.y = ground; g.vel.y = 0;
      if (down > 9 || Math.abs(g.roll) > .5) { api.hurt(down * 5 + Math.abs(g.roll) * 40, "Crashed the plane"); api.impact(g.x, g.z, down * 2); }
      else if (down > 1 && g.airT > 3) api.say(down < 2.5 ? "Smooth landing" : "Firm landing", 2);
      g.airT = 0;
    } else if (g.y > ground + .3) g.airT = (g.airT || 0) + dt;
    g.mode = g.y > ground + .4 ? "air" : "drive";
    const hit = api.hitBox(g.x, g.z, g.y + .5, 2.5); if (hit) { api.hurt(V * 2, "Hit a building"); api.impact(g.x, g.z, V); g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.multiplyScalar(.2); }
    if (g.y < WY + .5 && api.isWater(g.x, g.z) && onGround) api.wreck("Ditched in the water");
  }
  function heli(g, I, dt) {
    const ground = api.groundAt(g.x, g.z, g.y).h;
    g.thr = lerp(g.thr || 0, I.up || I.gas || g.y > ground + .5 ? 1 : .35, clamp(dt * 1.5));
    const coll = I.up - I.down;
    g.vel.y += ((coll * 8 + (g.thr > .6 ? 0 : -3)) - g.vel.y) * clamp(dt * 1.6) ;
    if (g.y <= ground + .05 && coll <= 0) g.vel.y = Math.max(0, g.vel.y);
    // tilt to move: forward/back on gas/brake, turn on steer; slides a little like the real thing
    const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw), push = (I.gas ? 1 : 0) - (I.brake ? 1 : 0) + (I.stickY || 0) * 0;
    g.yaw += I.turn * 1.3 * dt * (g.y > ground + .5 ? 1 : .3);
    const top = I.nitro ? 1.6 : 1;
    if (g.y > ground + .5) { g.vel.x += fx * push * 15 * top * dt; g.vel.z += fz * push * 15 * top * dt; }
    const damp = Math.exp(-.55 * dt); g.vel.x *= damp; g.vel.z *= damp;
    const sp = g.vel.x * fx + g.vel.z * fz, side = -g.vel.x * fz + g.vel.z * fx;
    g.pitch = lerp(g.pitch, -clamp(push * .22 + sp * .002, -.3, .3), clamp(dt * 3)); g.roll = lerp(g.roll, clamp(I.turn * .25 + side * .01, -.4, .4), clamp(dt * 3));
    g.x += g.vel.x * dt; g.y += g.vel.y * dt; g.z += g.vel.z * dt;
    if (g.y < ground) { const down = -g.vel.y; g.y = ground; if (down > 8) { api.hurt(down * 4, "Hard landing"); api.impact(g.x, g.z, down * 2); } g.vel.y = 0; g.vel.x *= .8; g.vel.z *= .8; }
    g.mode = g.y > ground + .4 ? "air" : "drive";
    const hit = api.hitBox(g.x, g.z, g.y + 1, 3.5); if (hit) { const imp = Math.hypot(g.vel.x, g.vel.z); if (imp > 6) { api.hurt(imp * 2, "Rotor strike"); api.impact(g.x, g.z, imp); } g.x += hit.nx * hit.d; g.z += hit.nz * hit.d; g.vel.x *= -.3; g.vel.z *= -.3; }
    if (g.y <= WY + .2 && api.isWater(g.x, g.z)) api.wreck("Ditched in the water");
  }
  const BOATS = {
    speedboat: { thrust: 13, boost: 9, drag: .0055, turn: .8, side: 2.6, bob: 1, plane: 1, r: 3.4 },
    jetski: { thrust: 15, boost: 10, drag: .0075, turn: 1.7, side: 3.2, bob: 1.6, plane: .6, r: 1.6, hop: true },
    yacht: { thrust: 5.5, boost: 3, drag: .0075, turn: .3, side: 1.2, bob: .45, plane: .3, r: 7.5 },
    tug: { thrust: 7, boost: 3, drag: .012, turn: .55, side: 1.6, bob: .6, plane: .2, r: 4.5, heavy: true },
    hover: { thrust: 11, boost: 7, drag: .006, turn: .85, side: .7, bob: .3, plane: 0, r: 3.6, land: true },
  };
  // the sea: a few long swells, the same function everywhere so boats and the river traffic agree
  const wave = (x, z, t) => .22 * Math.sin(x * .08 + t * 1.3) + .16 * Math.sin(z * .11 - t * 1.05) + .08 * Math.sin((x + z) * .23 + t * 2.1);
  function boat(g, I, dt) {
    const B = BOATS[g.boat] || BOATS.speedboat, t = performance.now() / 1000;
    const fx = Math.cos(g.yaw), fz = Math.sin(g.yaw);
    let u = g.vel.x * fx + g.vel.z * fz, w = -g.vel.x * fz + g.vel.z * fx;
    const inAir = B.hop && g.y > WY + .5;
    if (!inAir) {
      u += ((I.gas ? B.thrust : 0) + (I.nitro ? B.boost : 0) - (I.brake ? (u > 1 ? 10 : 5) : 0)) * dt;
      g.yaw += I.turn * (.35 + clamp(Math.abs(u) / 14)) * B.turn * Math.sign(u || 1) * dt;
    }
    u -= (u * .18 + Math.sign(u) * u * u * B.drag) * dt; u = Math.max(-7, u);
    w *= Math.exp(-B.side * dt);
    const nfx = Math.cos(g.yaw), nfz = Math.sin(g.yaw);
    g.vel.x = nfx * u - nfz * w; g.vel.z = nfz * u + nfx * w;
    let nx = g.x + g.vel.x * dt, nz = g.z + g.vel.z * dt;
    // river traffic: bump them aside (and get bumped)
    for (const b of npcBoats) { const dx = nx - b.x, dz = nz - b.z, d = Math.hypot(dx, dz), rr = B.r + b.r; if (d < rr && d > .01) { nx += dx / d * (rr - d); nz += dz / d * (rr - d); const sp = Math.abs(u); if (sp > 6 && !(b.hitT > 0)) { b.hitT = 1.5; api.hurt(sp * (B.heavy ? .4 : 1.1), "Rammed a " + b.type); api.impact(g.x, g.z, sp); } g.vel.multiplyScalar(B.heavy ? .8 : .5); } }
    const ground = B.land ? api.groundAt(nx, nz, g.y + 1) : null, ok = B.land ? !api.hitBox(nx, nz, g.y + .8, 2) : api.isWater(nx, nz);
    if (ok) { g.x = nx; g.z = nz; } else { g.vel.multiplyScalar(-.25); if (Math.abs(u) > 9) { api.hurt(Math.abs(u) * 1.5, B.land ? "Hit a wall" : "Ran aground"); api.impact(g.x, g.z, Math.abs(u)); } }
    // float: a spring toward the wave surface (or the ground for the hovercraft); light boats leave the water off a crest at speed
    const surf = B.land && !api.isWater(g.x, g.z) ? Math.max(0, api.groundAt(g.x, g.z, g.y + 1).h) + .35 : WY + .02 + wave(g.x, g.z, t) * B.bob + (B.land ? .35 : 0);
    g.vy = (g.vy || 0);
    if (g.y > surf + .05) g.vy -= G * dt; else { g.vy += ((surf - g.y) * 40 - g.vy * 7) * dt; if (B.hop && Math.abs(u) > 16) { const ahead = wave(g.x + nfx * 3, g.z + nfz * 3, t) - wave(g.x, g.z, t); if (ahead > .07 && g.vy > 0) g.vy += ahead * 25; } }
    g.y += g.vy * dt; if (g.y < surf - .4) { g.y = surf - .4; g.vy = Math.max(0, g.vy); }
    // attitude: the bow rises as it gets on the plane then settles, it banks into turns, and the swell rocks it
    const slopeAlong = (wave(g.x + nfx * 2, g.z + nfz * 2, t) - wave(g.x - nfx * 2, g.z - nfz * 2, t)) / 4 * B.bob, slopeSide = (wave(g.x - nfz * 1.5, g.z + nfx * 1.5, t) - wave(g.x + nfz * 1.5, g.z - nfx * 1.5, t)) / 3 * B.bob;
    const planeLift = B.plane * (clamp(u / 12) * .11 - clamp((u - 14) / 14) * .07);
    g.pitch = lerp(g.pitch, planeLift + Math.atan(slopeAlong) + (inAir ? .12 : 0), clamp(dt * (B.heavy ? 2 : 4)));
    g.roll = lerp(g.roll, I.turn * clamp(Math.abs(u) / 18) * (B.land ? -.06 : .22) + Math.atan(slopeSide), clamp(dt * 3));
    g.mode = "drive"; g.thr = clamp(Math.abs(u) / 25);
  }
  // ---- river traffic: ferries, tugs, sailboats, yachts and speedboats going up and down the rivers ----
  const NB_TYPES = {
    ferry: { L: 26, W: 8, v: 7, r: 12, parts: [[0, 1.2, 0, 26, 2.4, 8, [.95, .5, .1]], [0, 3.4, 0, 20, 2, 7, [.95, .95, .95]], [1, 5.2, 0, 12, 1.6, 6, [.95, .95, .95]], [1, 5.3, 0, 12.2, .6, 6.1, [.1, .15, .2]]] },
    tug: { L: 9, W: 3.4, v: 5, r: 4.5, parts: [[0, .9, 0, 9, 1.8, 3.4, [.7, .08, .1]], [-.4, 2.6, 0, 3.2, 1.8, 2.4, [.95, .95, .95]], [-2.1, 3.6, 0, .7, 1.6, .7, [.1, .1, .1]]] },
    sailboat: { L: 10, W: 3, v: 4, r: 4.5, parts: [[0, .8, 0, 10, 1.4, 3, [.96, .96, .96]], [0, 6, 0, .15, 10, .15, [.8, .8, .8]], [-1.6, 5.6, 0, 3.2, 8.4, .05, [1, 1, 1]]] },
    yacht: { L: 15, W: 4.2, v: 6, r: 7, parts: [[0, 1, 0, 15, 2, 4.2, [.96, .96, .96]], [-1.2, 2.6, 0, 6.3, 1.2, 3, [.96, .96, .96]], [-1.2, 2.75, 0, 6.4, .5, 3.1, [.05, .08, .12]], [-1.8, 3.6, 0, 3.9, .9, 2.5, [.96, .96, .96]]] },
    speedboat: { L: 7, W: 2.5, v: 13, r: 3.4, parts: [[0, .6, 0, 7, 1.1, 2.5, [.96, .96, .96]], [-.6, 1.25, 0, 3.4, .5, 2, [.75, .1, .12]], [.6, 1.4, 0, .1, .5, 1.9, [.1, .15, .2]]] },
  };
  const npcBoats = [], nbMeshes = {}, wakeGeo = new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), wakes = new THREE.InstancedMesh(wakeGeo, new THREE.MeshBasicMaterial({ color: 0xf4fbff, transparent: true, opacity: .45, depthWrite: false }), 40);
  wakes.frustumCulled = false; scene.add(wakes);
  for (const [type, T] of Object.entries(NB_TYPES)) {
    const geo = mergeGeometries(T.parts.map(([x, y, z, w, h, d, c]) => { const b = new THREE.BoxGeometry(w, h, d).translate(x, y, z).toNonIndexed(), n = b.attributes.position.count, a = new Float32Array(n * 3); for (let i = 0; i < n; i++) a.set(c, i * 3); b.setAttribute("color", new THREE.BufferAttribute(a, 3)); return b; }));
    const mesh = new THREE.InstancedMesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: .5 }), 8); mesh.castShadow = true; mesh.frustumCulled = false; scene.add(mesh); nbMeshes[type] = mesh;
  }
  {
    const R = W.city.rivers, lanes = [[R.hudson[0] + 120, -1], [R.hudson[0] + 260, 1], [R.hudson[1] - 150, -1], [R.east[0] + 140, 1], [R.east[0] + 330, -1], [R.east[1] - 140, 1]];
    const order = ["ferry", "tug", "sailboat", "yacht", "speedboat", "sailboat", "tug", "yacht", "speedboat", "ferry", "sailboat", "speedboat", "yacht", "tug", "speedboat", "sailboat", "ferry", "yacht"];
    order.forEach((type, k) => { const [x, dir] = lanes[k % lanes.length]; npcBoats.push({ type, x, lx: x, z: -2400 + (k * 997) % 4800, dir, v: NB_TYPES[type].v * (.8 + (k % 5) * .08), r: NB_TYPES[type].r, off: 0, hitT: 0 }); });
  }
  const nm = new THREE.Matrix4(), nq = new THREE.Quaternion(), ne = new THREE.Euler(), np = new THREE.Vector3(), ns = new THREE.Vector3(1, 1, 1);
  function tick(dt, time, player) {
    const counts = {}; let wk = 0;
    for (const b of npcBoats) {
      if (b.hitT > 0) b.hitT -= dt;
      // slow for a boat ahead in the same lane, and steer round the player
      let want = b.v * (b.hitT > 0 ? .3 : 1);
      for (const o of npcBoats) if (o !== b && Math.abs(o.lx - b.lx) < 20 && (o.z - b.z) * b.dir > 0 && Math.abs(o.z - b.z) < 60) want = Math.min(want, o.v * .9);
      let offT = 0;
      if (player) { const dz = (player.z - b.z) * b.dir, dx = player.x - (b.x); if (dz > -5 && dz < 70 && Math.abs(dx) < 25) { offT = dx > 0 ? -28 : 28; if (dz < 25) want *= .4; } }
      b.off += (offT - b.off) * Math.min(1, dt * .5); b.cur = (b.cur ?? want) + (want - (b.cur ?? want)) * Math.min(1, dt * .6);
      b.z += b.cur * b.dir * dt; b.x = b.lx + b.off;
      if (Math.abs(b.z) > 2700) { b.dir *= -1; b.z = Math.sign(b.z) * 2690; }
      const yaw = b.dir > 0 ? Math.PI / 2 : -Math.PI / 2, y = WY + wave(b.x, b.z, time) * .5;
      ne.set(Math.sin(time * .9 + b.lx) * .02, -yaw + b.off * .002 * b.dir, Math.sin(time * 1.1 + b.z * .01) * .015, "YXZ"); nq.setFromEuler(ne); nm.compose(np.set(b.x, y, b.z), nq, ns.set(1, 1, 1));
      const mesh = nbMeshes[b.type], k = counts[b.type] = (counts[b.type] || 0); mesh.setMatrixAt(k, nm); counts[b.type]++;
      if (wk < 40 && b.cur > 2) { nm.compose(np.set(b.x, WY + .03, b.z - b.dir * (NB_TYPES[b.type].L * .5 + 6)), nq.set(0, 0, 0, 1), ns.set(NB_TYPES[b.type].W * 1.3, 1, 12 + b.cur)); wakes.setMatrixAt(wk++, nm); }
    }
    for (const [type, mesh] of Object.entries(nbMeshes)) { mesh.count = counts[type] || 0; mesh.instanceMatrix.needsUpdate = true; }
    wakes.count = wk; wakes.instanceMatrix.needsUpdate = true;
  }
  // where each one starts when you pick it
  function spawn(g, type) {
    g.thr = 0; g.sRoll = g.sPitch = 0; g.pitch = g.roll = 0; g.airT = 0;
    if (type === "plane") { const fly = g.mode === "air" && g.y > 30; if (fly) { g.vel.set(Math.cos(g.yaw) * 55, 0, Math.sin(g.yaw) * 55); g.thr = .7; } else { Object.assign(g, { x: RUNWAY.x0 + 30, z: RUNWAY.z, y: 0, yaw: 0 }); g.vel.set(0, 0, 0); } }
    else if (type === "heli") { g.y += .5; g.vel.multiplyScalar(.3); }
    else if (type === "boat") {
      if (g.boat !== "hover" && !api.isWater(g.x, g.z)) {
        const R = W.city.rivers, lake = W.city.lake, opts = [[(R.hudson[0] + R.hudson[1]) / 2, g.z], [(R.east[0] + R.east[1]) / 2, Math.abs(g.z) < 40 ? 80 : g.z], [lake.x, lake.z]];
        const [x, z] = opts.sort((a, b) => Math.hypot(a[0] - g.x, a[1] - g.z) - Math.hypot(b[0] - g.x, b[1] - g.z))[0];
        g.x = x; g.z = z; g.yaw = Math.abs(g.z) > 3000 ? 0 : Math.PI / 2;
      }
      g.vel.set(0, 0, 0); g.y = g.boat === "hover" ? Math.max(WY, api.groundAt(g.x, g.z, g.y + 1).h) + .4 : WY; g.vy = 0;
    }
  }
  return {
    models, spawn, tick, boats: npcBoats,
    step(g, I, dt) { if (g.veh === "plane") plane(g, I, dt); else if (g.veh === "heli") heli(g, I, dt); else if (g.veh === "boat") boat(g, I, dt); },
    show(type, pos, quat, st, time, dt) {
      if (type === "boat" && st && st.boat && st.boat !== "speedboat") type = "boat_" + st.boat;
      for (const [k, m] of Object.entries(models)) { m.root.visible = k === type; if (k === type) { m.root.position.copy(pos); m.root.quaternion.copy(quat); m.update(st, time, dt); } }
    },
  };
}