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,361 @@
1
+ // Horse: a rideable entity — Synty horse mesh + Malbers in-place gait clips on a
2
+ // RAW mixer (the clips target the quadruped rig directly, so they bypass the
3
+ // Synty→Synty retargeting Animator). Movement is code-driven through the capsule
4
+ // controller (terrain-follow + building collision); the seated rider is handled
5
+ // by the Riding controller, which reads this horse's saddle + rein bones.
6
+ import * as THREE from 'three/webgpu';
7
+ import { instantiate, loadClips, loadTexture, applyAtlas } from '../core/assets.js';
8
+ import { Character } from './entity.js';
9
+ import { angleLerp, clamp } from '../core/utils.js';
10
+ // the ridable world's edge clamp is game data: setHorseWorld({ worldSize })
11
+ let HW = { worldSize: 4320 };
12
+ export function setHorseWorld(w) { HW = { ...HW, ...w }; }
13
+ import { steerCarrot, mountedTurn } from './pathFollow.js';
14
+
15
+ const _hDir = new THREE.Vector3(); // ambling horses borrow Character.walkDir (whisker steering)
16
+ const _hAim = { x: 0, z: 0 }; // scratch: the carrot point steerCarrot hands the companion horse to steer at
17
+
18
+ const HA = '/assets/horse/anims';
19
+ const HORSE_CLIPS = {
20
+ idle: `${HA}/H_Idle_01.fbx`, walk: `${HA}/H_Walk_IP.fbx`, trot: `${HA}/H_Trot_IP.fbx`,
21
+ canter: `${HA}/H_Canter_IP.fbx`, gallop: `${HA}/H_Gallop_IP.fbx`,
22
+ jump: `${HA}/H_Jump_Forward.fbx`,
23
+ eat: `${HA}/H_Eat.fbx`, drink: `${HA}/H_Drink.fbx`,
24
+ look: `${HA}/H_Idle_03_Look.fbx`, headShake: `${HA}/H_Idle_02_HeadShake.fbx`,
25
+ };
26
+ // Synty PolygonHorse coat variants (one mesh, many textures) — picked per horse.
27
+ // Only 01–06; 07–10 are Indian war-paint horses, not used here.
28
+ const HORSE_COATS = Array.from({ length: 6 }, (_, i) => `/assets/textures/horse_${String(i + 1).padStart(2, '0')}.png`);
29
+ const UP = new THREE.Vector3(0, 1, 0);
30
+ const _off = new THREE.Vector3();
31
+ const _nrm = new THREE.Vector3(), _qYaw = new THREE.Quaternion(), _qTgt = new THREE.Quaternion();
32
+ const _frustum = new THREE.Frustum(), _projScreen = new THREE.Matrix4(), _sphere = new THREE.Sphere();
33
+
34
+ // load + de-root-motion the horse clips once; every horse shares them (per-mixer actions)
35
+ let _clipCache = null;
36
+ async function horseClips() {
37
+ if (!_clipCache) {
38
+ _clipCache = await loadClips(HORSE_CLIPS);
39
+ for (const c of Object.values(_clipCache)) c.tracks = c.tracks.filter((t) => !t.name.endsWith('.position'));
40
+ }
41
+ return _clipCache;
42
+ }
43
+
44
+ // Warm the FBX + clip caches once at boot so the first lazy build is a ~1ms clone, not
45
+ // a multi-hundred-ms FBX parse on first approach.
46
+ export async function preloadHorse() {
47
+ await instantiate('/assets/horse/SyntyHorse.fbx', { texture: '/assets/textures/horse_01.png' });
48
+ await horseClips();
49
+ }
50
+
51
+ export class Horse {
52
+ constructor(game, { x = 0, z = 0, heading = 0, anchor = null, roam = 9, tethered = false, saddle = true, coat = null } = {}) {
53
+ this.game = game;
54
+ this.root = new THREE.Group();
55
+ this.root.position.set(x, game.world.groundAt(x, z), z);
56
+ this.model = null; this.mixer = null; this._building = false; // model built at spawn (resident for life)
57
+ this.tethered = tethered; // tethered horses never wander (hitched at a post)
58
+ this.saddle = saddle; // false = bareback/wild (hide saddle + reins meshes)
59
+ this.coat = coat || HORSE_COATS[Math.floor(Math.random() * HORSE_COATS.length)]; // random coat unless specified
60
+ this.heading = heading;
61
+ this.root.rotation.y = heading;
62
+ this.vy = 0; this.airborne = false;
63
+ this.capsuleR = 0.7; this.capsuleH = 1.6; this.capsuleSteps = 3; this.radius = 0.7;
64
+ this.rider = null; // mounted rider (null = mountable)
65
+ this.speed = 0;
66
+ this.saddleBone = null; this.reinL = null; this.reinR = null; this.neckBone = null;
67
+ this.cur = null; this.curName = null;
68
+ // ambient AI (riderless): graze/idle/wander near a group anchor
69
+ this.anchor = anchor || { x, z };
70
+ this.roam = roam;
71
+ this.aiState = 'idle'; this.aiT = 1 + Math.random() * 2; this.target = null;
72
+ this.owner = null; // set to the player once he mounts it — then it TRAILS him afoot (updateAI → _followOwner)
73
+ }
74
+
75
+ get position() { return this.root.position; }
76
+
77
+ // Build the heavy skinned model (a clone of the cached master) and attach it. Called
78
+ // lazily by the spawner, throttled to one per frame — a herd coming into range fades
79
+ // in over a few frames instead of all-at-once (that one-frame burst was the freeze).
80
+ async _buildModel() {
81
+ // explicit scale BLOCKS the glb-twin redirect (resolveModelUrl): this rig + all its
82
+ // seat/rein tuning are cm-FBX; a metre twin baked later must not silently swap in
83
+ this.model = await instantiate('/assets/horse/SyntyHorse.fbx', { texture: HORSE_COATS[0], scale: 0.01 });
84
+ this.model.scale.setScalar(0.01);
85
+ applyAtlas(this.model, await loadTexture(this.coat)); // give this clone its own coat (master material is shared)
86
+ this.model.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.receiveShadow = true; o.frustumCulled = false; } });
87
+
88
+ // raw mixer (no retarget); clips have baked root motion stripped (code-driven move)
89
+ const clips = await horseClips();
90
+ this.mixer = new THREE.AnimationMixer(this.model);
91
+ this.actions = {};
92
+ for (const [n, clip] of Object.entries(clips)) this.actions[n] = this.mixer.clipAction(clip);
93
+
94
+ this.model.traverse((o) => { // mount point + rein IK targets, built into the rig
95
+ if (o.name === 'Spine1') this.saddleBone = o; // back bone (Horse_Saddle locator sits at Y0)
96
+ else if (o.name === 'Reins_Bn_Hand_L') this.reinL = o;
97
+ else if (o.name === 'Reins_Bn_Hand_R') this.reinR = o;
98
+ else if (o.name === 'Neck') this.neckBone = o; // used to keep the rider's hands clear of the neck
99
+ });
100
+
101
+ this.play('idle');
102
+ this.root.add(this.model);
103
+ }
104
+
105
+ get shown() { return !!(this.model && this.model.parent); }
106
+
107
+ // Build (first time) or re-attach the model. The spawner gates how often this runs so
108
+ // adds never bunch up in a single frame. Cloning is ~1ms, the first render ~8ms.
109
+ ensureShown() {
110
+ if (this._building) return;
111
+ if (this.model) { if (!this.model.parent) this.root.add(this.model); return; }
112
+ this._building = true;
113
+ this._buildModel().then(() => { this._building = false; }).catch((e) => { this._building = false; console.warn('horse build failed', e); });
114
+ }
115
+
116
+ // Detach (don't dispose) the model when far — keeps it cached for a cheap re-attach.
117
+ hide() { if (this.model && this.model.parent) this.root.remove(this.model); }
118
+
119
+ get name() { return this.curName; }
120
+
121
+ play(name, { once = false, fade = 0.22 } = {}) {
122
+ const a = this.actions[name];
123
+ if (!a || (a === this.cur && !once)) return;
124
+ a.reset(); a.enabled = true; a.setEffectiveTimeScale(1); a.setEffectiveWeight(1);
125
+ a.loop = once ? THREE.LoopOnce : THREE.LoopRepeat; a.clampWhenFinished = once;
126
+ a.fadeIn(fade).play();
127
+ if (this.cur && this.cur !== a) this.cur.fadeOut(fade);
128
+ this.cur = a; this.curName = name;
129
+ }
130
+
131
+ // walk sits under the trot now — a horse ambling at 1.8 m/s used to TROT on the spot, which is
132
+ // what made every gentle stick push read as a jog (Nick). The trot/canter/gallop cuts are untouched.
133
+ gaitFor(sp) { return sp < 0.4 ? 'idle' : sp < 2.4 ? 'walk' : sp < 4 ? 'trot' : sp < 9.5 ? 'canter' : 'gallop'; }
134
+
135
+ // Saddle world position = the back bone + a local-frame offset (rotated by heading).
136
+ saddlePos(out, off) {
137
+ if (this.saddleBone) this.saddleBone.getWorldPosition(out); else out.copy(this.position);
138
+ if (off) out.add(_off.copy(off).applyAxisAngle(UP, this.heading));
139
+ return out;
140
+ }
141
+
142
+ // Driven by the Riding controller each frame while mounted.
143
+ driveMove(speed, turn, dt) {
144
+ this.speed = speed;
145
+ this.heading += turn * dt;
146
+ this.game.world.moveCapsule(this, Math.sin(this.heading) * speed * dt, Math.cos(this.heading) * speed * dt, dt);
147
+ const E = HW.worldSize / 2 - 20; // ridden path skips player.js clamp — keep 20m inside
148
+ this.position.x = clamp(this.position.x, -E, E); this.position.z = clamp(this.position.z, -E, E);
149
+ // SOLID TRAFFIC. A driven horse shoulders through nobody — walkers, other horses, the man on
150
+ // foot (Nick rode clean through a road walker and an oncoming rider). The same push-apart the
151
+ // player uses on foot, throttled to ~8Hz per horse, wall-resolved after so a shove can't put
152
+ // half a ton of animal inside a building.
153
+ this._trafT = (this._trafT ?? Math.random() * 0.12) - dt;
154
+ if (this._trafT <= 0) {
155
+ this._trafT = 0.12;
156
+ let moved = false;
157
+ const push = (o, orad = 0.45) => {
158
+ if (!o || o === this || o.alive === false) return;
159
+ const op = o.position ?? o.root?.position;
160
+ if (!op) return;
161
+ const dx = this.position.x - op.x, dz = this.position.z - op.z;
162
+ const d = Math.hypot(dx, dz), min = 1.05 + orad;
163
+ if (d < min && d > 1e-4) { const k = (min - d) / d; this.position.x += dx * k; this.position.z += dz * k; moved = true; }
164
+ };
165
+ for (const n of this.game.npcs ?? []) { if (n !== this.rider && !n.seated) push(n); }
166
+ for (const h of this.game.horses ?? []) push(h, 1.05);
167
+ const pl = this.game.player;
168
+ if (pl && this.rider !== pl) push(pl, 0.5);
169
+ if (moved) {
170
+ const c = this.game.world.collide(this.position.x, this.position.z, 0.6, 0, this.position.y);
171
+ if (c) { this.position.x = c.x; this.position.z = c.z; }
172
+ }
173
+ }
174
+ this._applyGroundTilt(dt); // pitch/roll to the slope (incl. heading yaw)
175
+ if (!this.airborne) this.play(this.gaitFor(Math.abs(speed)));
176
+ this._applyBob(dt);
177
+ }
178
+
179
+ // Gait-synced vertical bob of the whole model. The _IP gait clips have their root
180
+ // motion (incl. the vertical bob) stripped, so the body glides flat and a mounted
181
+ // rider feels glued in place. Bobbing the model makes the body, saddle bone, and the
182
+ // rider seated on it all rise/fall together. Always ≥0 so hooves never sink.
183
+ _applyBob(dt) {
184
+ if (!this.model) return;
185
+ const sp = Math.abs(this.speed);
186
+ let target = 0;
187
+ if (!this.airborne && sp > 0.5) {
188
+ this._bobPhase = (this._bobPhase ?? 0) + dt * (4 + sp * 0.7); // faster stride at higher gaits
189
+ const amp = Math.min(0.07, 0.012 + sp * 0.004);
190
+ target = (Math.sin(this._bobPhase) * 0.5 + 0.5) * amp; // 0..amp
191
+ }
192
+ this.model.position.y += (target - this.model.position.y) * Math.min(1, dt * 12); // ease in/out
193
+ }
194
+
195
+ // THE GROUND UNDER A STANDING HORSE — the terrain, unless his current height says he is ON
196
+ // something (the player rode him onto the station platform and stepped off: the planks are his
197
+ // floor, not the dirt a metre under them). The BVH ray is only paid in that rare case; a horse
198
+ // standing on the dirt, which is every horse nearly always, costs exactly what it used to.
199
+ _settleY() {
200
+ const w = this.game.world;
201
+ const ty = w.groundAt(this.position.x, this.position.z);
202
+ return (this.position.y - ty > 0.25 && w.walkSurfaceAt)
203
+ ? Math.max(ty, w.walkSurfaceAt(this.position.x, this.position.z)) : ty;
204
+ }
205
+
206
+ // Orient the body to the ground: tilt up→terrain-normal (pitch/roll) + heading yaw, eased so it
207
+ // doesn't snap. Sampled from groundAt gradients (cheap, analytic — no BVH).
208
+ _applyGroundTilt(dt) {
209
+ const x = this.position.x, z = this.position.z, e = 1.4, w = this.game.world;
210
+ // slope gradient, split into the body frame: full fore-aft PITCH (climbing reads right)
211
+ // but lateral ROLL damped to 25% — legs compensate side-slopes, a real horse (and its
212
+ // rider) stays near-upright crossing a hillside instead of leaning with the ground
213
+ const gx = (w.groundAt(x + e, z) - w.groundAt(x - e, z)) / (2 * e);
214
+ const gz = (w.groundAt(x, z + e) - w.groundAt(x, z - e)) / (2 * e);
215
+ const fx = Math.sin(this.heading), fz = Math.cos(this.heading);
216
+ const rx = fz, rz = -fx;
217
+ const sF = gx * fx + gz * fz, sR = (gx * rx + gz * rz) * 0.25;
218
+ _nrm.set(-(fx * sF + rx * sR), 1, -(fz * sF + rz * sR)).normalize();
219
+ _qYaw.setFromAxisAngle(UP, this.heading);
220
+ _qTgt.setFromUnitVectors(UP, _nrm).multiply(_qYaw); // yaw first, then tilt onto the slope
221
+ this.root.quaternion.slerp(_qTgt, Math.min(1, dt * 8));
222
+ }
223
+
224
+ jump(impulse = 6) {
225
+ if (this.airborne) return;
226
+ this.vy = impulse;
227
+ this.play('jump', { once: true, fade: 0.1 });
228
+ // Fit the WHOLE leap (rear → back-legs push-off → tuck → land) into the actual airborne time so
229
+ // the clip isn't cut after only the front-leg rear when the gait resumes on landing (Nick: "front
230
+ // legs go up, back doesn't"). Airborne = rise+fall time under gravity (20 m/s²); speed the clip to it.
231
+ const a = this.actions.jump, air = 2 * impulse / 20;
232
+ if (a) a.setEffectiveTimeScale(clamp(a.getClip().duration / air, 1, 3.5));
233
+ }
234
+
235
+ update(dt) {
236
+ if (!this.shown) return; // not built yet, or hidden (far) — nothing to do
237
+ if (this.rider) { if (!this._mixerTicked) this.mixer.update(dt); this._mixerTicked = false; return; } // ridden: Riding ticks the mixer first (see riding.update) so the seat reads the current pose
238
+ this._idleVoice(dt); // a hitched horse is not a silent horse
239
+ this.updateAI(dt); // riderless: graze / idle / look / wander
240
+ if (this.model && !this.model.visible) return; // off-screen (main safety-net hid it): AI keeps moving, skip mixer+tilt
241
+ this.mixer.update(dt);
242
+ this._applyGroundTilt(dt); // riderless: tilt to the slope too
243
+ }
244
+
245
+ // A HITCHED HORSE IS NOT A SILENT HORSE. The Malbers recordings (breathing, snorts,
246
+ // head-shakes, whinnies, the odd neigh) were only ever played from the saddle — so the three
247
+ // tied at the rail stood there mute, which is the loudest thing about them. They speak now,
248
+ // but only when someone is close enough to hear (and rarely enough not to nag).
249
+ _idleVoice(dt) {
250
+ const a = this.game.audio, p = this.game.player;
251
+ if (!a?.sample || !p) return;
252
+ const d = Math.hypot(this.position.x - p.position.x, this.position.z - p.position.z);
253
+ if (d > 22) { this._voiceT = 4 + Math.random() * 6; return; } // out of earshot: don't even count down
254
+ this.game.player?.riding?._loadSfx?.(); // samples load lazily on first mount — pull them in
255
+ this._voiceT = (this._voiceT ?? 4 + Math.random() * 6) - dt;
256
+ if (this._voiceT > 0) return;
257
+ this._voiceT = 7 + Math.random() * 11;
258
+ const gain = (1 - d / 22) * 0.5; // quieter the further off you are
259
+ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
260
+ const r = Math.random();
261
+ if (r < 0.45) a.sample(pick(['breath1', 'breath2']), { gain });
262
+ else if (r < 0.65) a.sample('snort', { gain });
263
+ else if (r < 0.8) a.sample('headshake', { gain });
264
+ else if (r < 0.94) a.sample(pick(['whinny1', 'whinny2', 'whinny3']), { gain: gain * 0.9 });
265
+ else a.sample(pick(['neigh', 'neigh2', 'neigh3']), { gain: gain * 0.85 });
266
+ }
267
+
268
+ // Ambient herd/town behavior: graze, idle, look about, and occasionally amble to a
269
+ // new spot near the anchor. Standing horses settle to terrain with a cheap
270
+ // heightfield lookup — only a WANDERING horse runs the BVH capsule controller.
271
+ updateAI(dt) {
272
+ this.speed = 0;
273
+ if (this.owner) return this._followOwner(dt); // the player's own horse trails him, it doesn't graze with the herd
274
+ this.aiT -= dt;
275
+ if (this.aiState === 'wander' && this.target && this.aiT > 0) {
276
+ const dx = this.target.x - this.position.x, dz = this.target.z - this.position.z;
277
+ const dd = Math.hypot(dx, dz);
278
+ if (dd > 0.6) {
279
+ // whisker steering borrowed from Character (walkDir works on anything with game/position/root)
280
+ // — ambling horses stop nosing into fences/wells/people; probes are throttled + 45m-gated
281
+ const sd = Character.prototype.walkDir.call(this, _hDir.set(dx / dd, 0, dz / dd), dt);
282
+ this.heading = angleLerp(this.heading, Math.atan2(sd.x, sd.z), Math.min(1, dt * 2.5));
283
+ const mx = Math.sin(this.heading) * 1.7 * dt, mz = Math.cos(this.heading) * 1.7 * dt;
284
+ // terrain-follow only — NO BVH. Ambient grazers don't need building collision,
285
+ // and the per-frame BVH sweep was a needless cost.
286
+ const nx = this.position.x + mx, nz = this.position.z + mz;
287
+ this.root.position.set(nx, this.game.world.groundAt(nx, nz), nz);
288
+ this.play('walk');
289
+ return;
290
+ }
291
+ this.aiT = 0; // arrived
292
+ }
293
+ this.root.position.y = this._settleY(); // cheap settle — BVH only if he's visibly on a deck
294
+ if (this.aiT <= 0) this._pickBehavior();
295
+ if (this.aiState !== 'wander') this.play({ graze: 'eat', drink: 'drink', look: 'look', headShake: 'headShake' }[this.aiState] || 'idle');
296
+ }
297
+
298
+ // THE PLAYER'S HORSE. Once he mounts it, it is his — and a good horse doesn't drift off to graze with
299
+ // the herd. It trails him at a distance while he's afoot, closing a big gap at a trot or canter and
300
+ // ambling in when near, settling when he stops. Steers around fences/people with the same whiskers the
301
+ // ambient amble uses. (RDR2's ever-present companion; a whistle to summon it from further off comes next.)
302
+ _followOwner(dt) {
303
+ const p = this.game.player;
304
+ if (!p) return;
305
+ const dx = p.position.x - this.position.x, dz = p.position.z - this.position.z;
306
+ const dd = Math.hypot(dx, dz);
307
+ // WHISTLED (player.whistle sets _summoned): gallop right up to him, close enough to mount, and stop
308
+ // trailing at arm's length instead of the lazy 4.5 m follow gap.
309
+ const summoned = (this._summoned ?? 0) > 0;
310
+ if (summoned) this._summoned -= dt;
311
+ // BOND (missions horse_bond, fed through the horse-kit items): a known horse heels closer and
312
+ // answers the whistle harder. bond_mid at 4, bond_high at 8.
313
+ const bondHigh = this.game.missions?.flag?.('bond_high'), bondMid = bondHigh || this.game.missions?.flag?.('bond_mid');
314
+ const KEEP = summoned ? 2.2 : bondHigh ? 3.0 : bondMid ? 3.6 : 4.5;
315
+ if (dd <= KEEP) {
316
+ this.root.position.y = this._settleY(); this.speed = 0;
317
+ if ((this._eatT ?? 0) > 0) { this._eatT -= dt; this.play('eat'); } // fed from the satchel — the eat clip
318
+ else this.play('idle');
319
+ if (dd <= 2.6) this._summoned = 0;
320
+ return;
321
+ }
322
+ const spd = summoned ? (bondHigh ? 11 : 10) : (dd > 16 ? 7 : dd > 6 ? 3.5 : 1.8); // summoned: gallop · else canter to close · trot · amble in
323
+ // AIM: A* round buildings when a static blocks the straight line to him — steerCarrot (the same the
324
+ // mounted riders use) holds a path on the settlement's baked grid and hands back a look-ahead carrot;
325
+ // a clear line just aims straight. Then whiskers bend it for people/fences, probed from the ground.
326
+ let aimX = p.position.x, aimZ = p.position.z;
327
+ if (steerCarrot(this, this.game, p.position, this.position.x, this.position.z, p.position.x, p.position.z, _hAim,
328
+ { dt, arrive: 1.5, lookahead: clamp(spd * 0.5, 2.5, 4.5), wide: true })) { aimX = _hAim.x; aimZ = _hAim.z; }
329
+ const adx = aimX - this.position.x, adz = aimZ - this.position.z, L = Math.hypot(adx, adz) || 1;
330
+ const sd = Character.prototype.walkDir.call(this, _hDir.set(adx / L, 0, adz / L), dt);
331
+ // pure-pursuit turn with a real minimum radius, then the BVH capsule move so it rounds walls instead
332
+ // of clipping through them. moveCapsule (not driveMove) because horse.update() applies the ground tilt
333
+ // once AFTER updateAI, exactly as the ambient amble relies on — driveMove would tilt a second time.
334
+ const m = mountedTurn(this.heading, this.position.x, this.position.z, this.position.x + sd.x, this.position.z + sd.z, Math.max(0.6, spd), 3.5, 2.4);
335
+ this.heading += m.turn * dt;
336
+ const s = spd * m.speedMul;
337
+ this.game.world.moveCapsule(this, Math.sin(this.heading) * s * dt, Math.cos(this.heading) * s * dt, dt);
338
+ this.speed = s;
339
+ this.play(this.gaitFor(s));
340
+ }
341
+
342
+ _pickBehavior() {
343
+ const r = Math.random();
344
+ if (r < 0.40) { this.aiState = 'graze'; this.aiT = 4 + Math.random() * 6; }
345
+ else if (r < 0.58) { this.aiState = 'idle'; this.aiT = 2 + Math.random() * 3; }
346
+ else if (r < 0.70) { this.aiState = 'drink'; this.aiT = 3 + Math.random() * 3; }
347
+ else if (r < 0.80) { this.aiState = 'look'; this.aiT = 3 + Math.random() * 2; }
348
+ else if (r < 0.88 || this.tethered) { this.aiState = 'headShake'; this.aiT = 2.5; } // tethered: calm, never wander
349
+ else { // amble to a new spot near the anchor
350
+ this.aiState = 'wander'; this.aiT = 6 + Math.random() * 5;
351
+ const a = Math.random() * Math.PI * 2, dd = 2 + Math.random() * this.roam;
352
+ this.target = { x: this.anchor.x + Math.cos(a) * dd, z: this.anchor.z + Math.sin(a) * dd };
353
+ }
354
+ }
355
+ }
356
+
357
+ // walkDir is BORROWED from Character (line ~201) — since the whisker-steering change, walkDir
358
+ // internally calls this._probeSteer(), so the borrower needs that method too. Without it, any
359
+ // horse AMBLING WITHIN 45m OF THE PLAYER threw mid-animation-loop and KILLED the render loop:
360
+ // the intermittent "black screen of death" (HUD alive, viewport dead at whatever frame it hit).
361
+ Horse.prototype._probeSteer = Character.prototype._probeSteer;