sindicate 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/climbing.js +136 -0
  23. package/src/systems/deadeye.js +332 -0
  24. package/src/systems/encounters.js +123 -0
  25. package/src/systems/entity.js +524 -0
  26. package/src/systems/fistCurl.js +38 -0
  27. package/src/systems/footsteps.js +161 -0
  28. package/src/systems/glass.js +67 -0
  29. package/src/systems/herd.js +277 -0
  30. package/src/systems/horse.js +361 -0
  31. package/src/systems/mountedTraveller.js +518 -0
  32. package/src/systems/nav.js +343 -0
  33. package/src/systems/npc.js +880 -0
  34. package/src/systems/pathFollow.js +107 -0
  35. package/src/systems/platform.js +484 -0
  36. package/src/systems/riding.js +580 -0
  37. package/src/systems/seatedRider.js +396 -0
  38. package/src/systems/skybirds.js +129 -0
  39. package/src/systems/trainCamera.js +414 -0
  40. package/src/systems/traveller.js +254 -0
  41. package/src/systems/tumbleweeds.js +92 -0
  42. package/src/systems/vat.js +327 -0
  43. package/src/systems/vfx.js +472 -0
  44. package/src/world/blobShadows.js +109 -0
  45. package/src/world/clouds.js +61 -0
  46. package/src/world/flora.js +87 -0
  47. package/src/world/footprints.js +117 -0
  48. package/src/world/lampField.js +58 -0
  49. package/src/world/lampGlow.js +92 -0
  50. package/src/world/scatter.js +1077 -0
  51. package/src/world/sky.js +363 -0
  52. package/src/world/tileManager.js +197 -0
  53. package/src/world/walkHumps.js +74 -0
  54. package/src/world/weather.js +312 -0
