sindicate 0.4.0 → 0.5.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.
@@ -0,0 +1,1957 @@
1
+ // THE DUSTWATER & COPPERHEAD RAILROAD — the trains that run on it.
2
+ //
3
+ // A train is the coach's bigger, heavier cousin, and almost every idea in here is lifted from
4
+ // game/coach.js on purpose: a vehicle following a route polyline, wheels turned BY THE GROUND
5
+ // (radians = distance / radius, never a guess), a look-ahead that brakes for whatever is in the way,
6
+ // moving colliders pushed into the world list and rewritten every frame, seated crew who can be
7
+ // shot, E-key interactables that travel with the vehicle, and player.aboard for the ride.
8
+ // Where it differs, it differs because eighty tons is not four:
9
+ //
10
+ // IT CANNOT SWERVE AND IT CANNOT STOP. 0.35 m/s^2 up, 0.9 m/s^2 down, 16 m/s cruise — so from
11
+ // line speed it needs 142 m of clear road to come to a stand. Everything about how it is driven
12
+ // falls out of that one number: it asks for the road a long way out, and it brakes on a CURVE
13
+ // (v = sqrt(2*a*d)), not by easing toward a target. An exponential ease is how a COACH stops; it
14
+ // approaches the stop asymptotically and would put the loco through the buffers every time.
15
+ //
16
+ // IT IS ON RAILS, SO THE POLYLINE IS THE CENTRELINE. coach.js's LANE offset is the only thing
17
+ // that puts a coach beside its own route line; a train has no lane, so the offset is ABSENT, not
18
+ // zeroed. And it does not take its height from groundAt(): a rigid 55 m consist reading raw
19
+ // terrain at each car articulates over every bump, and the ground under this line kinks 12.8 deg
20
+ // at one joint and steps 1.1 m at the level crossing where the road's own bench lifts it. It rides
21
+ // a GRADED PROFILE, baked once on the loading screen (see makePath).
22
+ //
23
+ // TWO TRAINS ON ONE TRACK WILL MEET. See THE SIGNALMAN. That is the whole ballgame.
24
+ //
25
+ // PERF: every car, every crewman, the whole fleet is built ONCE on the loading screen and stays
26
+ // resident for life (main.js:118 — a skinned mesh entering the scene mid-play is a pipeline build
27
+ // and a multi-second freeze). Nothing is spawned when a train arrives. A train that has left the
28
+ // county is not removed from the scene: it is STABLED — dropped below the world with its colliders
29
+ // switched off — and it climbs back onto the rails when its next path is granted.
30
+ import * as THREE from 'three/webgpu';
31
+ import { instantiate, loadModel, loadTexture } from '../core/assets.js';
32
+ import { SeatedRider, SEAT, passengerPosture, driverPosture } from './seatedRider.js';
33
+ // Train content is GAME data, registered before construction (partials merge):
34
+ // setTrainContent({ colliders, dropItemsFor, railPaths, railStops, crossing, halts })
35
+ let colliders = [], dropItemsFor = () => null;
36
+ let SURVEY_PATHS = [], SURVEY_STOPS = [], SURVEY_CROSSING = null, RAIL_HALTS = [];
37
+ export function setTrainContent(c = {}) {
38
+ if (c.colliders) colliders = c.colliders;
39
+ if (c.dropItemsFor) dropItemsFor = c.dropItemsFor;
40
+ if (c.railPaths) SURVEY_PATHS = c.railPaths;
41
+ if (c.railStops) SURVEY_STOPS = c.railStops;
42
+ if (c.crossing !== undefined) SURVEY_CROSSING = c.crossing;
43
+ if (c.halts) RAIL_HALTS = c.halts;
44
+ }
45
+ import { dist2D } from '../core/utils.js';
46
+ import { rayCapsule } from './aim.js';
47
+ import { alarmTown } from './npc.js';
48
+ import { castFares } from './coach.js';
49
+ import { attachTrainCamera } from './trainCamera.js';
50
+
51
+ // ── WHERE THE TRACK IS ────────────────────────────────────────────────────────────────────────
52
+ // The permanent way is somebody else's module (src/world/railway.js — the track author's). It may
53
+ // not exist yet, and a bare `import` of a missing file is a BUILD ERROR, not a runtime warning — so
54
+ // it comes in through import.meta.glob, which resolves to {} when the file is absent and lets the
55
+ // build stay green either way. The fallback below is the SITED route — the one that was checked
56
+ // clear of every settlement, farm, ranch and the quarry works — and deliberately NOT the old draft
57
+ // in world/data/railway.js, which puts the line at z=-150 with the spur leaving east of the
58
+ // crossing. A train laid on that draft would run eighty metres from the nearest rail.
59
+ // THE TRACK'S OWN SURVEY, from the file that solved it.
60
+ //
61
+ // This globbed ../world/railway.js — the BUILDER — which exports buildRailway() and nothing else.
62
+ // So RAIL.RAIL_PATHS was undefined, the fallback took over, and the trains ran on a flat polyline
63
+ // with heights of their own invention while the track sat on the graded formation: the rails on the
64
+ // ground and the train seven to ten METRES above them, sailing over the county.
65
+ //
66
+ // The survey lives in ../world/data/railway.js — headless, no three, a plain import — and it is the
67
+ // same module the builder laid every sleeper from. One survey, one railway.
68
+ // THE TRACK AUTHOR'S MODULE. src/world/railway.js LAYS the permanent way; src/world/data/railway.js
69
+ // says WHERE it goes, and publishes the only three things a train needs to know:
70
+ //
71
+ // RAIL_LINES the surveyed centreline as a dense node table — {x, y, z, s} per node, where y is
72
+ // the GRADED FORMATION HEIGHT. That is the gift. The raw ground under this line
73
+ // ranges over 25 m, kinks 12.8 degrees at a joint and steps 1.1 m at the level
74
+ // crossing where the road's own bench lifts it; a rigid 55 m consist reading
75
+ // groundAt() at each car saws up and down like a caterpillar. The formation is the
76
+ // surface his rails are actually laid on, so the trains ride HIS ballast rather
77
+ // than my guess at it, and the wheels sit on the iron instead of hovering over it.
78
+ // RAIL_STOPS the calls, as arc length along a named line. Not an (x,z) to be projected — the
79
+ // graded centreline is up to 1.25 m off the surveyed polyline, so projecting a pin
80
+ // onto it is a guess where he has an answer.
81
+ // LEVEL_CROSSING where the north road crosses the rails, found rather than assumed.
82
+ // JUNCTION where the ore spur leaves the main line (the spur's own s = 0).
83
+ //
84
+ // Both files come in through import.meta.glob, which resolves to {} when a file is absent — a bare
85
+ // import of a module that has not landed yet is a BUILD ERROR, not a runtime warning, and this had
86
+ // to compile green before the track existed. The polyline fallback below is the SITED route, and it
87
+ // bakes its own grade (see gradePolyline). It is dead code the moment the track author lands, and it
88
+ // stays because a game that will not boot without another author's file is a game with a fuse in it.
89
+ const _mods = import.meta.glob(['../world/railway.js', '../world/data/railway.js'], { eager: true });
90
+ const RD = _mods['../world/data/railway.js'] ?? {};
91
+
92
+ const LINES_IN = RD.RAIL_LINES ?? null; // { main: {nodes,len}, spur: {nodes,len} }
93
+ const STOPS_IN = RD.RAIL_STOPS ?? null; // [{ id, name, line, s, platformSide, wait }]
94
+ const XING_IN = RD.LEVEL_CROSSING ?? null; // { line, s, x, z }
95
+ const JUNC_IN = RD.JUNCTION ?? null; // { line, s, x, z }
96
+
97
+ // The railhead above the formation — his number, not mine. A car's origin sits at RAILHEAD CONTACT
98
+ // (measured: min.y = 0 on all four models), so this is exactly how high off the ballast it rides,
99
+ // less a flange: the wheel's lowest point is its flange, not its tread, and standing the tread ON
100
+ // the rail is what closes the hairline of daylight under a stationary loco.
101
+ const RAIL_TOP = RD.RAIL_TOP ?? 0.3285;
102
+ const FLANGE = 0.06;
103
+ const RAIL_Y = RAIL_TOP - FLANGE;
104
+
105
+ // ── THE FALLBACK ROUTE (only if data/railway.js has not landed) ───────────────────────────────
106
+ const FALLBACK_PATHS = [
107
+ { id: 'main', pts: [
108
+ { x: -1040, z: -230 }, { x: -520, z: -230 }, { x: -160, z: -232 }, { x: 0, z: -230 },
109
+ { x: 200, z: -215 }, { x: 520, z: -208 }, { x: 1040, z: -202 },
110
+ ] },
111
+ { id: 'spur', pts: [
112
+ { x: -300, z: -230 }, { x: -340, z: -224 }, { x: -368, z: -212 }, { x: -372, z: -200 },
113
+ ] },
114
+ ];
115
+ const FALLBACK_STOPS = [
116
+ { id: 'dustwater', name: 'Dustwater Station', line: 'main', x: -20, z: -230, wait: 16 },
117
+ { id: 'bonebreak', name: 'Bonebreak Loading', line: 'spur', x: -372, z: -200, wait: 26 },
118
+ ];
119
+ const FALLBACK_CROSSINGS = [{ x: 0, z: -230 }];
120
+
121
+ // ── THE MODELS ────────────────────────────────────────────────────────────────────────────────
122
+ const W = '/assets/western/';
123
+ const ATLAS = '/assets/textures/atlas_western.png';
124
+
125
+ // MEASURED off the loaded geometry's bounding box, never guessed — the stagecoach came out the size
126
+ // of a matchbox once for exactly this reason. Metres, after the cm→m scale (there are no GLB twins
127
+ // for the train assets, so they load as raw FBX at 0.01).
128
+ // len IS the coupling pitch. Synty's own demo spaces these cars at (len_n + len_{n+1}) / 2 with
129
+ // 3–10 cm of NEGATIVE slack: the boxes already contain their buffers. The old draft's
130
+ // COUPLING = 0.6 would leave a hand's width of daylight between every vehicle in the train.
131
+ // top is the collider's y1. A collider with no height band is solid all the way to the sky, so
132
+ // the tender becomes a bulletproof wall, you cannot shoot the man on the footplate (he
133
+ // stands on top of the thing), and you cannot shoot over a van at the man behind it.
134
+ // coach.js:281 paid for that lesson once already.
135
+ const MODELS = {
136
+ loco: { url: `${W}SM_Veh_Train_01.fbx`, len: 12.495, halfW: 1.64, top: 4.10 },
137
+ tender: { url: `${W}SM_Veh_Train_Coal_01.fbx`, len: 7.559, halfW: 1.64, top: 3.30 },
138
+ carriage: { url: `${W}SM_Veh_Train_Carriage_01.fbx`, len: 17.336, halfW: 1.88, top: 4.20 },
139
+ freight: { url: `${W}SM_Veh_Train_Freight_01.fbx`, len: 13.884, halfW: 1.85, top: 4.30 },
140
+ };
141
+
142
+ // The loco faces +Z: the pony truck (r 0.514) and the smokestack are at +Z, the driving wheels
143
+ // (r 0.931) and the cab at -Z.
144
+ const ROD_THROW = 0.22; // metres of piston stroke, converted into the node's own (cm) space at load
145
+ const CAB_FLOOR = 1.94; // measured: the strongest horizontal plane in the cab (263 verts at y=1.94)
146
+ const STACK = { y: 5.80, z: 3.12 }; // the top of SM_Veh_Train_01_Alt_Smokestack
147
+
148
+ // ── THE INSIDE OF THE CARRIAGE, AS THE MODEL ACTUALLY BUILT IT ────────────────────────────────
149
+ // Every number here came off the loaded geometry — traverse the car, transform each triangle into
150
+ // car.root's own frame, cluster the up-facing ones — because the seat table that used to be here was
151
+ // authored by eye, and the whole of it was wrong: four seats in a car that has thirty-six, two of
152
+ // them landing in the gangway between the benches, and all four of them 0.59 m too high. The fares
153
+ // rode to Dustwater sitting on nothing, with their heads level with the window glass.
154
+ //
155
+ // DECK y = 1.578 one continuous plane, x ±1.481, z −8.13…8.04
156
+ // BENCH PAN y = 2.109 18 a side, each 1.055 wide (|x| 0.407…1.462, centre 0.934) and 0.292 deep
157
+ // BACKREST 2.109 → 2.653
158
+ // GLASS x = ±1.521, y 2.319…3.640 — the sill is 0.21 above the pan, which is what a window is
159
+ //
160
+ // The saloon is NINE BAYS: two benches facing each other across a window, neighbouring bays sharing
161
+ // a backrest back to back. Every bay is centred on a pane (9 for 9, to within 0.15 m). There is no
162
+ // such thing as a bad seat in this car.
163
+ const DECK = 1.578;
164
+ const PAN = 2.109;
165
+
166
+ // WHAT seat.y MEANS — AND IT IS NEITHER THE ARSE NOR THE BOOTS.
167
+ // A seat is the rider's MODEL ORIGIN (seatedRider.js:167), and the seated pose only ROTATES bones:
168
+ // his hips stay at their standing height above it and his boots fold up above it too. Measured off
169
+ // the posed skin of a live fare, and identical on every one of them, so these are constants of the
170
+ // pose and not of the man: boot sole +0.456, ARSE UNDERSIDE +0.742, head bone +1.556, hat +1.871.
171
+ // To put a man's arse on a surface at height H there is exactly one answer: seat.y = H − 0.742.
172
+ const ARSE = 0.742;
173
+ const SEAT_Y = PAN - ARSE; // 1.367 — thighs on the pan, head bone dead centre of the glass
174
+
175
+ // HIS BOOTS DO NOT REACH THE DECK, AND NO NUMBER IN THIS FILE CAN MAKE THEM. The pan stands 0.531
176
+ // above the floor and this rig's arse-to-sole is 0.286 (0.742 − 0.456) with the shin already hanging
177
+ // vertical — 0.245 short, and straightening the knee buys back less than half of it. Of the two lies
178
+ // on offer, boots tucked 0.245 over the deck is the one nobody sees; boots ON the deck sinks a
179
+ // quarter of a metre of him into the bench, which is the bug we came here to fix, upside down.
180
+
181
+ // The nine bays, as the z of each pan (measured, both sides identical). The lower one of a pair faces
182
+ // +Z, the upper faces −Z, and the two of them look at each other across the window between them.
183
+ const BAYS = [
184
+ [-6.427, -5.450], [-4.810, -3.887], [-3.247, -2.361], [-1.721, -0.799], [-0.159, 0.764],
185
+ [1.405, 2.278], [2.918, 3.840], [4.481, 5.403], [6.044, 7.213],
186
+ ];
187
+ const PAN_X = 0.934;
188
+ // A seat entry, from a bay. side: −1 or +1. half: 0 = the forward-facing bench, 1 = the backward one.
189
+ const bench = (side, bay, half) => ({ x: side * PAN_X, y: SEAT_Y, z: BAYS[bay][half], ry: half ? Math.PI : 0 });
190
+
191
+ // FOUR FARES, FOUR REAL BENCHES — spread down the car, both sides, both facings, the way a half-empty
192
+ // branch-line coach fills up. Bay 5 is left clear: that one is yours.
193
+ const FARE_SEATS = [bench(1, 1, 0), bench(-1, 2, 1), bench(1, 5, 1), bench(-1, 7, 0)];
194
+ const PLAYER_BAY = 4; // mid-car, so the lens has 7 m of saloon behind him whichever way he sits
195
+
196
+ // KEEPING THE LENS INSIDE THE COACH. The saloon is 2.98 m wall to wall and the follow camera wants to
197
+ // be metres behind him; player.js's pull-in ray only knows about BUILDINGS (its BVH is baked out of
198
+ // the town at world build — the train is not in it), so the lens would simply sit outside the
199
+ // planking looking at the outside of it. That is why the man who wrote this first put the player on
200
+ // the veranda and called the inside "invisible". It isn't: it just needs to be told where the walls
201
+ // are, in the car's own frame, once a frame (Train.makeSeat → camConfine).
202
+ const SALOON = { hx: 1.30, y0: DECK + 0.35, y1: 4.10, z0: -6.50, z1: 7.30 };
203
+
204
+ // ── THE BOXCAR, AND WHY YOU RIDE ON TOP OF IT ─────────────────────────────────────────────────
205
+ // Two of the four trains have no carriage in them at all — the ore is a loco, a tender and three
206
+ // vans — and a train standing at a platform with nothing to press E on is a train the county cannot
207
+ // use. There is nowhere INSIDE a van to sit: it has no end platform, no step and no ladder (measured:
208
+ // the only things down there are the brake gear), and both its side doors are shut flush in the wall.
209
+ // But the roof is enormous and it is dead flat:
210
+ //
211
+ // ROOF y = 4.270, x ±1.77, z ±6.4 — 45 m² of it
212
+ // CLERESTORY a raised centre: y = 4.612 out to x ±1.30, 4.747 out to x ±0.70
213
+ //
214
+ // which leaves a 0.47 m shoulder of flat roof outboard of the clerestory, running the whole length of
215
+ // the van. So he rides it hobo-fashion: arse on the shoulder, back against the clerestory, boots
216
+ // swinging over the side of a moving boxcar. Every surface in that sentence is one the model has.
217
+ const ROOF = 4.270;
218
+ const HOBO = { x: 1.52, y: ROOF - ARSE, z: 0, ry: Math.PI / 2 }; // 1.52 = the middle of the shoulder
219
+
220
+ // ── WHAT IT WEIGHS ────────────────────────────────────────────────────────────────────────────
221
+ const CRUISE = 16.0; // m/s — ~36 mph, a respectable branch-line speed for 1880
222
+ const SHUNT = 4.5; // ...but not down the ore spur, which is a siding with a 40 m radius on it
223
+ const ACCEL = 0.35; // m/s^2. Half a minute to line speed. It does not leap away from a platform.
224
+ const BRAKE = 0.9; // m/s^2. 16^2 / (2*0.9) = 142 m to stop. THIS number drives everything below:
225
+ // the brake curve v = sqrt(2*BRAKE*d) means she shuts off 142 m from the
226
+ // platform, which is where a driver actually shuts off.
227
+ const STOP_PAD = 2.5; // ...aiming two and a half metres short of it, because a discrete
228
+ // integrator tracks a hair ABOVE the curve and would otherwise roll
229
+ // through the buffer stops at walking pace
230
+ const CREEP = 0.8; // and the last couple of metres at a crawl: she kisses the stop
231
+
232
+ // THE LOOK-AHEAD, coach.js's _blocked() in a bigger hat: how far in front do I care, and how wide is
233
+ // the corridor. The reach must cover the braking distance or it is decoration — a train that sees
234
+ // 30 m ahead at 16 m/s has already hit whatever it saw. LOOK_PER_MS is 9.5 seconds of road: at line
235
+ // speed that is 30 + 152 = 182 m, which is the 142 m stopping distance plus the margin plus slack.
236
+ const LOOK = 30;
237
+ const LOOK_PER_MS = 9.5;
238
+ const CORRIDOR = 3.4; // half-width, metres
239
+ const SAFE_GAP = 26; // ...and it stops THIS far short. A train does not nose up against another
240
+ // train's buffers; it stands off, and the guard walks up with a lamp.
241
+
242
+ // THE DWELLS. A minute at the platform: long enough to walk up, get on, and find a seat before she
243
+ // pulls out, which sixteen seconds was not. The cost is the level crossing — the platform is twenty
244
+ // metres from it and a fifty-five metre train cannot stand at one without standing on the other, so
245
+ // the gates are shut for that minute and the road waits. The berth clamp (berthLead) already stands
246
+ // her as clear of it as the geometry allows; the rest is what a level crossing has always cost.
247
+ const DWELL_STATION = 60;
248
+ const DWELL_LOAD = 26; // Bonebreak: they are shovelling ore into it. It takes longer.
249
+ const DWELL_TERMINAL = 10; // the layover INSIDE THE TUNNEL before she works back out turned
250
+ // (Nick's edge-of-world design: in, ten seconds, out the other way)
251
+ const TUNNEL_S = 15; // the main line's first/last metres are BORED — a car wholly inside
252
+ // the hill is invisible (the portals are dressed in world/railway.js)
253
+ const ASK_EVERY = 1.0; // a signalman does not re-ask for the road a hundred and twenty times a second
254
+
255
+ // SHOOT THE MAN ON THE REGULATOR. The stage bolts (coach.js:106). A train cannot bolt — it is already
256
+ // doing sixteen metres a second and there is nowhere for it to go — so it does the worse thing: it
257
+ // RUNS ON. Nobody shuts the regulator, it does not make its booked call, it storms through Dustwater
258
+ // without stopping. If the fireman lives he gets across the footplate and shuts it off. If he does
259
+ // not, she runs to the end of the line and stands there with her fire dying until the company sends
260
+ // out another driver — because a railroad does not stop because a man was shot on it, and without
261
+ // the relief crew a single bullet retires a train permanently and the fleet bleeds out over an
262
+ // afternoon, one loco at a time.
263
+ const REGULATOR_SECS = 34;
264
+ const RELIEF_SECS = 75;
265
+ const FOOTPLATE_PURSE = [40, 80]; // the driver's pay tin — the railroad's money, and heavier than a stage's
266
+ const FIREMAN_PURSE = [14, 32];
267
+ const PASSENGER_PURSE = [5, 26];
268
+ const roll = ([a, b]) => a + Math.floor(Math.random() * (b - a + 1));
269
+
270
+ // ── THE PERMANENT WAY, AS SOMETHING A TRAIN CAN BE AT A POINT ON ──────────────────────────────
271
+ // A path is a polyline turned into arc length, plus a HEIGHT PROFILE — and the profile is the whole
272
+ // reason this exists. `groundAt` along the main line ranges over 25 metres, kinks up to 12.8 degrees
273
+ // at a joint and steps 1.1 m at the level crossing where the road's own bench lifts it. Put a rigid
274
+ // 55 m train on that and the cars saw up and down against each other like a caterpillar. So the
275
+ // ground is sampled once, at load, gradient-limited to a ruling grade, and smoothed — and THAT is
276
+ // what the trains ride. It is a railway engineer's answer rather than a graphics one: you do not
277
+ // follow the ground, you cut and fill until the ground follows you.
278
+ const STEP = 8; // metres between profile samples
279
+ const RULING_GRADE = 0.02; // 1 in 50. Steeper and no loco on earth lifts the ore out of Bonebreak.
280
+
281
+ function gradePolyline(id, pts, world) {
282
+ const P = { id, pts, cum: [0], len: 0 };
283
+ for (let i = 1; i < pts.length; i++) {
284
+ P.len += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].z - pts[i - 1].z);
285
+ P.cum.push(P.len);
286
+ }
287
+ const n0 = pts.length;
288
+
289
+ // Where the line is, at arc length s. It EXTRAPOLATES off both ends on purpose: a 55 m train
290
+ // berthed at the end of the line has thirty metres of itself behind the last sleeper, and a clamp
291
+ // there would concertina every car in the consist into the same point.
292
+ P.pos = (s, out) => {
293
+ if (s <= 0) {
294
+ const dx = pts[1].x - pts[0].x, dz = pts[1].z - pts[0].z;
295
+ const L = Math.hypot(dx, dz) || 1;
296
+ return out.set(pts[0].x + (dx / L) * s, 0, pts[0].z + (dz / L) * s);
297
+ }
298
+ if (s >= P.len) {
299
+ const dx = pts[n0 - 1].x - pts[n0 - 2].x, dz = pts[n0 - 1].z - pts[n0 - 2].z;
300
+ const L = Math.hypot(dx, dz) || 1;
301
+ const o = s - P.len;
302
+ return out.set(pts[n0 - 1].x + (dx / L) * o, 0, pts[n0 - 1].z + (dz / L) * o);
303
+ }
304
+ let i = 1;
305
+ while (i < n0 - 1 && P.cum[i] < s) i++;
306
+ const a = pts[i - 1], b = pts[i];
307
+ const t = (s - P.cum[i - 1]) / (P.cum[i] - P.cum[i - 1] || 1);
308
+ return out.set(a.x + (b.x - a.x) * t, 0, a.z + (b.z - a.z) * t);
309
+ };
310
+
311
+ // ---- CUT AND FILL ---------------------------------------------------------------------------
312
+ const n = Math.max(2, Math.ceil(P.len / STEP) + 1);
313
+ const h = new Float32Array(n);
314
+ const _v = new THREE.Vector3();
315
+ for (let i = 0; i < n; i++) {
316
+ P.pos(i * STEP, _v);
317
+ h[i] = world.groundAt(_v.x, _v.z);
318
+ }
319
+ {
320
+ // One sweep of a gradient limiter run FORWARD, one run BACK, then averaged. A single pass biases
321
+ // the whole formation the way it swept (every summit shaved, every hollow left, or the reverse);
322
+ // averaging the two keeps the line near the natural ground while making the grade legal, which is
323
+ // exactly the trade a surveyor makes with a theodolite and a budget.
324
+ const maxStep = RULING_GRADE * STEP;
325
+ const up = Float32Array.from(h), dn = Float32Array.from(h);
326
+ for (let i = 1; i < n; i++) up[i] = Math.max(Math.min(up[i], up[i - 1] + maxStep), up[i - 1] - maxStep);
327
+ for (let i = n - 2; i >= 0; i--) dn[i] = Math.max(Math.min(dn[i], dn[i + 1] + maxStep), dn[i + 1] - maxStep);
328
+ for (let i = 0; i < n; i++) h[i] = (up[i] + dn[i]) * 0.5;
329
+ // ...and then smooth it, because a limiter leaves corners and a train notices corners.
330
+ for (let pass = 0; pass < 6; pass++) {
331
+ const c = Float32Array.from(h);
332
+ for (let i = 1; i < n - 1; i++) h[i] = c[i - 1] * 0.25 + c[i] * 0.5 + c[i + 1] * 0.25;
333
+ }
334
+ }
335
+ P.h = h;
336
+ P.height = (s) => {
337
+ const u = s / STEP;
338
+ if (u <= 0) return h[0];
339
+ if (u >= n - 1) return h[n - 1];
340
+ const i = Math.floor(u);
341
+ return h[i] + (h[i + 1] - h[i]) * (u - i);
342
+ };
343
+ // The arc position of the point on this path nearest (x,z) — how a station PIN becomes a place on
344
+ // the line, and how the spur's first point becomes the junction.
345
+ P.sNear = (x, z) => {
346
+ let best = 0, bd = Infinity;
347
+ for (let i = 1; i < pts.length; i++) {
348
+ const a = pts[i - 1], b = pts[i];
349
+ const dx = b.x - a.x, dz = b.z - a.z;
350
+ const L2 = dx * dx + dz * dz || 1;
351
+ let t = Math.max(0, Math.min(1, ((x - a.x) * dx + (z - a.z) * dz) / L2));
352
+ const px = a.x + dx * t, pz = a.z + dz * t;
353
+ const d = dist2D(px, pz, x, z);
354
+ if (d < bd) { bd = d; best = P.cum[i - 1] + Math.hypot(px - a.x, pz - a.z); }
355
+ }
356
+ return best;
357
+ };
358
+ return P;
359
+ }
360
+
361
+ // THE TRACK AUTHOR'S OWN CENTRELINE, as something a train can be at a point on. His node table is
362
+ // dense (~1 m) and already carries the graded formation in y, so there is nothing to bake: read it.
363
+ //
364
+ // It EXTRAPOLATES off both ends, and that is not a nicety — a 55 m train berthed at the end of the
365
+ // line has thirty metres of itself behind the last sleeper, and a clamp there would concertina every
366
+ // car in the consist into the same point.
367
+ function pathFromNodes(id, line) {
368
+ const N = line.nodes;
369
+ const n = N.length;
370
+ const P = { id, len: line.len ?? N[n - 1].s, nodes: N };
371
+ const idx = (s) => {
372
+ let lo = 0, hi = n - 1;
373
+ while (lo < hi - 1) { const m = (lo + hi) >> 1; if (N[m].s <= s) lo = m; else hi = m; }
374
+ return lo;
375
+ };
376
+ const ends = (s, out, key) => {
377
+ // linear run-out along the end tangent
378
+ const a = s <= 0 ? N[0] : N[n - 1], b = s <= 0 ? N[1] : N[n - 2];
379
+ const d = Math.abs(a.s - b.s) || 1;
380
+ const k = (s <= 0 ? s - a.s : s - a.s) / d;
381
+ const dx = (a.x - b.x) / 1, dz = (a.z - b.z) / 1, dy = (a.y - b.y) / 1;
382
+ const t = s <= 0 ? (a.s - s) / d : (s - a.s) / d;
383
+ if (key === 'y') return a.y + dy * t;
384
+ return out.set(a.x + dx * t, 0, a.z + dz * t);
385
+ };
386
+ P.pos = (s, out) => {
387
+ if (s <= 0 || s >= P.len) return ends(s, out, 'pos');
388
+ const i = idx(s), a = N[i], b = N[i + 1];
389
+ const t = (s - a.s) / ((b.s - a.s) || 1);
390
+ return out.set(a.x + (b.x - a.x) * t, 0, a.z + (b.z - a.z) * t);
391
+ };
392
+ P.height = (s) => {
393
+ if (s <= 0 || s >= P.len) return ends(s, null, 'y');
394
+ const i = idx(s), a = N[i], b = N[i + 1];
395
+ const t = (s - a.s) / ((b.s - a.s) || 1);
396
+ return a.y + (b.y - a.y) * t;
397
+ };
398
+ P.sNear = (x, z) => {
399
+ let bs = 0, bd = Infinity;
400
+ for (let i = 0; i < n; i++) {
401
+ const d = (N[i].x - x) ** 2 + (N[i].z - z) ** 2;
402
+ if (d < bd) { bd = d; bs = N[i].s; }
403
+ }
404
+ return bs;
405
+ };
406
+ return P;
407
+ }
408
+
409
+ // A ROUTE is a train's AUTHORITY: the run from one standing place to the next, as legs on the
410
+ // physical paths. It exists because the ore train's run from Dustwater to Bonebreak is two legs —
411
+ // back down the main line to the junction, then out along the spur — and the train in the middle of
412
+ // it is on both. `u` is distance along the route; each leg knows which way it runs on its path, so a
413
+ // train working back down the line has an INCREASING u and a DECREASING s, and nothing else in this
414
+ // file has to know that.
415
+ function makeRoute(legs, from, to) {
416
+ const R = { legs, from, to, base: 0, len: 0, off: [] };
417
+ for (const l of legs) {
418
+ R.off.push(R.base);
419
+ l.len = Math.abs(l.s1 - l.s0);
420
+ l.dir = Math.sign(l.s1 - l.s0) || 1;
421
+ R.base += l.len;
422
+ }
423
+ R.len = R.base; // the caller extends this by the train's berth overrun (see Train.berthLead)
424
+ R.at = (u) => {
425
+ let i = 0;
426
+ while (i < legs.length - 1 && u > R.off[i] + legs[i].len) i++;
427
+ const l = legs[i];
428
+ return { l, s: l.s0 + l.dir * (u - R.off[i]) }; // extrapolates off both ends; P.pos/P.height do too
429
+ };
430
+ R.pos = (u, out) => { const { l, s } = R.at(u); return l.path.pos(s, out); };
431
+ R.height = (u) => { const { l, s } = R.at(u); return l.path.height(s); };
432
+ return R;
433
+ }
434
+
435
+ // ── THE SIGNALMAN ─────────────────────────────────────────────────────────────────────────────
436
+ //
437
+ // THE PROBLEM, STATED HONESTLY: this is a SINGLE TRACK with no passing loops. Two trains running at
438
+ // each other on it will meet, and a look-ahead alone does NOT save you — it stops them a safe
439
+ // distance apart and then they sit there, nose to nose, forever. A brake is not a signalling system.
440
+ // Deadlock is not a bug you fix afterwards; avoiding it is the thing the system exists to do.
441
+ //
442
+ // So there are two mechanisms and they do different jobs:
443
+ //
444
+ // 1. THE INTERLOCKING (here). A train may not leave a standing place until the WHOLE road to the
445
+ // next standing place is clear — every metre of it, and the berth at the far end — and it holds
446
+ // that authority until it arrives. This is the electric-token system, and it is what makes a
447
+ // head-on meeting IMPOSSIBLE rather than merely survivable.
448
+ //
449
+ // 2. THE LOOK-AHEAD (Train._roadAhead — coach.js's _blocked pattern). The safety net. If the
450
+ // interlocking is right it never fires. If anything ever goes wrong with it, the train still
451
+ // brakes and HOLDS instead of driving through another train. Belt, and braces.
452
+ //
453
+ // WHY IT CANNOT DEADLOCK. A waiting train holds only the berth it is standing in. A moving train
454
+ // holds a road nothing else can enter, so it always arrives, so it always releases. The two ends of
455
+ // the line are SINKS — a train that reaches one has left the county, is stabled, and blocks nothing.
456
+ // So the only contended berth is Dustwater (and Bonebreak, which only the ore train ever visits). A
457
+ // circular wait needs TWO contended berths. There is one. Therefore there is no cycle.
458
+ //
459
+ // That is why the terminals must be sinks and not sidings, and it is the single most load-bearing
460
+ // decision in this file. Give the west end a capacity of one and the fleet deadlocks within the
461
+ // hour: the Flyer stands at the west end wanting Dustwater, the Goods stands at Dustwater wanting
462
+ // the west end, and neither will ever move again.
463
+ const CLEAR_R = 24; // metres: how close another train's iron may come to my road and still be
464
+ // "clear". Wider than the vehicles on purpose — it is the clearance a
465
+ // signalman gives, not the width of a carriage.
466
+ const SAMPLE = 14; // metres between the samples the clearance test runs on
467
+
468
+ class Signalman {
469
+ constructor(trains) { this.trains = trains; this.grants = []; }
470
+
471
+ // Everything a train currently makes unavailable: the iron it is standing on, and the road it has
472
+ // already been given. A STABLED train contributes NOTHING — that is the sink rule, above, and it
473
+ // is what the deadlock-freedom argument rests on.
474
+ _footprint(t, out) {
475
+ if (t.stabled) return out;
476
+ // A CAR IS SEVENTEEN METRES LONG, NOT A POINT. This used to push the car's centre and test it
477
+ // against CLEAR_R — which quietly gives a carriage 8.7 m of itself OUTSIDE the clearance, and
478
+ // that was enough slack for the signalman to thread a second train into Dustwater alongside one
479
+ // already standing there. They then wanted to leave through each other, and the railway stopped
480
+ // for good. Carry the half-length with the point.
481
+ for (const c of t.cars) out.push({ p: c.root.position, r: c.len / 2 });
482
+ if (t.route && t.state === 'running') {
483
+ for (let u = t.u; u <= t.route.len + 1; u += SAMPLE) out.push({ p: t.route.pos(u, new THREE.Vector3()), r: 0 });
484
+ }
485
+ return out;
486
+ }
487
+
488
+ clear(route, me) {
489
+ const foot = [];
490
+ for (const t of this.trains) if (t !== me) this._footprint(t, foot);
491
+ if (!foot.length) return true;
492
+ const p = _sv;
493
+ for (let u = -me.len; u <= route.len + 1; u += SAMPLE) { // -me.len: my own tail sweeps the road behind me too
494
+ route.pos(u, p);
495
+ for (const f of foot) if (dist2D(f.p.x, f.p.z, p.x, p.z) < CLEAR_R + f.r) return false;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ // THE TOKEN. Granted or refused; there is no third answer and no queue. A refused train simply
501
+ // asks again in a second, from where it is standing — which is a berth, and the only safe place
502
+ // for it to be.
503
+ //
504
+ // TWO tests, and the first one is the one the whole deadlock argument rests on. A BERTH HOLDS ONE
505
+ // TRAIN. I tried to get that for free out of the geometry — if two trains cannot be within CLEAR_R
506
+ // of each other then surely they cannot both be at Dustwater — and it is not true: the berth clamp
507
+ // (Train.berthLead) legitimately stands a westbound train one side of the level crossing and an
508
+ // eastbound one the other side, 32 m apart, both of them "at Dustwater", and both of them then
509
+ // wanting to leave through the other. Measured: the fleet ran for 187 seconds and stopped forever.
510
+ // So the berth is a TOKEN, held explicitly, and only the sinks (which are off the county) may hold
511
+ // more than one train.
512
+ grant(t, route) {
513
+ const B = t.line.places[route.to];
514
+ if (!B.sink) {
515
+ for (const o of this.trains) {
516
+ if (o === t || o.stabled) continue;
517
+ if (o.state === 'standing' ? o.place === route.to : o.route?.to === route.to) return false;
518
+ }
519
+ }
520
+ if (!this.clear(route, t)) return false;
521
+ this.grants.push({ train: t.id, from: route.from, to: route.to });
522
+ if (this.grants.length > 300) this.grants.shift();
523
+ return true;
524
+ }
525
+ }
526
+ const _sv = new THREE.Vector3();
527
+
528
+ // ── THE GLASS ─────────────────────────────────────────────────────────────────────────────────
529
+ // The carriage's windows came out of the FBX as OPAQUE PLANKING — nine flat panels a side that you
530
+ // cannot see a thing through, which is why the man sitting behind them looked like he was buried in
531
+ // the wall. The panes are their own sub-meshes (SM_..._Glass, plus one per door), so the fix is a
532
+ // material and not a mesh; the trap is WHICH material.
533
+ //
534
+ // applyAtlas (assets.js:268) hands every mesh that shares an atlas and a transparency flag the SAME
535
+ // MATERIAL OBJECT, game-wide — and the FBX does not flag its glass transparent, so the panes are
536
+ // holding the identical MeshStandardMaterial as the carriage's own planking, the loco, the bell, the
537
+ // smokestack and every wheel on the railway (113 meshes in the live scene, counted). assets.js:265
538
+ // says it in plain words: never mutate one on an instance; clone first. Set .transparent on that one
539
+ // and the western prop set turns to fog.
540
+ //
541
+ // So: ONE clone, made the first time a pane is seen, wearing the house transparency (transparent,
542
+ // depthWrite off, DoubleSide, an explicit renderOrder — weather.js:73, sky.js:173, water.js:148 and
543
+ // this file's own smoke all do exactly this), and handed to every pane in the fleet. It keeps the
544
+ // atlas map, because a window with no texel in it is not glass, it is a hole. One material, one extra
545
+ // pipeline, built on the loading screen with everything else.
546
+ let _glass = null;
547
+ function glassLike(src) {
548
+ if (!_glass) {
549
+ _glass = src.clone();
550
+ _glass.transparent = true;
551
+ _glass.opacity = 0.30; // enough to read the seats and the fares through it from the platform
552
+ _glass.depthWrite = false;
553
+ _glass.side = THREE.DoubleSide; // the panes are single-sided quads; you sit on the inside of them
554
+ _glass.roughness = 0.12; // and glass is not sawn timber, whatever the atlas says
555
+ _glass.metalness = 0;
556
+ _glass.flatShading = false;
557
+ _glass.name = 'trainGlass';
558
+ }
559
+ return _glass;
560
+ }
561
+
562
+ // ── THE WHEELS THAT WERE NOT WHEELS ───────────────────────────────────────────────────────────
563
+ //
564
+ // ONLY THE LOCO HAS WHEELS YOU CAN GRAB. SM_Veh_Train_01 carries eight of them as named children —
565
+ // SM_Veh_Train_01_Wheel_L_01…04 and the same to starboard — and those are the ones that turn. The
566
+ // other three models do not have them at all: the carriage, the van and the tender have their wheels
567
+ // BAKED INTO THE BODY MESH, welded into the same lump of triangles as the solebar and the buffer
568
+ // beam, with not one node in the file carrying the word Wheel in its name. Everything on the railway
569
+ // that was not a locomotive skidded the length of the county on frozen iron.
570
+ //
571
+ // So the wheels are FOUND, and found from the SHAPE, because a railway wheel is geometrically
572
+ // unmistakable. Break the body into the parts it was modelled from — connected components of its
573
+ // triangles — and put every part through a sieve:
574
+ //
575
+ // ROUND a wheel is a disc. Its bounding box is as tall as it is long: |sy − sz| / max measures
576
+ // 0.049 on every wheel in this pack and 0.001 on the loco's drivers, against 0.35–0.65
577
+ // for the axleboxes and spring hangers that live in the same corner of the underframe.
578
+ // THIN ...and thin across the axle: sx / diameter is 0.203, where those boxes are 0.45–0.95.
579
+ // BIG ...and it is HALF A METRE in the radius. This is the test that earns its keep. The
580
+ // carriage's own axlebox is 0.290 m and the loco's crank pin 0.211 m, and BOTH of them
581
+ // are round AND thin AND low AND outboard — every other test passes them. Only the size
582
+ // tells a wheel from the bearing it turns in.
583
+ // LOW ...it is under the solebar (0.91 m) and nowhere near the deck (1.578 m).
584
+ // OUTBOARD ...and it is out on the end of an axle: the hubs are at x = ±0.855 m. Nothing on the
585
+ // centreline of a railway carriage is a wheel.
586
+ //
587
+ // THE THRESHOLDS ARE CALIBRATED ON THE LOCO'S OWN AUTHORED WHEELS, which is the only honest way to
588
+ // set them: measure the thing the artist himself called a wheel, then go and look for what it looks
589
+ // like. And the sieve is CROSS-CHECKED, because a classifier that cannot be wrong is a classifier
590
+ // nobody has tested. Run over the loco's body — 232 components, an entire steam engine — it finds
591
+ // NOTHING, which is exactly right, and is the proof that it does not invent wheels out of boiler
592
+ // bands. Run over the other three it finds 8, 8 and 4: two four-wheel bogies under the carriage, four
593
+ // axles under the tender, and a plain four-wheel van. Every one of them comes out in an exact
594
+ // left/right pair at a matching z, and every one of them has a radius of 0.5143 m — because they are
595
+ // all the SAME 180-triangle casting, the one the loco's own pony truck is turned out of (0.514).
596
+ // The triangles balance to the last one: carriage 3698 + 1440 = 5138, van 2076 + 720 = 2796, tender
597
+ // 936 + 1440 = 2376. Nothing is dropped and nothing is duplicated.
598
+ const WHEEL_ROUND = 0.12; // |sy − sz| / diameter. The real wheels are 0.001 and 0.049 — the 0.049 is
599
+ // the pack's ten-sided rim, flat to vertex, and not a modelling error.
600
+ const WHEEL_THIN = 0.35; // sx / diameter. The real wheels are 0.093 (drivers) and 0.203 (the rest).
601
+ const WHEEL_MIN_R = 0.35; // metres. Wheels 0.514; axlebox 0.290; crank pin 0.211. It splits them.
602
+ const WHEEL_LOW = 1.20; // metres, hub height: between the solebar (0.91) and the deck (1.578)
603
+ const WHEEL_OUT = 0.40; // metres, |hub x|: the axles put them at 0.855
604
+ const WELD = 0.01; // the geometry is in centimetres, so this is a tenth of a millimetre
605
+
606
+ // THE BODY, BROKEN INTO THE PARTS IT WAS MODELLED FROM. Union-find over the triangles, joined where
607
+ // they touch — and touching means SHARING A POSITION, not sharing an index. Synty's FBX geometry is
608
+ // non-indexed: three corners per triangle, each its own vertex, so no two triangles share an index
609
+ // with anything and an index-based union-find hands back one component per triangle and no wheels at
610
+ // all. Weld on a grid instead. A tenth of a millimetre is far below the smallest rivet in the pack
611
+ // and far above float noise.
612
+ function components(geom) {
613
+ const P = geom.attributes.position.array;
614
+ const nT = P.length / 9;
615
+ const vid = new Int32Array(nT * 3);
616
+ const seen = new Map();
617
+ const q = 1 / WELD;
618
+ for (let i = 0; i < nT * 3; i++) {
619
+ const k = `${Math.round(P[i * 3] * q)},${Math.round(P[i * 3 + 1] * q)},${Math.round(P[i * 3 + 2] * q)}`;
620
+ let v = seen.get(k);
621
+ if (v === undefined) seen.set(k, v = seen.size);
622
+ vid[i] = v;
623
+ }
624
+ const par = new Int32Array(seen.size);
625
+ for (let i = 0; i < par.length; i++) par[i] = i;
626
+ const find = (a) => { while (par[a] !== a) { par[a] = par[par[a]]; a = par[a]; } return a; };
627
+ for (let t = 0; t < nT; t++) {
628
+ const a = find(vid[t * 3]), b = find(vid[t * 3 + 1]), c = find(vid[t * 3 + 2]);
629
+ if (b !== a) par[b] = a;
630
+ if (c !== a) par[find(c)] = a;
631
+ }
632
+ const out = new Map();
633
+ for (let t = 0; t < nT; t++) {
634
+ const r = find(vid[t * 3]);
635
+ let co = out.get(r);
636
+ if (!co) out.set(r, co = { tris: [], box: new THREE.Box3() });
637
+ co.tris.push(t);
638
+ for (let k = 0; k < 3; k++) co.box.expandByPoint(_wc.fromArray(P, (t * 3 + k) * 3));
639
+ }
640
+ return [...out.values()];
641
+ }
642
+
643
+ // Lift a list of triangles out into a BufferGeometry of their own, carrying every attribute across
644
+ // (this pack has position, normal and uv, and no index). Given a hub, the positions are translated
645
+ // onto it — which is the whole trick: a wheel whose geometry is centred on its own axle turns about
646
+ // that axle when you touch its node's rotation.x, and a wheel that is not turns about the car.
647
+ function cut(geom, tris, hub) {
648
+ const src = geom.attributes;
649
+ const g = new THREE.BufferGeometry();
650
+ for (const name of Object.keys(src)) {
651
+ const a = src[name], n = a.itemSize;
652
+ const arr = new a.array.constructor(tris.length * 3 * n);
653
+ let w = 0;
654
+ for (const t of tris) {
655
+ for (let k = 0; k < 3; k++) {
656
+ const v = (t * 3 + k) * n;
657
+ for (let c = 0; c < n; c++) arr[w++] = a.array[v + c];
658
+ }
659
+ }
660
+ g.setAttribute(name, new THREE.BufferAttribute(arr, n));
661
+ }
662
+ if (hub) g.translate(-hub.x, -hub.y, -hub.z);
663
+ return g;
664
+ }
665
+
666
+ // Cut the baked wheels out of one mesh and hang them back on it as nodes of their own. Returns how
667
+ // many it took, which is the only number worth logging.
668
+ function cutWheels(body) {
669
+ const geom = body.geometry;
670
+ const comps = components(geom);
671
+ if (comps.length < 2) return 0; // a mesh that is ONE part has no part to take out of it
672
+ const s = body.getWorldScale(_ws).y || 1; // the cm→m scale, so the sieve can think in metres
673
+ const hits = [];
674
+ for (const co of comps) {
675
+ co.box.getSize(_wz);
676
+ co.box.getCenter(_wc);
677
+ const dia = Math.max(_wz.y, _wz.z);
678
+ if (Math.abs(_wz.y - _wz.z) / dia > WHEEL_ROUND) continue; // not a disc
679
+ if (_wz.x / dia > WHEEL_THIN) continue; // not thin across the axle
680
+ if ((_wz.y / 2) * s < WHEEL_MIN_R) continue; // an axlebox, or a crank pin
681
+ if (_wc.y * s > WHEEL_LOW || Math.abs(_wc.x) * s < WHEEL_OUT) continue; // not down on the rail, out on an axle
682
+ hits.push({ tris: co.tris, hub: _wc.clone() });
683
+ }
684
+ if (!hits.length) return 0;
685
+
686
+ // Named and numbered the way the pack does it, so they are indistinguishable from the loco's own
687
+ // and the registration below does not have to know which kind it is holding: L is the −x side, and
688
+ // they run up the car from the back (SM_Veh_Train_01_Wheel_L_01…04 are in ascending z).
689
+ hits.sort((a, b) => a.hub.z - b.hub.z);
690
+ const taken = new Set();
691
+ const n = { L: 0, R: 0 };
692
+ for (const h of hits) {
693
+ for (const t of h.tris) taken.add(t);
694
+ const side = h.hub.x < 0 ? 'L' : 'R';
695
+ const w = new THREE.Mesh(cut(geom, h.tris, h.hub), body.material);
696
+ w.name = `${body.name}_Wheel_${side}_${String(++n[side]).padStart(2, '0')}`;
697
+ w.position.copy(h.hub);
698
+ w.castShadow = body.castShadow; // applyAtlas set these on the body before this mesh existed
699
+ w.receiveShadow = body.receiveShadow; // (assets.js:343) and a wheel that casts no shadow floats
700
+ // A CHILD OF THE BODY MESH — which is where the loco keeps its own wheels: they are children of
701
+ // SM_Veh_Train_01, at the identity. A child of a mesh lives in the frame that mesh's GEOMETRY is
702
+ // written in, so the hub we just measured off that geometry IS the child's position, with no
703
+ // assumption about the body node's own transform, and the child's local X is the axle: the very
704
+ // axis the loco's wheels have always spun about.
705
+ body.add(w);
706
+ }
707
+ // AND TAKE THEM OUT OF THE BODY. A body that keeps the triangles it has just given away has a
708
+ // wheel standing still inside every wheel that turns — which is the ghost the loco's old re-pivot
709
+ // comment was warning about, and it is far more obvious than no wheels at all.
710
+ const nT = geom.attributes.position.count / 3;
711
+ const keep = [];
712
+ for (let t = 0; t < nT; t++) if (!taken.has(t)) keep.push(t);
713
+ body.geometry = cut(geom, keep, null);
714
+ return hits.length;
715
+ }
716
+
717
+ // ── PREPARING A MODEL, ONCE, ON THE MASTER ────────────────────────────────────────────────────
718
+ //
719
+ // ONCE PER MODEL, NOT ONCE PER CAR, and that distinction is the entire safety argument.
720
+ //
721
+ // instantiate() clones the node tree but SHARES the geometry with the master — that is what the
722
+ // cache is FOR (assets.js:214). So a car that re-pivots its own wheels is editing geometry every
723
+ // other car of its class is looking at: the second loco re-pivots what the first has already
724
+ // re-pivoted, hubs come out of an already-moved bounding box, and she arrives with her wheels
725
+ // scattered across the desert. The coach paid for that once (coach.js:194) and this file used to pay
726
+ // its way out of it by CLONING the geometry of every wheel of every car — a hundred and twelve
727
+ // copies across the fleet, each one made so it could be translated by the same vector as the last.
728
+ //
729
+ // Cutting the baked wheels out of the body kills that dodge stone dead anyway: the body geometry is
730
+ // shared, so if the first carriage cut its wheels out of it the second would find none left to cut
731
+ // and ride to Dustwater on nothing. There is exactly one right place to do this and it is the MASTER,
732
+ // before a single car is cloned off it. It runs on the loading screen, it runs once per model, and
733
+ // every car in the fleet then inherits a model whose wheels are already nodes standing on their own
734
+ // hubs. Nothing whatever is added to the scene during play — this file's first law.
735
+ //
736
+ // The contract is the ordering: Car.load() awaits this BEFORE it calls instantiate(). Both go
737
+ // through the same cached master promise, so the surgery is always finished before the first clone.
738
+ const _prepped = new Map(); // url → promise. loadModel keys its master by url|texture|scale, and this
739
+ // file asks for exactly one texture at exactly one scale, so url is the key.
740
+ function prepareWheels(url) {
741
+ if (!_prepped.has(url)) {
742
+ _prepped.set(url, loadModel(url, { texture: ATLAS }).then((master) => {
743
+ if (master.userData.wheelsReady) return master; // the memo is the braces; this is the belt
744
+ master.userData.wheelsReady = true;
745
+
746
+ // Sieve everything that is not already a wheel, a rod or a pane. On the loco that is the body,
747
+ // the bell and the smokestack, and it yields nothing; on the other three it is the body (which
748
+ // yields the wheels), the saloon interior and the doors, which yield nothing.
749
+ let found = 0;
750
+ const bodies = [];
751
+ master.traverse((o) => { if (o.isMesh && o.geometry && !/(_Wheel_[LR]_\d+|_Stick_\d+|_Glass)$/i.test(o.name)) bodies.push(o); });
752
+ for (const b of bodies) found += cutWheels(b);
753
+
754
+ // AND NOW EVERY WHEEL IN THE MODEL GETS ITS HUB — the loco's authored ones and the ones just
755
+ // cut, through the same three lines. One place in this file measures a wheel, and it measures
756
+ // both kinds the same way.
757
+ const wheels = [];
758
+ master.traverse((o) => { if (o.isMesh && /_Wheel_[LR]_\d+$/i.test(o.name)) wheels.push(o); });
759
+ for (const o of wheels) {
760
+ o.geometry.computeBoundingBox();
761
+ const box = o.geometry.boundingBox;
762
+ const dia = box.max.y - box.min.y;
763
+ box.getCenter(_wc);
764
+ // The re-pivot is a no-op on a wheel that was cut (it came out hub-centred) and the whole job
765
+ // on one that was authored. o.position lives in the same centimetres the geometry does, so it
766
+ // needs no conversion; see below for the one number that does.
767
+ o.geometry.translate(-_wc.x, -_wc.y, -_wc.z);
768
+ o.position.add(_wc);
769
+ // THE GEOMETRY IS IN CENTIMETRES. THE WORLD IS IN METRES.
770
+ //
771
+ // None of the train assets has a .glb twin, so instantiate() loads the raw FBX and hangs the
772
+ // cm→m conversion on the model's SCALE (assets.js:177, SYNTY_SCALE). The node tree and the
773
+ // geometry under it are still Synty's centimetres — so a bounding box read here measures the
774
+ // driving wheel at 93.12, not 0.931. Feed THAT to `radians = distance / radius` and every
775
+ // wheel on the railway turns a HUNDRED TIMES TOO SLOWLY: the fleet skates the length of the
776
+ // county with its wheels almost still. The coach never had to know (it loads a GLB twin,
777
+ // already in metres) — which is precisely why copying its loader was not enough.
778
+ //
779
+ // Measured: drivers 0.931 m, pony truck 0.514, and every carriage, van and tender wheel
780
+ // 0.514 — they are the same casting. A loco whose leading truck turns at the drivers' rate is
781
+ // a loco dragging its front wheels down the rail, so the radius is read off each wheel's own
782
+ // geometry and never off a guess. It rides in userData because Object3D.copy deep-copies it
783
+ // (three's Object3D.js:1618), so every car cloned off this master is handed the measurement
784
+ // instead of taking it again.
785
+ o.userData.radius = (dia / 2) * (o.getWorldScale(_ws).y || 1) || 0.5;
786
+ }
787
+ if (found) console.log(`[train] ${url.split('/').pop()}: ${found} wheels cut out of the body (${wheels.length} on the car)`);
788
+ return master;
789
+ }));
790
+ }
791
+ return _prepped.get(url);
792
+ }
793
+ const _wc = new THREE.Vector3(), _wz = new THREE.Vector3();
794
+
795
+ // ── ONE CAR ───────────────────────────────────────────────────────────────────────────────────
796
+ class Car {
797
+ constructor(train, kind) {
798
+ this.train = train;
799
+ this.kind = kind;
800
+ this.def = MODELS[kind];
801
+ this.len = this.def.len;
802
+ this.root = new THREE.Group();
803
+ this.root.rotation.order = 'YXZ'; // yaw round the line, then pitch onto the bank
804
+ this.wheels = [];
805
+ this.sticks = [];
806
+ this.crew = []; // combat.js walks this (through the proxy, below)
807
+ this.riders = []; // crime.js walks this
808
+ this.back = 0; // metres from the train's NOSE to this car's centre
809
+ }
810
+
811
+ async load() {
812
+ // THE SURGERY FIRST, AND ON THE MASTER — see prepareWheels. By the time instantiate() hands this
813
+ // car its clone, every wheel in the model is already a node standing on its own hub with its own
814
+ // measured radius, whether the artist put it there or we cut it out of the body. So this traverse
815
+ // has nothing left to do but WRITE THEM DOWN, and there is not a geometry copy in it.
816
+ await prepareWheels(this.def.url);
817
+ const model = await instantiate(this.def.url, { texture: ATLAS });
818
+ this.root.add(model);
819
+ this.root.updateWorldMatrix(false, true); // so getWorldScale below tells the truth (see the cm/m note)
820
+ model.traverse((o) => {
821
+ if (!o.isMesh || !o.geometry) return;
822
+ // THE WINDOWS. Done here, on the loading screen, and never again — a material swap at runtime is
823
+ // a pipeline build and this file's first law forbids it.
824
+ if (/_Glass$/i.test(o.name)) { o.material = glassLike(o.material); o.renderOrder = 2; return; }
825
+ if (/_Wheel_[LR]_\d+$/i.test(o.name)) { this.wheels.push({ mesh: o, radius: o.userData.radius || 0.5 }); return; }
826
+ if (!/_Stick_\d+$/i.test(o.name)) return;
827
+ // The rods are NOT re-pivoted. A rod swung about a wheel hub it does not sit on is a hammer
828
+ // throw — and these sit at y 1.94–3.24, well above the 0.93 hub. They are slid fore-and-aft
829
+ // in the loco's own axis, which is what a piston rod actually does, and at speed it is enough.
830
+ // The throw is authored in metres and converted INTO the node's own space (which is Synty's
831
+ // centimetres), for the same reason prepareWheels converts the radius out of it.
832
+ o.getWorldScale(_ws);
833
+ this.sticks.push({ mesh: o, z0: o.position.z, phase: this.sticks.length * 2.1, throw: ROD_THROW / (_ws.z || 1) });
834
+ });
835
+
836
+ // SOLID — one oriented box per car, pushed ONCE, rewritten every frame (coach.js:288). Nothing
837
+ // removes it: World.collide walks the array live, so a moving vehicle just keeps its entry
838
+ // current. Without these you walk through eighty tons of iron and out the far side.
839
+ this.col = { x: 0, z: 0, hx: this.def.halfW, hz: this.len / 2, cs: 1, sn: 0, y0: 0, y1: 0 };
840
+ colliders.push(this.col);
841
+ return this;
842
+ }
843
+
844
+ // THE PROXY THAT MAKES THE STAGECOACH STOP FOR THE TRAIN.
845
+ //
846
+ // coach.js is not mine to edit, and its look-ahead already scans exactly one kind of moving thing:
847
+ // the other coaches — `for (const other of this.game.coaches?.coaches ?? [])`, testing
848
+ // `other.root.position`. So each train car is registered in that same array as something that
849
+ // duck-types a coach. That is not a hack in the pejorative sense: game.coaches.coaches is the one
850
+ // list in this game that means "a large vehicle, somewhere, right now", and a railway carriage is
851
+ // precisely that. What it buys, with no edit to any file I do not own:
852
+ //
853
+ // coach.js:414 the stage brakes for a train instead of driving through it
854
+ // combat.js:525 the bullet sweep tests the footplate crew (it iterates c.crew)
855
+ // crime.js:259 the crew are WITNESSES to what you do in front of them (it iterates c.riders)
856
+ // player.js:984 they are aim-assist targets like anybody else
857
+ // ui.js:558 the train draws on the map as the string of white dots that it is
858
+ //
859
+ // The price is that CoachLine.update() and CoachLine.syncRiders() will call update() and
860
+ // _syncRiders() on it, so it must answer both. update() is a no-op: TrainLine has already moved us.
861
+ proxy() {
862
+ const car = this;
863
+ return {
864
+ root: this.root,
865
+ get crew() { return car.crew; },
866
+ get riders() { return car.riders; },
867
+ update() { /* the TrainLine drives us — this exists so CoachLine.update() can call it */ },
868
+ _syncRiders() { car.syncRiders(); },
869
+ };
870
+ }
871
+
872
+ // Every crewman is a child of a moving car and has no world position of his own, so he is GIVEN one
873
+ // — the same rotate-the-offset arithmetic the coach uses: no getWorldPosition matrix walk, no
874
+ // allocation. combat.js calls this (through the proxy) immediately before it sweeps its bullets,
875
+ // because main.js ticks combat BEFORE the vehicles: without it every round is fired at where he was
876
+ // last frame, which at 16 m/s is a quarter of a metre behind him.
877
+ syncRiders() {
878
+ const ry = this.root.rotation.y;
879
+ const s = Math.sin(ry), c = Math.cos(ry);
880
+ const ox = this.root.position.x, oy = this.root.position.y, oz = this.root.position.z;
881
+ for (const r of this.riders) {
882
+ const p = r.pivot.position;
883
+ r.position.set(ox + p.x * c + p.z * s, oy + p.y - r.feetDrop, oz - p.x * s + p.z * c);
884
+ }
885
+ }
886
+
887
+ syncCollider(stabled) {
888
+ // hx <= 0 is OFF (world.js:238). A stabled train has left the county and must not go on being an
889
+ // invisible wall in the middle of the desert with nothing standing where it is.
890
+ this.col.hx = stabled ? 0 : this.def.halfW;
891
+ if (stabled) return;
892
+ const ry = this.root.rotation.y;
893
+ this.col.x = this.root.position.x;
894
+ this.col.z = this.root.position.z;
895
+ this.col.cs = Math.cos(ry);
896
+ this.col.sn = Math.sin(ry);
897
+ this.col.y0 = this.root.position.y - 0.4; // the frames and the wheels...
898
+ this.col.y1 = this.root.position.y + this.def.top; // ...up to the roof. Above that you are shooting sky.
899
+ }
900
+ }
901
+
902
+ // A car is standing ON the road when its END is within this of the crossing. Used by the berth clamp
903
+ // (Train.berthLead) as well as the guard, so the two can never disagree about what "fouling" means.
904
+ const FOUL_M = 6;
905
+
906
+ // ── ONE TRAIN ─────────────────────────────────────────────────────────────────────────────────
907
+ class Train {
908
+ constructor(line, spec) {
909
+ this.line = line;
910
+ this.game = line.game;
911
+ this.id = spec.id;
912
+ this.name = spec.name;
913
+ this.spec = spec;
914
+ this.consist = spec.consist;
915
+ this.cars = [];
916
+ this.speed = 0;
917
+ this.u = 0; // arc position of the LEADING END of the movement, along this.route
918
+ this.route = null;
919
+ this.state = 'standing'; // standing | running
920
+ this.stabled = false;
921
+ this.reversed = false; // the loco is at the BACK, propelling (see _reverseFor)
922
+ this.place = spec.start;
923
+ this.sched = 0;
924
+ this.dwell = 0;
925
+ this.askT = 0;
926
+ this.held = false; // refused the road: standing with the regulator shut
927
+ this.heldT = 0; // ...for this long, in total. The proof that it waited.
928
+ this.runaway = 0;
929
+ this.relief = 0;
930
+ this.dist = 0; // metres run. The wheels are turned by THIS and by nothing else.
931
+ this.travelYaw = 0; // the way the TRAIN is going (a reversed loco points the other way)
932
+ this.locoS = 0; // the loco's arc position on its current PATH — the anchor that
933
+ this.locoPath = null; // survives a change of route, and therefore a reversal (see _adopt)
934
+ }
935
+
936
+ get len() { return this.consist.reduce((a, k) => a + MODELS[k].len, 0); }
937
+
938
+ async load() {
939
+ let back = 0;
940
+ for (const kind of this.consist) {
941
+ const car = new Car(this, kind);
942
+ await car.load();
943
+ car.back = back + car.len / 2;
944
+ back += car.len;
945
+ this.game.scene.add(car.root);
946
+ this.cars.push(car);
947
+ }
948
+
949
+ // THE FOOTPLATE. Driver on the right, fireman on the left, on the cab floor — measured at y=1.94,
950
+ // not guessed at. They are SeatedRiders: the same class, the same posture and the same shootable
951
+ // contract as the men on the stagecoach, so a bullet through the cab does what a bullet through
952
+ // the cab ought to do and the railroad's pay tin goes into the dirt with the man carrying it.
953
+ const loco = this.cars[0];
954
+ const cast = castFares(2);
955
+ this.driver = await SeatedRider.create(loco.root, cast[0], { x: 0.72, y: CAB_FLOOR + 0.40, z: -4.95, ry: 0 }, driverPosture(SEAT));
956
+ this.fireman = await SeatedRider.create(loco.root, cast[1], { x: -0.72, y: CAB_FLOOR + 0.40, z: -4.95, ry: 0 }, passengerPosture(SEAT));
957
+ loco.riders.push(this.driver, this.fireman);
958
+
959
+ // THE FARES — only in carriages. Four men sitting in a freight van is a mystery, not a detail.
960
+ // Built NOW and kept forever, riding with their models hidden when they are not aboard today
961
+ // (coach.js:244): a man is not BORN when a train pulls in, he becomes visible.
962
+ this.fares = [];
963
+ const PAX = passengerPosture(SEAT);
964
+ for (const car of this.cars) {
965
+ if (car.kind !== 'carriage') continue;
966
+ for (const def of castFares(FARE_SEATS.length)) {
967
+ const r = await SeatedRider.create(car.root, def, FARE_SEATS[car.riders.length], PAX);
968
+ car.riders.push(r);
969
+ this.fares.push(r);
970
+ }
971
+ }
972
+
973
+ this.riders = [this.driver, this.fireman, ...this.fares].filter(Boolean);
974
+ for (const r of this.riders) {
975
+ r.game = this.game;
976
+ r.coach = this; // SeatedRider.die() calls coach._crewDown() — that is us
977
+ r.role = r === this.driver ? 'driver' : r === this.fireman ? 'mate' : 'fare';
978
+ }
979
+ this._seatFares();
980
+ this._park();
981
+ return this;
982
+ }
983
+
984
+ // How full is she today? Drawn fresh at every call, because the train that is always half empty is
985
+ // as fake as the one that is always full. A DEAD fare is not re-drawn: he rides on, visible, in the
986
+ // seat he was shot in, which is the entire point.
987
+ _seatFares() {
988
+ const live = this.fares.filter((f) => f.alive);
989
+ const n = Math.floor(Math.random() * (live.length + 1));
990
+ const who = live.map((_, i) => i).sort(() => Math.random() - 0.5).slice(0, n);
991
+ live.forEach((f, i) => { f.pivot.visible = who.includes(i); });
992
+ for (const car of this.cars) car.crew = car.riders.filter((r) => r.pivot.visible);
993
+ }
994
+
995
+ // WHERE THE TRAIN STOPS RELATIVE TO THE PIN. Not ON it — a berth is where the PASSENGERS are, so
996
+ // the leading end runs past the station by however far back in the train the carriages sit, and the
997
+ // carriages come to rest at the platform. A train with nothing to put at a platform stops on the pin.
998
+ _carriageLead() {
999
+ let a = null, b = 0, at = 0;
1000
+ for (const k of this.consist) {
1001
+ if (k === 'carriage') { if (a === null) a = at; b = at + MODELS[k].len; }
1002
+ at += MODELS[k].len;
1003
+ }
1004
+ if (a === null) return 0;
1005
+ const fromNose = (a + b) / 2;
1006
+ return this.reversed ? this.len - fromNose : fromNose;
1007
+ }
1008
+
1009
+ // ...AND IT MUST NOT STAND ON THE LEVEL CROSSING.
1010
+ //
1011
+ // Dustwater's platform is TWENTY METRES from the road, and the shortest train on this railway is
1012
+ // thirty-seven. Berth it by the platform and half the fleet parks its tender squarely across the
1013
+ // north road — measured: the gates were shut 74% of the time and the stagecoach simply never got
1014
+ // over. So the berth is CLAMPED: of the two places a train can stand clear of the road (short of
1015
+ // it, or drawn right through it), take whichever is nearer to where the carriages want to be.
1016
+ //
1017
+ // It works out better than it sounds. Westbound the clamp moves the train four metres and the
1018
+ // carriages land exactly on the platform. Eastbound it stops her 23 m short, which stands the LOCO
1019
+ // at the platform with her carriages trailing back down the line — which is what every branch-line
1020
+ // station with a short platform looks like anyway. The alternative was gates that are never open.
1021
+ berthLead(route) {
1022
+ const ideal = this._carriageLead();
1023
+ const B = route && this.line.places[route.to];
1024
+ // A SINK IS A TUNNEL NOW: pull the WHOLE consist past the last sleeper before stopping, so the
1025
+ // stable happens with every car inside the hill. Half-in-half-out (the carriage-centred lead)
1026
+ // left a third of the train blinking out on open desert.
1027
+ if (B?.sink) return this.len + 6;
1028
+ if (!B || B.path !== this.line.paths.main || !this.line.crossS.length) return ideal;
1029
+ const last = route.legs[route.legs.length - 1];
1030
+ const dir = last.dir;
1031
+ // work in TRAVEL coordinate (tau = dir * s), so the train always runs forwards and the two
1032
+ // options are simply "stop before it" and "pull past it"
1033
+ const pin = dir * B.s, L = this.len;
1034
+ const M = FOUL_M + 4; // ...with slack, or she berths EXACTLY on the guard's trip-wire and the
1035
+ // gates flicker shut for a train that is standing still
1036
+ let lead = ideal;
1037
+ for (const cs of this.line.crossS) {
1038
+ const tc = dir * cs;
1039
+ const head = pin + lead;
1040
+ if (head - L > tc + M || head < tc - M) continue; // already clear of the road
1041
+ const before = tc - M - pin; // leading end stops short of the road
1042
+ const beyond = tc + M + L - pin; // ...or the whole train is drawn past it
1043
+ lead = Math.abs(before - ideal) <= Math.abs(beyond - ideal) ? before : beyond;
1044
+ }
1045
+ return lead;
1046
+ }
1047
+
1048
+ // Distance from the LEADING END of the movement to car i's centre. When the loco is propelling,
1049
+ // the leading end is the far end of the train and every offset is measured from there instead.
1050
+ _off(car) { return this.reversed ? this.len - car.back : car.back; }
1051
+
1052
+ // ── the timetable ──────────────────────────────────────────────────────────────────────────
1053
+ _nextPlace() { return this.spec.calls[(this.sched + 1) % this.spec.calls.length]; }
1054
+
1055
+ // IS THE LOCO POINTING THE WAY WE ARE ABOUT TO GO? If not, she PROPELS — the loco stays at the
1056
+ // quarry end and pushes the wagons out ahead of her.
1057
+ //
1058
+ // This is the dead-end problem and it must be solved or the loco teleports. The ore train runs
1059
+ // nose-first into Bonebreak, which is a stub with no run-round; it cannot come out nose-first, so
1060
+ // it must come out backwards, and the whole consist keeps its facing while the DIRECTION OF TRAVEL
1061
+ // reverses. Anchor everything on the loco's own position on the path (this.locoS) and the maths
1062
+ // works out exactly: every car's world position and heading are unchanged across the reversal,
1063
+ // which is the test — nothing moves, only the direction it is about to move in.
1064
+ //
1065
+ // Out of a SINK she always leads: the ends of the line are off the county, and a loco that runs
1066
+ // round its train in the yard at the end of the line is a loco doing its job where nobody is looking.
1067
+ _reverseFor(route, fromSink) {
1068
+ if (fromSink || !this.locoPath) return false;
1069
+ const l0 = route.legs[0];
1070
+ if (l0.path !== this.locoPath) return this.reversed; // shouldn't happen: a berth is on one path
1071
+ const uLoco = l0.dir * (this.locoS - l0.s0);
1072
+ route.pos(uLoco + 3, _a); route.pos(uLoco - 3, _b);
1073
+ const tx = _a.x - _b.x, tz = _a.z - _b.z; // the way the ROUTE goes, here
1074
+ const ly = this.cars[0].root.rotation.y;
1075
+ return (Math.sin(ly) * tx + Math.cos(ly) * tz) < 0; // ...against the way the LOCO points
1076
+ }
1077
+
1078
+ // Take up a new route without moving an inch. The loco's arc position on the path is the anchor;
1079
+ // everything else follows from it, including a reversal.
1080
+ _adopt(route) {
1081
+ const l0 = route.legs[0];
1082
+ const uLoco = l0.dir * (this.locoS - l0.s0);
1083
+ this.route = route;
1084
+ this.u = uLoco + this._off(this.cars[0]);
1085
+ }
1086
+
1087
+ // ASK FOR THE ROAD. Refused, she stands where she is — which is a berth, which is safe — and asks
1088
+ // again in a second. She does NOT creep forward to have a look, which is how a train ends up
1089
+ // fouling the very section it was refused.
1090
+ // TRAIN ROBBERY (the platform hold-up — see the 'standing' branch). Mirrors the coach's
1091
+ // _heldUp: drawn gun AIMED at the driver (ray-tested at the cab, <=12m) while she stands; hold
1092
+ // ~2s and the FOOTPLATE pay tin comes down — the railroad's money, heavier than the stage's —
1093
+ // plus a 'coachRobbery' on the books and the county's regard down two. Once per train per session.
1094
+ _heldUp(dt) {
1095
+ const g = this.game, p = g.player;
1096
+ if (!p || this.driver?.alive === false) return false;
1097
+ const lp = this.cars?.[0]?.root?.position ?? this.driver?.pivot?.position;
1098
+ if (!lp) return false;
1099
+ const d = dist2D(lp.x, lp.z, p.position.x, p.position.z);
1100
+ if (this._robbed) return p.drawn && p.aiming && d < 14;
1101
+ let aimed = false;
1102
+ if (p.drawn && p.aiming && d < 12 && !p.riding?.mounted) {
1103
+ const dp = (this._drvPos ??= new THREE.Vector3());
1104
+ this.driver?.pivot?.getWorldPosition?.(dp);
1105
+ const from = (this._huFrom ??= new THREE.Vector3()).copy(p.position); from.y += 1.5;
1106
+ const dir = p.aimDir?.((this._huDir ??= new THREE.Vector3()));
1107
+ if (dir) { dp.y -= 1.0; aimed = !!rayCapsule(from.x, from.y, from.z, dir.x, dir.y, dir.z, dp, 0.8); }
1108
+ }
1109
+ if (!aimed) { this._holdT = 0; return false; }
1110
+ this._holdT = (this._holdT ?? 0) + dt;
1111
+ if (this._holdT < 0.3) g.ui?.toast?.(`The driver's hands leave the regulator. Hold the iron on him.`, 3200);
1112
+ if (this._holdT > 2.2) {
1113
+ this._robbed = true; this._holdT = 0;
1114
+ const at = (this._drvPos ?? this.driver?.pivot?.position ?? p.position).clone(); at.y = g.world.groundAt(at.x, at.z) + 0.1; at.x += 1.6;
1115
+ g.loot?.dropFor?.('driver', at, 5); // the railroad's tin — heavier than the stage's
1116
+ g.crime?.report?.('coachRobbery', at.clone());
1117
+ g.missions?.addStat?.('reputation', -2);
1118
+ g.ui?.toast?.(`The pay tin hits the platform. The railroad has lawyers AND rifles — ride wide a while.`, 6500);
1119
+ }
1120
+ return true;
1121
+ }
1122
+
1123
+ _askForTheRoad() {
1124
+ const to = this._nextPlace();
1125
+ const from = this.place;
1126
+ const route = this.line.route(from, to);
1127
+ if (!route) return false;
1128
+ const wasSink = !!this.line.places[from].sink;
1129
+ const rev = this._reverseFor(route, wasSink);
1130
+ const was = this.reversed;
1131
+ this.reversed = rev;
1132
+ route.len = route.base + this.berthLead(route);
1133
+ if (!this.line.signals.grant(this, route)) {
1134
+ this.reversed = was; // refused: nothing changes, not even which way she faces
1135
+ if (!this.held) { this.held = true; this.line.holds.push({ train: this.id, at: from, want: to, t: this.line.clock }); }
1136
+ return false;
1137
+ }
1138
+ this.held = false;
1139
+ this._adopt(route);
1140
+ this.stabled = false;
1141
+ this.state = 'running';
1142
+ this.sched = (this.sched + 1) % this.spec.calls.length;
1143
+ this._planHalts(route);
1144
+ console.log(`[train] ${this.id}: road cleared ${from} → ${to}${rev ? ' (propelling)' : ''}`);
1145
+ return true;
1146
+ }
1147
+
1148
+ // ── THE END-OF-LINE HALTS (Nick's design) ────────────────────────────────────────────────────
1149
+ // A halt is a PAUSE ON THE ROAD, not a standing place: the train keeps its whole granted road and
1150
+ // simply stands a moment at the boards — so the interlocking, and the deadlock-freedom argument it
1151
+ // rests on (ONE contended berth), are untouched. Carriage trains only: the ore empties have no
1152
+ // fares and run through. The halt nearest a TUNNEL end is where a riding player is put off —
1153
+ // nobody gets carried into the hill.
1154
+ _planHalts(route) {
1155
+ this._halts = [];
1156
+ this._haltDwell = 0;
1157
+ const lead = this._carriageLead();
1158
+ if (!lead) return; // no carriages, no call
1159
+ const main = this.line.paths.main;
1160
+ for (const H of RAIL_HALTS) {
1161
+ for (let i = 0; i < route.legs.length; i++) {
1162
+ const l = route.legs[i];
1163
+ if (l.path !== main) continue;
1164
+ const lo = Math.min(l.s0, l.s1), hi = Math.max(l.s0, l.s1);
1165
+ if (H.s <= lo + 2 || H.s >= hi - 2) continue; // a halt AT a route end is that end's berth, not a call
1166
+ this._halts.push({ name: H.name, u: route.off[i] + Math.abs(H.s - l.s0) + lead, wait: H.wait ?? 12, stand: H.stand });
1167
+ break;
1168
+ }
1169
+ }
1170
+ this._halts.sort((a, b) => a.u - b.u);
1171
+ if (this.line.places[route.to]?.sink && this._halts.length) this._halts[this._halts.length - 1].eject = true;
1172
+ }
1173
+
1174
+ _callAtHalt(h) {
1175
+ console.log(`[train] ${this.id}: calling at ${h.name}`);
1176
+ const seat = this.seats?.find((s) => s.rider?.player);
1177
+ if (seat && h.eject) {
1178
+ const player = seat.rider.player;
1179
+ seat.alight(player);
1180
+ if (h.stand) {
1181
+ const py = this.game.world?.walkSurfaceAt?.(h.stand.x, h.stand.z);
1182
+ player.setPosition?.(h.stand.x, py ?? player.position.y, h.stand.z);
1183
+ player._camSnap = true;
1184
+ }
1185
+ this.game.ui?.toast?.(`End of the line — ${h.name}. She turns in the hill; catch one going the other way.`, 5600);
1186
+ }
1187
+ }
1188
+
1189
+ // THE LOOK-AHEAD — the safety net, and coach.js's _blocked() almost line for line. How far in front
1190
+ // of my chimney is the nearest piece of somebody else's train, inside MY corridor?
1191
+ //
1192
+ // What it does NOT ask about is the point: it does not brake for the player, it does not brake for
1193
+ // the townsfolk, and it does not brake for the stagecoach. A TRAIN DOES NOT STOP FOR A MAN ON THE
1194
+ // LINE. The colliders shove him aside and being there is his problem — the same trade coach.js's
1195
+ // runaway branch already makes (coach.js:554), and the reason the crossing has a guard on it.
1196
+ _roadAhead() {
1197
+ const lead = this.reversed ? this.cars[this.cars.length - 1] : this.cars[0];
1198
+ const fx = Math.sin(this.travelYaw), fz = Math.cos(this.travelYaw);
1199
+ const ox = lead.root.position.x, oz = lead.root.position.z;
1200
+ const reach = LOOK + this.speed * LOOK_PER_MS;
1201
+ let nearest = Infinity;
1202
+ for (const other of this.line.trains) {
1203
+ if (other === this || other.stabled) continue;
1204
+ for (const c of other.cars) {
1205
+ const dx = c.root.position.x - ox, dz = c.root.position.z - oz;
1206
+ const ahead = dx * fx + dz * fz;
1207
+ if (ahead < 0.5 || ahead > reach) continue;
1208
+ const side = Math.abs(dx * fz - dz * fx);
1209
+ if (side > CORRIDOR + c.def.halfW) continue;
1210
+ nearest = Math.min(nearest, ahead - c.len / 2); // stop short of his BUFFERS, not his middle
1211
+ }
1212
+ }
1213
+ return nearest;
1214
+ }
1215
+
1216
+ update(dt) {
1217
+ if (this.runaway > 0) this.runaway -= dt;
1218
+ if (this.relief > 0) { this.relief -= dt; if (this.relief <= 0) this._reliefCrew(); }
1219
+ if (this.held) this.heldT += dt;
1220
+
1221
+ if (this.state === 'standing') {
1222
+ this.speed = 0;
1223
+ // A GUN ON THE FOOTPLATE (train robbery v1 — the PLATFORM version; nobody flags down 140
1224
+ // tons at line speed). Iron aimed at the driver while she stands: the dwell holds — she
1225
+ // will not leave a platform with her driver's hands up — and two seconds buys the pay tin.
1226
+ if (this._heldUp(dt)) this.dwell = Math.max(this.dwell, 1.2);
1227
+ this.dwell -= dt;
1228
+ if (this.dwell <= 0) {
1229
+ this.askT -= dt;
1230
+ if (this.askT <= 0) { this.askT = ASK_EVERY; this._askForTheRoad(); }
1231
+ }
1232
+ this._place();
1233
+ this._after(dt);
1234
+ return;
1235
+ }
1236
+
1237
+ // CALLING AT A HALT: stand at the boards for `wait` seconds without giving up the road.
1238
+ const nextHalt = this._halts?.find((h) => !h.done);
1239
+ if (nextHalt && this._haltDwell > 0) {
1240
+ this.speed = 0;
1241
+ this._haltDwell -= dt;
1242
+ if (this._haltDwell <= 0) nextHalt.done = true;
1243
+ this._place();
1244
+ this._after(dt);
1245
+ return;
1246
+ }
1247
+ if (nextHalt && this.u >= nextHalt.u - 0.6 && this.speed < 0.06) {
1248
+ this._haltDwell = nextHalt.wait;
1249
+ this._callAtHalt(nextHalt);
1250
+ }
1251
+
1252
+ // HOW FAST AM I ALLOWED TO BE GOING? The authority runs out at the far end of the route (my
1253
+ // berth); the look-ahead may shorten it. Then brake on the CURVE that gets me stopped exactly
1254
+ // there: v = sqrt(2 a d). Anything else and the stopping distance depends on how fast I happened
1255
+ // to be going, which is not how a brake works.
1256
+ let limit = this.route.len - this.u;
1257
+ if (nextHalt) limit = Math.min(limit, nextHalt.u - this.u); // the brake curve aims at the halt first
1258
+ const obstacle = this._roadAhead();
1259
+ if (obstacle < Infinity) limit = Math.min(limit, obstacle - SAFE_GAP);
1260
+ this.held = obstacle < Infinity && obstacle - SAFE_GAP < 80;
1261
+
1262
+ const lineSpeed = this.route.shunt ? SHUNT : CRUISE;
1263
+ let want = Math.min(lineSpeed, Math.sqrt(2 * BRAKE * Math.max(0, limit - STOP_PAD)));
1264
+ if (limit <= 0.4) want = 0;
1265
+ else if (want < CREEP) want = Math.min(CREEP, limit); // kiss the stop; do not lurch into it
1266
+
1267
+ if (want > this.speed) this.speed = Math.min(want, this.speed + ACCEL * dt);
1268
+ else this.speed = Math.max(want, this.speed - BRAKE * dt);
1269
+ // A DEADBAND BELONGS ON THE WAY DOWN, NOT ON THE WAY UP.
1270
+ // This used to be `if (this.speed < 0.02) this.speed = 0` — lifted straight from coach.js, where
1271
+ // it is harmless because a coach's speed is an exponential ease that clears 0.02 in its first
1272
+ // frame. A train accelerates at 0.35 m/s^2, so at 120 fps it gains THREE MILLIMETRES PER SECOND
1273
+ // of speed per frame — less than the deadband — and the next line took it away again. Every
1274
+ // frame. Forever. The fleet could not start: it only ever moved when a frame ran long enough
1275
+ // (>57 ms) to clear the band in one go, which is why it looked intermittent rather than broken,
1276
+ // and why the whole railway then deadlocked behind whichever train had got away. So the snap to
1277
+ // zero applies only when we are ASKING for zero.
1278
+ if (want <= 0 && this.speed < 0.05) this.speed = 0;
1279
+
1280
+ const step = this.speed * dt;
1281
+ this.u += step;
1282
+ this.dist += step;
1283
+
1284
+ if (this.u >= this.route.len - 0.6 && this.speed < 0.06) {
1285
+ this.u = this.route.len;
1286
+ this.place = this.route.to;
1287
+ this.state = 'standing';
1288
+ this.askT = 0;
1289
+ const P = this.line.places[this.place];
1290
+ // THE ENDS OF THE LINE ARE OFF THE COUNTY. A train that reaches one is STABLED: dropped below
1291
+ // the world, colliders off, blocking nothing — the property the whole signalling argument rests
1292
+ // on. It is NOT removed from the scene; that would be a pipeline build on the way back in.
1293
+ if (P.sink) {
1294
+ // ...AND HE IS NOT GOING WITH HER. `stabled` drops every car 400 m (see _place), and it did it
1295
+ // with the man still on his bench: ride the Flyer east out of Dustwater — which is the ride the
1296
+ // dev flag gives you, and the only one the timetable offers most of the time — and the journey
1297
+ // ends with the player at y = −396, inside the ground, in the dark, until she is booked out
1298
+ // again. Put him off FIRST, on the dirt, at the end of the line. alight() takes its x,z from
1299
+ // the car (still up here, still on the rails) and probes him down onto the real world.
1300
+ const seat = this.seats?.find((s) => s.rider?.player);
1301
+ if (seat) {
1302
+ const player = seat.rider.player;
1303
+ seat.alight(player);
1304
+ // ...and NOT inside the mountain. The full pull-through berths his car beyond the last
1305
+ // sleeper, so alight()'s beside-the-seat spot is in the dark of the bore. Walk him back
1306
+ // out: set him down beside the PORTAL, on the county side of the mouth.
1307
+ const outS = P.s === 0 ? TUNNEL_S + 9 : P.s - TUNNEL_S - 9;
1308
+ P.path.pos(outS, _a);
1309
+ const py = this.game.world?.walkSurfaceAt?.(_a.x, _a.z + 5.5) ?? _a.y;
1310
+ player.setPosition?.(_a.x, py, _a.z + 5.5);
1311
+ this.game.ui?.toast?.(`End of the line — ${P.name}. She runs into the hill and stables.`, 4200);
1312
+ }
1313
+ this.stabled = true;
1314
+ this.dwell = DWELL_TERMINAL + Math.random() * 6;
1315
+ this.runaway = 0; // she is in the yard: they find her another driver
1316
+ this.relief = 0;
1317
+ } else if (this.runaway > 0) {
1318
+ this.dwell = 0; // she does not stop for anybody
1319
+ console.log(`[train] ${this.id}: RAN THROUGH ${P.name} — nobody on the regulator`);
1320
+ } else {
1321
+ this.dwell = P.wait ?? DWELL_STATION;
1322
+ this._seatFares();
1323
+ if (P.id === 'dustwater') this.game.ui?.toast?.(`${this.name} — calling at Dustwater`, 2400);
1324
+ }
1325
+ }
1326
+ this._place();
1327
+ this._after(dt);
1328
+ }
1329
+
1330
+ // ── where every car is, and which way it points ─────────────────────────────────────────────
1331
+ // A car sits on its TWO BOGIES, not on its centre: sample the route at centre ± 0.32 * len, put the
1332
+ // body on the midpoint and point it along the chord. That is what lets a rigid 17 m carriage take a
1333
+ // corner on a polyline whose joints kink up to 12.8 degrees without snapping its heading at every
1334
+ // waypoint and snaking the whole consist.
1335
+ _place() {
1336
+ const R = this.route;
1337
+ if (!R) return;
1338
+ for (const car of this.cars) {
1339
+ const u = this.u - this._off(car);
1340
+ const half = car.len * 0.32;
1341
+ R.pos(u + half, _a); _a.y = R.height(u + half) + RAIL_Y;
1342
+ R.pos(u - half, _b); _b.y = R.height(u - half) + RAIL_Y;
1343
+ car.root.position.set((_a.x + _b.x) / 2, (_a.y + _b.y) / 2, (_a.z + _b.z) / 2);
1344
+ // IN THE HILL, OUT OF SIGHT: a car wholly inside the main line's bored ends vanishes into the
1345
+ // dark of the portal (railway.js dresses the mouths). This is what makes the sink's
1346
+ // stable/book-out blink invisible — it happens where nobody can see it (Nick's tunnels).
1347
+ const atLeg = R.at(u);
1348
+ const onMain = atLeg.l.path === this.line.paths.main;
1349
+ const inHill = onMain && (atLeg.s < TUNNEL_S - car.len / 2 || atLeg.s > atLeg.l.path.len - TUNNEL_S + car.len / 2);
1350
+ car.root.visible = !inHill;
1351
+ if (this.stabled) car.root.position.y -= 400;
1352
+ const yaw = Math.atan2(_a.x - _b.x, _a.z - _b.z);
1353
+ car.root.rotation.y = this.reversed ? yaw + Math.PI : yaw;
1354
+ // ...and she leans back going up the bank. The cheapest thing in this file and the first thing
1355
+ // you notice the absence of. (The sign flips with the car: a reversed car's own +Z is astern.)
1356
+ const run = Math.hypot(_a.x - _b.x, _a.z - _b.z) || 1;
1357
+ const pitch = Math.atan2(_a.y - _b.y, run);
1358
+ car.root.rotation.x = this.reversed ? pitch : -pitch;
1359
+ car.syncCollider(this.stabled);
1360
+ car.syncRiders();
1361
+ }
1362
+ // The anchor that survives a change of route — and therefore a reversal. Everything else about
1363
+ // where the train is can be rebuilt from the loco's own place on the iron.
1364
+ const hit = R.at(this.u - this._off(this.cars[0]));
1365
+ this.locoS = hit.s;
1366
+ this.locoPath = hit.l.path;
1367
+ R.pos(this.u + 3, _a); R.pos(this.u - 3, _b);
1368
+ this.travelYaw = Math.atan2(_a.x - _b.x, _a.z - _b.z);
1369
+ // The E-prompts ride WITH the car they belong to — a prompt planted in the dirt is a prompt beside
1370
+ // an empty platform. There is a row of them down each flank of each rideable car now (see
1371
+ // TrainLine.build), so this walks the list rather than writing one point.
1372
+ if (this._its) {
1373
+ for (const it of this._its) {
1374
+ _a.set(it.cx, 0, it.cz);
1375
+ it.car.root.localToWorld(_a);
1376
+ it.x = _a.x;
1377
+ it.z = _a.z;
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // Put the train in its berth at build: give it the road it is ABOUT to work and stand it at the far
1383
+ // end of nothing — u = berthLead is exactly where it would be standing had it just arrived.
1384
+ _park() {
1385
+ const R = this.line.route(this.place, this._nextPlace());
1386
+ if (!R) { console.warn('[train] no road out of', this.place); return; }
1387
+ this.reversed = false;
1388
+ this.route = R;
1389
+ R.len = R.base + this.berthLead(R);
1390
+ this.u = this._carriageLead(); // stood in its start berth as though it had just arrived
1391
+ this.state = 'standing';
1392
+ this.stabled = !!this.line.places[this.place].sink;
1393
+ this.dwell = 3 + Math.random() * 26; // ...staggered, so the fleet does not all leave at once
1394
+ this._place();
1395
+ }
1396
+
1397
+ // ── everything that follows from having moved ───────────────────────────────────────────────
1398
+ _after(dt) {
1399
+ // THE WHEELS ARE TURNED BY THE GROUND: radians = distance / radius. Never by a rate — a wheel on
1400
+ // a rate slips the instant the train changes speed, and a slipping wheel is the most obvious lie
1401
+ // a vehicle can tell.
1402
+ //
1403
+ // ...AND THE GROUND GOES THE OTHER WAY WHEN SHE IS PROPELLING. A reversed working keeps its
1404
+ // facing and reverses its direction of travel (see _place: every car is turned a half turn), so
1405
+ // the wheel spinning about its own local axis comes out turning FORWARD while the train runs
1406
+ // BACKWARD — which is exactly what it looked like. The sign belongs on the distance, because the
1407
+ // distance is what turns them.
1408
+ //
1409
+ // AND IT IS EVERY WHEEL ON THE TRAIN NOW, not just the loco's. This loop never changed; what
1410
+ // changed is that car.wheels is no longer empty on a carriage, a van or a tender (prepareWheels
1411
+ // cuts their wheels out of the body mesh on the loading screen, because the model has them welded
1412
+ // into the planking and offers nothing to turn). 112 wheels across the fleet, all of them turned
1413
+ // by the same one line, none of them turned by a rate.
1414
+ const rolled = this.speed * dt * (this.reversed ? -1 : 1);
1415
+ for (const car of this.cars) {
1416
+ for (const w of car.wheels) w.mesh.rotation.x += rolled / w.radius;
1417
+ if (car.sticks.length) {
1418
+ const ph = this.dist / 0.931; // the rods reciprocate with the DRIVERS: one stroke a revolution
1419
+ for (const s of car.sticks) s.mesh.position.z = s.z0 + Math.sin(ph + s.phase) * s.throw;
1420
+ }
1421
+ }
1422
+ // Everyone aboard holds his pose — four two-bone solves a man, no mixer, no clip. It must run
1423
+ // every frame because the IK is solved fresh from the base pose each time (solve on top of
1424
+ // yesterday's solve and he shakes himself apart — seatedRider.js). A DEAD man is not re-solved;
1425
+ // he only falls.
1426
+ for (const r of this.riders) {
1427
+ if (!r.pivot.visible) continue;
1428
+ if (r.alive) r.solve();
1429
+ else r.slump(dt);
1430
+ }
1431
+ if (!this.stabled && this.cars[0]?.root.visible !== false) this.line.smoke.chuff(this, dt); // no smoke rising out of the hill
1432
+ }
1433
+
1434
+ // ── SHOOT THE MAN ON THE REGULATOR ──────────────────────────────────────────────────────────
1435
+ _crewDown(r, killer = null) {
1436
+ const g = this.game;
1437
+ const x = r.position.x, z = r.position.z;
1438
+ const at = new THREE.Vector3(x, g.world.groundAt(x, z), z);
1439
+ // Only the player's kills are the player's crimes — an outlaw's stray round is not a robbery.
1440
+ // The kind is 'coachRobbery' because that is the heaviest carrier-robbery HEAT crime.js knows
1441
+ // (crime.js:68) and it means the right thing: the county's money, taken off a common carrier. A
1442
+ // railroad payroll ought to out-price a stage's takings and deserves its own HEAT row — crime.js
1443
+ // is not mine to edit, so the PURSE is heavier and the heat is the same. Flagged for Nick.
1444
+ if (killer == null || killer === g.player) {
1445
+ g.crime?.report('coachRobbery', at);
1446
+ g.loot?.drop(at, roll(r.role === 'driver' ? FOOTPLATE_PURSE : r.role === 'mate' ? FIREMAN_PURSE : PASSENGER_PURSE), dropItemsFor(r.role));
1447
+ }
1448
+ alarmTown(g, x, z, 30, 6);
1449
+ for (const car of this.cars) car.crew = car.riders.filter((c) => c.pivot.visible);
1450
+ if (r === this.driver) this._noHandOnTheRegulator();
1451
+ }
1452
+
1453
+ _noHandOnTheRegulator() {
1454
+ if (this.runaway > 0) return;
1455
+ this.runaway = REGULATOR_SECS;
1456
+ if (!(this.fireman?.alive && this.fireman.pivot.visible)) this.relief = RELIEF_SECS;
1457
+ this.dwell = 0;
1458
+ console.log(`[train] ${this.id}: the driver is down — she runs on`);
1459
+ }
1460
+
1461
+ _reliefCrew() { this.runaway = 0; console.log(`[train] ${this.id}: relief crew aboard — she works her booked path again`); }
1462
+
1463
+ // ── THE RIDE ────────────────────────────────────────────────────────────────────────────────
1464
+ // player.aboard expects a COACH (player.js:513). It reads root.rotation.y, seatWorld(), rider and
1465
+ // driven off it — and now two more things, because a stagecoach is an open box and a railway
1466
+ // carriage is a room with a roof on: how long the follow camera may be (camDist), and where its lens
1467
+ // is allowed to go (camConfine). Both are read through `?? default` in player.js, so the stagecoach,
1468
+ // which answers neither, behaves exactly as it did.
1469
+ //
1470
+ // He used to ride the REAR VERANDA — except this carriage has no veranda. It is a closed-vestibule
1471
+ // coach: the deck stops at z = 8.04 and the end wall is at 8.11, so the old seat (y 1.75, z 8.15)
1472
+ // stood him on the outer lip of a door sill, half outside the end wall, with 0.63 m of air under his
1473
+ // boots. That is the whole of "we dont sit in it properly". He sits on a bench now.
1474
+ //
1475
+ // `root` is a bare Object3D that never enters the scene. It exists only to carry the heading
1476
+ // player.js reads, and player.js takes a QUARTER TURN off whatever it finds there (a coachman sits
1477
+ // across his box) — so it is pre-loaded with that quarter turn on top of the bench's own facing.
1478
+ makeSeat(car) {
1479
+ const t = this;
1480
+ const inside = car.kind === 'carriage';
1481
+ const host = {
1482
+ car,
1483
+ root: new THREE.Object3D(),
1484
+ rider: null,
1485
+ driven: false, // a passenger, not a driver: the camera does not chase the train's heading and
1486
+ // he looks wherever he likes
1487
+ // 9 m of follow camera in a 2.98 m saloon puts the lens through the far wall. The van's roof has
1488
+ // all the room in the world, so it only wants pulling in enough to see the county go past him.
1489
+ camDist: inside ? 2.7 : 5.5,
1490
+ camAdsDist: inside ? 2.1 : 4.0,
1491
+ seat: inside ? bench(-1, PLAYER_BAY, 0) : HOBO,
1492
+ get speed() { return t.speed; },
1493
+ seatWorld(out) {
1494
+ out.set(host.seat.x, host.seat.y, host.seat.z);
1495
+ // localToWorld, and not the rotate-the-offset arithmetic that was here: that one drops the x
1496
+ // term entirely (harmless on the centreline, a metre out on a bench beside a window) and knows
1497
+ // nothing about the car's PITCH, which is real — she leans back going up the bank.
1498
+ return car.root.localToWorld(out);
1499
+ },
1500
+ // WHERE THE LENS MAY GO, in the car's own frame. Called by player.js's follow camera before it
1501
+ // eases, so a camera swung round to look out of the left-hand window ends up pressed against the
1502
+ // right-hand wall instead of a metre outside it, staring at the planking.
1503
+ camConfine: inside ? (x, y, z) => {
1504
+ _cam.set(x, y, z);
1505
+ car.root.worldToLocal(_cam);
1506
+ _cam.x = Math.max(-SALOON.hx, Math.min(SALOON.hx, _cam.x));
1507
+ _cam.y = Math.max(SALOON.y0, Math.min(SALOON.y1, _cam.y));
1508
+ _cam.z = Math.max(SALOON.z0, Math.min(SALOON.z1, _cam.z));
1509
+ return car.root.localToWorld(_cam);
1510
+ } : null,
1511
+ board: (player) => {
1512
+ host.rider = { player, kind: 'inside' };
1513
+ player.aboard = host;
1514
+ // TAKE THE SEAT THAT FACES THE WAY SHE IS GOING. Both benches of bay 5 are kept clear of fares
1515
+ // for you, and they face opposite ways; the car already knows which of its own ends is the
1516
+ // front, because travelYaw is where the route is about to take it. Riding backwards is a thing
1517
+ // that happens to you on a train, not a thing you choose.
1518
+ if (inside) host.seat = bench(-1, PLAYER_BAY, Math.cos(car.root.rotation.y - t.travelYaw) > 0 ? 0 : 1);
1519
+ // A PASSENGER IS NOT A DRIVER. The coach's baked player-seat wears the DRIVER's posture (fists
1520
+ // up on the reins) because on a coach the player is always the man on the box. On a train he
1521
+ // is a fare, so his hands go in his lap. The posture is a plain field and swapping it is
1522
+ // exactly what coach.js's guard does when he takes the reins (coach.js:706). Put back on alight.
1523
+ const pr = t.game.coaches?.playerRider;
1524
+ if (pr) { host._wasPosture = pr.posture; pr.posture = passengerPosture(SEAT); }
1525
+ // AND THE GUN GOES BACK IN THE LEATHER, exactly as it does when he swings into a saddle
1526
+ // (player.js:522). Two reasons, and the second is the one that bites: a fare sits with his
1527
+ // hands in his lap — that is the posture two lines up — and gunInputs does not run at all on
1528
+ // the aboard path (player.js:582 returns above it), so a man who boards with his revolver out
1529
+ // can never put it away, and `drawn` stays up for the whole journey. trainCamera.js reads
1530
+ // `drawn` as "he is reaching for it, give him his camera back", so that man would ride two
1531
+ // kilometres looking at the back of his own hat and never see the film at all.
1532
+ player.holsterGun?.();
1533
+ // PUT THE LENS BEHIND HIS HEAD. camYaw is whatever he happened to be facing when he stepped up,
1534
+ // so without this you sit down and find yourself looking at your own back through a wall. The
1535
+ // camera sits at +(sin, cos) of camYaw from him, so behind him is his heading plus a half turn.
1536
+ // And FLATTEN THE PITCH: the walking camera looks down on him from above, which indoors means
1537
+ // it goes straight into the roof and stays there, clamped, filming the ceiling. A man who has
1538
+ // just sat down in a railway carriage is looking along it.
1539
+ player.camYaw = car.root.rotation.y + host.seat.ry + Math.PI;
1540
+ player.camPitch = 0.06;
1541
+ t.game.ui?.toast?.(inside ? 'Aboard. E to get off when she stops.'
1542
+ : 'On the roof of the van. E to get off when she stops.', 4200);
1543
+ },
1544
+ alight: (player) => {
1545
+ if (!host.rider) return;
1546
+ host.rider = null;
1547
+ player.aboard = null;
1548
+ player._visualY = undefined; // let the smoother re-latch onto wherever he lands
1549
+ // ...and the CINEMATIC LENS may be sixty metres away on a hillside when he steps down
1550
+ // (trainCamera.js). The follow camera EASES (player.js: lerp on rdt*10), so without this the
1551
+ // man who got off the train watches his own camera fly in at him across the desert for half a
1552
+ // second. It cuts. Free — the follow camera's own wall-clip snap is the same flag.
1553
+ player._camSnap = true;
1554
+ const pr = t.game.coaches?.playerRider;
1555
+ if (pr && host._wasPosture) pr.posture = host._wasPosture;
1556
+ // Step down on the PLATFORM side — the platform stands 6.8 m off the centreline on the town
1557
+ // side, and the town is south of the line. So of the two sides, take the one nearer the town,
1558
+ // and never step down into the six-foot. And step down BESIDE HIS OWN SEAT, not beside the
1559
+ // middle of a seventeen-metre car: a man who was sitting at the back of the coach does not
1560
+ // reappear at the front of it.
1561
+ const ry = car.root.rotation.y;
1562
+ const nx = Math.cos(ry), nz = -Math.sin(ry);
1563
+ _a.set(0, 0, host.seat.z);
1564
+ car.root.localToWorld(_a);
1565
+ const ax = _a.x + nx * 4.8, az = _a.z + nz * 4.8;
1566
+ const bx = _a.x - nx * 4.8, bz = _a.z - nz * 4.8;
1567
+ const [x, z] = az > bz ? [ax, az] : [bx, bz];
1568
+ // ...and step down onto WHATEVER IS THERE, which at a station is the platform: its deck
1569
+ // stands 0.908 m proud of the dirt and it is a COLLIDER, not terrain. groundAt() only knows
1570
+ // the terrain, so alighting onto the platform drops him through the planks and leaves him
1571
+ // standing in the ground with the deck at his waist. probe() settles a capsule onto the real
1572
+ // collision world — the deck if there is one, the dirt if there is not.
1573
+ //
1574
+ // START THE DROP JUST ABOVE HIS BOOTS. probe() defaults to falling from y = 50 and simulates
1575
+ // exactly one second of it (world.js:421), which at this gravity covers about 10 m — so out on
1576
+ // the flat it never reaches the ground at all and hands back 40, and the man who got off the
1577
+ // train appears forty metres over the county and falls. Drop him from a couple of metres above
1578
+ // the dirt instead: that clears the platform deck by 1.6 m and settles well inside the second.
1579
+ const W = t.game.world;
1580
+ player.position.set(x, W.probe(x, z, W.groundAt(x, z) + 2.5).y, z);
1581
+ },
1582
+ };
1583
+ // player.js takes a QUARTER TURN off root.rotation.y to get his heading (player.js:538), and that
1584
+ // turn is not a coach quirk to be cancelled — it is the correction for the SEATED POSE ITSELF,
1585
+ // which is baked facing across the body it is sitting on, because a coachman sits across his box.
1586
+ // Cancelling it (as this line used to, with a +PI/2) sat him on the bench facing the window
1587
+ // instead of the aisle: ninety degrees wrong, which is exactly how it looked. So hand player.js
1588
+ // the plain heading of the seat he is in — the car's, plus the bench's own facing — and let it
1589
+ // take its quarter turn. Refreshed every frame, because the carriage is going round a curve.
1590
+ //
1591
+ // ...and then PUT HIM BACK ON THE BENCH. main.js ticks the player BEFORE the vehicles (main.js:655,
1592
+ // :674), so player.js seated him against the car's matrix from LAST frame — at line speed that is
1593
+ // 0.27 m of lag, and on a pan 0.292 m deep he rides visibly back into the seat behind him. So he is
1594
+ // re-seated here, after the car has moved, on the car where it now IS. (player.position is
1595
+ // player.root.position — entity.js:183 — so this moves the man you can see. The only thing left a
1596
+ // frame behind is the camera, which eases anyway and cannot tell.)
1597
+ host.sync = () => {
1598
+ host.root.rotation.y = car.root.rotation.y + host.seat.ry;
1599
+ const p = host.rider?.player;
1600
+ if (!p) return;
1601
+ // _place has just written the car's position and rotation, but matrixWorld is still LAST FRAME'S
1602
+ // — three only rebuilds it at render. A carriage gets away with that by accident (its fares'
1603
+ // solve() walks updateWorldMatrix up through the car root and refreshes it as a side effect); a
1604
+ // boxcar has no fares and would not. Ask for it, rather than relying on a rider being there.
1605
+ car.root.updateWorldMatrix(false, false);
1606
+ host.seatWorld(p.position);
1607
+ p._visualY = p.position.y; // the smoother is not running on this path; keep it pinned (player.js:549)
1608
+ };
1609
+ // ...AND THE CAMERA THAT MAKES THE JOURNEY WORTH TAKING. A passenger has nothing to do with his
1610
+ // hands, so the ride is filmed: six shots on a loop, cut on a timer (game/trainCamera.js). It hangs
1611
+ // a `camera` field on the host and player.js calls it if it is there — which is why the stagecoach,
1612
+ // which has no such field, is untouched. Built HERE, on the loading screen, with the train: nothing
1613
+ // is allocated when he steps aboard, and nothing is thrown away when he steps off.
1614
+ attachTrainCamera(t, host);
1615
+ return host;
1616
+ }
1617
+ }
1618
+ const _cam = new THREE.Vector3();
1619
+
1620
+ const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _ws = new THREE.Vector3();
1621
+
1622
+ // ── SMOKE ─────────────────────────────────────────────────────────────────────────────────────
1623
+ // game.vfx does NOT make this cheap, and I checked before writing a line of it. VFX.spawn() allocates
1624
+ // a fresh THREE.Sprite AND a fresh THREE.SpriteMaterial per particle and does a scene.add / a
1625
+ // scene.remove per particle (vfx.js:301, 457), and FX_RECIPES has no smoke or steam recipe to play
1626
+ // instead (fire_explosion, impact_small, impact_dirt, blood, embers, fireflies, glow_spot, sparkles,
1627
+ // stun_stars — that is the lot). A loco chuffing twice a revolution through that would churn the
1628
+ // scene graph forever, which is precisely what this codebase's perf law forbids.
1629
+ //
1630
+ // So the smoke is its own RESIDENT pool: one InstancedMesh of camera-facing quads, built on the
1631
+ // loading screen, never added to or removed from the scene, count fixed for life, and every puff is
1632
+ // an instance-matrix write. One draw call for the whole fleet. It reuses the smoke_04 texture VFX has
1633
+ // already loaded, so it costs no new asset either.
1634
+ const PUFFS = 72;
1635
+ const PUFF_LIFE = 2.6;
1636
+ const SMOKE_R = 260; // don't chuff a loco nobody can see — the far end of the line is a kilometre out
1637
+
1638
+ class Smoke {
1639
+ constructor(game) {
1640
+ this.game = game;
1641
+ this.p = [];
1642
+ for (let i = 0; i < PUFFS; i++) this.p.push({ age: PUFF_LIFE + 1, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, s0: 1, rot: 0 });
1643
+ this.next = 0;
1644
+ }
1645
+
1646
+ async build(scene) {
1647
+ const tex = this.game.vfx?.textures?.smoke_04 ?? await loadTexture('/assets/particles/smoke_04.png');
1648
+ const mat = new THREE.MeshBasicMaterial({
1649
+ map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
1650
+ color: 0x39332c, opacity: 0.55, // coal smoke is dirty. It is not white steam.
1651
+ });
1652
+ this.mesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), mat, PUFFS);
1653
+ this.mesh.frustumCulled = false; // the plume moves; a stale bind sphere pops the whole pool out
1654
+ this.mesh.name = 'trainSmoke'; // NOT scatter:/scatterCol: — world.js must never bake it into the BVH
1655
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
1656
+ this.mesh.renderOrder = 3;
1657
+ const m = new THREE.Matrix4().makeScale(0, 0, 0);
1658
+ for (let i = 0; i < PUFFS; i++) this.mesh.setMatrixAt(i, m);
1659
+ this.mesh.instanceMatrix.needsUpdate = true;
1660
+ scene.add(this.mesh);
1661
+ return this;
1662
+ }
1663
+
1664
+ // A CHUFF IS NOT A TIMER. The exhaust beats with the driving wheels — twice a revolution here (a
1665
+ // real two-cylinder loco gives four; two reads just as well and halves the particle budget) — so a
1666
+ // loco working hard beats fast and a coasting one barely smokes. Because it is driven off the same
1667
+ // `dist / radius` the wheels are, it can never drift out of step with them. A standing loco still
1668
+ // simmers: the fire is lit even when the regulator is shut.
1669
+ chuff(train, dt) {
1670
+ const loco = train.cars[0];
1671
+ if (!loco || !this.mesh) return;
1672
+ const p = this.game.player?.position;
1673
+ if (p && dist2D(p.x, p.z, loco.root.position.x, loco.root.position.z) > SMOKE_R) return;
1674
+ const beats = (train.dist / (2 * Math.PI * 0.931)) * 2;
1675
+ if (train._beat === undefined) train._beat = beats;
1676
+ const standing = train.speed < 0.5;
1677
+ train._sim = standing ? (train._sim ?? 0) + dt : 0;
1678
+ const due = Math.floor(beats) > Math.floor(train._beat) || (standing && train._sim > 1.1);
1679
+ train._beat = beats;
1680
+ if (!due) return;
1681
+ if (standing) train._sim = 0;
1682
+ const ry = train.travelYaw;
1683
+ const lry = loco.root.rotation.y;
1684
+ const q = this.p[this.next = (this.next + 1) % PUFFS];
1685
+ q.age = 0;
1686
+ q.x = loco.root.position.x + STACK.z * Math.sin(lry);
1687
+ q.y = loco.root.position.y + STACK.y;
1688
+ q.z = loco.root.position.z + STACK.z * Math.cos(lry);
1689
+ q.vy = 2.5 + Math.random() * 1.3;
1690
+ // it leaves the chimney straight up and is immediately left behind by the train under it
1691
+ q.vx = -Math.sin(ry) * train.speed * 0.18 + (Math.random() - 0.5) * 0.6;
1692
+ q.vz = -Math.cos(ry) * train.speed * 0.18 + (Math.random() - 0.5) * 0.6;
1693
+ q.s0 = 1.6 + Math.random() * 0.9 + train.speed * 0.05;
1694
+ q.rot = Math.random() * 6.283;
1695
+ }
1696
+
1697
+ update(dt, camera) {
1698
+ if (!this.mesh) return;
1699
+ const cq = camera.quaternion;
1700
+ for (let i = 0; i < PUFFS; i++) {
1701
+ const q = this.p[i];
1702
+ if (q.age > PUFF_LIFE) continue;
1703
+ q.age += dt;
1704
+ if (q.age > PUFF_LIFE) { _m4.makeScale(0, 0, 0); this.mesh.setMatrixAt(i, _m4); continue; }
1705
+ q.x += q.vx * dt; q.y += q.vy * dt; q.z += q.vz * dt;
1706
+ q.vy += 0.5 * dt; // hot gas keeps climbing
1707
+ q.vx *= 1 - 0.6 * dt; q.vz *= 1 - 0.6 * dt;
1708
+ const t = q.age / PUFF_LIFE;
1709
+ const s = q.s0 * (1 + t * 3.4); // it billows out as it cools
1710
+ _q.copy(cq).multiply(_qz.setFromAxisAngle(_zax, q.rot));
1711
+ _m4.compose(_v3b.set(q.x, q.y, q.z), _q, _v3.set(s, s, s));
1712
+ this.mesh.setMatrixAt(i, _m4);
1713
+ }
1714
+ this.mesh.instanceMatrix.needsUpdate = true;
1715
+ }
1716
+ }
1717
+ const _m4 = new THREE.Matrix4(), _v3 = new THREE.Vector3(), _v3b = new THREE.Vector3();
1718
+ const _q = new THREE.Quaternion(), _qz = new THREE.Quaternion(), _zax = new THREE.Vector3(0, 0, 1);
1719
+
1720
+ // ── THE LEVEL CROSSING ────────────────────────────────────────────────────────────────────────
1721
+ // THE STAGECOACH MUST STOP FOR THE TRAIN, and coach.js is not mine to edit. Its look-ahead already
1722
+ // scans game.coaches.coaches and tests each `other.root.position` — and every train car is in that
1723
+ // array (Car.proxy), which alone stops the stage driving into iron it can already see. But the
1724
+ // coach's reach at a trot is 9.4 m and a train covers that in half a second, so a stage that only
1725
+ // reacts to what it can nearly touch is a stage that gets hit ON the crossing.
1726
+ //
1727
+ // So the crossing gets a GUARD: one more coach-shaped object, parked ON the crossing whenever a train
1728
+ // is closing on it or standing across it, and parked a hundred kilometres away the rest of the time.
1729
+ // To the stagecoach it is a coach stopped in the road, and it does what it has always done — brakes,
1730
+ // and holds, about eight metres short of the rails. That is a level crossing, expressed in the only
1731
+ // vocabulary coach.js speaks, with no edit to coach.js.
1732
+ //
1733
+ // The one thing it must NOT do is trap a coach that is already ON the crossing — and it doesn't:
1734
+ // coach.js's inTheWay() ignores anything less than 0.5 m in front of it, so a stage halfway over the
1735
+ // rails finds the guard BEHIND it and clears, which is exactly right.
1736
+ const CROSS_WARN = 190; // metres of approach at which the gates come down (12 s at line speed)
1737
+ // (FOUL_M, declared above the Train class, is how close a car's END may come
1738
+ // to the road. It was 16 m round the car's CENTRE, which shuts the gates on a
1739
+ // carriage whose nearest corner is twenty-five metres clear of the dirt.)
1740
+
1741
+ class CrossingGuard {
1742
+ constructor(line, at) {
1743
+ this.line = line;
1744
+ this.at = at;
1745
+ this.crew = [];
1746
+ this.riders = [];
1747
+ this.root = new THREE.Object3D();
1748
+ this.root.position.set(1e5, 0, 1e5); // off the map: it blocks nothing until it has to
1749
+ this.shut = false;
1750
+ }
1751
+ update() { /* CoachLine.update() will call this; the work is in set(), driven by TrainLine */ }
1752
+ _syncRiders() { }
1753
+
1754
+ set() {
1755
+ let shut = false;
1756
+ for (const t of this.line.trains) {
1757
+ if (t.stabled) continue;
1758
+ for (const c of t.cars) {
1759
+ if (dist2D(c.root.position.x, c.root.position.z, this.at.x, this.at.z) < FOUL_M + c.len / 2) { shut = true; break; }
1760
+ }
1761
+ if (shut) break;
1762
+ // ...and CLOSING on it. A train that has just cleared the crossing is the same DISTANCE from it
1763
+ // as one about to reach it, so distance alone would hold the gates shut behind every train for
1764
+ // another mile of road. Ask which side of the loco it is on.
1765
+ const lead = t.reversed ? t.cars[t.cars.length - 1] : t.cars[0];
1766
+ const d = dist2D(lead.root.position.x, lead.root.position.z, this.at.x, this.at.z);
1767
+ if (t.speed > 0.2 && d < CROSS_WARN) {
1768
+ const fx = Math.sin(t.travelYaw), fz = Math.cos(t.travelYaw);
1769
+ const ahead = (this.at.x - lead.root.position.x) * fx + (this.at.z - lead.root.position.z) * fz;
1770
+ if (ahead > -10) { shut = true; break; }
1771
+ }
1772
+ }
1773
+ this.shut = shut;
1774
+ if (shut) this.root.position.set(this.at.x, 0, this.at.z);
1775
+ else this.root.position.set(1e5, 0, 1e5);
1776
+ }
1777
+ }
1778
+
1779
+ // ── THE FLEET ─────────────────────────────────────────────────────────────────────────────────
1780
+ // Four trains, four jobs. The ORE is the one that pays for the line; the rest ride on its back, which
1781
+ // is how every real branch line in the west got built.
1782
+ export const FLEET = [
1783
+ { id: 'passenger', name: 'The Dustwater Flyer', consist: ['loco', 'tender', 'carriage', 'carriage'],
1784
+ start: 'west', calls: ['west', 'dustwater', 'east', 'dustwater'] },
1785
+ { id: 'ore', name: 'The Bonebreak Ore', consist: ['loco', 'tender', 'freight', 'freight', 'freight'],
1786
+ start: 'east', calls: ['east', 'dustwater', 'bonebreak', 'dustwater'] }, // empties down, ore back
1787
+ { id: 'freight', name: 'The Copperhead Goods', consist: ['loco', 'tender', 'freight', 'carriage'],
1788
+ start: 'east', calls: ['east', 'dustwater', 'west', 'dustwater'] }, // a mixed train: a van and a coach on the back
1789
+ { id: 'local', name: 'The Dustwater Local', consist: ['loco', 'tender', 'carriage'],
1790
+ start: 'west', calls: ['west', 'dustwater'] }, // a short working, out and back
1791
+ ];
1792
+
1793
+ export class TrainLine {
1794
+ constructor(game) {
1795
+ this.game = game;
1796
+ this.trains = [];
1797
+ this.places = {};
1798
+ this.guards = [];
1799
+ this.holds = []; // every time a train was refused the road. The proof that it waited.
1800
+ this.clock = 0;
1801
+ }
1802
+
1803
+ async build(count = FLEET.length) {
1804
+ const world = this.game.world;
1805
+ this.paths = {};
1806
+ // The track author's graded centreline if it has landed; my own cut-and-fill of the sited route
1807
+ // if it has not. Everything downstream sees the same two methods (pos, height) either way.
1808
+ if (LINES_IN?.main?.nodes?.length) {
1809
+ for (const [id, line] of Object.entries(LINES_IN)) this.paths[id] = pathFromNodes(id, line);
1810
+ this.laidBy = 'world/data/railway.js';
1811
+ } else {
1812
+ for (const p of FALLBACK_PATHS) this.paths[p.id] = gradePolyline(p.id, p.pts, world);
1813
+ this.laidBy = 'the fallback survey (data/railway.js has not landed)';
1814
+ }
1815
+ const main = this.paths.main, spur = this.paths.spur;
1816
+ if (!main) throw new Error('[train] no main line');
1817
+
1818
+ const stops = STOPS_IN ?? FALLBACK_STOPS;
1819
+ // A CALL IS AN ARC LENGTH, NOT A PIN. The graded centreline is up to 1.25 m off the surveyed
1820
+ // polyline, so projecting an (x,z) onto it is a guess where the track author has an answer.
1821
+ const sOf = (st, path) => (st?.s !== undefined ? st.s : path.sNear(st?.x ?? 0, st?.z ?? 0));
1822
+ const dw = stops.find((st) => st.id === 'dustwater');
1823
+ const bb = stops.find((st) => st.id === 'bonebreak');
1824
+ this.junctionS = JUNC_IN?.s ?? (spur ? main.sNear(spur.nodes?.[0]?.x ?? -300, spur.nodes?.[0]?.z ?? -230) : main.len / 2);
1825
+ this.crossings = XING_IN ? [{ x: XING_IN.x, z: XING_IN.z, s: XING_IN.s }] : FALLBACK_CROSSINGS.map((c) => ({ ...c, s: main.sNear(c.x, c.z) }));
1826
+ this.crossS = this.crossings.map((c) => c.s); // the berth clamp works in arc length (berthLead)
1827
+ this.places = {
1828
+ west: { id: 'west', name: 'the west end of the line', path: main, s: 0, sink: true },
1829
+ dustwater: { id: 'dustwater', name: dw?.name ?? 'Dustwater Station', path: main, s: sOf(dw, main), wait: DWELL_STATION },
1830
+ east: { id: 'east', name: 'the east end of the line', path: main, s: main.len, sink: true },
1831
+ };
1832
+ if (spur && bb) this.places.bonebreak = { id: 'bonebreak', name: bb.name ?? 'Bonebreak Loading', path: spur, s: sOf(bb, spur), shunt: true, wait: DWELL_LOAD };
1833
+
1834
+ this.signals = new Signalman(this.trains);
1835
+ this.smoke = await new Smoke(this.game).build(this.game.scene);
1836
+
1837
+ for (const spec of FLEET.slice(0, count)) {
1838
+ if (!spec.calls.every((c) => this.places[c])) { console.warn('[train] no such place for', spec.id); continue; }
1839
+ try {
1840
+ const t = new Train(this, spec);
1841
+ await t.load();
1842
+ this.trains.push(t);
1843
+ } catch (e) { console.warn('[train] failed to build', spec.id, e); }
1844
+ }
1845
+
1846
+ // REGISTER WITH THE COACHES — see Car.proxy(). This is the whole of "the stagecoach stops for the
1847
+ // train", and it is also what makes the footplate crew shootable, witnesses and aim targets. If
1848
+ // the coach line failed to build, stand an empty one up: that array is what five other modules
1849
+ // read, and an empty coach line is a perfectly good coach line with no coaches on it.
1850
+ if (!this.game.coaches) this.game.coaches = { coaches: [], playerRider: null, update() {}, syncRiders() {} };
1851
+ this.guards = this.crossings.map((c) => new CrossingGuard(this, c));
1852
+ for (const t of this.trains) for (const c of t.cars) this.game.coaches.coaches.push(c.proxy());
1853
+ for (const g of this.guards) this.game.coaches.coaches.push(g);
1854
+
1855
+ // RIDE THE TRAIN — FROM ANYWHERE ALONGSIDE THE CAR, BOTH SIDES, ITS WHOLE LENGTH.
1856
+ //
1857
+ // There used to be ONE circle, on the middle of the last carriage, written `r: 4.4`. That radius is
1858
+ // a fiction: main.js starts its search at bestD = 3.2 (main.js:733) and never looks further,
1859
+ // whatever number you write. So the only place on a fifty-five metre train where E did anything was
1860
+ // a 3.2 m bubble round the CENTRE of a seventeen-metre coach — and since you must stand outside a
1861
+ // body 1.88 m half-wide, that left a couple of paces of dirt, on whichever side you happened to
1862
+ // approach from. "i cant get on the train", and quite right too.
1863
+ //
1864
+ // So the circles go in TWO ROWS, one down each FLANK of the car, RIDE_OUT clear of the body —
1865
+ // which is where a man wanting to get on a train actually stands. From there a 3.2 m circle reaches
1866
+ // from the planking out to five metres of dirt, and RIDE_STEP apart they overlap the whole length
1867
+ // of the car. Both sides, end to end, and the same E gets you off again.
1868
+ //
1869
+ // EVERY carriage of the train is boardable, not just the last: on the Flyer the front coach is
1870
+ // forty metres from the back one, and a passenger walks to the door he is nearest. Each car has its
1871
+ // own seat host, so you sit down in the coach you got into.
1872
+ //
1873
+ // AND EVERY TRAIN OFFERS A RIDE. Two of the four have no carriage in them at all (the ore is five
1874
+ // cars of loco, tender and vans), and a train standing at a platform with nothing to press E on is
1875
+ // a train the county cannot use — so those are ridden on the roof of the rear van, which is what a
1876
+ // man with no ticket has always done.
1877
+ const RIDE_R = 3.2; // main.js will not look further than this, however big a number you write
1878
+ const RIDE_OUT = 2.6; // car-local |x| of the two rows: 0.7 m clear of the widest body (halfW 1.88)
1879
+ const RIDE_STEP = 4.0; // ...so the worst point between two circles is 2.0 m of z, and 2.5 m of
1880
+ // dirt still reads: sqrt(3.2^2 - 2.0^2) = 2.5 m out beyond the row itself
1881
+ for (const t of this.trains) {
1882
+ const coaches = t.cars.filter((c) => c.kind === 'carriage');
1883
+ const ride = coaches.length ? coaches : t.cars.filter((c) => c.kind === 'freight').slice(-1);
1884
+ if (!ride.length) continue; // a loco and a tender and nothing else: nowhere to put a man
1885
+ t.seats = [];
1886
+ t._its = [];
1887
+ for (const car of ride) {
1888
+ const host = t.makeSeat(car);
1889
+ t.seats.push(host);
1890
+ const label = car.kind === 'carriage' ? `E — Ride ${t.name}` : `E — Ride the roof of ${t.name}`;
1891
+ const n = Math.max(1, Math.ceil(car.len / RIDE_STEP));
1892
+ for (let i = 0; i < n; i++) {
1893
+ for (const side of [-1, 1]) {
1894
+ const it = {
1895
+ x: 0, z: 0, r: RIDE_R,
1896
+ car, cx: side * RIDE_OUT, cz: -car.len / 2 + (i + 0.5) * (car.len / n), // car-local; _place puts them in the world
1897
+ // `when` turns one prompt into the other: you cannot leap onto a moving train, and once
1898
+ // aboard the only thing worth offering is the way off. (The names carry their own "The".)
1899
+ // Once aboard, the prompt is where the camera keys are ADVERTISED — there is no manual,
1900
+ // and a cinematic rig nobody knows how to skip or switch off is a cinematic rig that
1901
+ // traps people (trainCamera.js owns the string).
1902
+ get label() { return host.rider ? (host.camPrompt ?? 'E — Get off the train') : label; },
1903
+ // ...and you get off where she STOPS. Offering it at sixteen metres a second put a man
1904
+ // on the ballast in open desert two kilometres from anywhere, which is not a feature —
1905
+ // so once you are aboard, the prompt comes back only when she is standing at a call.
1906
+ when: () => (host.rider?.player === this.game.player
1907
+ ? (t.speed < 0.6 && t.state === 'standing')
1908
+ : (!this.game.player.aboard && !t.stabled && t.speed < 0.6)),
1909
+ action: () => (host.rider ? host.alight(this.game.player) : host.board(this.game.player)),
1910
+ };
1911
+ t._its.push(it);
1912
+ this.game.interactables.push(it);
1913
+ }
1914
+ }
1915
+ }
1916
+ t.seat = t.seats[0];
1917
+ }
1918
+
1919
+ console.log(`[train] the railroad is working: ${this.trains.length} trains on ${main.len.toFixed(0)}m of main line`
1920
+ + `${spur ? ` + ${spur.len.toFixed(0)}m of ore spur` : ''}, ${Object.keys(this.places).length} places, laid by ${this.laidBy}`);
1921
+ return this;
1922
+ }
1923
+
1924
+ // A ROAD from one place to the next. The only interesting one is the spur: a run between Dustwater
1925
+ // and Bonebreak is two legs — back down the main line to the junction, then out along the spur —
1926
+ // and the junction is a point on BOTH paths, which is what makes it a junction.
1927
+ route(from, to) {
1928
+ const A = this.places[from], B = this.places[to];
1929
+ if (!A || !B || from === to) return null;
1930
+ const main = this.paths.main, spur = this.paths.spur;
1931
+ let legs;
1932
+ if (A.path === main && B.path === main) legs = [{ path: main, s0: A.s, s1: B.s }];
1933
+ else if (A.path === main && B.path === spur) legs = [{ path: main, s0: A.s, s1: this.junctionS }, { path: spur, s0: 0, s1: B.s }];
1934
+ else if (A.path === spur && B.path === main) legs = [{ path: spur, s0: A.s, s1: 0 }, { path: main, s0: this.junctionS, s1: B.s }];
1935
+ else return null;
1936
+ const R = makeRoute(legs, from, to);
1937
+ R.shunt = !!(A.shunt || B.shunt); // you do not take a loaded ore train down a siding at 36 mph
1938
+ return R;
1939
+ }
1940
+
1941
+ update(dt) {
1942
+ this.clock += dt;
1943
+ // THE ONE WHO HAS WAITED LONGEST ASKS FIRST. The trains used to ask for the road in the order they
1944
+ // were built, and the road always went to whoever asked first — so the Copperhead Goods, third in
1945
+ // the list, sat at the east end for the whole of a twenty-minute run and never turned a wheel
1946
+ // (measured: 0.00 km in 1200 s). A signalman works a queue, not a stack.
1947
+ this._order ??= [...this.trains];
1948
+ this._order.sort((a, b) => b.heldT - a.heldT);
1949
+ for (const t of this._order) { t.update(dt); for (const h of t.seats ?? []) h.sync(); }
1950
+ for (const g of this.guards) g.set();
1951
+ this.smoke.update(dt, this.game.camera);
1952
+ }
1953
+
1954
+ // combat.js calls game.coaches.syncRiders() before it sweeps its bullets and the car proxies answer
1955
+ // it. This is here for anything that would rather ask the trains directly.
1956
+ syncRiders() { for (const t of this.trains) for (const c of t.cars) c.syncRiders(); }
1957
+ }