sindicate 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/climbing.js +136 -0
  23. package/src/systems/deadeye.js +332 -0
  24. package/src/systems/encounters.js +123 -0
  25. package/src/systems/entity.js +524 -0
  26. package/src/systems/fistCurl.js +38 -0
  27. package/src/systems/footsteps.js +161 -0
  28. package/src/systems/glass.js +67 -0
  29. package/src/systems/herd.js +277 -0
  30. package/src/systems/horse.js +361 -0
  31. package/src/systems/mountedTraveller.js +518 -0
  32. package/src/systems/nav.js +343 -0
  33. package/src/systems/npc.js +880 -0
  34. package/src/systems/pathFollow.js +107 -0
  35. package/src/systems/platform.js +484 -0
  36. package/src/systems/riding.js +580 -0
  37. package/src/systems/seatedRider.js +396 -0
  38. package/src/systems/skybirds.js +129 -0
  39. package/src/systems/trainCamera.js +414 -0
  40. package/src/systems/traveller.js +254 -0
  41. package/src/systems/tumbleweeds.js +92 -0
  42. package/src/systems/vat.js +327 -0
  43. package/src/systems/vfx.js +472 -0
  44. package/src/world/blobShadows.js +109 -0
  45. package/src/world/clouds.js +61 -0
  46. package/src/world/flora.js +87 -0
  47. package/src/world/footprints.js +117 -0
  48. package/src/world/lampField.js +58 -0
  49. package/src/world/lampGlow.js +92 -0
  50. package/src/world/scatter.js +1077 -0
  51. package/src/world/sky.js +363 -0
  52. package/src/world/tileManager.js +197 -0
  53. package/src/world/walkHumps.js +74 -0
  54. package/src/world/weather.js +312 -0