@@ -0,0 +1,107 @@
1
+ // pathFollow.js — the piece the ROAD travellers were missing.
2
+ //
3
+ // Enemies (enemy.js) and town wanderers (npc.js) already route AROUND static obstacles: they ask the
4
+ // baked grid A* in nav.js for a path and follow its waypoints, with the whisker layer (entity.js
5
+ // walkDir) only handling DYNAMIC dodging on top. The men and women OUT ON THE ROADS — travellers on
6
+ // foot and the horsemen — never got wired to that A*. They aimed straight at the next road point and
7
+ // let the whiskers swerve, which is a few metres of reactive side-step: fine for a person crossing the
8
+ // street, useless against a fence PANEL that runs twenty metres. So a horseman met a paddock rail
9
+ // leaving Dustwater, the whiskers could not find a way round a wall that long, and he rode into it and
10
+ // kept riding into it (mountedTraveller had no give-up at all). Nick: "he keeps trying to ride through
11
+ // it instead of turning to find a better route."
12
+ //
13
+ // This module is the shared bridge to the EXISTING grid A*, for both riders. It is deliberately small:
14
+ // steerCarrot LOS-gate + follow. When the straight line to the goal is clear (the common case,
15
+ // every metre of open road), it does nothing and the caller aims straight — zero cost,
16
+ // identical behaviour. Only when the grid says the line is BLOCKED by static geometry
17
+ // does it hold an A* path around it and hand back a look-ahead "carrot" to steer at.
18
+ // mountedTurn pure-pursuit turn with a real MINIMUM TURN RADIUS. The horse's TURN_RATE had no cap,
19
+ // so a boxed-in animal spun on the spot and snapped straight back into the rail; a
20
+ // wmax = speed/Rmin gives it an arc it has to follow, so it curves out and round.
21
+ //
22
+ // It allocates nothing in the hot path: module scratch objects, and the A* path array is only rebuilt
23
+ // on a throttled re-path (nav.js reuses its own scratch inside path()).
24
+ import { dist2D } from '../core/utils.js';
25
+
26
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
27
+ const _f = { x: 0, z: 0 }, _t = { x: 0, z: 0 }; // scratch endpoints handed to the nav queries
28
+
29
+ export function angleDelta(a, b) { let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; }
30
+
31
+ // The nearer of a route's two ENDS. Both ends of every route are a settlement (data/travellers.js), so
32
+ // this is the settlement whose baked grid should serve the traveller while he is near it — and it is
33
+ // the same 80 m tile that settlement's own wanderers keyed their grid on, so the grid is SHARED, not
34
+ // baked twice. Out in the middle of the road it is far from both ends and nav returns null → straight.
35
+ export function nearestEndpoint(road, x, z) {
36
+ const a = road[0], b = road[road.length - 1];
37
+ return dist2D(x, z, a.x, a.z) <= dist2D(x, z, b.x, b.z) ? a : b;
38
+ }
39
+
40
+ // LOS-gated A* follow. Writes the point to aim at into `out` and returns true when it is steering along
41
+ // a path (out ≠ the raw goal); returns false when the way is clear or ungridded and the caller should
42
+ // aim straight at the goal itself. Path state lives on `s` under _rt* so it never collides with the
43
+ // Character._navPath the wanderers/enemies use.
44
+ //
45
+ // s the traveller (any object — foot NPC or the MountedTraveller); holds _rtPath/_rtI/…
46
+ // centre nearestEndpoint(): which settlement grid to use when no baked one already covers us
47
+ // cx,cz where the agent is now gx,gz the raw goal (the lane target on the road)
48
+ // out {x,z} written with the aim point
49
+ // opt.dt frame time (throttles re-paths) opt.arrive waypoint-reached radius
50
+ // opt.lookahead carrot distance ahead along the path (0 = aim at the current waypoint; horses use >0)
51
+ export function steerCarrot(s, game, centre, cx, cz, gx, gz, out, opt = {}) {
52
+ out.x = gx; out.z = gz;
53
+ const nav = game.nav;
54
+ if (!nav) return false;
55
+ const wide = !!opt.wide; // horses test/route on the clearance grid
56
+ _f.x = cx; _f.z = cz; _t.x = gx; _t.z = gz;
57
+ const clear = nav.losHere(_f, _t, centre, wide); // true = clear · false = blocked · null = no grid
58
+ if (clear !== false) { s._rtPath = null; return false; } // clear / unknown → aim straight (no statics to route)
59
+
60
+ // A static obstacle sits across the line to the goal — keep (or refresh) an A* path around it.
61
+ s._rtRepathT = (s._rtRepathT ?? 0) - (opt.dt ?? 0);
62
+ const goalMoved = !s._rtGoal || dist2D(s._rtGoal.x, s._rtGoal.z, gx, gz) > 3;
63
+ if (!s._rtPath || goalMoved || s._rtRepathT <= 0) {
64
+ _f.x = cx; _f.z = cz; _t.x = gx; _t.z = gz;
65
+ const p = nav.pathHere(_f, _t, centre, wide);
66
+ if (p && p.length) {
67
+ s._rtPath = p; s._rtI = 0;
68
+ (s._rtGoal ??= { x: 0, z: 0 }).x = gx; s._rtGoal.z = gz;
69
+ s._rtRepathT = 0.75;
70
+ } else {
71
+ s._rtRepathT = 0.4; // grid still baking / unreachable — try again soon
72
+ }
73
+ }
74
+ const path = s._rtPath;
75
+ if (!path || !path.length) return false; // nothing to follow yet → straight (+ caller's stuck backstop)
76
+
77
+ // advance past any waypoints we have reached
78
+ const arrive = opt.arrive ?? 1.2;
79
+ while (s._rtI < path.length - 1 && dist2D(cx, cz, path[s._rtI].x, path[s._rtI].z) <= arrive) s._rtI++;
80
+
81
+ const Ld = opt.lookahead ?? 0;
82
+ if (Ld <= 0) { out.x = path[s._rtI].x; out.z = path[s._rtI].z; return true; }
83
+ // CARROT: walk forward along the smoothed polyline accumulating look-ahead distance. Aiming a few
84
+ // metres down the path (not at the 0.8 m next node) is what stops a horse snapping between close
85
+ // waypoints — it reads the bend early and curves through it.
86
+ let ax = cx, az = cz, rem = Ld;
87
+ for (let i = s._rtI; i < path.length; i++) {
88
+ const wx = path[i].x, wz = path[i].z, seg = dist2D(ax, az, wx, wz);
89
+ if (seg >= rem || i === path.length - 1) {
90
+ const u = seg > 1e-6 ? clamp(rem / seg, 0, 1) : 1;
91
+ out.x = ax + (wx - ax) * u; out.z = az + (wz - az) * u;
92
+ return true;
93
+ }
94
+ rem -= seg; ax = wx; az = wz;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ // Pure-pursuit turn toward (aimX,aimZ). Returns a turn RATE (rad/s) capped by a MINIMUM TURN RADIUS
100
+ // (wmax = speed / Rmin) so the animal cannot pivot on the spot, plus a speed multiplier that reins it
101
+ // in on a hard heading error so it slows and turns instead of overshooting straight into the obstacle.
102
+ export function mountedTurn(heading, cx, cz, aimX, aimZ, speed, Rmin = 3.5, gain = 2.4) {
103
+ const desired = Math.atan2(aimX - cx, aimZ - cz);
104
+ const e = angleDelta(desired, heading);
105
+ const wmax = Math.max(0.3, speed / Rmin);
106
+ return { turn: clamp(e * gain, -wmax, wmax), speedMul: clamp(1 - Math.abs(e) / (Math.PI / 2), 0.35, 1), err: e };
107
+ }
@@ -0,0 +1,484 @@
1
+ // A MOVING FLOOR. Sprint at a stagecoach, jump, and land on the roof — and then stay on it while
2
+ // she rolls and turns underneath you. The coach is the first client; a boxcar is the same problem
3
+ // and a train is a line of them, so this is a PRIMITIVE, not a coach feature.
4
+ //
5
+ // THE RIDER IS LOGICALLY PARENTED, NOT REPARENTED. While he is up there, the thing that is true
6
+ // about him is (lx, ly, lz) — where he stands in the platform's OWN frame. His world position is
7
+ // DERIVED from that once a frame, AFTER the platform has moved (postUpdate). That is the whole
8
+ // trick, and it is why standing still on a coach at a gallop does not drift: nothing is integrated,
9
+ // no delta is accumulated, and the yaw ORBIT falls out for free — a man on the back of a coach that
10
+ // turns is simply re-derived at the rotated point, which is exactly what happens to him.
11
+ //
12
+ // He is NOT added to the coach's Object3D. coach.js:426 says why, and it still holds: collision,
13
+ // the camera, the aim and the law's line of sight all read player.position, and a player who is
14
+ // secretly a child of a moving group is a player none of them can find. So his position stays a
15
+ // real world position, every frame, and everything else in the game keeps working on the roof —
16
+ // the capsule, the locomotion FSM, the gun, the dodge. He is a man standing on a coach, not a
17
+ // passenger: `aboard` (player.js:513) is the OTHER thing, and the two can never both be set.
18
+ //
19
+ // ORDER. main.js ticks the player BEFORE the coaches, so nothing inside player.update() may depend
20
+ // on where the coach ends up this frame — and nothing does, because everything he does up there is
21
+ // said in LOCAL terms. postUpdate() runs after the coaches and the trains have moved, turns the
22
+ // local coords back into a world position, and hands him his camera at that final position (which
23
+ // is why _camera is a separate method — player.js:755).
24
+ import * as THREE from 'three/webgpu';
25
+ import { clamp } from '../core/utils.js';
26
+
27
+ // Gravity, as the capsule controller applies it (world.js:347). The mantle impulse is solved
28
+ // against it, so if one changes the other must.
29
+ const GRAV = 20;
30
+
31
+ // THE LEDGE CATCH, and it is load-bearing — the numbers do not otherwise meet.
32
+ // A flat-out sprint jump takes off at vy = 7.2 × 1.33 = 9.576 m/s (player.js:1141), so the FEET
33
+ // reach v²/2g = 2.29m. The coach roof is at 2.71m. A running jump is 0.42m SHORT of it and always
34
+ // will be, so a man who gets his feet up level with the roof lip catches it and pulls himself up.
35
+ // MANTLE_REACH is how far below the deck the lip can still be grabbed: 0.85 clears the 0.42m
36
+ // deficit with room for a coach standing on ground higher than yours.
37
+ const MANTLE_REACH = 0.85;
38
+ // ...but ONLY at the top of the arc. At the top of the coach's body (1.86m — where the hull ends,
39
+ // see coach.js) a sprint jump is still travelling upward at 4.4 m/s, so a 2.0 ceiling cannot fire
40
+ // as a free boost off the ground: you have to have actually got up there.
41
+ const MANTLE_VY = 2.0;
42
+ const MANTLE_CLEAR = 0.12; // how far above the lip the vault carries him — a hand's breadth, no more
43
+ const MANTLE_PULL = 1.5; // m/s of inward drag, so he comes up OVER the rail and not beside it
44
+ const MANTLE_LOCK = 0.4; // seconds: no re-trigger, and the body hull lets him through while it runs
45
+ const GRAB = 0.50; // ≈ the player's capsule radius (0.45): you catch the lip from against the flank
46
+
47
+ // LANDING. The test is SWEPT — feet above the plane at the top of the frame, below it at the
48
+ // bottom — so a 20 m/s fall (0.33m in a frame) cannot tunnel through a zero-thickness deck.
49
+ const LAND_PAD = 0.20; // ...plus however far the platform moved last frame: main.js ticks the
50
+ // player BEFORE the coaches, so the transform this test reads is one
51
+ // frame stale (17cm at a gallop, 27cm at line speed on a train).
52
+ const RELEASE_PAD = 0.30; // and you keep the deck until you are 30cm off it. Acquire tight,
53
+ // release loose, or the roof edge strobes grounded/airborne and the
54
+ // locomotion FSM flickers between run and jumpIdle.
55
+ const STEP_UP = 0.45; // walk up onto a deck this much higher (a tender to a footplate)...
56
+ const STEP_DOWN = 0.60; // ...and stay glued to one this much lower as she crests a rise.
57
+
58
+ // A TELEPORT IS NOT A MOVE. Coach._rejoinRoute (coach.js:485) can snap her 20m back onto the route
59
+ // line in a single frame; a train car can be stabled 400m underground (train.js). She goes without
60
+ // him. A legitimate frame at the gallop, at the 0.1s dt clamp, is 1.05m — and a route terminus
61
+ // swings her ~0.25 rad in ONE frame (coach.js:381 eases yaw per frame, not per second), which is
62
+ // violent but real, so the yaw threshold must sit well above it.
63
+ const TELE_XZ = 2.0, TELE_Y = 2.0, TELE_YAW = 0.5;
64
+
65
+ // The lens, not the man. The position orbit and his body yaw are carried IN FULL — that is
66
+ // physically what the coach does to you — but the CAMERA is rate-limited, so a terminus reversal
67
+ // reads as a swing rather than an 14° snap in one frame.
68
+ const CAM_YAW_RATE = 2.5; // rad/s
69
+
70
+ const AIR_BLEED = 0.25; // seconds: how fast inherited speed dies once his feet are down again.
71
+ // There is NO bleed in the air — a jump off a moving coach is pure
72
+ // ballistics, which is what makes "jump straight up and land back on
73
+ // her" true, and what flings you when you jump off a galloping team.
74
+ const COL_GRACE = 0.25; // he keeps ignoring his own platform's collider box for this long after
75
+ // leaving it — a train car's box is banded to its own ROOF (train.js),
76
+ // so without it stepping off one would shove him out of the flank.
77
+
78
+ const _v = new THREE.Vector3();
79
+ const _v2 = new THREE.Vector3();
80
+ const _m = new THREE.Matrix4();
81
+
82
+ const wrapPi = (a) => { while (a > Math.PI) a -= Math.PI * 2; while (a < -Math.PI) a += Math.PI * 2; return a; };
83
+ const inRect = (d, lx, lz, pad) => lx > d.x0 - pad && lx < d.x1 + pad && lz > d.z0 - pad && lz < d.z1 + pad;
84
+
85
+ export class PlatformRegistry {
86
+ constructor(game) {
87
+ this.game = game;
88
+ this.hosts = [];
89
+ }
90
+
91
+ // The host contract is duck-typed on purpose — the same shape a train car can be registered with
92
+ // from OUTSIDE its own file:
93
+ // root a Group whose position and rotation.y are the platform (pitch/roll tolerated)
94
+ // owner whoever it belongs to — the identity a look-ahead exempts its own roof rider by
95
+ // decks local-space rects with a flat top: {x0,x1,z0,z1,y,surface}. Highest wins.
96
+ // hull optional local box that pushes an airborne man OUT of the body (never a bullet)
97
+ // cols its entries in world/town.js's `colliders` — skipped for whoever is riding it
98
+ // mantle how far below a deck its lip can be caught (0 = no ledge catch)
99
+ // radius broadphase reach from the root, XZ
100
+ register(spec) {
101
+ const h = {
102
+ root: spec.root,
103
+ owner: spec.owner ?? null,
104
+ decks: spec.decks ?? [],
105
+ hull: spec.hull ?? null,
106
+ cols: spec.cols ?? null,
107
+ mantle: spec.mantle ?? MANTLE_REACH,
108
+ radius: spec.radius ?? 4,
109
+ enabled: spec.enabled ?? (() => true),
110
+ // The registry measures the platform's own per-frame delta itself, from a snapshot it takes
111
+ // at the end of every postUpdate. The owner therefore needs no per-frame hook and no
112
+ // bookkeeping — which is the only reason a train car can be registered without editing
113
+ // train.js at all.
114
+ px: 0, py: 0, pz: 0, pry: 0, pm: new THREE.Matrix4(),
115
+ dx: 0, dy: 0, dz: 0, dyaw: 0,
116
+ vx: 0, vy: 0, vz: 0, yawRate: 0,
117
+ teleported: false,
118
+ };
119
+ this._snap(h);
120
+ this.hosts.push(h);
121
+ return h;
122
+ }
123
+
124
+ unregister(host) {
125
+ const i = this.hosts.indexOf(host);
126
+ if (i < 0) return;
127
+ const p = this.game.player;
128
+ if (p?.platform?.host === host) this.leave(p, 'drop');
129
+ this.hosts.splice(i, 1);
130
+ }
131
+
132
+ // Who is carrying him — the accessor a coach's look-ahead asks, so it does not brake for the man
133
+ // standing on its own roof. Handed out rather than compared by identity, because a train car is
134
+ // proxied into game.coaches.coaches as a plain object and the identities would not line up.
135
+ carrier(entity) { return entity?.platform?.host.owner ?? null; }
136
+
137
+ // ── local ↔ world ────────────────────────────────────────────────────────────────────────────
138
+ // The yaw-only fast path is the same rotate-the-offset arithmetic the coach already uses for its
139
+ // riders and its horse colliders (coach.js:314-322/345), and its exact inverse. A platform that
140
+ // PITCHES (a train car on a bank/grade: train.js sets rotation.order='YXZ' and writes rotation.x)
141
+ // takes the matrix path instead — a flat deck plane read through a yaw-only frame is wrong by
142
+ // halfLength·sin(pitch), which is half a metre at the end of a 17m carriage.
143
+ _pitched(h) { return Math.abs(h.root.rotation.x) + Math.abs(h.root.rotation.z) > 1e-4; }
144
+
145
+ _toWorld(h, lx, ly, lz, out) {
146
+ if (this._pitched(h)) {
147
+ h.root.updateWorldMatrix(true, false);
148
+ return out.set(lx, ly, lz).applyMatrix4(h.root.matrixWorld);
149
+ }
150
+ const p = h.root.position, ry = h.root.rotation.y;
151
+ const c = Math.cos(ry), s = Math.sin(ry);
152
+ return out.set(p.x + lx * c + lz * s, p.y + ly, p.z - lx * s + lz * c);
153
+ }
154
+
155
+ // ...the same point through LAST frame's transform. Only the vertical is ever wanted (what the
156
+ // deck under his boots did while he stood still), and that is what keeps the visual-height
157
+ // smoother honest — see postUpdate.
158
+ _toWorldPrev(h, lx, ly, lz, out) {
159
+ if (this._pitched(h)) return out.set(lx, ly, lz).applyMatrix4(h.pm);
160
+ const c = Math.cos(h.pry), s = Math.sin(h.pry);
161
+ return out.set(h.px + lx * c + lz * s, h.py + ly, h.pz - lx * s + lz * c);
162
+ }
163
+
164
+ _toLocal(h, wx, wy, wz, out) {
165
+ if (this._pitched(h)) {
166
+ h.root.updateWorldMatrix(true, false);
167
+ _m.copy(h.root.matrixWorld).invert();
168
+ return out.set(wx, wy, wz).applyMatrix4(_m);
169
+ }
170
+ const p = h.root.position, ry = h.root.rotation.y;
171
+ const c = Math.cos(ry), s = Math.sin(ry);
172
+ const dx = wx - p.x, dz = wz - p.z;
173
+ return out.set(dx * c - dz * s, wy - p.y, dx * s + dz * c);
174
+ }
175
+
176
+ _near(h, x, z) {
177
+ const p = h.root.position;
178
+ const dx = x - p.x, dz = z - p.z;
179
+ const r = h.radius + 2;
180
+ return dx * dx + dz * dz < r * r;
181
+ }
182
+
183
+ _snap(h) {
184
+ const p = h.root.position;
185
+ h.px = p.x; h.py = p.y; h.pz = p.z; h.pry = h.root.rotation.y;
186
+ if (this._pitched(h)) { h.root.updateWorldMatrix(true, false); h.pm.copy(h.root.matrixWorld); }
187
+ }
188
+
189
+ _measure(h, dt) {
190
+ const p = h.root.position;
191
+ h.dx = p.x - h.px; h.dy = p.y - h.py; h.dz = p.z - h.pz;
192
+ h.dyaw = wrapPi(h.root.rotation.y - h.pry);
193
+ h.teleported = Math.abs(h.dx) + Math.abs(h.dz) > TELE_XZ || Math.abs(h.dy) > TELE_Y || Math.abs(h.dyaw) > TELE_YAW;
194
+ // the velocities SURVIVE a teleport frame: the last sane ones are what a rider thrown off by
195
+ // one takes with him
196
+ if (!h.teleported && dt > 1e-5) {
197
+ h.vx = h.dx / dt; h.vy = h.dy / dt; h.vz = h.dz / dt; h.yawRate = h.dyaw / dt;
198
+ }
199
+ }
200
+
201
+ // How fast the platform is moving THE POINT HE IS STANDING ON — the translation plus the
202
+ // tangential term of the yaw. (Check: a man at local +X on a platform whose yaw increases is
203
+ // swung toward -Z, and this gives (0, -yawRate·offX). Correct.)
204
+ _velAt(h, wx, wz, out) {
205
+ const p = h.root.position;
206
+ const ox = wx - p.x, oz = wz - p.z;
207
+ return out.set(h.vx + h.yawRate * oz, h.vy, h.vz - h.yawRate * ox);
208
+ }
209
+
210
+ // ── the deck search ──────────────────────────────────────────────────────────────────────────
211
+ // The HIGHEST deck under his feet wins, across every platform — so walking from one train car to
212
+ // the next is a rebind on the same frame, with no airborne frame and no release. `ride` is the
213
+ // one he is already on, and only IT gets the release pad: acquire tight, release loose.
214
+ _deckUnder(wx, feetY, wz, ride) {
215
+ let best = null;
216
+ for (const h of this.hosts) {
217
+ if (!h.enabled() || !this._near(h, wx, wz)) continue;
218
+ this._toLocal(h, wx, feetY, wz, _v);
219
+ for (const d of h.decks) {
220
+ const mine = ride && ride.host === h && ride.deck === d;
221
+ if (!inRect(d, _v.x, _v.z, mine ? RELEASE_PAD : 0)) continue;
222
+ const y = this._toWorld(h, _v.x, d.y, _v.z, _v2).y;
223
+ if (y > feetY + STEP_UP || y < feetY - STEP_DOWN) continue;
224
+ if (!best || y > best.y) best = { host: h, deck: d, y, lx: _v.x, lz: _v.z };
225
+ }
226
+ }
227
+ return best;
228
+ }
229
+
230
+ // ── getting on ───────────────────────────────────────────────────────────────────────────────
231
+ // Called from player.js right after the capsule solve, and ONLY when he is not already riding.
232
+ // `prevFeetY` is his height at the top of the frame, so the plane test is over the whole frame's
233
+ // vertical sweep rather than an instant.
234
+ tryLand(e, prevFeetY, wasAir) {
235
+ if (!e.rideable || e.platform || !e.alive) return;
236
+ const fx = e.position.x, fy = e.position.y, fz = e.position.z;
237
+ let best = null, mantle = null;
238
+ for (const h of this.hosts) {
239
+ if (!h.enabled() || h.teleported || !this._near(h, fx, fz)) continue;
240
+ this._toLocal(h, fx, fy, fz, _v);
241
+ const lx = _v.x, lz = _v.z;
242
+ // the transform is one frame stale (the coaches move after the player), so forgive the
243
+ // footprint by however far she actually travelled in that frame
244
+ const pad = LAND_PAD + Math.abs(h.dx) + Math.abs(h.dz);
245
+ for (const d of h.decks) {
246
+ const y = this._toWorld(h, lx, d.y, lz, _v2).y;
247
+ // DOWN through the plane: the ordinary landing. `h.dy` is in the test because the deck can
248
+ // come UP past a man who is falling — a coach cresting a rise into a jumper — and he was
249
+ // above it before she moved, which still counts.
250
+ const down = e.vy <= 0.01 && prevFeetY >= y - 0.02 - Math.max(0, h.dy) && fy < y;
251
+ // UP through it: he is finishing a vault (below), and pulling yourself onto a roof does not
252
+ // require you to fall onto it afterwards.
253
+ const up = e._mantleT > 0 && e.vy > 0 && prevFeetY < y && fy >= y - 0.02;
254
+ if ((down || up) && inRect(d, lx, lz, pad)) {
255
+ if (!best || y > best.y) best = { host: h, deck: d, y, lx, lz };
256
+ continue;
257
+ }
258
+ // THE LEDGE. Feet up level with the lip, at the top of the arc, and inside the footprint by
259
+ // a capsule's reach: he catches it. Gated on MANTLE_VY so it cannot be a free boost taken
260
+ // off the ground beside her — see the constants.
261
+ if (!wasAir && !e.airborne) continue;
262
+ if (e._mantleT > 0 || e.vy > MANTLE_VY || h.mantle <= 0) continue;
263
+ if (fy < y - h.mantle || fy >= y - 0.02) continue;
264
+ if (!inRect(d, lx, lz, GRAB)) continue;
265
+ if (!mantle || y > mantle.y) mantle = { host: h, deck: d, y, lx, lz };
266
+ }
267
+ }
268
+ if (best) { this._board(e, best); return; }
269
+ if (mantle) this._vault(e, mantle);
270
+ }
271
+
272
+ _vault(e, m) {
273
+ const gap = m.y - e.position.y;
274
+ e.vy = Math.sqrt(2 * GRAV * (gap + MANTLE_CLEAR));
275
+ e._mantleT = MANTLE_LOCK;
276
+ // Take her speed with you or she rolls out from under the vault — at a trot she covers 0.4m in
277
+ // the 0.12s it takes him to get his feet over the lip. Plus a drag INWARD, so he comes up over
278
+ // the rail rather than beside it.
279
+ this._velAt(m.host, e.position.x, e.position.z, _v2);
280
+ const d = m.deck;
281
+ const cx = clamp(m.lx, d.x0, d.x1) - m.lx, cz = clamp(m.lz, d.z0, d.z1) - m.lz;
282
+ const len = Math.hypot(cx, cz);
283
+ const ix = len > 1e-3 ? cx / len : 0, iz = len > 1e-3 ? cz / len : 0;
284
+ const ry = m.host.root.rotation.y, c = Math.cos(ry), s = Math.sin(ry);
285
+ e._airVX = _v2.x + (ix * c + iz * s) * MANTLE_PULL;
286
+ e._airVZ = _v2.z + (-ix * s + iz * c) * MANTLE_PULL;
287
+ }
288
+
289
+ _board(e, b) {
290
+ e.position.y = b.y;
291
+ this._toLocal(b.host, e.position.x, e.position.y, e.position.z, _v);
292
+ b.lx = _v.x; b.lz = _v.z;
293
+ e.vy = 0;
294
+ e.airborne = false;
295
+ e._capGrounded = false; // the capsule's snap-to-ground pass (world.js:314) must never
296
+ // raycast a man off a roof onto the dirt below
297
+ e._airVX = 0; e._airVZ = 0; // his flight is over: no 1.5m skid across the roof
298
+ e._mantleT = 0;
299
+ e.platform = {
300
+ host: b.host, deck: b.deck,
301
+ lx: b.lx, ly: b.deck.y, lz: b.lz,
302
+ yawDebt: 0, vx: 0, vy: 0, vz: 0, fresh: true,
303
+ };
304
+ e._skipCols = b.host.cols; e._skipT = COL_GRACE;
305
+ if (e._visualY === undefined) e._visualY = e.position.y;
306
+ }
307
+
308
+ // ── staying on ───────────────────────────────────────────────────────────────────────────────
309
+ // Replaces world.moveCapsule while he is riding: no gravity, no terrain, no BVH — the deck IS the
310
+ // floor. He still gets the 2D collider pass (props, trees, a hitching rail) so the road can still
311
+ // scrape him off, with his own platform's box skipped.
312
+ deckMove(e, hx, hz, dt) {
313
+ const ride = e.platform;
314
+ const W = this.game.world;
315
+ if (!ride.host.enabled() || ride.host.teleported || !e.alive) {
316
+ this.leave(e, 'drop');
317
+ return W.moveCapsule(e, hx, hz, dt);
318
+ }
319
+ // jump() runs at the END of player.update, after this — so a positive vy here is the first
320
+ // frame of a jump that was taken ON the deck, and he is ballistic from now on.
321
+ if (e.vy > 0.01) {
322
+ this.leave(e, 'jump');
323
+ return W.moveCapsule(e, hx, hz, dt);
324
+ }
325
+ // WHERE HE STANDS IS A LOCAL FACT. Start from his deck coords put through the platform's
326
+ // transform as it is RIGHT NOW — never from his last world position. The two are the same
327
+ // number in the ordinary tick, but only this one is immune to the platform having been moved
328
+ // by something other than its own update: an out-of-band transform is then a carry, which is
329
+ // what it is, instead of quietly becoming local drift that slides him off the back.
330
+ const r = e.capsuleR ?? e.radius ?? 0.45;
331
+ this._toWorld(ride.host, ride.lx, ride.ly, ride.lz, _v);
332
+ const feetY = _v.y;
333
+ let wx = _v.x + hx, wz = _v.z + hz;
334
+ const air = feetY - W.groundAt(wx, wz);
335
+ const c = W.collide(wx, wz, r, air, feetY, e._skipCols);
336
+ wx = c.x; wz = c.z;
337
+
338
+ const found = this._deckUnder(wx, feetY, wz, ride);
339
+ if (!found) {
340
+ // he has walked off the edge. Not a fall from a standstill: he goes with her.
341
+ e.position.x = wx; e.position.z = wz;
342
+ this.leave(e, 'step');
343
+ e.vy = Math.min(e.vy, 0);
344
+ return W.moveCapsule(e, 0, 0, dt);
345
+ }
346
+ if (found.host !== ride.host || found.deck !== ride.deck) {
347
+ ride.host = found.host; ride.deck = found.deck; ride.yawDebt = 0; ride.fresh = true;
348
+ e._skipCols = found.host.cols;
349
+ }
350
+ ride.lx = found.lx; ride.lz = found.lz; ride.ly = found.deck.y;
351
+ e.position.set(wx, found.y, wz);
352
+ e.vy = 0;
353
+ e.airborne = false;
354
+ e._capGrounded = false;
355
+ e._skipT = COL_GRACE;
356
+ return true;
357
+ }
358
+
359
+ // The dodge burst (player.js:564) goes through Character.move → moveCapsule, which would drop him
360
+ // off the roof onto the terrain. On a deck it is a plain local displacement instead.
361
+ step(e, dx, dz) {
362
+ if (!e.platform) return;
363
+ this.deckMove(e, dx, dz, 0);
364
+ }
365
+
366
+ // ── getting off ──────────────────────────────────────────────────────────────────────────────
367
+ // Every exit funnels through here so they cannot drift apart: jumping off, walking off the edge,
368
+ // the platform dropping him, and sitting down on the box as her driver.
369
+ // 'jump' / 'step' — you keep her speed. Jump off a galloping team and you are FLUNG.
370
+ // 'aboard' — you are sitting down on her; player.js's aboard path takes over.
371
+ // 'drop' — she teleported, was stabled, or you died. No inheritance from a lie.
372
+ leave(e, why) {
373
+ const ride = e.platform;
374
+ if (!ride) return;
375
+ e.platform = null;
376
+ e._capGrounded = false;
377
+ e._skipT = COL_GRACE;
378
+ if (why === 'jump' || why === 'step') {
379
+ e._airVX = clamp(ride.vx, -18, 18);
380
+ e._airVZ = clamp(ride.vz, -18, 18);
381
+ e.airborne = true;
382
+ e._visualY = undefined; // let the smoother re-latch wherever he lands (Coach.alight:465)
383
+ } else {
384
+ e._airVX = 0; e._airVZ = 0;
385
+ e._skipCols = null; e._skipT = 0;
386
+ if (why !== 'aboard') e._visualY = undefined;
387
+ }
388
+ ride.host.owner?.onRiderLeave?.(e);
389
+ }
390
+
391
+ // ── the carry ────────────────────────────────────────────────────────────────────────────────
392
+ // main.js calls this AFTER the coaches and the trains have moved, and unconditionally — with the
393
+ // map open the platform has not moved, the delta is zero, and this degrades to handing him his
394
+ // camera. It is idempotent: the snapshot is retaken at the end, so a second call in the same
395
+ // frame measures a zero delta and carries nothing twice.
396
+ postUpdate(dt, input, camera) {
397
+ const p = this.game.player;
398
+ const riding = !!p?.platform;
399
+ for (const h of this.hosts) this._measure(h, dt);
400
+
401
+ if (riding) {
402
+ const ride = p.platform;
403
+ const h = ride.host;
404
+ if (!p.alive || p.riding?.mounted || p.aboard || !h.enabled() || h.teleported) {
405
+ this.leave(p, 'drop');
406
+ } else {
407
+ // where the deck under his boots WAS, before she moved — the platform's own vertical motion,
408
+ // measured at his feet
409
+ const wasY = this._toWorldPrev(h, ride.lx, ride.ly, ride.lz, _v2).y;
410
+ this._toWorld(h, ride.lx, ride.ly, ride.lz, p.position);
411
+ const deckDY = p.position.y - wasY;
412
+
413
+ p.heading += h.dyaw;
414
+ p.root.rotation.y = p.heading;
415
+ // the lens is rate-limited where the body is not: coach.js eases her yaw PER FRAME, so a
416
+ // route terminus reverses her by ~0.25 rad in one of them. Pay the debt off over a few.
417
+ ride.yawDebt += h.dyaw;
418
+ const step = clamp(ride.yawDebt, -CAM_YAW_RATE * dt, CAM_YAW_RATE * dt);
419
+ p.camYaw += step;
420
+ ride.yawDebt -= step;
421
+
422
+ this._velAt(h, p.position.x, p.position.z, _v);
423
+ const k = ride.fresh ? 1 : Math.min(1, dt * 20); // a couple of frames of smoothing, so one
424
+ ride.vx += (_v.x - ride.vx) * k; // noisy frame cannot fling him
425
+ ride.vy += (_v.y - ride.vy) * k;
426
+ ride.vz += (_v.z - ride.vz) * k;
427
+ ride.fresh = false;
428
+
429
+ // THE VISUAL HEIGHT. player.js's smoother eases the drawn feet DOWN toward the real ones
430
+ // over micro-bumps, and the camera is driven from it (player.js:763). Left alone on a coach
431
+ // it would smooth HER descent as if it were a bump in his path: on a downhill he would be
432
+ // drawn hanging above the roof for as long as the hill lasted, and the camera with him. So
433
+ // the deck's own rise and fall is added to it at the same instant it lands in his feet —
434
+ // then the only thing left in (feet - _visualY) is HIS motion relative to the deck, which
435
+ // on a flat roof is nothing at all. The smoother keeps its real job (a step between decks)
436
+ // and contributes no error to a moving one.
437
+ if (p._visualY === undefined) p._visualY = p.position.y;
438
+ else p._visualY += deckDY;
439
+ const py = p.position.y;
440
+ if (Math.abs(py - p._visualY) > 1.5) p._visualY = py;
441
+ else p._visualY += (py - p._visualY) * Math.min(1, dt * 12);
442
+ if (p.model) p.model.position.y = (p._baseModelY2 ?? 0) + (p._visualY - py);
443
+ }
444
+ }
445
+
446
+ for (const h of this.hosts) this._snap(h);
447
+
448
+ // ONE camera update per frame, always. player.update skips its own when he is riding (its
449
+ // position would be a frame behind the coach he is standing on); it runs it itself on the frame
450
+ // he leaves. A dead man's camera is left where it was, exactly as player.update leaves it.
451
+ if (riding && p.alive) p._camera(dt, input, camera);
452
+ }
453
+
454
+ // ── the body ─────────────────────────────────────────────────────────────────────────────────
455
+ // The coach's collider box stops at the footboard (coach.js:341: y1 = root.y + 1.55) so that a
456
+ // bullet aimed at the driver does not die on the chassis — which leaves the CABIN, between the
457
+ // footboard and the roof, as a metre-high hole a mistimed jump sails straight into. This is that
458
+ // wall: entity-only, never pushed into world/town.js's `colliders`, so it cannot stop a round.
459
+ // Mistime the jump and you smack into the side of the coach and fall, which is the honest answer.
460
+ hullPush(e) {
461
+ if (!e.rideable || e.platform || e._mantleT > 0 || !e.alive) return;
462
+ const r = e.capsuleR ?? e.radius ?? 0.45;
463
+ for (const h of this.hosts) {
464
+ const hull = h.hull;
465
+ if (!hull || !h.enabled() || !this._near(h, e.position.x, e.position.z)) continue;
466
+ const y0 = h.root.position.y + hull.y0, y1 = h.root.position.y + hull.y1;
467
+ // the same band test world.collide uses (world.js:254): feet above the top or a whole body
468
+ // below the bottom and the box is not there
469
+ if (e.position.y > y1 || e.position.y + 1.8 < y0) continue;
470
+ this._toLocal(h, e.position.x, e.position.y, e.position.z, _v);
471
+ const cx = (hull.x0 + hull.x1) / 2, cz = (hull.z0 + hull.z1) / 2;
472
+ const ex = (hull.x1 - hull.x0) / 2 + r, ez = (hull.z1 - hull.z0) / 2 + r;
473
+ const lx = _v.x - cx, lz = _v.z - cz;
474
+ if (Math.abs(lx) >= ex || Math.abs(lz) >= ez) continue;
475
+ // push out of the nearest face
476
+ const px = ex - Math.abs(lx), pz = ez - Math.abs(lz);
477
+ const nx = px < pz ? (lx < 0 ? -ex : ex) : lx;
478
+ const nz = px < pz ? lz : (lz < 0 ? -ez : ez);
479
+ this._toWorld(h, nx + cx, _v.y, nz + cz, _v2);
480
+ e.position.x = _v2.x;
481
+ e.position.z = _v2.z;
482
+ }
483
+ }
484
+ }