sindicate 0.1.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 (65) 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/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,518 @@
1
+ // A HORSEMAN COMING THE OTHER WAY ON AN EMPTY ROAD.
2
+ //
3
+ // It is the single most western image there is, and until now this county had four thousand metres of
4
+ // road and not one man on a horse using them. So: a Horse path-following one of terrainData's ROADS,
5
+ // with a man in the saddle — trotting out to the ranch by day, home by dark, and coming past you on
6
+ // the fort road at the exact moment you had decided the desert was empty.
7
+ //
8
+ // ── THE MAN IN THE SADDLE IS AN NPC, AND THAT IS THE WHOLE TRICK ────────────────────────────────
9
+ // He is in game.npcs. So combat.js's bullet loop finds him (shoot him off his horse), crime.js counts
10
+ // him as a witness and calls his killing a MURDER, loot.js drops his purse, alarmTown scatters him,
11
+ // and — this is the one that matters — coach.js's _blocked() look-ahead sees him standing in the road
12
+ // and the stagecoach BRAKES for him, without coach.js knowing that horses exist. Not one of those
13
+ // files needed an edit. fable's version kept its riders in a private list and paid for it in five
14
+ // systems that could not see them.
15
+ //
16
+ // The horse goes in game.horses, which buys the same trick again: the whisker steering already
17
+ // considers horses (entity.js _probeSteer), the player is already pushed out of them
18
+ // (player.separateFromPeople), and they already trample the grass.
19
+ //
20
+ // ── WHAT IS REUSED, VERBATIM ───────────────────────────────────────────────────────────────────
21
+ // horse.js driveMove terrain-follow + gait + bob. A traveller's horse moves the way yours does.
22
+ // riding.js SADDLE the rider-root offset, dialled in on the player's own mount.
23
+ // riding.js makeSeatedPose(root, BIPED_TO_SYNTY) + twoBoneIK — the seated pose and the rein IK, the
24
+ // same two calls the player's Riding controller makes. Baked at BOOT (a retarget
25
+ // mid-gunfight is a stall) and cached per source FBX — see _poses.
26
+ // traveller.js LANE, DAWN/DUSK, laneTarget, railShut — a horseman keeps to the same side of the same
27
+ // road in the same daylight as a man on foot. One definition, two riders.
28
+ //
29
+ // PERF CONTRACT (main.js:118): horse and rider are built ONCE on the loading screen and never leave
30
+ // the scene. Nothing here spawns.
31
+ import * as THREE from 'three/webgpu';
32
+ import { Horse } from './horse.js';
33
+ import { Character } from './entity.js';
34
+ import { NPC } from './npc.js';
35
+ import { makeSeatedPose, twoBoneIK, realBonesOf, BIPED_TO_SYNTY, SADDLE } from './riding.js';
36
+
37
+ import { LANE, DAWN, DUSK, BVH_R, roadCull, laneTarget, railShut } from './traveller.js';
38
+ import { steerCarrot, mountedTurn, nearestEndpoint } from './pathFollow.js';
39
+ // routes + venue hours are GAME data: setMountedContent({ routes, venueOpen })
40
+ let MC = { routes: [], venueOpen: () => true };
41
+ export function setMountedContent(c) { MC = { ...MC, ...c }; }
42
+ import { dist2D } from '../core/utils.js';
43
+
44
+ // A WORKING TROT (3.4 m/s). horse.js's gaitFor puts anything from 0.4 to 4 m/s on the trot clip, so
45
+ // this is the gait as well as the speed. It is also the right speed against the traffic: the stage
46
+ // cruises at a canter (6.5, coach.js CRUISE), so she comes up behind a rider at 3.1 m/s and passes
47
+ // him — which is what a stage did, and why the man on the box has a whip.
48
+ const TROT = 3.4;
49
+ const BOLT = 9.0; // …and what a frightened horse does instead. gaitFor: a canter.
50
+ const BOLT_SECS = 4.5; // how long it runs before it remembers it has somewhere to be
51
+ const TURN_RATE = 2.4; // rad/s toward the steered waypoint line — a horse turns, it does not pivot
52
+ const HORSE_RMIN = 3.5; // minimum turn radius (m): the turn is capped at wmax = speed / Rmin so a
53
+ // boxed-in horse curves out and round a fence instead of pivoting into it
54
+ const ARRIVE = 3.2; // a horse is longer than a man: give the waypoint a wider gate
55
+ const UP = new THREE.Vector3(0, 1, 0);
56
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
57
+ const _aim = { x: 0, z: 0 }; // scratch: the point steerCarrot hands back to steer at
58
+ // THE SALOON CALL — where a passing rider ties up for a drink. Two rail spots on the street's east
59
+ // side, clear of the town's three hitched horses (z −16/−4/10); riders alternate between them.
60
+ const SALOON_HITCH = [{ x: 6.5, z: 2 }, { x: 6.5, z: 18 }];
61
+ let _hitchNext = 0;
62
+
63
+ // THE SEATED POSE IS CACHED PER SOURCE FBX, NOT PER RIDER AND NOT ONCE FOR EVERYONE.
64
+ //
65
+ // A pose is {boneName → localQuaternion}, and it is only transferable between two bodies that share a
66
+ // BIND. The cast comes out of two different packs (data/characters.js: WesternTownChars.fbx and
67
+ // WesternCharacters.fbx), and this game has been bitten before by assuming two Synty rigs agree —
68
+ // see the retarget notes in clips.js and the contorted-NPC trap. A skeleton is a property of the FBX,
69
+ // so that is what the cache is keyed on: two bakes for six riders, both on the loading screen, and a
70
+ // pack that turns out to bind differently sits correctly anyway instead of subtly wrong.
71
+ const _poses = new Map(); // model src → {bone: localQuat}
72
+ const _tl = new THREE.Vector3(), _tr = new THREE.Vector3();
73
+ const _tgt = new THREE.Vector3(), _dir = new THREE.Vector3(), _prevT = new THREE.Vector3();
74
+
75
+ 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; }
76
+
77
+ // THE MAN. An ordinary NPC in every respect the rest of the game can see — but while he is up there
78
+ // his legs are not his own: the MountedTraveller writes his position and his pose every frame, so
79
+ // every AI branch NPC would otherwise take (wander the street, run away on foot, strike up a chat)
80
+ // has to be switched off, and switched back ON the moment he hits the dirt.
81
+ class Rider extends NPC {
82
+ get cullDist() { return roadCull(this.game); }
83
+ get aiRange() { return this.cullDist; }
84
+
85
+ update(dt) {
86
+ // SEATED: Character-level only. The health, the stagger clock, the distance cull, the frustum cull
87
+ // and the animator — everything a body needs — and NOT the AI, which would try to walk him home.
88
+ if (this.seated) { Character.prototype.update.call(this, dt); return; }
89
+ // INSIDE THE SALOON (the saloon-call, below): past the batwings he is fiction — invisible,
90
+ // untickable, waiting out his drink on the traveller's clock.
91
+ if (this._inSaloon) return;
92
+ // WALKING A GOAL (saloon-call): dismounted, crossing the street to the batwings or back to his
93
+ // horse. Whisker-steered, then his errand's callback fires and the traveller moves him on.
94
+ if (this._goal) {
95
+ const g = this._goal;
96
+ const dx = g.x - this.position.x, dz = g.z - this.position.z, L = Math.hypot(dx, dz);
97
+ if (L < 1.4) { const then = g.then; this._goal = null; this.play('idle'); then?.(); }
98
+ else {
99
+ const sd = this.walkDir(_dir.set(dx / L, 0, dz / L), dt);
100
+ this.heading = Math.atan2(sd.x, sd.z);
101
+ this.root.rotation.y = this.heading;
102
+ this.move(sd, 1.8, dt);
103
+ this.play('walk');
104
+ }
105
+ return;
106
+ }
107
+ super.update(dt); // in the dirt: he is a townsman again, and NPC does the rest
108
+ }
109
+
110
+ // A MAN IN THE SADDLE DOES NOT RUN AWAY. HIS HORSE DOES.
111
+ // scare() is what a gunshot, a body in the street or a shut level crossing does to a townsman
112
+ // (npc.js alarmTown → scare → flee on foot). A seated man cannot flee on foot, and a rider who
113
+ // dismounts to jog away from a bang is a puppet. So the fright is passed to the animal under him,
114
+ // which is where a fright belongs: it BOLTS (see MountedTraveller.spook).
115
+ scare(x, z, secs = 2.5, force = false) {
116
+ if (!this.alive) return;
117
+ if (this.seated) { this.mount?.spook(x, z, secs); return; }
118
+ super.scare(x, z, secs, force);
119
+ }
120
+
121
+ // SHOT OFF THE HORSE. He goes into the dirt FIRST and dies there — because NPC.die() reads his
122
+ // position for the crime report and for the purse, and a purse that spills at saddle height hangs in
123
+ // the air a metre and a half over the road.
124
+ die() {
125
+ if (!this.alive) return;
126
+ this.mount?.unseat();
127
+ super.die();
128
+ }
129
+ }
130
+
131
+ export class MountedTraveller {
132
+ constructor(game, def) {
133
+ this.game = game;
134
+ this.def = def;
135
+ const rt = MC.routes[def.route];
136
+ if (!rt) throw new Error(`[rider] ${def.id}: no such route '${def.route}'`);
137
+ this.road = rt.pts;
138
+ this.built = rt.built; // where the road runs through a town: keep the BVH on
139
+ this.routeName = def.route;
140
+ this.dir = def.dir ?? 1;
141
+ const last = this.road.length - 1;
142
+ this._start = Math.max(0, Math.min(last, Math.round((def.start ?? 0) * last)));
143
+ this._wp = Math.max(0, Math.min(last, this._start + this.dir));
144
+ this.pauseT = 0;
145
+ this.bolt = 0;
146
+ this._home = false;
147
+ this._day = true; // the sky starts at 10:00 (world/sky.js). A bolt can call _advance before the
148
+ // daylight branch has ever run, and an undefined `_day` there would send a
149
+ // frightened horse "home" at ten in the morning.
150
+ this.spot = { out: Math.random() * 3, along: (Math.random() - 0.5) * 16 }; // a horse wants more room
151
+ }
152
+
153
+ async load(clips) {
154
+ const g = this.game;
155
+ const p = this.road[this._start];
156
+ const horse = new Horse(g, { x: p.x, z: p.z, heading: 0, saddle: true, coat: this.def.coat });
157
+ await horse._buildModel(); // the ctor already put it on the ground at (x,z)
158
+ horse.heading = Math.atan2(this.road[this._wp].x - p.x, this.road[this._wp].z - p.z);
159
+ this.horse = horse;
160
+
161
+ const rider = new Rider(g, { ...this.def, roam: { x: p.x, z: p.z, r: 0 } }, clips);
162
+ await rider.load();
163
+ // THE DEFERRED FOOT-GROUNDING MUST NOT RUN. Character.load() has already measured his feetDrop from
164
+ // the BIND pose, which is the right answer; the re-measure at t+0.5 s would measure him in the SEATED
165
+ // pose, find his boots up in the stirrups, and lift the whole man half a metre off his own horse.
166
+ // (riding.js never hits this because the player is not re-measured while he is mounted.)
167
+ rider._grounded = true;
168
+ rider.seated = true;
169
+ rider.mount = this;
170
+ this.rider = rider;
171
+
172
+ // The seated pose is a RETARGET — a retarget mid-gunfight is a stall, so it is baked HERE, on the
173
+ // loading screen, and never again. Cached per source FBX (see _poses).
174
+ const src = rider.modelUrl;
175
+ if (!_poses.has(src)) _poses.set(src, (await makeSeatedPose(rider.root, BIPED_TO_SYNTY)).pose);
176
+ this.pose = _poses.get(src);
177
+ this.pbone = {};
178
+ for (const b of realBonesOf(rider.root)) this.pbone[b.name] = b;
179
+
180
+ // EVERYTHING IS BUILT — only now does the world find out about them. Publish the horse before it is
181
+ // whole and a load that throws halfway leaves game.horses holding an animal marked `rider`: it can
182
+ // never graze, can never be mounted, and nothing will ever tick it, because the thing that was
183
+ // going to is the thing that threw.
184
+ horse.rider = this; // ridden → the horse skips its own graze/wander AI
185
+ g.scene.add(horse.root);
186
+ g.scene.add(rider.root);
187
+ g.horses.push(horse); // …now the steering, the shove and the trample see it
188
+ g.npcs.push(rider); // …and the bullet, the law, the alarm and the stage
189
+ this._seat();
190
+ return this;
191
+ }
192
+
193
+ // WHERE HE IS. Always — on screen or off, drawn or not. His `position` is what combat.js aims a
194
+ // bullet at, what crime.js measures a witness by, and what the stagecoach's look-ahead brakes for,
195
+ // and a rider whose body is one place while his position is another is a rider you cannot shoot.
196
+ _place() {
197
+ const h = this.horse, r = this.rider;
198
+ h.saddlePos(r.root.position, SADDLE); // getWorldPosition walks the horse's root: no stale bone
199
+ r.root.rotation.y = h.heading;
200
+ r.heading = h.heading; // the minimap arrow and faceToward read this
201
+ }
202
+
203
+ // …and how he SITS. The baked seated pose, then his hands two-bone-IK'd onto the horse's rein bones
204
+ // — exactly what riding.js does for the player, on the same horse. Only worth doing when he is
205
+ // actually being drawn: it is four solves and a matrix refresh, and nobody can see a hidden man's grip.
206
+ _pose() {
207
+ const h = this.horse, r = this.rider;
208
+ for (const n in this.pose) { const b = this.pbone[n]; if (b) b.quaternion.copy(this.pose[n]); }
209
+ if (!h.reinL || !h.reinR) return;
210
+ r.root.updateMatrixWorld(true); // pose applied → refresh before the arm IK
211
+ h.reinL.getWorldPosition(_tl);
212
+ h.reinR.getWorldPosition(_tr);
213
+ twoBoneIK(this.pbone.Shoulder_L, this.pbone.Elbow_L, this.pbone.Hand_L, _tl);
214
+ twoBoneIK(this.pbone.Shoulder_R, this.pbone.Elbow_R, this.pbone.Hand_R, _tr);
215
+ }
216
+
217
+ _seat() { this._place(); this._pose(); }
218
+
219
+ // Something frightened the horse — a shot, a body in the street, a train on the crossing. It runs,
220
+ // AWAY, at a canter, for a few seconds; if the fright is ahead of it on the road it turns round
221
+ // first. Then it remembers it has somewhere to be. (This is the mounted answer to npc.js's flee,
222
+ // and it is the reason a rider is not just a townsman with an expensive hat.)
223
+ spook(x, z, secs = 2.5) {
224
+ if (!this.rider.alive) return;
225
+ const first = this.bolt <= 0;
226
+ this.bolt = Math.max(this.bolt, Math.min(secs, BOLT_SECS));
227
+ this.pauseT = 0;
228
+ this._home = false;
229
+ // WHICH WAY TO RUN IS DECIDED ONCE, AT THE BANG. A gunfight is a dozen alarmTown calls in four
230
+ // seconds and a man standing on a level crossing is frightened off it every frame he is still on
231
+ // it — and a horse that re-chooses its direction on every one of them turns round, turns round
232
+ // again, and dies exactly where it was standing.
233
+ if (!first) return;
234
+ const h = this.horse;
235
+ const away = (h.position.x - x) * Math.sin(h.heading) + (h.position.z - z) * Math.cos(h.heading);
236
+ if (away < 0) this._turnBack(); // the fright is in FRONT of him — he does not gallop into it
237
+ }
238
+
239
+ // A rider who is shot goes into the dirt beside his horse, and the horse — reins slack, a shot going
240
+ // off behind its ears — bolts. Same beat as the coach's team (coach.js _boltTeam), same reason.
241
+ unseat() {
242
+ const r = this.rider, h = this.horse;
243
+ if (!r.seated) return;
244
+ r.seated = false;
245
+ // Down beside the horse, on the ground, with his own feet back under him: his model still carries
246
+ // the feetDrop Character.load() measured from the bind, so the root going to the terrain puts his
247
+ // boots on it. The death clip then plays on an ordinary standing body.
248
+ const bx = h.position.x + Math.cos(h.heading) * 1.3;
249
+ const bz = h.position.z - Math.sin(h.heading) * 1.3;
250
+ r.setPosition(bx, this.game.world.groundAt(bx, bz), bz);
251
+ r.root.rotation.set(0, h.heading, 0);
252
+ r._visualY = undefined; // let the descent smoother re-latch where he landed
253
+ // The bones are LEFT where the sit left them, and are not reset to identity: _seat() stops running
254
+ // the moment he is unseated, and the death clip his Animator is about to play (VILLAGER_CLIPS
255
+ // carries a full-body one) writes every bone in it. Identity is not the bind pose — snapping the
256
+ // skeleton to it would break every bone the clip does not happen to name.
257
+ this.bolt = BOLT_SECS;
258
+ }
259
+
260
+ // PULLED FROM THE SADDLE. The player walked up and stole the horse (main.js E-scan). The rider
261
+ // lands on his feet beside it and bolts on foot; horse.rider goes null, so update() returns early
262
+ // from here on and the horse is free for riding.mount() to take. Returns the horse to mount.
263
+ dismountForTheft(threatX, threatZ) {
264
+ if (!this.rider.seated) return null;
265
+ this.unseat(); // rider to the dirt, seated = false — now an ordinary foot NPC
266
+ this.horse.rider = null; // decouple: the loop below stops touching horse and rider
267
+ this.rider.scare?.(threatX, threatZ, 6, true); // on his feet now, scare = flee on foot
268
+ return this.horse;
269
+ }
270
+
271
+ update(dt) {
272
+ const g = this.game, h = this.horse, r = this.rider;
273
+
274
+ // ONCE THE TRAVELLER NO LONGER OWNS THE HORSE, IT LETS GO ENTIRELY. horse.rider goes null when the
275
+ // rider is shot and the animal runs itself out — OR it becomes the PLAYER when he steals the horse
276
+ // (dismountForTheft). Either way it is not `this` any more, and the moment it isn't we stop: keep
277
+ // driving it and the ride controller fights us over the gait (legs freeze) while _place/_pose drag
278
+ // the thrown rider back onto the saddle. horse.js's own AI or the player's Riding ticks it now.
279
+ if (h.rider !== this) return;
280
+
281
+ // A DEAD MAN'S HORSE. It bolts — and it does so wherever it is, whether or not you are watching,
282
+ // because a horse left with `rider` set is a horse that never grazes again and cannot be mounted.
283
+ // Once it has run itself out, main.js's frustum net and horse.js's ambient AI take it from there,
284
+ // and it grazes wherever it fetched up. A riderless horse standing on an empty road is the most
285
+ // eloquent thing a road can have.
286
+ if (!r.alive) {
287
+ if (this.bolt > 0) {
288
+ this.bolt -= dt;
289
+ this._drive(h, BOLT, 0, dt, true);
290
+ h.mixer?.update(dt); // 4½ seconds of one horse: not worth a cull test
291
+ if (this.bolt <= 0) { h.rider = null; h.speed = 0; h.anchor = { x: h.position.x, z: h.position.z }; }
292
+ }
293
+ h._mixerTicked = true; // (once released, main.js's net takes its visibility back)
294
+ return;
295
+ }
296
+
297
+ // ── THE CULL, AND IT IS THE WALKERS' CULL, NOT A SECOND ONE ────────────────────────────────
298
+ // PAST cullDist he FREEZES, exactly as npc.js freezes a townsman past aiRange: hidden, no AI, no
299
+ // terrain sample, one dist2D a frame. That is what makes twenty-five extra bodies affordable.
300
+ // INSIDE it he keeps riding EVEN WHEN OFF SCREEN — the frustum only stops him being DRAWN and
301
+ // SKINNED. Freeze him on the frustum instead and a rider you glance away from is a rider standing
302
+ // in exactly the same place when you glance back, which is entity.js's stated "no pop" contract
303
+ // broken. So the two tests do different jobs, and this is the line that keeps them apart.
304
+ const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
305
+ if (pd > roadCull(g)) {
306
+ if (h.model) h.model.visible = false;
307
+ h._mixerTicked = true; // …and don't skin a horse nobody can see
308
+ return;
309
+ }
310
+ // Inside the cull, his visibility IS the rider's: Character.update already frustum-tested the man
311
+ // in the saddle this frame (main.js ticks game.npcs before this loop), and the horse sits inside
312
+ // his 2.2 m body sphere. One cull, not two. main.js's own horse net skips ridden horses — which is
313
+ // exactly why this line has to exist.
314
+ const shown = !!r.model?.visible;
315
+ if (h.model) h.model.visible = shown;
316
+
317
+ // A SHUT LEVEL CROSSING IS A FRIGHT, NOT A NEGOTIATION — the same guard the walkers read. Asked
318
+ // only when he is NOT already running from it, or spook() would be re-deciding his direction every
319
+ // frame he stood on the rails.
320
+ if (this.bolt <= 0) {
321
+ const xing = railShut(g, h.position.x, h.position.z);
322
+ if (xing) this.spook(xing.at.x, xing.at.z, 3);
323
+ }
324
+
325
+ // ── THE SALOON CALL ──────────────────────────────────────────────────────────────────────────
326
+ // A rider passing Dustwater with the bar pouring sometimes turns in for one: hitch at the rail,
327
+ // walk to the batwings, a drink's worth of minutes inside, back out, swing up, ride on. Rolled
328
+ // ONCE per approach (reset when he's clear of town), never while bolting or after dark's ride home.
329
+ if (this._saloon) { this._saloonTick(dt, shown); return; }
330
+ if (this.bolt <= 0 && !this._home) {
331
+ const dS = dist2D(h.position.x, h.position.z, -11, 10);
332
+ if (dS < 45 && !this._saloonTried) {
333
+ this._saloonTried = true;
334
+ if (Math.random() < 0.45 && MC.venueOpen(g.world.sky?.hour ?? 12)) {
335
+ this._saloon = { phase: 'ride', hitch: SALOON_HITCH[(_hitchNext++) % SALOON_HITCH.length] };
336
+ }
337
+ } else if (dS > 90) this._saloonTried = false;
338
+ }
339
+
340
+ let speed = 0, turn = 0;
341
+ if (this.bolt > 0) {
342
+ this.bolt -= dt;
343
+ // It runs down the ROAD, not off across the desert — and at 9 m/s it eats a 25 m leg in under
344
+ // three seconds, so it has to keep taking the next one or it circles the waypoint it overshot.
345
+ laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
346
+ if (dist2D(h.position.x, h.position.z, _tgt.x, _tgt.z) <= ARRIVE * 1.6) this._advance();
347
+ turn = this._steer(dt); // sets _steerMul (reins in on a hard turn)
348
+ speed = BOLT * (this._steerMul ?? 1);
349
+ } else {
350
+ const hour = g.world.sky?.hour ?? 10;
351
+ const day = hour >= DAWN && hour < DUSK;
352
+ const last = this.road.length - 1;
353
+ if (day !== this._day) {
354
+ this._day = day;
355
+ if (!day) this.dir = this._wp * 2 <= last ? -1 : 1; // home, by the nearer end
356
+ else if (this._home) { this._home = false; this.dir = this._wp <= 0 ? 1 : -1; } // …and out again at first light
357
+ }
358
+ if (this._home) speed = 0; // stood at the rail at the end of his road
359
+ else if (this.pauseT > 0) { this.pauseT -= dt; speed = 0; }
360
+ else {
361
+ laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
362
+ if (dist2D(h.position.x, h.position.z, _tgt.x, _tgt.z) <= ARRIVE) this._advance();
363
+ else { turn = this._steer(dt); speed = TROT * (h._steerSlow ? 0.6 : 1) * (this._steerMul ?? 1); } // rein in while boxed-in
364
+ }
365
+ }
366
+
367
+ // STUCK BACKSTOP. A horse pinned on a static corner the whiskers can't clear used to grind there
368
+ // forever — the mount had NO give-up. The A* path is the proper way round; this catches what it
369
+ // can't (grid still baking, an unreachable goal, a dynamic pin): if we mean to move but haven't in
370
+ // 1.4 s, drop the held path and skip to the next road leg. Same backstop traveller.js gives a walker.
371
+ if (speed > 0.1) {
372
+ this._stuckT = (this._stuckT ?? 0) + dt;
373
+ if (this._stuckT >= 1.4) {
374
+ const moved = dist2D(h.position.x, h.position.z, this._lastSX ?? h.position.x, this._lastSZ ?? h.position.z);
375
+ this._lastSX = h.position.x; this._lastSZ = h.position.z; this._stuckT = 0;
376
+ if (moved < 0.5) { this._rtPath = null; if (this.bolt <= 0) this._advance(); }
377
+ }
378
+ } else { this._stuckT = 0; this._lastSX = h.position.x; this._lastSZ = h.position.z; }
379
+
380
+ this._drive(h, speed, turn, dt, this.bolt > 0 || this.built[this._wp]);
381
+ // THE GAIT TICKS BEFORE THE SEAT, and riding.js says why: read the saddle bone before the mixer has
382
+ // advanced it and the horse's fore-aft surge and tilt reach the rider a frame late, which reads as
383
+ // "the saddle moves but he doesn't". Hidden, there is no tick and the bone is last-drawn — but
384
+ // saddlePos walks the horse's ROOT, so he still rides with it, centimetres out and nobody looking.
385
+ if (shown) h.mixer?.update(dt);
386
+ h._mixerTicked = true; // either we ticked it, or it must not be ticked at all
387
+ this._place(); // he rides with the horse whether or not he is drawn…
388
+ if (shown) this._pose(); // …but only a man being LOOKED at needs his grip solved
389
+ }
390
+
391
+ // THE SALOON CALL's clock (see update). Phases: ride → (dismount) → walk → drink → return → resume.
392
+ // While the man drinks, the horse stands the rail like the town's own — idle mixer, no seat solve.
393
+ _saloonTick(dt, shown) {
394
+ const s = this._saloon, h = this.horse, r = this.rider, g = this.game;
395
+ if (!r.alive) { this._saloon = null; return; } // shot on his way to a drink — update()'s death path owns it next frame
396
+ if (s.phase === 'ride') {
397
+ const d = dist2D(h.position.x, h.position.z, s.hitch.x, s.hitch.z);
398
+ if (d > 2.4) {
399
+ const sd = Character.prototype.walkDir.call(h, _dir.set((s.hitch.x - h.position.x) / d, 0, (s.hitch.z - h.position.z) / d), dt);
400
+ const m = mountedTurn(h.heading, h.position.x, h.position.z, h.position.x + sd.x, h.position.z + sd.z, Math.max(0.8, h.speed), HORSE_RMIN, TURN_RATE);
401
+ this._drive(h, TROT * 0.8 * m.speedMul, m.turn, dt, true);
402
+ if (shown) h.mixer?.update(dt);
403
+ h._mixerTicked = true;
404
+ this._place(); if (shown) this._pose();
405
+ return;
406
+ }
407
+ // pulled up at the rail: step down (the CALM dismount — unseat() is the shot man's, it bolts the horse)
408
+ h.speed = 0;
409
+ r.seated = false;
410
+ const bx = h.position.x + Math.cos(h.heading) * 1.3, bz = h.position.z - Math.sin(h.heading) * 1.3;
411
+ r.setPosition(bx, g.world.groundAt(bx, bz), bz);
412
+ r.root.rotation.set(0, h.heading, 0);
413
+ r._visualY = undefined;
414
+ r._goal = { x: -10.6, z: 10, then: () => { r._inSaloon = true; if (r.root) r.root.visible = false; } };
415
+ s.phase = 'walk'; s.dwell = 26 + Math.random() * 22;
416
+ } else if (s.phase === 'walk') {
417
+ if (r._inSaloon) s.phase = 'drink';
418
+ } else if (s.phase === 'drink') {
419
+ s.dwell -= dt;
420
+ if (s.dwell <= 0) {
421
+ r._inSaloon = false;
422
+ if (r.root) r.root.visible = true;
423
+ r.setPosition(-10.6, g.world.groundAt(-10.6, 10), 10);
424
+ const bx = h.position.x + Math.cos(h.heading) * 1.3, bz = h.position.z - Math.sin(h.heading) * 1.3;
425
+ r._goal = { x: bx, z: bz, then: () => { r.seated = true; this._saloon = null; } }; // swing up, ride on
426
+ s.phase = 'return';
427
+ }
428
+ }
429
+ // walk/drink/return: the horse stands the rail — idle gait, breathing mixer, no rider to place
430
+ h.play?.('idle');
431
+ if (shown) h.mixer?.update(dt);
432
+ h._mixerTicked = true;
433
+ }
434
+
435
+ // Point him at the lane target, but let the WHISKERS bend it — the same steering the walkers use,
436
+ // probed from the HORSE (which stands on the ground) and not from the saddle (which is a metre and a
437
+ // half in the air, where the fences are not). horse.js already borrows walkDir/_probeSteer for its
438
+ // grazers; this is the same borrow.
439
+ _steer(dt) {
440
+ const h = this.horse, g = this.game;
441
+ laneTarget(_tgt, this.road, this._wp, this.dir, LANE, this.spot);
442
+ let aimX = _tgt.x, aimZ = _tgt.z;
443
+ // LINE-FOLLOWING, NOT NODE-HOMING. Aiming AT the waypoint's lane point makes the steering error
444
+ // blow up as he closes on it — a half-metre of lateral drift is a few degrees at 20 m and thirty
445
+ // at three — so he over-turns, crosses the line, and the next node hauls him back: the
446
+ // left-right-left diagonals Nick watched. Project onto the LEG instead and chase a carrot a
447
+ // fixed distance ahead ALONG it: the correction stays proportional to the drift, and the line
448
+ // he rides is the road's own.
449
+ const pw = this._wp - this.dir;
450
+ if (pw >= 0 && pw < this.road.length) {
451
+ laneTarget(_prevT, this.road, pw, this.dir, LANE, this.spot);
452
+ const ex = _tgt.x - _prevT.x, ez = _tgt.z - _prevT.z, L2 = ex * ex + ez * ez;
453
+ if (L2 > 4) {
454
+ const L = Math.sqrt(L2);
455
+ const t = clamp(((h.position.x - _prevT.x) * ex + (h.position.z - _prevT.z) * ez) / L2, 0, 1);
456
+ const tc = Math.min(1, t + 7.0 / L); // the carrot: 7 m ahead along the leg, never past the node
457
+ aimX = _prevT.x + ex * tc; aimZ = _prevT.z + ez * tc;
458
+ }
459
+ }
460
+ // STATIC-obstacle pathing, but only where the capsule is even on — inside BVH_R of the player or on
461
+ // a built-up stretch. Out on the open road _drive is terrain-follow only (it rides THROUGH a fence
462
+ // harmlessly, unseen), so there is nothing to route around and we keep the cheap straight aim. When
463
+ // the grid says the road ahead is blocked, steerCarrot holds an A* path round it (the settlement's
464
+ // own baked grid — reused, not re-baked) and hands back a look-ahead carrot to curve onto.
465
+ const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
466
+ if (pd < BVH_R || this.built[this._wp]) {
467
+ const centre = nearestEndpoint(this.road, h.position.x, h.position.z);
468
+ if (steerCarrot(this, g, centre, h.position.x, h.position.z, _tgt.x, _tgt.z, _aim,
469
+ { dt, arrive: ARRIVE, lookahead: clamp(h.speed * 0.5, 2.5, 4.5), wide: true })) { aimX = _aim.x; aimZ = _aim.z; }
470
+ } else { this._rtPath = null; }
471
+ // WHISKERS bend the aim for DYNAMIC things (people, other horses), probed from the HORSE — on the
472
+ // ground where the fences are, not from the saddle a metre and a half up in the air.
473
+ const dx = aimX - h.position.x, dz = aimZ - h.position.z, L = Math.hypot(dx, dz) || 1;
474
+ const sd = Character.prototype.walkDir.call(h, _dir.set(dx / L, 0, dz / L), dt);
475
+ // Pure-pursuit turn with a real minimum radius: TURN_RATE alone (an uncapped gain) let a boxed-in
476
+ // horse spin on the spot and snap straight back into the rail. mountedTurn caps it at wmax =
477
+ // speed/Rmin and reins the pace in on a hard heading error, so it slows and curves round instead.
478
+ const m = mountedTurn(h.heading, h.position.x, h.position.z, h.position.x + sd.x, h.position.z + sd.z, Math.max(0.6, h.speed), HORSE_RMIN, TURN_RATE);
479
+ this._steerMul = m.speedMul;
480
+ return m.turn;
481
+ }
482
+
483
+ // Move the animal. Inside 45 m of the player — and ALWAYS through a town, and always while it is
484
+ // bolting — that is the full capsule controller (horse.driveMove: BVH + terrain-follow + gait + bob).
485
+ // Out on the open road, where the whiskers are already off (entity.js walkDir) and the going is
486
+ // graded, straight and empty, it is terrain-follow only: the trade horse.js's own ambient grazers
487
+ // make, for the reason they state ("NO BVH… a needless cost").
488
+ _drive(h, speed, turn, dt, hard = false) {
489
+ const g = this.game;
490
+ const pd = g.player ? dist2D(h.position.x, h.position.z, g.player.position.x, g.player.position.z) : 1e9;
491
+ if (pd < BVH_R || hard) { h.driveMove(speed, turn, dt); return; }
492
+ h.speed = speed;
493
+ h.heading += turn * dt;
494
+ h.position.x += Math.sin(h.heading) * speed * dt;
495
+ h.position.z += Math.cos(h.heading) * speed * dt;
496
+ h.position.y = g.world.groundAt(h.position.x, h.position.z);
497
+ h.root.quaternion.setFromAxisAngle(UP, h.heading);
498
+ if (!h.airborne) h.play(h.gaitFor(Math.abs(speed)));
499
+ h._applyBob(dt);
500
+ }
501
+
502
+ _turnBack() {
503
+ this.dir = -this.dir;
504
+ this._wp = Math.max(0, Math.min(this.road.length - 1, this._wp + this.dir));
505
+ }
506
+
507
+ _advance() {
508
+ const next = this._wp + this.dir;
509
+ if (next < 0 || next >= this.road.length) {
510
+ if (!this._day) { this._home = true; return; }
511
+ this.dir = -this.dir;
512
+ this.pauseT = 3 + Math.random() * 5; // he stands at the rail a while before he rides back
513
+ this._wp += this.dir;
514
+ return;
515
+ }
516
+ this._wp = next;
517
+ }
518
+ }