@@ -0,0 +1,414 @@
1
+ // THE TRAIN CAMERA — what riding a train is actually FOR.
2
+ //
3
+ // A passenger is not driving. He cannot steer it, he cannot stop it, and the whole value of the
4
+ // journey is the one thing a third-person game normally refuses to show you: the train itself, from
5
+ // outside, moving. So while he rides, the camera stops being a follow camera and becomes a CUTTING
6
+ // ROOM — six shots on a loop, about five seconds each, which is GTA V's train and the right reference.
7
+ //
8
+ // THREE RULES. Every bug in here came from breaking one of them.
9
+ //
10
+ // 1. CUT, DO NOT EASE. The shots are sixty metres apart. Interpolating between two of them flies the
11
+ // lens through the middle of a boxcar at 40 m/s: it cannot be kept out of the geometry, and it
12
+ // does not read as an edit, it reads as a drone. So the cuts are HARD and the movement lives
13
+ // INSIDE each shot — a dolly in, a slow crane, a pan that lags its subject. That is what film does.
14
+ //
15
+ // 2. PLANT IT, DO NOT CHASE IT. Every lens position here is written EXACTLY — set(), never a lerp
16
+ // toward a moving target. Measured on the live game at 16 m/s, sampling 1800 frames of the whole
17
+ // cycle: the lens sits still in the loco's own frame to within 11 mm a frame (that 11 mm IS the
18
+ // dolly), the vertical never changes sign twice, and the look direction moves 0.02° a frame.
19
+ // A smoother chasing a body doing 16 m/s sits v/k metres behind it and every wobble in the frame
20
+ // time wobbles the lag — THAT is what "bouncing like mad" was. There was never anything to chase:
21
+ // the train's own transform is smooth, so a camera written straight off it is smooth too. The only
22
+ // thing low-passed in this file is the TERRAIN, because the ground under a lens doing 16 m/s is a
23
+ // lattice and sampling it raw is its own chatter.
24
+ //
25
+ // 3. IT IS FILMED AFTER THE IRON HAS MOVED. main.js updates the player at :673 and the trains at
26
+ // :692 — so any car transform read from inside player.update() is a frame old, which at 16 m/s is
27
+ // 27 cm, resampled every frame against a frame time that jitters. That is a camera bolted to a
28
+ // carriage that is shivering. This rig runs from Player.aboardPost, at the END of the tick, once
29
+ // the train has actually moved. (platform.js's postUpdate exists for precisely this reason.)
30
+ // ...and it refreshes the matrices of the two cars it reads (see update): three only rebuilds
31
+ // matrixWorld at RENDER, and the loco's is otherwise only fresh by accident — the crew's IK solve
32
+ // walks updateWorldMatrix up through the loco root as a side effect. Shoot the driver and the
33
+ // fireman and that accident stops happening, which would put a frame of lag into every exterior
34
+ // shot in the middle of a train robbery.
35
+ //
36
+ // AND IT GETS OUT OF THE WAY. Aim, draw, take a round, stop at a platform, or press V, and it hands the
37
+ // lens back to the ordinary follow camera in the SAME FRAME, with a snap and not a swoop. A cinematic
38
+ // camera that traps a man who is being shot at is not a feature.
39
+ import * as THREE from 'three/webgpu';
40
+
41
+ // Module temporaries. Nothing in this file allocates after construction — it runs every frame at 120.
42
+ const _p = new THREE.Vector3(); // where the lens goes
43
+ const _t = new THREE.Vector3(); // what it looks at
44
+ const _a = new THREE.Vector3();
45
+ const _b = new THREE.Vector3();
46
+
47
+ const GRACE = 1.1; // seconds of ordinary follow camera after she starts to move, before the first
48
+ // cut — you get to watch her pull out of the platform from your own seat.
49
+ const ROLLING = 1.2; // m/s. Below this she is standing at a call and the film is over: he wants his
50
+ // own camera and his mouse back, to look about and to find the way off.
51
+ const PASS_SPEED = 6; // below this the trackside pass-by has nothing to pass, so it is skipped
52
+ const BLOCK_CUT = 0.45; // seconds of a lost sightline before a shot gives up and cuts early. Long
53
+ // enough that a lineside rock flicking through frame is a wipe, not a bug.
54
+
55
+ // ── THE SHOTS ─────────────────────────────────────────────────────────────────────────────────
56
+ // `dur` is per shot and deliberately not shared. A tight shot on a set of coupling rods outstays its
57
+ // welcome in four seconds; a trackside pass-by needs seven, because a 55 m consist doing 16 m/s takes
58
+ // three and a half of them just to go by. Average 5.4 s, which is the "about five" that was asked for.
59
+ //
60
+ // Each shot writes _p and _t and returns false if it cannot be taken right now (nothing to film, or a
61
+ // wall in the way), in which case the rig moves on to the next one. `guard` means the shot's sightline
62
+ // is worth testing against the world; the interior shots do not need it, because they are written in
63
+ // the car's own frame and are inside it by construction.
64
+ const SHOTS = [
65
+ // 1 ── THE SEAT. Establish that you are ON a train before you show him the train. Him on his bench in
66
+ // the right of frame with the county tearing past the glass at his shoulder, the saloon running away
67
+ // down the left, and the lens creeping in on him over the shot.
68
+ //
69
+ // The numbers are the carriage's, not a guess (train.js:145): bench pan 2.109, BACKREST TOP 2.653,
70
+ // roof 4.10, glass 2.319–3.640, seat.y 1.367 and his head bone seat.y + 1.556. The first version of
71
+ // this shot sat at seat.y + 1.30 = 2.667 — fourteen millimetres above the backrest — so a bench back
72
+ // filled the bottom half of the frame and you could not see the man or the window. It is at head
73
+ // height plus a shoulder now, which clears the backs by 0.66 m and looks down the car.
74
+ { id: 'seat', dur: 5.0, shot(r, k) {
75
+ const car = r.host.car, s = r.host.seat, f = r.fore();
76
+ if (car.kind === 'carriage') {
77
+ // Across the aisle, behind his shoulder, creeping in 0.75 m. x 0.75 and |z − his bench| < 2.6
78
+ // are INSIDE the saloon box (hx 1.30, y 1.93…4.10, z −6.5…7.3) by construction — this lens
79
+ // cannot clip the planking, because it is never outside it.
80
+ _p.set(0.75, s.y + 1.95, s.z - f * (2.55 - k * 0.75));
81
+ _t.set(s.x - 0.25, s.y + 1.52, s.z + f * 1.60);
82
+ } else {
83
+ // The roof rider. Outboard of his shoulder and well above the clerestory (4.747), looking
84
+ // along the train past him. Everything under this lens is roof; there is nothing to clip.
85
+ _p.set(3.05, 6.40, f * (-5.6 + k * 1.3));
86
+ _t.set(1.30, 4.80, f * 3.0);
87
+ }
88
+ car.root.localToWorld(_p);
89
+ car.root.localToWorld(_t);
90
+ return true;
91
+ } },
92
+
93
+ // 2 ── THE TRACKSIDE PASS-BY. The best one, and the reason the file exists. A camera is PLANTED
94
+ // beside the rails ahead of the train — on the line's own geometry, from the route it is actually
95
+ // running, not a guess off the loco's heading — and then it does not move an inch. The train comes
96
+ // to IT. It is the only shot here with no camera motion at all, and it is the one that sells the
97
+ // weight of the thing, because the only thing moving in frame is eighty tons of iron.
98
+ //
99
+ // The pan LAGS its subject. A real operator on a tripod cannot whip-pan an express through ninety
100
+ // degrees at twelve metres either: the train slides out of frame at the abeam point and he catches it
101
+ // up going away. That lag is the shot.
102
+ { id: 'trackside', dur: 7.0, guard: true, plant(r) {
103
+ const T = r.train;
104
+ if (T.speed < PASS_SPEED || !T.route) return false;
105
+ const R = T.route;
106
+ // 44 m of run-up: the loco reaches the lens at 2.7 s and the tail clears it at about 6.1 s, so
107
+ // the whole consist passes inside the shot instead of the shot ending halfway down the train.
108
+ const u = T.u + 44;
109
+ if (u > R.len + 30) return false; // she is about to berth: there is no pass-by
110
+ R.pos(u, _a); _a.y = R.height(u);
111
+ R.pos(u + 4, _b); // the LINE's own tangent here, not the loco's
112
+ const dx = _b.x - _a.x, dz = _b.z - _a.z;
113
+ const L = Math.hypot(dx, dz) || 1;
114
+ const nx = dz / L, nz = -dx / L; // the perpendicular, in the six-foot's own frame
115
+ // Try both sides of the road. 12 m of standoff and 2.4 m of tripod: near enough that the loco
116
+ // fills the frame, far enough that it does not become a blur of planking.
117
+ for (let i = 0; i < 2; i++) {
118
+ const side = i ? -1 : 1;
119
+ const x = _a.x + nx * 12 * side, z = _a.z + nz * 12 * side;
120
+ if (r.world.waterDepthAt?.(x, z) > 0.25) continue; // not in the creek
121
+ const y = Math.max(r.world.groundAt(x, z) + 2.4, _a.y + 1.5);
122
+ _p.set(x, y, z);
123
+ r.aimCar(_t, r.host.car, 1.6);
124
+ // A camera INSIDE the station or the water tower cannot see the train it is pointing at, so
125
+ // the sightline test throws that plant out for us — no special case for the buildings.
126
+ if (!r.world.segmentBlocked(_p, _t)) { r.px = x; r.py = y; r.pz = z; return true; }
127
+ }
128
+ return false;
129
+ },
130
+ shot(r) {
131
+ _p.set(r.px, r.py, r.pz);
132
+ r.aimCar(_a, r.host.car, 1.6);
133
+ // ...and the shot is over the moment its subject is gone: his car forty metres past the lens is
134
+ // a camera filming an empty desert. Cut early rather than sit through it.
135
+ _b.set(_a.x - r.px, 0, _a.z - r.pz);
136
+ if (_b.x * r.fx + _b.z * r.fz > 40) return false;
137
+ _t.copy(_a);
138
+ return true;
139
+ } },
140
+
141
+ // 3 ── THE FOOTPLATE. Out on the running board ahead of the cab, looking BACK along the boiler at the
142
+ // driver in his window, with the smoke coming over the top of frame and the train trailing away
143
+ // behind him. It is outboard of the body (|x| 2.55 against a 1.64 half-width) and above the running
144
+ // board, so it is in fresh air on every axis.
145
+ //
146
+ // TWO SHOTS WERE THROWN AWAY TO GET THIS ONE, and both were photographed before the vote. The lens
147
+ // INSIDE the cab does frame the backhead, the gauges and both men — and the cab interior is unlit, so
148
+ // the top third of the frame is a black slab and the two of them are hard-cropped silhouettes. And
149
+ // the version looking FORWARD off the running board is a handsome shot of a boiler with the crew
150
+ // squarely behind the lens, which is a footplate shot with no footplate in it. This one has the man.
151
+ { id: 'footplate', dur: 5.0, guard: true, shot(r, k) {
152
+ const s = r.locoSide();
153
+ _p.set(s * 2.55, 3.45, 3.2 - k * 1.1); // creeping back down the board toward him
154
+ _t.set(s * 0.95, 3.15, -5.4); // the cab window, and the driver in it (crew z −4.95)
155
+ r.loco.root.localToWorld(_p);
156
+ r.loco.root.localToWorld(_t);
157
+ return true;
158
+ } },
159
+
160
+ // 4 ── THE WIDE. High and behind, far enough back to hold all 55 m of her at once with the smoke
161
+ // trailing off the stack — the shot that says "this is a train" and not "this is a carriage". It
162
+ // cranes down and in over the shot (24 m → 17 m up, 27 m → 16 m out), which is the only movement it
163
+ // needs: at this range the train's own progress does all the work.
164
+ { id: 'wide', dur: 5.5, guard: true, shot(r, k) {
165
+ const mid = r.train.len * 0.5;
166
+ r.along(_a, -mid, 0, 0); // the middle of the consist
167
+ const back = 46 - k * 4, out = 27 - k * 11, up = 24 - k * 7;
168
+ r.along(_p, -mid - back, r.side * out, up);
169
+ _p.y = Math.max(_p.y, r.floorAt(_p.x, _p.z, 5)); // never behind a ridge
170
+ _t.set(_a.x, _a.y + 2.2, _a.z);
171
+ return true;
172
+ } },
173
+
174
+ // 5 ── THE RODS. Mine, and it earns its place because a steam locomotive is the only vehicle in this
175
+ // game whose MECHANISM is worth filming: the drivers turn on distance rolled and the coupling rods
176
+ // reciprocate off the same number (train.js: radians = distance / radius), so they can never be out
177
+ // of step with the ground. Wheel height, four metres out, tracking forward along the running board.
178
+ // Ballast blurring, rods hammering. It is the shortest shot in the cycle on purpose — a tight
179
+ // mechanical hold is over in four seconds.
180
+ { id: 'rods', dur: 4.5, guard: true, shot(r, k) {
181
+ const s = r.locoSide();
182
+ _p.set(s * (4.8 - k * 1.2), 1.95, -3.6 + k * 3.6);
183
+ _t.set(s * 0.6, 1.35, -1.8); // the drivers (hub 0.93) and the rods above them
184
+ r.loco.root.localToWorld(_p);
185
+ r.loco.root.localToWorld(_t);
186
+ _p.y = Math.max(_p.y, r.floorAt(_p.x, _p.z, 0.7)); // a cutting must not swallow the lens
187
+ return true;
188
+ } },
189
+
190
+ // 6 ── THE LOW THREE-QUARTER. The lens runs ahead of her, low, off the shoulder of the track, and she
191
+ // comes ON to it — 24 m of clear air in front of the smokebox closing to 7, so the loco GROWS in
192
+ // frame while the camera holds its line. The only shot that gets the cowcatcher in your face.
193
+ //
194
+ // MEASURED FROM HER NOSE, and that is the whole of the fix. along() is written off the LEAD CAR'S
195
+ // CENTRE, and the first version of this shot asked for `train.len + d` — which is 85 m in front of
196
+ // the loco's middle — and then aimed at a point 50 m ahead of her. The result was a speck of a train
197
+ // on the horizon and four hundred square metres of sand, held for five and a half seconds. The lead
198
+ // car's half-length is the number that turns "forward of the lead car" into "clear of her buffers".
199
+ { id: 'front', dur: 5.5, guard: true, shot(r, k) {
200
+ if (r.train.reversed) return false; // she is being propelled: what is coming at the lens is the
201
+ // arse end of a carriage, and that is not this shot
202
+ const nose = r.train.cars[0].len * 0.5;
203
+ const d = 24 - k * 17, out = r.side * (9.0 - k * 4.2);
204
+ r.along(_p, nose + d, out, 1.3);
205
+ _p.y = Math.max(_p.y, r.floorAt(_p.x, _p.z, 1.5));
206
+ r.along(_t, 3.0, 0, 2.3); // the smokebox, not the sky above it
207
+ return true;
208
+ } },
209
+ ];
210
+
211
+ // ── THE RIG ───────────────────────────────────────────────────────────────────────────────────
212
+ class TrainCamera {
213
+ constructor(train, host) {
214
+ this.train = train;
215
+ this.host = host;
216
+ this.game = train.game;
217
+ this.world = train.game.world;
218
+ this.i = 0;
219
+ this.t = 0;
220
+ this.grace = GRACE;
221
+ this.off = false; // V: he wants his own camera back
222
+ this.side = 1; // which flank the exterior shots stand on — chosen at each cut
223
+ this.blockT = 0;
224
+ this.floor = null; // a LOW-PASSED ground height under the lens (see floorAt)
225
+ this.px = 0; this.py = 0; this.pz = 0; // the trackside plant, in plain numbers: no vector to alias
226
+ this.fx = 0; this.fz = 1; // the way she is going
227
+ this.was = null; // the rider we set up for, so a re-board resets the cycle
228
+ this._planted = null; // the shot whose plant() has been run for this take
229
+ this._held = false;
230
+ this._k = 0;
231
+ this._f = 0;
232
+ }
233
+
234
+ get loco() { return this.train.cars[0]; }
235
+
236
+ // +1 if the car the player is in has its own +Z pointing the way the train is travelling. Every
237
+ // "behind him" in the seat shot is written in car-local z and needs this, because a train that has
238
+ // reversed out of Bonebreak is running with its cars facing backwards.
239
+ fore() {
240
+ return Math.cos(this.host.car.root.rotation.y - this.train.travelYaw) >= 0 ? 1 : -1;
241
+ }
242
+
243
+ // `side` is chosen in the WORLD's terms (see cut). A locomotive that is propelling from the back of
244
+ // the train is turned end for end, so its own +X is the other flank — and a shot written in loco
245
+ // coordinates would stand on the town side of the line and film the back of the station.
246
+ locoSide() { return this.train.reversed ? -this.side : this.side; }
247
+
248
+ // A point in the TRAIN's frame: `f` metres forward of the LEAD CAR'S CENTRE, `s` to its right, `u`
249
+ // up. Taken off the leading car's own position, so it inherits the curve the train is actually on.
250
+ along(out, f, s, u) {
251
+ const lead = this.train.reversed ? this.train.cars[this.train.cars.length - 1] : this.train.cars[0];
252
+ const p = lead.root.position;
253
+ return out.set(p.x + this.fx * f + this.fz * s, p.y + u, p.z + this.fz * f - this.fx * s);
254
+ }
255
+
256
+ // Where a car IS, plus `up` — the aim point for anything filming the player's own coach.
257
+ aimCar(out, car, up) {
258
+ const p = car.root.position;
259
+ return out.set(p.x, p.y + up, p.z);
260
+ }
261
+
262
+ // THE GROUND, LOW-PASSED. A raw groundAt() under a lens doing 16 m/s samples the terrain lattice and
263
+ // steps with it, and a max() against a stepping floor is chatter — the exact thing this rig exists
264
+ // not to do. So the floor is a first-order lag (4/s): it still lifts the camera out of a rise, it
265
+ // just cannot buzz. Re-latched on every cut, because a cut may move the lens sixty metres.
266
+ floorAt(x, z, clear) {
267
+ const g = this.world.groundAt(x, z) + clear;
268
+ this.floor = this.floor == null ? g : this.floor + (g - this.floor) * this._k;
269
+ return this.floor;
270
+ }
271
+
272
+ cut(to) {
273
+ this.i = ((to ?? this.i + 1) % SHOTS.length + SHOTS.length) % SHOTS.length;
274
+ this.t = 0;
275
+ this.blockT = 0;
276
+ this.floor = null;
277
+ this._planted = null;
278
+ // WHICH FLANK THE EXTERIOR SHOTS STAND ON. The town is on the +z side of the line — that is the
279
+ // platform, the station building and the water tower, and it is the side train.js already steps
280
+ // you down onto (makeSeat: of the two sides, take the one nearer the town). So the lens takes the
281
+ // OTHER one: open desert, no building to bury it in, and the town in the background of the frame
282
+ // instead of pressed against the glass. The right-of-travel vector is (fz, −fx), so its z is
283
+ // −fx·side, and away from the town means driving that negative.
284
+ this.side = this.fx >= 0 ? 1 : -1;
285
+ }
286
+
287
+ // ── the one call player.js makes ────────────────────────────────────────────────────────────
288
+ // Returns TRUE when it owns the lens (and the mouse is dead), FALSE when it has handed it back.
289
+ update(dt, camera, player, input) {
290
+ const T = this.train;
291
+ // A NEW RIDE IS A NEW CYCLE. The rig lives on the seat host for the life of the game (no
292
+ // allocation on boarding, and none on alighting either), so it notices a change of rider itself
293
+ // and needs no hook in train.js's board/alight to do it.
294
+ if (this.was !== this.host.rider) {
295
+ this.was = this.host.rider;
296
+ this.i = 0; this.t = 0; this.grace = GRACE; this.off = false;
297
+ this.floor = null; this.blockT = 0; this._planted = null;
298
+ }
299
+
300
+ const ui = this.game.ui;
301
+ const menu = ui?.menuOpen || ui?.dialogueOpen;
302
+ if (!menu && input) {
303
+ // TRIANGLE works the lens from the pad (Nick): the pad synthesizes Triangle as KeyQ, and Q
304
+ // is free aboard — the weapon cycle lives in combatInputs, which the aboard path never runs.
305
+ if (input.hit('KeyV') || input.hit('KeyQ')) { this.off = !this.off; this.grace = 0; input.consume('KeyV'); input.consume('KeyQ'); }
306
+ if (input.hit('KeyC') && !this.off) { this.cut(); this.grace = 0; input.consume('KeyC'); }
307
+ }
308
+
309
+ // HAND IT BACK, INSTANTLY. He reached for the gun, he is reloading, he took a round, a fight
310
+ // started, he died, she has stopped at a platform, or he asked for it back.
311
+ //
312
+ // gunInputs does not run at all on the aboard path (player.js returns above it at :582), so
313
+ // `aiming` and `drawn` can never LATCH while he is sitting down — the RAW right button is the only
314
+ // honest test of "he is trying to bring the gun up", and it is the one that matters. (`drawn` is
315
+ // still tested, because a man can board with it already in his hand — and train.js's board() takes
316
+ // it off him for exactly that reason, so this is the belt to that pair of braces.)
317
+ const armed = player.aiming || player.drawn || player.reloading || player._drawT > 0
318
+ || (input?.acting && (input.held(2) || input.held(0)));
319
+ const fight = player._combatT > 0 || this.game._inCombat || !player.alive;
320
+ // ...and STANDING AT A CALL is not a journey. Cutting between six angles of a train that is not
321
+ // moving is a slideshow, and the man in it is trying to find the door: give him back his own
322
+ // camera and his mouse the moment she comes to a stand.
323
+ if (this.off || armed || fight || T.stabled || T.speed < ROLLING) {
324
+ this.hold(player);
325
+ return false;
326
+ }
327
+
328
+ if (this.grace > 0) { this.grace -= dt; this.hold(player); return false; }
329
+
330
+ // THE MATRICES OF THE TWO CARS THIS RIG READS. See rule 3 at the top: three rebuilds matrixWorld at
331
+ // render, and a shot written in a car's own frame off last frame's matrix is 27 cm of lag at line
332
+ // speed, jittering with the frame time. host.sync() has already refreshed the player's car; the
333
+ // LOCO's is only fresh as a side effect of its crew's IK solve, and the crew can be shot.
334
+ this.loco.root.updateWorldMatrix(false, false);
335
+
336
+ this._k = Math.min(1, dt * 4);
337
+ this.fx = Math.sin(T.travelYaw);
338
+ this.fz = Math.cos(T.travelYaw);
339
+
340
+ this.t += dt;
341
+ let S = SHOTS[this.i];
342
+
343
+ // Take the shot. A shot that cannot be taken (nothing to pass, no clear line, a loco on the wrong
344
+ // end of the train) is SKIPPED rather than shown — a camera pointing at sand for five seconds is
345
+ // worse than a shorter cycle. The bounded loop is the whole safety argument: it can never spin,
346
+ // and if every shot in the list refuses, the follow camera takes over.
347
+ for (let tries = 0; tries < SHOTS.length; tries++) {
348
+ S = SHOTS[this.i];
349
+ if (this._planted !== S) {
350
+ if (S.plant && !S.plant(this)) { this.cut(); continue; }
351
+ this._planted = S;
352
+ }
353
+ if (S.shot(this, Math.min(1, this.t / S.dur)) !== false) break;
354
+ this.cut();
355
+ }
356
+ if (this._planted !== SHOTS[this.i]) { this.hold(player); return false; }
357
+
358
+ // THE SIGHTLINE. A rock, a shed or the water tower between the lens and the train is a black frame,
359
+ // and the user WILL see it. Tested every third frame (it is a BVH raycast, and 25 ms of latency on
360
+ // a cut-away is invisible), and only after BLOCK_CUT of it, so a fence post flicking through frame
361
+ // is a wipe and not a bug.
362
+ if (S.guard) {
363
+ this._f++;
364
+ if (this._f % 3 === 0 && this.world.segmentBlocked(_p, _t)) this.blockT += dt * 3;
365
+ else this.blockT = Math.max(0, this.blockT - dt);
366
+ if (this.blockT > BLOCK_CUT) this.cut();
367
+ }
368
+
369
+ camera.position.copy(_p);
370
+ camera.lookAt(_t);
371
+ if (this.t >= S.dur) this.cut();
372
+ return true;
373
+ }
374
+
375
+ // Handing the lens back. The follow camera EASES toward its target (player.js: lerp on rdt*10), so
376
+ // without a snap it would swoop in from sixty metres away over half a second — with a gun in his hand
377
+ // and someone shooting at him. So it CUTS, and it cuts to a camera that is already behind his head:
378
+ // camYaw is re-derived from the car's CURRENT heading, because the carriage has been going round
379
+ // curves since he sat down and the yaw board() gave him is a minute stale.
380
+ //
381
+ // ...and the shot that was up is ABANDONED, not paused. Come back to it after ten seconds of a
382
+ // gunfight and its trackside tripod is a hundred and sixty metres behind the train, filming the
383
+ // desert. The take restarts from the top when the film does.
384
+ hold(player) {
385
+ if (this._held) return;
386
+ this._held = true;
387
+ this.t = 0;
388
+ this._planted = null;
389
+ this.floor = null;
390
+ player._camSnap = true;
391
+ player.camYaw = this.host.car.root.rotation.y + (this.host.seat?.ry ?? 0) + Math.PI;
392
+ player.camPitch = 0.06;
393
+ }
394
+ }
395
+
396
+ // The prompt, so the keys are discoverable without a manual. main.js renders whatever the interactable
397
+ // hands it and splits on the first dash, so the leading "E —" is what lights up as the key cap.
398
+ const PROMPT = 'E — Get off · C next shot · V free camera';
399
+
400
+ // Hang a rig on a seat host. Built ONCE, on the loading screen, with the train — nothing here is
401
+ // allocated when he boards. player.js calls host.camera() while he is aboard and falls back to the
402
+ // ordinary follow camera the moment it answers false. A stagecoach host has no `camera` field at all,
403
+ // which is exactly why the coach is untouched by any of this.
404
+ export function attachTrainCamera(train, host) {
405
+ const rig = new TrainCamera(train, host);
406
+ host.cam = rig;
407
+ host.camPrompt = PROMPT;
408
+ host.camera = (dt, camera, player, input) => {
409
+ const on = rig.update(dt, camera, player, input);
410
+ if (on) rig._held = false;
411
+ return on;
412
+ };
413
+ return rig;
414
+ }