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,1034 @@
1
+ // THE STAGECOACH LINE — Dustwater's public transport.
2
+ //
3
+ // A coach is a box on four wheels, a team of two, and a man on the box. It runs a ROUTE between
4
+ // the places on the map, and it does the one thing that makes a coach feel like a service rather
5
+ // than set dressing: IT CALLS AT TOWN. It rolls in off the north road, pulls up at the depot,
6
+ // the doors swing open, whoever was riding gets down and walks away into the town, whoever was
7
+ // waiting climbs aboard, the doors shut, the driver takes them out again — and later you meet
8
+ // the same coach on the road between two places, doing exactly what it said it was doing.
9
+ //
10
+ // WHAT IS AUTHORED, WHAT IS DERIVED:
11
+ // coachRig.json — where the horses and the driver sit, RELATIVE TO THE COACH (tuned in
12
+ // /coachtest.html, because that relationship is a thing you look at, not a
13
+ // thing you calculate).
14
+ // ROUTE — the stops, here. Everything else — headings, gaits, wheel spin, when a
15
+ // passenger opens a door — falls out of the coach's own motion.
16
+ //
17
+ // PERF: everything is built ONCE, on the loading screen, and stays resident for life — the same
18
+ // law the horses and the townsfolk obey (main.js:118: adding or toggling skinned meshes during
19
+ // play triggers pipeline setup and multi-second freezes). Passengers do not spawn when a coach
20
+ // arrives; they are already aboard, riding along inside the box with their models hidden, and
21
+ // "getting out" is them becoming visible at the door and walking off.
22
+ import * as THREE from 'three/webgpu';
23
+ import { instantiate, loadClips, loadModel } from '../core/assets.js';
24
+ import { RawAnimator } from '../core/anim.js';
25
+ import { SeatedRider, SEAT, passengerPosture, driverPosture } from './seatedRider.js';
26
+ import { rayCapsule } from './aim.js';
27
+ import { makeSeatedPose, BIPED_TO_SYNTY } from './riding.js';
28
+ // Coach content is GAME data, registered before construction (partials merge):
29
+ // setCoachContent({ characters, colliders, dropItemsFor, rig, route })
30
+ // route = { ROUTE, LEGS, TOTAL_LEN, placeAt, LANE, EASE_M } (from the game's route derivation)
31
+ let CHARACTERS = {}, colliders = [], dropItemsFor = () => null, RIG = {};
32
+ let ROUTE = [], LEGS = [], TOTAL_LEN = 0, placeAt = () => null, LANE = 1.6, EASE_M = 6;
33
+ export function setCoachContent(c = {}) {
34
+ if (c.characters) CHARACTERS = c.characters;
35
+ if (c.colliders) colliders = c.colliders;
36
+ if (c.dropItemsFor) dropItemsFor = c.dropItemsFor;
37
+ if (c.rig) RIG = c.rig;
38
+ if (c.route) ({ ROUTE = ROUTE, LEGS = LEGS, TOTAL_LEN = TOTAL_LEN, placeAt = placeAt, LANE = LANE, EASE_M = EASE_M } = c.route);
39
+ }
40
+
41
+ // WHO IS ON THE COACH TODAY. Not a fixed four: the same faces riding the same box past you every
42
+ // half hour is the thing that turns a service into a prop. So each coach draws its own fares from
43
+ // the pool, in their own clothes, and how many are actually aboard changes at every stop —
44
+ // sometimes the coach rolls through empty, sometimes it is full.
45
+ export const FARE_POOL = [
46
+ 'Character_Woman_01', 'Character_Salesman_Male_01', 'Character_Priest_Male_01', 'Character_Cowgirl_01',
47
+ 'Character_Business_Man_01', 'Character_Traveller_Female_01', 'Character_GoldMiner_Male_01',
48
+ 'Character_Mexican_Male_01', 'Character_Mexican_Female_01', 'Character_Sheriff_01',
49
+ ];
50
+ // The Synty bodies are UV'd for their own pack's atlas, but the pack ships several colourways of
51
+ // it — so the same body in a different atlas is a different person in different clothes, for free.
52
+ export const FARE_SKINS = [
53
+ '/assets/textures/atlas_western.png', '/assets/textures/atlas_western_b.png',
54
+ '/assets/textures/atlas_western_c.png', '/assets/textures/atlas_western_alt1.png',
55
+ '/assets/textures/atlas_western_alt2.png', '/assets/textures/atlas_western_alt3.png',
56
+ ];
57
+ export const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
58
+
59
+ // Draw a fresh coachload: n different bodies, each in a colourway of its own. Shared with
60
+ // /coachtest.html so the fares you shuffle in the bench are the fares the game actually runs.
61
+ export function castFares(n) {
62
+ const cast = [...FARE_POOL].sort(() => Math.random() - 0.5).slice(0, n); // no twins aboard
63
+ return cast.map((id) => {
64
+ const known = CHARACTERS.find((c) => c.id === id) ?? CHARACTERS[0];
65
+ return { id, src: known.src, keep: id, tex: pick(FARE_SKINS) };
66
+ });
67
+ }
68
+ import { dist2D } from '../core/utils.js';
69
+ import { alarmTown } from './npc.js';
70
+ export { ROUTE }; // the timetable's shape is public, as it always was — it just isn't authored here
71
+
72
+ const COACH_URL = '/assets/western/SM_Veh_Stagecoach_01.fbx';
73
+ const ATLAS = '/assets/textures/atlas_western.png';
74
+ const HORSE_URL = '/assets/horse/SyntyHorse.fbx';
75
+ // Six coats in the pack. A team of two identical horses, on every coach, forever, is the kind of
76
+ // thing you stop seeing after a week and a player notices in ten seconds — so each coach draws its
77
+ // own pair. Deliberately allowed to draw the same coat twice: a MATCHED team is a real thing a
78
+ // stage line would be proud of, and forbidding it would be a different kind of wrong.
79
+ const HORSE_COATS = [
80
+ '/assets/textures/horse_01.png', '/assets/textures/horse_02.png', '/assets/textures/horse_03.png',
81
+ '/assets/textures/horse_04.png', '/assets/textures/horse_05.png', '/assets/textures/horse_06.png',
82
+ ];
83
+ const HORSE_TEX = HORSE_COATS[0];
84
+ const HA = '/assets/horse/anims';
85
+ // A man on a BENCH, not on a horse: the Malbers chair sit (knees forward, feet down).
86
+ const SIT_CLIP = `${HA}/Sit_Loop.fbx`;
87
+ const DRIVER_DROOP = 0.3; // radians: a coachman's hands rest lower than a rider's (tuned in /coachtest.html)
88
+
89
+ // Road speeds, metres/sec — the same numbers the bench rolls at, so what you tuned is what runs.
90
+ const GAIT_SPEED = { idle: 0, walk: 1.6, trot: 3.4, canter: 6.5, gallop: 10.5 };
91
+ // A CANTER, NOT A TROT — AND THAT IS THE COUNTY DOUBLING, NOT A TASTE CHANGE.
92
+ // The circuit (depot → fort → depot → mission → depot) was ~1,703 m and is ~3,406 m now. At a trot
93
+ // (3.4 m/s) plus four 24 s stands that is an EIGHTEEN AND A HALF MINUTE lap, and with two coaches you
94
+ // stand at the depot for nine minutes waiting for one. Unplayable. At a canter (6.5) with three coaches
95
+ // (main.js: build(3)) it is a 10.3-minute lap and a coach at the depot every ~3.4 minutes — better than
96
+ // it was before the resize. It is also simply more honest: a stage trotted through town and MADE TIME on
97
+ // the open road, which is exactly what it now does. (Traffic braking is unchanged: LOOK scales with
98
+ // speed, so she still sees further the faster she goes.)
99
+ const CRUISE = 'canter'; // a working coach makes time on the open road; it does not gallop
100
+ const STOP_SECS = 24; // how long it stands at a stop
101
+ const DOOR_HOLD = 20; // ...of which the door stands OPEN for this long
102
+ const DOOR_OPEN = 1.45; // radians the door swings
103
+ const DOOR_SECS = 0.8; // and how long the swing takes
104
+
105
+ // TRAFFIC. A coach that drives through you, through the townsfolk and through the other coach is
106
+ // not a vehicle, it is a poster of a vehicle sliding along a rail.
107
+ // (LANE — metres right of the route line each coach drives — lives in coachRoute.js now, beside
108
+ // the polylines it offsets and the deck that must cover it. The keep-right story is told there:
109
+ // the return leg runs the same line the other way, so offsetting each direction to ITS OWN right
110
+ // puts them on opposite sides of the road and they pass instead of sharing one strip of dirt.)
111
+ const LOOK = 6.0; // metres it looks ahead even at a DEAD STOP...
112
+ const LOOK_PER_MS = 1.0; // ...plus this much per m/s: a trotting coach looks ~9m ahead
113
+ const HOLD_PAD = 1.5; // once stopped, the way must be clear by THIS much more before she pulls
114
+ // away. Without it the look-ahead shrinks as she slows, so she stops seeing
115
+ // the man in front of her, pulls away, sees him again, stops — and creeps
116
+ // into him at 1.8m/s forever. Hysteresis, not a bigger number.
117
+ const CORRIDOR = 2.0; // how wide the road ahead has to be clear (half-width, metres)
118
+ const YIELD_NEAR = 7.0; // people inside this are STRAGGLERS the coach swings round; further out it
119
+ // keeps rolling — they have been shouted at and they are clearing
120
+ const YELL_FLEE = 1.3; // seconds of perpendicular scurry per shout (re-upped while in the corridor)
121
+ const PASS_SHIFT = 2.6; // opposing coaches: how far each pulls to her own right to pass
122
+ const BRAKE = 3.2; // how hard it hauls up (1/s). A loaded coach does not stop dead.
123
+ const PULL = 0.6; // and how lazily it gets going again
124
+ const TURN_RATE = 0.9; // radians/sec at speed, when YOU are driving — she is four tons on wood
125
+ const REVERSE = 1.4; // m/s: what a team will back a coach at, and no more. She gets a reverse because
126
+ // she now has COLLISION (BODY, below) — and without one, a coach nosed into the
127
+ // front of the saloon is a coach that can never be driven again: the steering
128
+ // bites off her speed, and her speed against a wall is nought.
129
+
130
+ // HER BODY, for the world to push on. Four discs down her long axis: the box is 2.4 x 5.0 m (col:
131
+ // hx 1.2, hz 2.5) and the team is hitched 3.5 m out in front of it (coachRig.json), so the thing
132
+ // that actually reaches a wall first is a horse's nose, and leaving the team out would sink two
133
+ // horses into the planking before anything touched. Local +z is forward — the same frame
134
+ // _syncColliders already writes her horse colliders in.
135
+ const BODY = [
136
+ { z: 3.5, r: 1.05 }, // the team
137
+ { z: 1.7, r: 1.20 }, // the box and the fore-carriage
138
+ { z: 0.0, r: 1.20 },
139
+ { z: -1.7, r: 1.20 }, // the boot
140
+ ];
141
+ const _push = new THREE.Vector3();
142
+
143
+ // THE ROOF. Sprint at her, jump, and you are riding on top — see game/platform.js, which owns the
144
+ // primitive; the coach is only its first client.
145
+ //
146
+ // MEASURED, not guessed: the body mesh's top surface rasterised in coach-local metres (a 10cm grid,
147
+ // max triangle Y per cell). The cabin top is DEAD FLAT at y = 2.71 from x -1.02..+1.08 and
148
+ // z -1.27..+1.13, with a luggage rail 22cm proud of it around the rim. The rect below is pulled in
149
+ // from that: off the rail at the sides and the back, and stopped short at the FRONT, because the
150
+ // driver's pivot is at z 1.385 and his head sits 0.49m above the deck — walk any further and your
151
+ // boots go through the coachman's hat. (Nothing would push you apart: a SeatedRider is in none of
152
+ // separateFromPeople's lists.)
153
+ const ROOF = { x0: -0.90, x1: 0.90, z0: -1.15, z1: 0.70, y: 2.71, surface: 'stone' };
154
+ // ...and the BODY, as a wall for an entity only — never a collider, so it cannot stop a bullet and
155
+ // the band that lets you shoot the driver (below) is preserved by construction. Its floor is the
156
+ // footboard, where `col` stops. Its CEILING is the roof less the ledge-catch reach, which leaves
157
+ // the top 0.85m of her open: that is the lip, and a man whose feet are up there is grabbing it, not
158
+ // bouncing off it. Below that you are hitting the side of a coach, and you fall.
159
+ const HULL = { x0: -1.10, x1: 1.10, z0: -1.30, z1: 1.15, y0: 1.50, y1: ROOF.y - 0.85 };
160
+
161
+ // SHOOT THE MAN ON THE BOX.
162
+ // Horses do not stand politely over a dead coachman. The reins go slack, the shot goes off behind
163
+ // their ears, and they BOLT: she runs flat out with nobody steering — past the depot without
164
+ // calling, straight through the traffic she would have braked for — until the team blows itself
165
+ // out and drags her to a halt in the middle of the road with a corpse folded over the box. If the
166
+ // guard beside him lived, he takes the reins from where he sits and the line goes on. If he did
167
+ // not, that coach never runs again, and the road has a derelict on it. That is the price.
168
+ const BOLT_SECS = 14; // how long a panicked team can hold a flat canter before it is blown
169
+ const BOLT_RUSH = 2.4; // and how violently it gets there (PULL is 0.6 — this is not a working start)
170
+ const DRAG_DOWN = 0.8; // 1/s: a blown team hauling itself up is far slower than a driver braking (BRAKE 3.2)
171
+ const REINS_SECS = 3.5; // how long the guard looks at the body before he climbs onto the leather
172
+
173
+ // WHAT THEY CARRY. The driver has the takings — the whole reason a man ever shot a coachman — the
174
+ // guard beside him has his wages, and a fare has whatever was in his pocket.
175
+ const TAKINGS = [70, 130];
176
+ const WAGES = [18, 40];
177
+ const FARE_PURSE = [4, 22];
178
+ const roll = ([a, b]) => a + Math.floor(Math.random() * (b - a + 1));
179
+
180
+ // THE LINE. Neither the stops nor the waypoints are declared here. The stops come from
181
+ // world/data/coachStop.js — one list, two consumers, so the timetable and the stage buildings can
182
+ // never drift apart — and the LEGS BETWEEN THEM come from the ROAD GRAPH (coachRoute.js, imported
183
+ // above): the same net the minimap routes on, snapped at the stops, thinned, and threaded through
184
+ // the bridge chord because the chord IS the spliced road. The generations of hand-authored
185
+ // waypoints this block used to carry — the ±2 m offsets that fought the lane logic, the 'Town
186
+ // North' on-ramp with its site-local scaling trap, the BRIDGE_SPOTS-derived chord pair — all
187
+ // retire BY DERIVATION: the on-ramp is the route's own ≤6 m kerb stub (from live STOPS + live
188
+ // ROADS, scale-proof by construction), the crossing falls out of the road line with 0.00 m of
189
+ // deviation, and a leg that leaves the graded dirt cannot be authored at all. The complaint that
190
+ // forced this ("has just taken me through a building… and now across a field") lived on ONE
191
+ // straight 385 m leg that no waypoint patch was ever going to keep fixed; scripts/verify-coach.mjs
192
+ // now walks every driven lane line at 1 m and fails the build on the first off-road metre.
193
+
194
+ // One coach: the box, its team, its driver, and whoever is riding inside it.
195
+ class Coach {
196
+ constructor(game, leg = 0, t = 0) {
197
+ this.game = game;
198
+ this.leg = leg; // which stretch of the route it is on
199
+ this.t = t; // progress along that stretch, 0..1
200
+ this.state = 'rolling'; // rolling | standing
201
+ this.wait = 0;
202
+ this.doorK = 0; // 0 shut … 1 open (eased)
203
+ this.speed = 0;
204
+ this.root = new THREE.Group();
205
+ this.passengers = []; // NPCs riding inside
206
+ this.bolt = 0; // >0 = the team has run away with her (the driver is dead)
207
+ this.spent = false; // ...and now they're blown: dragging to a halt in the road
208
+ this.reinsT = 0;
209
+ this.crew = []; // who is aboard and shootable — combat.js walks this
210
+ }
211
+
212
+ async load() {
213
+ const coach = await instantiate(COACH_URL, { texture: ATLAS }); // GLB twin: already metres
214
+ this.root.add(coach);
215
+ // Wheels and doors are separate nodes, but every node sits at the origin with its geometry
216
+ // baked out at its real place — so re-pivot each one onto its own hub/hinge before turning
217
+ // it, or it swings around the coach's centre like a hammer throw. (Same as /coachtest.html.)
218
+ this.wheels = [];
219
+ this.doors = [];
220
+ const box = new THREE.Box3(), c = new THREE.Vector3();
221
+ coach.traverse((o) => {
222
+ if (!o.isMesh || !o.geometry) return;
223
+ const spin = /Wheel_(fl|fr|rl|rr)$/i.test(o.name);
224
+ const door = /Door_(l|r)$/i.test(o.name);
225
+ if (!spin && !door) return;
226
+ // COPY THE GEOMETRY BEFORE YOU MOVE IT.
227
+ // instantiate() clones the node tree but SHARES the geometry with the master — that is the
228
+ // whole point of the cache. Re-pivoting a wheel edits the geometry IN PLACE, so the second
229
+ // coach on the line re-pivoted the geometry the first coach had already re-pivoted: every
230
+ // wheel translated twice, hubs computed from an already-moved bounding box, and a coach that
231
+ // arrived with its wheels scattered across the desert. One coach hid it; two did not.
232
+ o.geometry = o.geometry.clone();
233
+ o.geometry.computeBoundingBox();
234
+ box.copy(o.geometry.boundingBox);
235
+ box.getCenter(c);
236
+ if (spin) {
237
+ o.geometry.translate(-c.x, -c.y, -c.z);
238
+ o.position.add(c);
239
+ this.wheels.push({ mesh: o, radius: (box.max.y - box.min.y) / 2 || 0.5 });
240
+ } else {
241
+ // hinge on the door's FORWARD edge, not its middle, or it scissors open through itself
242
+ const hinge = new THREE.Vector3(c.x, c.y, box.max.z);
243
+ o.geometry.translate(-hinge.x, -hinge.y, -hinge.z);
244
+ o.position.add(hinge);
245
+ // Which side of the coach this leaf is on. The coach's local +X is its RIGHT (it travels
246
+ // +Z), and the lane offset puts its right side against the kerb — so the right-hand door
247
+ // is the one that opens onto the depot, and the left one opens onto the road, which is
248
+ // where nobody gets out.
249
+ this.doors.push({ mesh: o, sign: c.x < 0 ? 1 : -1, right: c.x > 0, k: 0, want: 0 });
250
+ }
251
+ });
252
+
253
+ // the team
254
+ const clips = await loadClips({
255
+ idle: `${HA}/H_Idle_01.fbx`, walk: `${HA}/H_Walk_IP.fbx`,
256
+ trot: `${HA}/H_Trot_IP.fbx`, canter: `${HA}/H_Canter_IP.fbx`,
257
+ gallop: `${HA}/H_Gallop_IP.fbx`, // Shift on the box asks for it, so the team must have it
258
+ });
259
+ this.team = [];
260
+ for (const h of RIG.horses) {
261
+ const model = await instantiate(HORSE_URL, { texture: pick(HORSE_COATS), scale: 0.01 });
262
+ model.scale.setScalar(0.01);
263
+ const pivot = new THREE.Group();
264
+ pivot.position.set(h.x, h.y, h.z);
265
+ pivot.rotation.y = h.ry;
266
+ pivot.add(model);
267
+ this.root.add(pivot);
268
+ const animator = new RawAnimator(model, clips); // horse clips speak the horse's skeleton
269
+ animator.play('idle');
270
+ this.team.push({ animator, gait: 'idle' });
271
+ }
272
+
273
+ // THE PEOPLE. Driver, his mate beside him, and the fares on the benches inside — all of them
274
+ // SeatedRider, all of them wearing the ONE posture tuned in /coachtest.html. Nobody gets a
275
+ // bespoke sit: the day a driver's hands move, every passenger's hands move with him.
276
+ //
277
+ // The fares are BUILT NOW AND KEPT FOREVER, riding along inside the box with their models
278
+ // hidden — because adding a skinned mesh to a live scene costs a pipeline build and a
279
+ // multi-second freeze (main.js:118), and a coach pulling into town is precisely the worst
280
+ // moment to take one. "Getting out" is a man becoming visible, not a man being born.
281
+ const defOf = (role, fallbackId) => {
282
+ const id = RIG[`${role}Id`] ?? fallbackId;
283
+ const known = CHARACTERS.find((c2) => c2.id === id) ?? CHARACTERS[0];
284
+ return { id, src: RIG[`${role}Src`] ?? known.src, keep: id, tex: RIG[`${role}Tex`] ?? known.tex };
285
+ };
286
+ const PAX = passengerPosture(SEAT); // hands in the lap — only the driver holds reins
287
+ const DRV = driverPosture(SEAT);
288
+ this.driver = await SeatedRider.create(this.root, defOf('driver', 'Character_Business_Man_01'), RIG.driver, DRV);
289
+ if (RIG.mate) {
290
+ this.mate = await SeatedRider.create(this.root, defOf('mate', 'Character_Cowboy_01'), RIG.mate, PAX);
291
+ }
292
+ this.fares = [];
293
+ const seats = RIG.fares ?? [];
294
+ for (const def of castFares(seats.length)) {
295
+ this.fares.push(await SeatedRider.create(this.root, def, seats[this.fares.length], PAX));
296
+ }
297
+ this.riders = [this.driver, this.mate, ...this.fares].filter(Boolean);
298
+ // EVERY MAN ABOARD IS A SHOOTABLE PERSON (the seatedRider.js contract). Hand him the world he
299
+ // lives in, the coach he is riding, and who he is on it — the role decides what his purse is
300
+ // worth and, for the driver, what happens to the team when he dies.
301
+ this._drvPosture = DRV; // kept: the guard puts it on if he ever takes the reins
302
+ for (const r of this.riders) {
303
+ r.game = this.game;
304
+ r.coach = this;
305
+ r.role = r === this.driver ? 'driver' : r === this.mate ? 'mate' : 'fare';
306
+ }
307
+ this._seatFares(); // (also builds this.crew — must come AFTER this.riders)
308
+
309
+ // SOLID. The world's collider list is walked live by World.collide, so a moving vehicle can
310
+ // simply keep its entry up to date each frame — the box for the coach, a circle for each horse.
311
+ // Without these you walk straight through the team and out the far side of the coach, which is
312
+ // the single fastest way to tell a player that none of this is real.
313
+ // A HEIGHT BAND, or the coach shoots down your bullets for you.
314
+ // World.collide kills a bullet that crosses a collider, and a box with no band is solid all the
315
+ // way to the sky — so every round fired at the DRIVER, who sits on top of the thing, died on
316
+ // the chassis 2m below his boots. You could not rob the stage because the stage was bulletproof
317
+ // in a way it had never agreed to be. The band is the body of the coach: below it you are
318
+ // stopped, above it (the box seat, the roof rack, the man on it) you are not. y0/y1 are world
319
+ // heights, so they ride with her over the ground (see _syncColliders).
320
+ this.col = { x: 0, z: 0, hx: 1.2, hz: 2.5, cs: 1, sn: 0, y0: 0, y1: 0 };
321
+ colliders.push(this.col);
322
+ this.horseCols = this.team.map(() => {
323
+ const c2 = { x: 0, z: 0, r: 0.85 };
324
+ colliders.push(c2);
325
+ return c2;
326
+ });
327
+ // WHAT SHE MUST NEVER BE PUSHED OUT OF: herself. One list, two readers — platform.js hands it to
328
+ // the man on her roof so her own box does not shove him off it, and _drive hands it to the world
329
+ // so she does not shove herself across the road.
330
+ this.myCols = [this.col, ...this.horseCols];
331
+
332
+ this._place();
333
+ this._syncColliders();
334
+ this._syncRiders();
335
+ return this;
336
+ }
337
+
338
+ doorsShut() { return this.doors.every((d) => d.k < 0.02); }
339
+
340
+ // WHERE EACH MAN ACTUALLY IS, IN THE WORLD. He is a child of a moving, turning coach, so his
341
+ // world position has to be PRODUCED, not read. Same rotate-the-offset arithmetic the horse
342
+ // colliders already use (_syncColliders), written into a Vector3 he already owns: no
343
+ // getWorldPosition matrix walk, no allocation, twelve riders a frame, called from the coach's
344
+ // own update and again from combat.js before it tests a bullet (main.js ticks combat BEFORE the
345
+ // coaches, so without that second call every round is fired at where he was last frame).
346
+ //
347
+ // The y is his STANDING DATUM, not his seat — see seatedRider.js's STAND_HEAD. It sits below the
348
+ // floorboards on purpose: it is the ground he would be standing on if he stood up, which is the
349
+ // only thing combat.js's HEAD_Y (1.5m above the feet) can be measured from.
350
+ _syncRiders() {
351
+ const ry = this.root.rotation.y;
352
+ const s = Math.sin(ry), c = Math.cos(ry);
353
+ const ox = this.root.position.x, oy = this.root.position.y, oz = this.root.position.z;
354
+ for (const r of this.riders) {
355
+ const p = r.pivot.position;
356
+ r.position.set(ox + p.x * c + p.z * s, oy + p.y - r.feetDrop, oz - p.x * s + p.z * c);
357
+ }
358
+ }
359
+
360
+ // Drag the colliders along with the thing they belong to. Cheap: three objects, no allocation.
361
+ _syncColliders() {
362
+ if (!this.col) return;
363
+ const ry = this.root.rotation.y;
364
+ // the prompts ride with her: the door on her kerb side, the box up front
365
+ if (this._itDoor) {
366
+ const c2 = Math.cos(ry), s2 = Math.sin(ry);
367
+ this._itDoor.x = this.root.position.x + c2 * 1.4;
368
+ this._itDoor.z = this.root.position.z - s2 * 1.4;
369
+ this._itBox.x = this.root.position.x + s2 * 1.4 + c2 * 0.9;
370
+ this._itBox.z = this.root.position.z + c2 * 1.4 - s2 * 0.9;
371
+ }
372
+ this.col.x = this.root.position.x;
373
+ this.col.z = this.root.position.z;
374
+ this.col.cs = Math.cos(ry);
375
+ this.col.sn = Math.sin(ry);
376
+ this.col.y0 = this.root.position.y - 0.2; // the chassis...
377
+ this.col.y1 = this.root.position.y + 1.55; // ...up to the footboard. The crew sit above it.
378
+ for (let i = 0; i < this.horseCols.length; i++) {
379
+ const h = RIG.horses[i];
380
+ const s2 = Math.sin(ry), c2 = Math.cos(ry);
381
+ this.horseCols[i].x = this.root.position.x + h.x * c2 + h.z * s2;
382
+ this.horseCols[i].z = this.root.position.z - h.x * s2 + h.z * c2;
383
+ }
384
+ }
385
+
386
+ // where the coach is right now, and which way it points: straight off the route line.
387
+ _place(dt = 0.05) {
388
+ const a = ROUTE[this.leg], b = ROUTE[(this.leg + 1) % ROUTE.length];
389
+ let x = a.x + (b.x - a.x) * this.t;
390
+ let z = a.z + (b.z - a.z) * this.t;
391
+ // KEEP RIGHT. Slide off the route line onto its own side of the road. The return leg runs the
392
+ // same polyline the other way, so ITS right is the other side of the dirt — which is why two
393
+ // coaches meeting head-on pass each other rather than driving through one another. The ease is
394
+ // over the STOP-TO-STOP PATH (LEGS carries where this leg sits on it), not over each leg: road
395
+ // legs are ~20 m now, and a per-leg ease would send her back to the centreline at every survey
396
+ // peg — a 1.15 m weave every 20 m, and two coaches meeting near any vertex on the same dirt.
397
+ // She pulls out, takes her side within EASE_M metres, and holds it to the next stop.
398
+ const dx = b.x - a.x, dz = b.z - a.z;
399
+ const len = Math.hypot(dx, dz) || 1;
400
+ const rx = dz / len, rz = -dx / len; // the right-hand normal of the direction of travel
401
+ const meta = LEGS[this.leg];
402
+ const s = meta.s0 + this.t * meta.len;
403
+ const ease = Math.max(0, Math.min(1, s / EASE_M, (meta.pathLen - s) / EASE_M));
404
+ // THE OVERTAKE SWING (_blocked's dodge): a temporary extra lane offset, eased like a driver
405
+ // hauls the team — out, past the walker, and back to her side when the way ahead clears.
406
+ this._dodgeCur = (this._dodgeCur ?? 0) + ((this._dodgeTarget ?? 0) - (this._dodgeCur ?? 0)) * Math.min(1, dt * 1.6);
407
+ if (Math.abs(this._dodgeCur) < 0.02 && !this._dodgeTarget) this._dodgeCur = 0;
408
+ x += rx * (LANE * ease + this._dodgeCur);
409
+ z += rz * (LANE * ease + this._dodgeCur);
410
+ this.laneOffset = LANE * ease + this._dodgeCur; // what the road view reports: how far right of her own leg
411
+ const y = this.game.world.groundAt(x, z);
412
+ this.root.position.set(x, y, z);
413
+ // face the way you are going (not the way you were told to) — a coach cannot crab sideways
414
+ const want = Math.atan2(b.x - a.x, b.z - a.z);
415
+ if (this.state === 'standing') {
416
+ // ...except while standing, when it parks the way the stop says to
417
+ const park = a.ry ?? want;
418
+ let d = park - this.root.rotation.y;
419
+ while (d > Math.PI) d -= Math.PI * 2;
420
+ while (d < -Math.PI) d += Math.PI * 2;
421
+ this.root.rotation.y += d * 0.06;
422
+ } else {
423
+ let d = want - this.root.rotation.y;
424
+ while (d > Math.PI) d -= Math.PI * 2;
425
+ while (d < -Math.PI) d += Math.PI * 2;
426
+ this.root.rotation.y += d * 0.08; // eased, so it swings round a corner rather than snapping
427
+ }
428
+ }
429
+
430
+ // IS THE ROAD AHEAD CLEAR? Everything that can be stood in front of a coach gets asked: the
431
+ // player, the townsfolk, the other coach. The test is a CORRIDOR, not a radius — a man beside
432
+ // the road is not in the way, and a man ten metres back is not in the way either, but a man on
433
+ // the road in front is, and the further you are going the further ahead you have to care.
434
+ // THE COACH ROBBERY (the bloodless one — shooting the driver has always worked, and bolts the
435
+ // team). A drawn gun AIMED at the driver from the road (≤14m, ray-tested at his seat) reins the
436
+ // team up; hold it ~2 seconds and the takings come down off the box — a fat purse (dropFor
437
+ // 'driver' ×4), a 'coachRobbery' on the county's books, and a coach that stands trembling until
438
+ // you lower the iron or walk away, then drives on poorer. Once per coach per session.
439
+ _heldUp(dt) {
440
+ const g = this.game, p = g.player;
441
+ if (!p || this.rider || this.driver?.alive === false) return false;
442
+ const d = dist2D(this.root.position.x, this.root.position.z, p.position.x, p.position.z);
443
+ if (this._robbed) {
444
+ // stood and delivered already: she stays reined only while the gun still says so
445
+ return p.drawn && p.aiming && d < 16;
446
+ }
447
+ let aimedAtDriver = false;
448
+ if (p.drawn && p.aiming && d < 14 && !p.riding?.mounted) {
449
+ const dp = (this._drvPos ??= new THREE.Vector3());
450
+ this.driver?.pivot?.getWorldPosition?.(dp);
451
+ const from = (this._huFrom ??= new THREE.Vector3()).copy(p.position); from.y += 1.5;
452
+ const dir = p.aimDir?.((this._huDir ??= new THREE.Vector3()));
453
+ if (dir) { dp.y -= 1.0; aimedAtDriver = !!rayCapsule(from.x, from.y, from.z, dir.x, dir.y, dir.z, dp, 0.7); }
454
+ }
455
+ if (!aimedAtDriver) { this._holdT = 0; return false; }
456
+ this._holdT = (this._holdT ?? 0) + dt;
457
+ if (this._holdT < 0.3) g.ui?.toast?.(`The driver's hands come off the reins. Hold the iron on him.`, 3200);
458
+ if (this._holdT > 2.2) {
459
+ this._robbed = true; this._holdT = 0;
460
+ const at = this.root.position.clone(); at.x += Math.cos(this.root.rotation.y) * 2.2; at.z -= Math.sin(this.root.rotation.y) * 2.2;
461
+ at.y = g.world.groundAt(at.x, at.z) + 0.1;
462
+ g.loot?.dropFor?.('driver', at, 4); // the STRONGBOX takings, not his pocket
463
+ g.crime?.report?.('coachRobbery', this.root.position.clone());
464
+ g.missions?.addStat?.('reputation', -2);
465
+ g.ui?.toast?.(`The box comes down without a word. The line will wire every lawman in the county.`, 6500);
466
+ }
467
+ return true;
468
+ }
469
+
470
+ _blocked() {
471
+ const fwd = this.root.rotation.y;
472
+ const fx = Math.sin(fwd), fz = Math.cos(fwd);
473
+ const ox = this.root.position.x, oz = this.root.position.z;
474
+ const reach = LOOK + this.speed * LOOK_PER_MS + (this.held ? HOLD_PAD : 0);
475
+ // `shift` tests a corridor slid sideways — the overtake question: "if I swung out this far,
476
+ // would the way be clear?" (positive = to her right)
477
+ const inTheWay = (px, pz, pad = 0, shift = 0) => {
478
+ const dx = px - (ox + fz * shift), dz = pz - (oz - fx * shift);
479
+ const ahead = dx * fx + dz * fz; // how far in front (negative = behind us)
480
+ if (ahead < 0.5 || ahead > reach + pad) return false;
481
+ const side = Math.abs(dx * fz - dz * fx); // how far off the centreline
482
+ return side < CORRIDOR + pad;
483
+ };
484
+ // ...but NOT the man riding inside her. His position is the seat, which is half a metre forward
485
+ // of her centre — squarely inside her own look-ahead — so she braked for her own passenger and
486
+ // stood in the road forever, refusing to carry the person she was carrying.
487
+ // ...nor the man standing on her ROOF, for exactly the same reason: the front half of the deck
488
+ // is inside her own look-ahead corridor, so she would brake to a halt for her own passenger and
489
+ // stand in the road forever with him on top of her.
490
+ const people = []; // { x, z, ent } — ent null for the player (a coach does not shout the player aside)
491
+ const p = this.game.player;
492
+ const pp = p?.position ?? p?.root?.position;
493
+ const mine = this.rider?.player === p || this.game.platforms?.carrier(p) === this;
494
+ if (pp && !mine) people.push({ x: pp.x, z: pp.z, ent: null });
495
+ for (const n of this.game.npcs ?? []) {
496
+ if (!n.alive) continue; // a coach drives over a body. It does not queue behind one
497
+ // for the rest of the game, which is what it did.
498
+ const np = n.position ?? n.root?.position;
499
+ if (np) people.push({ x: np.x, z: np.z, ent: n });
500
+ }
501
+ // OTHER COACHES: two rules, by direction. SAME way → queue behind her (a bigger thing, more room,
502
+ // never overtaken). OPPOSING way → each keeps to her own RIGHT and they pass — the old rule braked
503
+ // BOTH of them nose to nose and they stood facing each other in the road until the end of days.
504
+ let oppose = false;
505
+ for (const other of this.game.coaches?.coaches ?? []) {
506
+ if (other === this) continue;
507
+ const ov = other.root.position;
508
+ const oy = other.root.rotation.y;
509
+ const sameWay = fx * Math.sin(oy) + fz * Math.cos(oy) >= -0.2;
510
+ if (sameWay) {
511
+ if (inTheWay(ov.x, ov.z, 2.6)) { this._dodgeTarget = 0; return true; }
512
+ } else {
513
+ // a WIDER ahead-window than the brake corridor, held through the pass itself (ahead > -4:
514
+ // don't straighten while we are abreast of her) so the swing lasts until she is truly behind
515
+ const dx = ov.x - ox, dz = ov.z - oz;
516
+ const ahead = dx * fx + dz * fz;
517
+ const side = Math.abs(dx * fz - dz * fx);
518
+ if (ahead > -4 && ahead < reach + 8 && side < CORRIDOR + 2.6) oppose = true;
519
+ }
520
+ }
521
+
522
+ // THE DRIVER'S SHOUT — people clear the road for a coach, not the coach for people (Nick).
523
+ // Everyone in the look-ahead corridor scurries PERPENDICULAR off her line: the scare point is a
524
+ // virtual spot planted a step to their road-centre side, so "run away from it" is exactly "step
525
+ // off the road", not the run-along-the-road-forever a scare at the coach itself produces. A
526
+ // seated rider's scare() spooks his horse aside instead — same effect from the saddle.
527
+ // Refresh while fleeT runs low so a man still in the corridor keeps clearing.
528
+ const blockers = people.filter((q) => inTheWay(q.x, q.z));
529
+ let nearest = Infinity;
530
+ for (const b of blockers) {
531
+ const dxq = b.x - ox, dzq = b.z - oz;
532
+ nearest = Math.min(nearest, dxq * fx + dzq * fz);
533
+ const n = b.ent;
534
+ if (n?.scare && (n.fleeT ?? 0) <= 0.35) {
535
+ const sgn = (dxq * fz - dzq * fx) >= 0 ? 1 : -1; // which side of her line he is already on
536
+ n.scare(b.x - sgn * fz * 1.5, b.z + sgn * fx * 1.5, YELL_FLEE);
537
+ }
538
+ }
539
+
540
+ if (oppose) {
541
+ // pull to her own right and hold it; brake only while a person is standing in the passing lane
542
+ // (he has just been shouted at — he will clear)
543
+ if (!people.some((q) => inTheWay(q.x, q.z, 0.4, PASS_SHIFT))) { this._dodgeTarget = PASS_SHIFT; return false; }
544
+ this._dodgeTarget = 0;
545
+ return true;
546
+ }
547
+
548
+ if (!blockers.length) { this._dodgeTarget = 0; return false; }
549
+ // they are clearing: keep rolling while the nearest man is still a team's length out
550
+ if (nearest > YIELD_NEAR) { this._dodgeTarget = 0; return false; }
551
+ // a CLOSE straggler (the player, a man who will not flee): swing round him like before. Try a
552
+ // sidestep either side; take the first whose slid corridor is clear of EVERY person. Blocked
553
+ // both sides (a crowd, a wall of folk) → brake like before.
554
+ for (const shift of [2.0, -2.0]) {
555
+ if (!people.some((q) => inTheWay(q.x, q.z, 0.4, shift))) { this._dodgeTarget = shift; return false; }
556
+ }
557
+ this._dodgeTarget = 0;
558
+ return true;
559
+ }
560
+
561
+ // ── GETTING ABOARD ────────────────────────────────────────────────────────────────────────
562
+ // Two ways on, and they mean different things. At the DOOR you are a fare: you get in, she runs
563
+ // her route, you look out of the window. At the BOX you take the reins: she stops being a
564
+ // timetable and starts being a vehicle, and the man who was driving her gets down.
565
+ //
566
+ // The player is not reparented onto the coach. His position is written to the seat every frame
567
+ // instead — because every other system in this game (collision, the camera, the aim, the law's
568
+ // line of sight) reads player.position, and a player who is secretly a child of a moving group is
569
+ // a player none of them can find.
570
+ seatWorld(out, kind) {
571
+ const r = kind === 'box' ? RIG.driver : (RIG.fares?.[0] ?? RIG.driver);
572
+ const ry = this.root.rotation.y;
573
+ const c = Math.cos(ry), sn = Math.sin(ry);
574
+ return out.set(
575
+ this.root.position.x + r.x * c + r.z * sn,
576
+ this.root.position.y + r.y,
577
+ this.root.position.z - r.x * sn + r.z * c,
578
+ );
579
+ }
580
+
581
+ // WHO HAS THE REINS IS A FACT, NOT A FLAG.
582
+ // This used to be a boolean: set in board(), cleared in alight() — and alight() is not the only way
583
+ // a man leaves the box. Get shot on it and main.js respawns you at the trough without ever telling
584
+ // the coach; mount a horse from it and the same. She kept the flag, and `driven` is the ONLY gate on
585
+ // _drive(), which reads the LIVE KEYBOARD. So she went on being steered by a man who was not there:
586
+ // walking away with A held turned her at 55 deg/s — TURN_RATE exactly — which is a closed circle,
587
+ // for the rest of the game, through the station building and anything else in the way, because the
588
+ // driven path consults neither the route nor a collider. Derived from the rider, it cannot lie.
589
+ get driven() { return this.rider?.kind === 'box'; }
590
+
591
+ board(player, kind) {
592
+ // a man who sits down is no longer a man standing on the roof (and `aboard` writes his position
593
+ // from the seat, so the two states must never both be live)
594
+ this.game.platforms?.leave(player, 'aboard');
595
+ this.rider = { player, kind };
596
+ // POINT THE CAMERA WHERE SHE IS GOING. It orbits the player at camYaw, and camYaw is whatever
597
+ // you happened to be looking at when you climbed up — so you took the reins and found yourself
598
+ // staring at the side of your own coach. The camera's forward is -(sin,cos) of camYaw, so
599
+ // looking along her heading means camYaw = ry + PI. (player.js keeps easing it there while you
600
+ // drive, so she stays in front of you through a turn.)
601
+ player.camYaw = this.root.rotation.y + Math.PI;
602
+ player.aboard = this; // player.js reads this and stops walking about
603
+ // he stays visible: he is the man on the box (player.js applies the seated pose each frame)
604
+ if (kind === 'box') {
605
+ this.speed = 0;
606
+ // the man on the box gets down and walks away — you cannot have two drivers
607
+ if (this.driver) this.driver.pivot.visible = false;
608
+ if (this.mate) this.mate.pivot.visible = false;
609
+ this.game.ui?.toast?.('You have the reins. W drives, S brakes and backs her, A/D steer, E to get down.');
610
+ } else {
611
+ this.game.ui?.toast?.('Aboard. E to get out.');
612
+ }
613
+ }
614
+
615
+ alight(player) {
616
+ if (!this.rider) return;
617
+ player._visualY = undefined; // let the smoother re-latch onto wherever he lands
618
+ // step down on the KERB side (her right), clear of the wheels
619
+ const ry = this.root.rotation.y;
620
+ const rx = Math.cos(ry), rz = -Math.sin(ry);
621
+ const x = this.root.position.x + rx * 2.4;
622
+ const z = this.root.position.z + rz * 2.4;
623
+ player.position.set(x, this.game.world.groundAt(x, z), z);
624
+ this._dropRider(); // ...and the one owner of the transition does the rest
625
+ }
626
+
627
+ // THE ONE PLACE A RIDE ENDS. Getting down is one way; being shot off the box, being teleported off
628
+ // it, respawning at the trough and throwing a leg over a horse are the others, and none of them
629
+ // used to say a word to the coach. So they don't have to: they only ever touch player.aboard, and
630
+ // update() calls this the instant the man on the box stops being hers. Whatever ends the ride, the
631
+ // reins are put down in exactly one place.
632
+ _dropRider() {
633
+ const r = this.rider;
634
+ if (!r) return;
635
+ this.rider = null; // `driven` is derived from this — she is nobody's now
636
+ if (r.player.aboard === this) r.player.aboard = null;
637
+ if (r.kind !== 'box') return;
638
+ if (this.driver?.alive) this.driver.pivot.visible = true;
639
+ if (this.mate?.alive) this.mate.pivot.visible = true;
640
+ this._rejoinRoute(); // she picks her timetable back up from where you left her
641
+ }
642
+
643
+ // Back on the timetable: find the leg she is nearest to, and how far along it she is. Without
644
+ // this she would teleport back to wherever she was when you took the reins.
645
+ _rejoinRoute() {
646
+ let bestD = Infinity;
647
+ for (let i = 0; i < ROUTE.length; i++) {
648
+ const a = ROUTE[i], b = ROUTE[(i + 1) % ROUTE.length];
649
+ const dx = b.x - a.x, dz = b.z - a.z;
650
+ const L2 = dx * dx + dz * dz || 1;
651
+ let t = ((this.root.position.x - a.x) * dx + (this.root.position.z - a.z) * dz) / L2;
652
+ t = Math.max(0, Math.min(1, t));
653
+ const px = a.x + dx * t, pz = a.z + dz * t;
654
+ const d = dist2D(px, pz, this.root.position.x, this.root.position.z);
655
+ if (d < bestD) { bestD = d; this.leg = i; this.t = t; }
656
+ }
657
+ this.state = 'rolling';
658
+ this.wait = 0;
659
+ }
660
+
661
+ // YOU are driving her. No route, no waypoints — a heading and a throttle, which is all a coach
662
+ // has ever been. She steers like the thing she is: four tons on wooden wheels, so she does not
663
+ // turn on the spot and she does not stop dead.
664
+ _drive(dt, input) {
665
+ const fwd = input?.down?.('KeyW') ? 1 : 0;
666
+ const brake = input?.down?.('KeyS') ? 1 : 0;
667
+ // SHIFT PUTS THEM AT A GALLOP. A team at a canter is a coach going somewhere; a team at a
668
+ // gallop is a coach being got away with. Same key that sprints a man, because that is the key
669
+ // your hand is already on.
670
+ const whip = input?.down?.('ShiftLeft') || input?.down?.('ShiftRight');
671
+ // S is the brake. S with nothing left to brake is the team BACKING her — a walk, no more, and
672
+ // the only way out of a wall now that a wall is a thing she can hit.
673
+ const want = brake ? (this.speed <= 0.05 ? -REVERSE : 0)
674
+ : fwd ? (whip ? GAIT_SPEED.gallop : GAIT_SPEED.canter) : 0;
675
+ const rate = brake ? BRAKE : (fwd ? PULL : 0.35);
676
+ this.speed += (want - this.speed) * Math.min(1, dt * rate);
677
+ if (Math.abs(this.speed) < 0.02) this.speed = 0;
678
+ // steering only bites when she is rolling — a stationary coach cannot be turned by the reins.
679
+ // The bite is SIGNED, so backing up swings her the other way, exactly as a wagon does.
680
+ const steer = (input?.down?.('KeyA') ? 1 : 0) - (input?.down?.('KeyD') ? 1 : 0);
681
+ const bite = Math.max(-1, Math.min(1, this.speed / 3));
682
+ this.root.rotation.y += steer * TURN_RATE * bite * dt;
683
+ const ry = this.root.rotation.y;
684
+ const s = Math.sin(ry), c = Math.cos(ry);
685
+ const ox = this.root.position.x, oz = this.root.position.z;
686
+ let x = ox + s * this.speed * dt;
687
+ let z = oz + c * this.speed * dt;
688
+ // AND SHE IS SOLID. This is the ONE path on which a coach's position is not written from the
689
+ // route — the timetable goes through _place(), and _place() is not called here — so nothing else
690
+ // in the game was ever going to stop her, and nothing did: driven at the station building she went
691
+ // straight through the middle of it at 6.3 m/s and came out the far side without losing a tenth of
692
+ // it (measured: closest approach to its centre, 0.1 m; speed through it, 6.25 → 6.37). So ask the
693
+ // same world the player walks in, at four points down her body, and take the DEEPEST push rather
694
+ // than the sum of them: her step is 11 cm at a canter, so a frame's penetration is small and the
695
+ // next contact resolves on the next frame, where summing four probes that are all in the same wall
696
+ // would fling her off it.
697
+ const W = this.game.world;
698
+ let bx = 0, bz = 0, worst = 0;
699
+ for (const b of BODY) {
700
+ W.solidPush(x + b.z * s, this.root.position.y, z + b.z * c, b.r, _push, this.myCols);
701
+ const m = _push.x * _push.x + _push.z * _push.z;
702
+ if (m > worst) { worst = m; bx = _push.x; bz = _push.z; }
703
+ }
704
+ if (worst > 0) {
705
+ x += bx; z += bz;
706
+ // ...and what she ACTUALLY made along her own heading is what she is actually doing. Head-on
707
+ // that is nothing, and she stands against the wall; at a graze it is most of it, and she scrapes
708
+ // along the front of the saloon and drives on. Her wheels and her team's gait both read
709
+ // this.speed, so a coach a wall has stopped stops her wheels too, instead of spinning them at a
710
+ // canter against the planking.
711
+ const made = ((x - ox) * s + (z - oz) * c) / Math.max(dt, 1e-4);
712
+ this.speed = this.speed >= 0 ? Math.min(this.speed, Math.max(0, made))
713
+ : Math.max(this.speed, Math.min(0, made));
714
+ }
715
+ this.root.position.set(x, W.groundAt(x, z), z);
716
+ }
717
+
718
+ update(dt) {
719
+ // IS THE MAN ON THE BOX STILL ON THE BOX? player.aboard is the one fact about who is riding what,
720
+ // and everything that can end a ride already maintains it — so ask it, every frame, instead of
721
+ // trusting a flag that four other systems would have to remember to clear. He is gone if he is
722
+ // aboard something else (or nothing), if he is dead, or if he is on a horse: any of those and the
723
+ // reins go down here. Without it she kept the reins after he died and drove herself in circles.
724
+ const r = this.rider;
725
+ if (r && (r.player.aboard !== this || !r.player.alive || r.player.riding?.mounted)) this._dropRider();
726
+
727
+ // DRIVEN BY THE PLAYER: she is a vehicle now, not a timetable.
728
+ if (this.driven) {
729
+ this._drive(dt, this.game.input);
730
+ for (const d of this.doors) d.want = 0; // doors shut while she's rolling under your hand
731
+ this._syncColliders();
732
+ this._syncRiders();
733
+ this._afterMove(dt);
734
+ return;
735
+ }
736
+
737
+ const a = ROUTE[this.leg], b = ROUTE[(this.leg + 1) % ROUTE.length];
738
+ const legLen = Math.max(1, Math.hypot(b.x - a.x, b.z - a.z));
739
+
740
+ if (this.state === 'standing') {
741
+ this.speed = 0;
742
+ this.wait -= dt;
743
+ // THE KERB-SIDE DOOR. It swings open the moment she pulls up, stands open for DOOR_HOLD
744
+ // while they get down and the next lot climb in, and is shut before she pulls away — the
745
+ // coach physically cannot leave until it is (see the doorsShut test below). The road-side
746
+ // door stays shut: nobody steps down into the traffic.
747
+ const open = this.wait > STOP_SECS - DOOR_HOLD ? 1 : 0;
748
+ for (const d of this.doors) d.want = d.right ? open : 0;
749
+ if (this.wait <= 0 && this.doorsShut()) {
750
+ this.state = 'rolling';
751
+ this.t = 0;
752
+ }
753
+ } else {
754
+ for (const d of this.doors) d.want = 0;
755
+ if (this.bolt > 0) {
756
+ // RUNAWAY. _blocked() is not even asked: a bolting team does not brake for a man in the
757
+ // road. The coach's colliders are walked live by World.collide, so whoever is in front of
758
+ // her gets shoved out of the way — being in the road is now HIS problem, and that is
759
+ // exactly the point of the beat.
760
+ this.bolt -= dt;
761
+ this.held = false;
762
+ this.speed += (GAIT_SPEED.canter - this.speed) * Math.min(1, dt * BOLT_RUSH);
763
+ if (this.bolt <= 0) this.spent = true;
764
+ } else if (this.spent) {
765
+ // BLOWN. Nobody on the reins, the team has had enough, and she drags herself down to a
766
+ // dead stop wherever she happens to be — in the road, across the road, it doesn't matter.
767
+ this.held = true;
768
+ this.speed += (0 - this.speed) * Math.min(1, dt * DRAG_DOWN);
769
+ if (this.speed < 0.05) this.speed = 0;
770
+ this._tryTheReins(dt);
771
+ } else {
772
+ // WHOA. Brake for whatever is in the road — or for the GUN ON THE DRIVER (_heldUp);
773
+ // pull away again only once the road is clear and the iron is down.
774
+ this.held = this._blocked() || this._heldUp(dt);
775
+ const want = this.held ? 0 : GAIT_SPEED[CRUISE];
776
+ const rate = this.held ? BRAKE : PULL;
777
+ this.speed += (want - this.speed) * Math.min(1, dt * rate);
778
+ if (this.speed < 0.02) this.speed = 0;
779
+ }
780
+ this.t += (this.speed * dt) / legLen;
781
+ if (this.t >= 1) {
782
+ this.t = 1;
783
+ this.leg = (this.leg + 1) % ROUTE.length;
784
+ this.t = 0;
785
+ const stop = ROUTE[this.leg];
786
+ // A runaway does not call at the depot, and a coach with a dead man on the box does not
787
+ // pull up and open its doors for fares.
788
+ if (stop.stop && this.bolt <= 0 && !this.spent) {
789
+ this.state = 'standing';
790
+ this.wait = STOP_SECS;
791
+ this._callAt(stop);
792
+ }
793
+ }
794
+ }
795
+
796
+ this._place(dt);
797
+ this._syncColliders();
798
+ this._syncRiders();
799
+
800
+ this._afterMove(dt);
801
+ }
802
+
803
+ // EVERYTHING THAT FOLLOWS FROM HER HAVING MOVED — however she came to move. The timetable and the
804
+ // player's hands on the reins both end up here, so a coach you are driving turns its wheels,
805
+ // works its team and holds its passengers exactly as one you are watching does. Two copies of
806
+ // this would drift apart the first time either was touched.
807
+ _afterMove(dt) {
808
+ // the wheels are turned BY THE GROUND: radians = distance / radius. Never by a guess.
809
+ for (const w of this.wheels) w.mesh.rotation.x += (this.speed * dt) / w.radius;
810
+ // Each leaf runs its own swing, eased — a door is heavy off the latch and it settles, it does
811
+ // not snap. (One shared angle meant both leaves always did the same thing, so the coach opened
812
+ // its road-side door into the traffic every time it called anywhere.)
813
+ for (const d of this.doors) {
814
+ const step = dt / DOOR_SECS;
815
+ d.k = Math.max(0, Math.min(1, d.k + Math.sign(d.want - d.k) * step));
816
+ const e = d.k * d.k * (3 - 2 * d.k);
817
+ d.mesh.rotation.y = d.sign * DOOR_OPEN * e;
818
+ }
819
+ // Everyone aboard holds their pose. It is four two-bone solves a man, no mixer, no clip — and
820
+ // it must run every frame because the IK is solved fresh from the base pose each time (solve on
821
+ // top of yesterday's solve and he shakes himself apart; see seatedRider.js).
822
+ // A DEAD MAN IS NOT SOLVED. He was solved once, limp, at the moment he was shot; from then on
823
+ // he only falls (slump), because re-solving him would sit him back up straight with his hands
824
+ // out for the reins — every frame, forever.
825
+ for (const r of this.riders) {
826
+ if (!r.pivot.visible) continue;
827
+ if (r.alive) r.solve();
828
+ else r.slump(dt);
829
+ }
830
+
831
+ // the team's gait follows the coach's actual speed, so they never trot on the spot
832
+ // THE GAIT FOLLOWS THE GROUND SPEED, and there is a gait for every speed she can reach. The
833
+ // ceiling used to be 'canter' — so the moment you took the whip to them (Shift: 10.5m/s) the
834
+ // team ran at gallop pace while still playing a canter, legs turning over half as fast as the
835
+ // ground going under them. The thresholds are where each gait's stride actually covers the
836
+ // ground: below them the horse moonwalks, above them he skates.
837
+ // ...off her GROUND SPEED, not her signed one: a team backing a coach at 1.4 m/s is walking, and
838
+ // asking a negative number which gait it is gets you a team standing stock still while the coach
839
+ // slides backwards out of the front of the saloon.
840
+ const v = Math.abs(this.speed);
841
+ const gait = v < 0.3 ? 'idle' : v < 2.4 ? 'walk' : v < 5 ? 'trot' : v < 8.2 ? 'canter' : 'gallop';
842
+ for (const t of this.team) {
843
+ if (t.gait !== gait) { t.gait = gait; t.animator.play(gait, { fade: 0.25 }); }
844
+ t.animator.update(dt);
845
+ }
846
+ }
847
+
848
+ // How full is she leaving? Nought to four, drawn fresh each time she pulls out — the coach that
849
+ // is always half full is as fake as the coach that is always empty.
850
+ //
851
+ // A DEAD FARE IS NOT A FARE. He does not get down at the next stop, and he is certainly not
852
+ // shuffled out of existence to hide the evidence: he rides on, visible, in the seat he was shot
853
+ // in. Only the living are re-drawn.
854
+ _seatFares() {
855
+ const live = this.fares.filter((f) => f.alive);
856
+ const n = Math.floor(Math.random() * (live.length + 1));
857
+ const who = live.map((_, i) => i).sort(() => Math.random() - 0.5).slice(0, n);
858
+ live.forEach((f, i) => { f.pivot.visible = who.includes(i); });
859
+ this._crew();
860
+ return n;
861
+ }
862
+
863
+ // Who is actually aboard, and therefore who can be shot: the list combat.js walks. Rebuilt only
864
+ // when the load changes (at a stop, or when a man dies) — never per frame. A hidden fare is a
865
+ // man who isn't on this coach today, and a bullet must pass straight through where he isn't.
866
+ _crew() {
867
+ this.crew = this.riders.filter((r) => r.pivot.visible);
868
+ }
869
+
870
+ // Somebody aboard has been killed (called by SeatedRider.die). His purse hits the DIRT — not the
871
+ // floor of a box that is about to run away with it — the town hears about it, and if it was the
872
+ // man on the box, the team goes.
873
+ _crewDown(r, killer = null) {
874
+ const g = this.game;
875
+ const x = r.position.x, z = r.position.z;
876
+ const at = new THREE.Vector3(x, g.world.groundAt(x, z), z);
877
+ // only the player's kills are the player's crimes — an outlaw's stray round is not a robbery
878
+ if (killer == null || killer === g.player) {
879
+ g.crime?.report('coachRobbery', at);
880
+ g.loot?.drop(at, roll(r.role === 'driver' ? TAKINGS : r.role === 'mate' ? WAGES : FARE_PURSE), dropItemsFor(r.role));
881
+ }
882
+ alarmTown(g, x, z, 26, 6);
883
+ this._crew(); // (he stays in it — combat skips the dead — but keep it honest)
884
+ if (r === this.driver) this._boltTeam();
885
+ }
886
+
887
+ // The reins go slack. See the BOLT block at the top of the file.
888
+ _boltTeam() {
889
+ if (this.bolt > 0) return;
890
+ this.bolt = BOLT_SECS;
891
+ this.spent = false;
892
+ this.reinsT = 0;
893
+ // she leaves NOW, whatever she was doing — the doors swing shut behind her as she goes
894
+ if (this.state === 'standing') { this.state = 'rolling'; this.wait = 0; this.t = 0; }
895
+ for (const d of this.doors) d.want = 0;
896
+ console.log('[coach] the driver is down — the team has bolted');
897
+ }
898
+
899
+ // THE GUARD TAKES THE REINS. He does not change seats: he is sitting eighteen inches from the
900
+ // driver on the same box, so taking the reins is a POSTURE, not a journey — his hands come up off
901
+ // his lap and onto the leather, and the dead man stays folded over beside him for the rest of the
902
+ // run. (Moving him into the driver's seat would have put two bodies in one seat, one of them a
903
+ // corpse.) He keeps his own role: the strongbox went into the road with the man who was carrying
904
+ // it, and shooting the guard now does not pay twice.
905
+ _tryTheReins(dt) {
906
+ if (this.speed > 0.05) return; // not until she has actually stopped
907
+ const m = this.mate;
908
+ if (!m || !m.alive || !m.pivot.visible) return; // nobody left who can drive — she stands here for good
909
+ this.reinsT += dt;
910
+ if (this.reinsT < REINS_SECS) return;
911
+ m.posture = this._drvPosture;
912
+ this.driver = m; // whoever is on the leather IS the driver now
913
+ this.mate = null;
914
+ this.spent = false;
915
+ this.reinsT = 0;
916
+ console.log('[coach] the guard takes the reins — the line goes on');
917
+ }
918
+
919
+ // Pulling in somewhere: put down whoever is aboard, take up whoever is waiting.
920
+ // (The NPC board/alight walk is the next piece of work; for now the load changes at each stop.)
921
+ _callAt(stop) {
922
+ this._seatFares();
923
+ const world = this.root.position;
924
+ for (const p of this.passengers) {
925
+ p.alight(world.x, world.z); // step down and walk off into wherever this is
926
+ }
927
+ this.passengers = [];
928
+ // and take up anyone standing about at this stop who fancies the ride
929
+ const board = (this.game.coachPool ?? []).filter((n) => n.wantsCoach && !n.aboard
930
+ && dist2D(n.position.x, n.position.z, world.x, world.z) < 14).slice(0, 3);
931
+ for (const n of board) { n.board(this); this.passengers.push(n); }
932
+ if (board.length || stop.stop) {
933
+ console.log(`[coach] calling at ${stop.name} — ${board.length} aboard`);
934
+ }
935
+ }
936
+ }
937
+
938
+ export class CoachLine {
939
+ constructor(game) { this.game = game; this.coaches = []; }
940
+
941
+ // Built on the loading screen, resident for life. The coaches are staggered by DISTANCE around
942
+ // the circuit — an equal share of TOTAL_LEN each, resolved to (leg, t) — so the road always has
943
+ // one on it somewhere and the depot is neither always empty nor always full. (It used to stagger
944
+ // by leg INDEX, which was fair when the legs were four uniform quarters; the road-derived legs
945
+ // run 6–630 m, and an index stagger would put the whole line nose to tail.)
946
+ async build(count = 2) {
947
+ for (let i = 0; i < count; i++) {
948
+ try {
949
+ const start = placeAt((TOTAL_LEN / count) * i);
950
+ const c = new Coach(this.game, start.leg, start.t);
951
+ await c.load();
952
+ this.game.scene.add(c.root);
953
+ this.coaches.push(c);
954
+ // A THING YOU CAN STAND ON. The registry measures her motion itself, so this is the whole
955
+ // of it — no per-frame hook, nothing for the coach to remember, and the same four lines
956
+ // will put a man on a boxcar. `cols` is what he stops being pushed by while he is up there.
957
+ c.plat = this.game.platforms?.register({
958
+ root: c.root, owner: c, decks: [ROOF], hull: HULL,
959
+ cols: c.myCols, radius: 4,
960
+ });
961
+ // TWO WAYS ON. The prompts are ordinary interactables — but a coach MOVES, so their points
962
+ // are rewritten every frame (_syncColliders) instead of being planted once in the dirt.
963
+ // `when` is what makes one prompt turn into the other: standing beside a coach you are
964
+ // already aboard, the only thing you want offered is the way OUT.
965
+ const p = this.game;
966
+ c._itDoor = {
967
+ x: 0, z: 0, r: 3.0,
968
+ get label() { return c.rider ? 'E — Get out' : 'E — Ride the stage'; },
969
+ // ...but not from her own roof: a man up there is offered the REINS (the box prompt
970
+ // below), not a seat in the cabin he is standing on.
971
+ when: () => c.rider?.player === p.player
972
+ || (!c.rider && c.speed < 1.2 && p.platforms?.carrier(p.player) !== c),
973
+ action: () => (c.rider ? c.alight(p.player) : c.board(p.player, 'inside')),
974
+ };
975
+ c._itBox = {
976
+ x: 0, z: 0, r: 2.6,
977
+ get label() { return c.rider?.kind === 'box' ? 'E — Get down' : 'E — Take the reins'; },
978
+ when: () => c.rider?.kind === 'box' || (!c.rider && c.speed < 1.2),
979
+ action: () => (c.rider ? c.alight(p.player) : c.board(p.player, 'box')),
980
+ };
981
+ this.game.interactables.push(c._itDoor, c._itBox);
982
+ } catch (e) { console.warn('[coach] build failed', e); }
983
+ }
984
+ // AND A SEAT FOR YOU. Baked HERE, on the loading screen, because makeSeatedPose does a retarget
985
+ // and a retarget in the middle of a gunfight is a stall. The player is a Synty body on the same
986
+ // skeleton as everyone else, so he wears the same posture the coach's own driver does — which
987
+ // means the man on the box looks like a man on the box, and not like an empty seat with a
988
+ // camera behind it. (He used to be simply HIDDEN when he boarded. Nobody was driving.)
989
+ // AND A SEAT FOR YOU. Baked HERE, on the loading screen, because makeSeatedPose does a retarget
990
+ // and a retarget in the middle of a gunfight is a stall. The player is a Synty body on the same
991
+ // skeleton as everyone else, so he wears the same posture the coach's own driver does.
992
+ const pl = this.game.player;
993
+ if (pl?.model) {
994
+ try {
995
+ const r = new SeatedRider(pl.root, pl.model, driverPosture(SEAT));
996
+ // NEVER TOUCH THE LIVE PLAYER'S SKELETON. I called skeleton.pose() here once to read his
997
+ // bind — and main.js runs newGame() AFTER the coaches are built, so respawning re-ran
998
+ // _groundFeet(), which measures the model's lowest BONE. It measured the bind skeleton,
999
+ // computed a foot-drop of TWENTY-FOUR METRES, and fired the player into the sky.
1000
+ const { pose, pbone } = await makeSeatedPose(pl.model, BIPED_TO_SYNTY);
1001
+ r.bones = pbone;
1002
+ r.pose = pose;
1003
+ for (const n of ['UpperLeg_L', 'LowerLeg_L', 'Ankle_L', 'UpperLeg_R', 'LowerLeg_R', 'Ankle_R']) {
1004
+ if (pbone[n]) r.bind[n] = pbone[n].quaternion.clone();
1005
+ }
1006
+ // Which side his left is on, asked of the POSE (arms out) rather than of whatever frame of
1007
+ // the idle he happens to be on — then his bones go back exactly as they were.
1008
+ const was = {};
1009
+ for (const n of Object.keys(pose)) if (pbone[n]) was[n] = pbone[n].quaternion.clone();
1010
+ for (const [n, q] of Object.entries(pose)) pbone[n]?.quaternion.copy(q);
1011
+ pl.model.updateWorldMatrix(true, true);
1012
+ r._measureSide();
1013
+ for (const [n, q] of Object.entries(was)) pbone[n].quaternion.copy(q);
1014
+ pl.model.updateWorldMatrix(true, true);
1015
+ this.playerRider = r;
1016
+ } catch (e) { console.warn('[coach] no seated pose for the player', e); }
1017
+ }
1018
+
1019
+ console.log(`[coach] the line is running: ${this.coaches.length} coaches on ${ROUTE.length} legs`);
1020
+ return this;
1021
+ }
1022
+
1023
+ update(dt) {
1024
+ for (const c of this.coaches) c.update(dt);
1025
+ }
1026
+
1027
+ // Bring every rider's world position up to date RIGHT NOW. combat.js calls this immediately
1028
+ // before it sweeps its bullets, because main.js ticks combat BEFORE the coaches: without it,
1029
+ // every round is tested against where the crew were a frame ago (11cm out at a canter — which
1030
+ // is survivable, but being exact is two sines and a loop).
1031
+ syncRiders() {
1032
+ for (const c of this.coaches) c._syncRiders();
1033
+ }
1034
+ }