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,580 @@
1
+ // Riding: the player's mount/ride/dismount controller. Owns the rider-side rig work
2
+ // proven out in src/tools/horsetest.js — a baked seated pose (world-space retarget
3
+ // of the Malbers rider idle onto our Synty rig), 2-bone rein IK, the climb clips,
4
+ // and the mount state machine + camera. The Horse entity provides the saddle + rein
5
+ // bones; this drives the horse's movement while mounted.
6
+ import * as THREE from 'three/webgpu';
7
+ import { FBXLoader } from '../vendor/FBXLoader.js';
8
+ import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
9
+ import { loadModel, loadClips } from '../core/assets.js';
10
+ import { clamp, angleLerp } from '../core/utils.js';
11
+
12
+ const HA = '/assets/horse/anims';
13
+ const RIDER_CLIPS = {
14
+ rideIdle: `${HA}/Rider_Idle_01.fbx`,
15
+ mountL: `${HA}/Rider_Mount_Dismount_Left.fbx`,
16
+ mountR: `${HA}/Rider_Mount_Dismount_Right.fbx`,
17
+ };
18
+ // Rider Biped → player-rig bone map, from Unity's Humanoid avatar (R_CG is the Hips). The
19
+ // player is the Modular Fantasy Hero rig (Hips/Shoulder/Elbow/UpperLeg naming), NOT the old
20
+ // peasant (Pelvis/UpperArm/Thigh). Foot→Ball, Toe→Toes (modular has no separate ankle bone).
21
+ export const BIPED_TO_SYNTY = {
22
+ R_CG: 'Hips', R_Spine: 'Spine_01', R_Spine1: 'Spine_02', R_Spine2: 'Spine_03', R_Neck: 'Neck', R_Head: 'Head',
23
+ R_L_Clavicle: 'Clavicle_L', R_L_UpperArm: 'Shoulder_L', R_L_Forearm: 'Elbow_L', R_L_Hand: 'Hand_L',
24
+ R_R_Clavicle: 'Clavicle_R', R_R_UpperArm: 'Shoulder_R', R_R_Forearm: 'Elbow_R', R_R_Hand: 'Hand_R',
25
+ R_L_Thigh: 'UpperLeg_L', R_L_Calf: 'LowerLeg_L', R_L_Foot: 'Ball_L', R_L_Toe0: 'Toes_L',
26
+ R_R_Thigh: 'UpperLeg_R', R_R_Calf: 'LowerLeg_R', R_R_Foot: 'Ball_R', R_R_Toe0: 'Toes_R',
27
+ };
28
+ // Rider_Mount_Dismount is one 3.4s clip: climb-on (0–1.1s) · seated hold · climb-off (2.5–3.4s).
29
+ const MOUNT_RANGE = [0, 1.1], DISMOUNT_RANGE = [2.5, 3.4];
30
+ // Rider-root offset from the horse's back bone. Exported because a road traveller sits on the SAME
31
+ // horse in the SAME rig, and two copies of this number is one copy too many (mountedTraveller.js).
32
+ export const SADDLE = new THREE.Vector3(0, -0.48, 0.12);
33
+ const REIN_OFF = new THREE.Vector3(0, 0, 0);
34
+ const REIN_CLEAR = 0.35; // metres the hands are held above the neck bone so they never punch into it
35
+ // Mounted aim camera: slide over the shoulder so the rider isn't dead-centre (you must see past him
36
+ // down the sights), and pull in for ADS. Idle keeps a small offset so the chase cam isn't bolt-behind.
37
+ const MOUNTED_SHOULDER = 0.85; // sideways framing offset while aiming (m)
38
+ const MOUNTED_SHOULDER_IDLE = 0.2; // small offset when not aiming
39
+ const MOUNTED_ADS_DIST = 2.6; // camera pulled in this close while aiming
40
+ const AIM_YAW_ARC = 1.4; // rad — while aiming the CAMERA itself can't twist past ±80° of forward
41
+ const AIM_PITCH_ARC = 0.5; // rad — and up/down is symmetric ±~28° (matches player.js aimDir cone)
42
+ const YAW_SIGN = -2; // body-turn correction during the climb
43
+ const UP = new THREE.Vector3(0, 1, 0);
44
+
45
+ function captureBind(root, only = null) {
46
+ root.updateMatrixWorld(true);
47
+ const m = new Map();
48
+ root.traverse((o) => { if (o.isBone && (!only || only.has(o))) { const q = new THREE.Quaternion(); o.getWorldQuaternion(q); m.set(o.name, q); } });
49
+ return m;
50
+ }
51
+
52
+ // First occurrence per bone name = exactly the bones THREE's AnimationMixer binds to (and the
53
+ // visible meshes follow). The modular FBX repeats every bone across ~70 part skeletons, many at
54
+ // a degenerate identity bind; capturing a later dupe collapses the retarget to identity (T-pose).
55
+ export function realBonesOf(root) {
56
+ const byName = new Map();
57
+ root.traverse((o) => { if (o.isBone && !byName.has(o.name)) byName.set(o.name, o); });
58
+ return new Set(byName.values());
59
+ }
60
+
61
+ // Rider Biped → old PEASANT rig bone map (SK_Chr_* NPCs — Pelvis/UpperArm_L/Thigh_L/calf_l
62
+ // naming, distinct from the modular hero above). Lets mounted road-traveller NPCs reuse the
63
+ // same seated-pose retarget. See makeSeatedPose / mountedTraveller.js.
64
+ export const BIPED_TO_PEASANT = {
65
+ R_CG: 'Pelvis', R_Spine: 'spine_01', R_Spine1: 'spine_02', R_Spine2: 'spine_03', R_Neck: 'neck_01', R_Head: 'head',
66
+ R_L_Clavicle: 'clavicle_l', R_L_UpperArm: 'UpperArm_L', R_L_Forearm: 'lowerarm_l', R_L_Hand: 'Hand_L',
67
+ R_R_Clavicle: 'clavicle_r', R_R_UpperArm: 'UpperArm_R', R_R_Forearm: 'lowerarm_r', R_R_Hand: 'Hand_R',
68
+ R_L_Thigh: 'Thigh_L', R_L_Calf: 'calf_l', R_L_Foot: 'Foot_L', R_L_Toe0: 'toes_l',
69
+ R_R_Thigh: 'Thigh_R', R_R_Calf: 'calf_r', R_R_Foot: 'Foot_R', R_R_Toe0: 'toes_r',
70
+ };
71
+
72
+ // World-space pose retarget of a single clip frame → { syntyBone: localQuaternion }.
73
+ // boneMap picks the target rig (modular hero for the player, peasant for NPC riders).
74
+ export function computeSeatedPose(riderRig, clip, peasantRoot, boneMap = BIPED_TO_SYNTY) {
75
+ const srcBind = captureBind(riderRig);
76
+ const savePos = peasantRoot.position.clone(), saveRot = peasantRoot.rotation.clone();
77
+ peasantRoot.position.set(0, 0, 0); peasantRoot.rotation.set(0, 0, 0); peasantRoot.updateMatrixWorld(true);
78
+ const real = realBonesOf(peasantRoot);
79
+ const tgtBind = captureBind(peasantRoot, real);
80
+ const mx = new THREE.AnimationMixer(riderRig);
81
+ mx.clipAction(clip).play(); mx.update(0); riderRig.updateMatrixWorld(true);
82
+ const srcPosed = new Map();
83
+ riderRig.traverse((o) => { if (o.isBone) { const q = new THREE.Quaternion(); o.getWorldQuaternion(q); srcPosed.set(o.name, q); } });
84
+ const tgtToSrc = {}; for (const s in boneMap) tgtToSrc[boneMap[s]] = s;
85
+ const pose = {};
86
+ const pw = new THREE.Quaternion(), want = new THREE.Quaternion(), loc = new THREE.Quaternion();
87
+ peasantRoot.traverse((o) => {
88
+ if (!o.isBone || !real.has(o)) return;
89
+ const sName = tgtToSrc[o.name];
90
+ if (!sName || !srcPosed.has(sName) || !srcBind.has(sName) || !tgtBind.has(o.name)) return;
91
+ const delta = srcBind.get(sName).clone().invert().multiply(tgtBind.get(o.name));
92
+ want.copy(srcPosed.get(sName)).multiply(delta);
93
+ o.parent.getWorldQuaternion(pw);
94
+ loc.copy(pw.invert()).multiply(want);
95
+ o.quaternion.copy(loc); o.updateWorldMatrix(false, false);
96
+ pose[o.name] = loc.clone();
97
+ });
98
+ peasantRoot.position.copy(savePos); peasantRoot.rotation.copy(saveRot); peasantRoot.updateMatrixWorld(true);
99
+ return pose;
100
+ }
101
+
102
+ // Reusable seated pose for a loaded character rig (NPC riders). Loads the Biped rider rig +
103
+ // idle clip once, then bakes the seated pose against `targetRoot` using `boneMap`. Returns
104
+ // { pose:{bone:localQuat}, pbone:{name:bone} } — apply pose to pbone every frame while seated.
105
+ let _riderRigPromise = null;
106
+ const _poseCache = new Map(); // clip url -> the loaded clip (the rig master is shared)
107
+
108
+ // `clipUrl` picks WHICH sit. Rider_Idle_01 is a man ON A HORSE — legs astride, which is right
109
+ // for the saddle and wrong for a bench. The Malbers set also ships a real chair sit (Actions/Sit,
110
+ // which comes with a Chair.fbx beside it): knees forward, feet down. Both are authored on the
111
+ // same Malbers biped, so the same retarget carries either one onto a Synty body.
112
+ export async function makeSeatedPose(targetRoot, boneMap = BIPED_TO_PEASANT, clipUrl = null) {
113
+ // FRESH FBX load (not the shared loadModel master — the player's Riding setup poses that and
114
+ // never restores it, so captureBind on it would read a posed rig, not bind).
115
+ if (!_riderRigPromise) _riderRigPromise = Promise.all([new FBXLoader().loadAsync('/assets/horse/Rider.fbx'), loadClips({ rideIdle: RIDER_CLIPS.rideIdle })]);
116
+ const [riderMaster, rClips] = await _riderRigPromise;
117
+ // Pick the clip into a LOCAL — never write it back into rClips. That shared object is the
118
+ // cache: overwriting rideIdle with the chair sit meant the very next call that asked for the
119
+ // riding pose silently got the sit instead, so the coachman's "rein arms" were the sit's arms
120
+ // and the numbers moved around like a rumour.
121
+ let clip = rClips.rideIdle;
122
+ if (clipUrl) {
123
+ if (!_poseCache.has(clipUrl)) _poseCache.set(clipUrl, (await loadClips({ pose: clipUrl })).pose);
124
+ clip = _poseCache.get(clipUrl) ?? clip;
125
+ }
126
+ // clone a FRESH bind rig each call — computeSeatedPose poses the rig it's given and never
127
+ // restores it, so reusing one master would corrupt every call after the first (T-poses).
128
+ const riderRig = skeletonClone(riderMaster);
129
+ const pbone = {};
130
+ for (const b of realBonesOf(targetRoot)) pbone[b.name] = b;
131
+ const pose = computeSeatedPose(riderRig, clip, targetRoot, boneMap);
132
+ return { pose, pbone };
133
+ }
134
+
135
+ // THE COACHMAN: a man driving a box, which is two poses at once.
136
+ // His LEGS want the chair sit (knees forward, feet on the footboard) and his ARMS want the
137
+ // RIDING pose — because a driver holds the reins exactly the way a rider does: hands together,
138
+ // forward, at his lap. Neither clip is right on its own: the chair sit leaves his arms hanging
139
+ // like a man on a park bench, and the horse sit puts his legs astride a horse that isn't there.
140
+ // So take the lower body from one and the arms from the other. They are both authored on the
141
+ // same Malbers biped, so they compose without a fight.
142
+ // A NOTE ON THE COACHMAN'S ARMS (unfinished, deliberately not bodged).
143
+ // The driver currently wears the RIDER seated pose, which is right about his hands — together,
144
+ // out in front, exactly where a driver holds the reins — and a little too high, because a
145
+ // rider's hands are at SADDLE height.
146
+ // Two things were tried and BOTH ARE WRONG, so don't repeat them:
147
+ // * The Malbers chair sit (Common/Human Anims/Actions/Sit) through computeSeatedPose comes out
148
+ // FOLDED — measured on the bench, the head ends up 0.06m BELOW the hips. That machinery was
149
+ // built for one specific clip on one specific rig; it does not generalise as-is.
150
+ // * Rotating Shoulder_L/R to drop the arms spins the whole body instead of the arm, so the
151
+ // bone the name resolves to is not the one that idea assumes.
152
+ // The honest fix is a real driving pose (or a proper look at why computeSeatedPose folds a
153
+ // second clip) — not another rotation stacked on top of a pose we already know is wrong.
154
+
155
+ // Per-frame world-space retarget of a whole rider clip → { clip, rootYaw, fps }.
156
+ function retargetClipWorldSpace(clip, riderRig, peasantRoot, fps = 30) {
157
+ const srcBind = captureBind(riderRig);
158
+ const savePos = peasantRoot.position.clone(), saveRot = peasantRoot.rotation.clone();
159
+ peasantRoot.position.set(0, 0, 0); peasantRoot.rotation.set(0, 0, 0); peasantRoot.updateMatrixWorld(true);
160
+ const real = realBonesOf(peasantRoot);
161
+ const tgtBind = captureBind(peasantRoot, real);
162
+ const riderBones = {}; riderRig.traverse((o) => { if (o.isBone) riderBones[o.name] = o; });
163
+ const tgtToSrc = {}; for (const s in BIPED_TO_SYNTY) tgtToSrc[BIPED_TO_SYNTY[s]] = s;
164
+ const targets = [];
165
+ peasantRoot.traverse((o) => { if (o.isBone && real.has(o) && tgtToSrc[o.name] && srcBind.has(tgtToSrc[o.name]) && tgtBind.has(o.name) && riderBones[tgtToSrc[o.name]]) targets.push(o); });
166
+ const delta = new Map();
167
+ for (const o of targets) delta.set(o, srcBind.get(tgtToSrc[o.name]).clone().invert().multiply(tgtBind.get(o.name)));
168
+ const mx = new THREE.AnimationMixer(riderRig); mx.clipAction(clip).play();
169
+ const dur = clip.duration, n = Math.max(2, Math.ceil(dur * fps) + 1);
170
+ const times = new Array(n), vals = {}; for (const o of targets) vals[o.name] = new Array(n * 4);
171
+ const rootBone = riderBones.R_CG || riderBones.R_Pelvis, rootYaw = new Array(n).fill(0);
172
+ const pw = new THREE.Quaternion(), want = new THREE.Quaternion(), loc = new THREE.Quaternion(), q = new THREE.Quaternion(), re = new THREE.Euler();
173
+ for (let i = 0; i < n; i++) {
174
+ const t = (i / (n - 1)) * dur; times[i] = t;
175
+ mx.setTime(t); riderRig.updateMatrixWorld(true);
176
+ if (rootBone) { rootBone.getWorldQuaternion(q); re.setFromQuaternion(q, 'YXZ'); rootYaw[i] = re.y; }
177
+ for (const o of targets) {
178
+ riderBones[tgtToSrc[o.name]].getWorldQuaternion(q);
179
+ want.copy(q).multiply(delta.get(o));
180
+ o.parent.getWorldQuaternion(pw);
181
+ loc.copy(pw.invert()).multiply(want);
182
+ o.quaternion.copy(loc); o.updateWorldMatrix(false, false);
183
+ loc.toArray(vals[o.name], i * 4);
184
+ }
185
+ }
186
+ peasantRoot.position.copy(savePos); peasantRoot.rotation.copy(saveRot); peasantRoot.updateMatrixWorld(true);
187
+ const tracks = targets.map((o) => new THREE.QuaternionKeyframeTrack(`${o.name}.quaternion`, times, vals[o.name]));
188
+ return { clip: new THREE.AnimationClip(clip.name + '_synty', dur, tracks), rootYaw, fps: (n - 1) / dur };
189
+ }
190
+
191
+ // --- analytic 2-bone IK so the hands reach the rein bones (IKReins.cs) ---
192
+ const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _c = new THREE.Vector3(), _t = new THREE.Vector3();
193
+ const _dBA = new THREE.Vector3(), _dBC = new THREE.Vector3(), _axis = new THREE.Vector3();
194
+ const _qd = new THREE.Quaternion(), _qp = new THREE.Quaternion(), _qpi = new THREE.Quaternion();
195
+ const _qBone = new THREE.Quaternion(), _qBone2 = new THREE.Quaternion(), _qBase = new THREE.Quaternion(), _qSeatEul = new THREE.Euler();
196
+ const SEAT_TILT = 0.25; // 0 = bolt upright, 1 = rigidly glued to the saddle bone (≈35° swing — too much)
197
+ const C = (x) => Math.max(-1, Math.min(1, x));
198
+ function rotateBoneWorld(bone, worldAxis, angle) {
199
+ if (!(Math.abs(angle) > 1e-5)) return;
200
+ _qd.setFromAxisAngle(worldAxis, angle);
201
+ bone.parent.getWorldQuaternion(_qp); _qpi.copy(_qp).invert();
202
+ bone.quaternion.premultiply(_qp).premultiply(_qd).premultiply(_qpi);
203
+ bone.updateWorldMatrix(false, true);
204
+ }
205
+ export function twoBoneIK(upper, lower, end, target) {
206
+ if (!upper || !lower || !end) return;
207
+ _t.copy(target);
208
+ upper.getWorldPosition(_a); lower.getWorldPosition(_b); end.getWorldPosition(_c);
209
+ const lab = _a.distanceTo(_b), lcb = _b.distanceTo(_c);
210
+ const lat = Math.max(1e-3, Math.min(_a.distanceTo(_t), lab + lcb - 1e-3));
211
+ _dBA.copy(_a).sub(_b).normalize(); _dBC.copy(_c).sub(_b).normalize();
212
+ const cur = Math.acos(C(_dBA.dot(_dBC)));
213
+ const des = Math.acos(C((lab * lab + lcb * lcb - lat * lat) / (2 * lab * lcb)));
214
+ _axis.copy(_dBC).cross(_dBA);
215
+ if (_axis.lengthSq() > 1e-8) { _axis.normalize(); rotateBoneWorld(lower, _axis, des - cur); }
216
+ end.getWorldPosition(_c);
217
+ _dBA.copy(_c).sub(_a).normalize(); _dBC.copy(_t).sub(_a).normalize();
218
+ _axis.copy(_dBA).cross(_dBC);
219
+ if (_axis.lengthSq() > 1e-8) { _axis.normalize(); rotateBoneWorld(upper, _axis, Math.acos(C(_dBA.dot(_dBC)))); }
220
+ }
221
+
222
+ const _sp = new THREE.Vector3(), _beside = new THREE.Vector3(), _tl = new THREE.Vector3(), _tr = new THREE.Vector3();
223
+ const _head = new THREE.Vector3(), _camTarget = new THREE.Vector3(), _neck = new THREE.Vector3();
224
+
225
+ export class Riding {
226
+ constructor(player) {
227
+ this.game = player.game;
228
+ this.player = player;
229
+ this.mode = 'foot'; // foot | mounting | mounted | dismounting
230
+ this.horse = null;
231
+ this.mountT = 0; this.speed = 0; this.jumpSpeed = 0; this.mountSide = -1;
232
+ this.pbone = {};
233
+ this.seatedPose = {};
234
+ this.mountActionL = null; this.mountActionR = null; this.mountAction = null;
235
+ this.mountYawL = null; this.mountYawR = null; this.mountYaw = null; this.mountFps = 30;
236
+ this._lookHold = 0; // grace period after free-look before the camera re-centers
237
+ this.ready = false;
238
+ this._stepT = 0; this._idleT = 3; this._sfxLoaded = false; // horse SFX cadence timers
239
+ this._reinLoc = null; // rein hand rest positions, captured in the horse's frame (static hands)
240
+ }
241
+
242
+ // Curated real recordings (Malbers Horse AnimSet Pro) — hoofsteps synced to gait,
243
+ // breathing/whinny at rest. Loaded once, lazily, on the first mount.
244
+ _loadSfx() {
245
+ if (this._sfxLoaded) return;
246
+ this._sfxLoaded = true;
247
+ const H = '/assets/audio/horse';
248
+ this.game.audio?.loadSamples({
249
+ step1: `${H}/step1.m4a`, step2: `${H}/step2.m4a`, step3: `${H}/step3.m4a`, step4: `${H}/step4.m4a`, step5: `${H}/step5.m4a`,
250
+ whinny1: `${H}/whinny1.m4a`, whinny2: `${H}/whinny2.m4a`, whinny3: `${H}/whinny3.m4a`,
251
+ neigh: `${H}/neigh.m4a`, neigh2: `${H}/neigh2.m4a`, neigh3: `${H}/neigh3.m4a`,
252
+ breath1: `${H}/breath1.m4a`, breath2: `${H}/breath2.m4a`, snort: `${H}/snort.m4a`, headshake: `${H}/headshake.m4a`,
253
+ whistle: `${H}/whistle.m4a`, whistleStop: `${H}/whistle_stop.m4a`,
254
+ });
255
+ }
256
+
257
+ // Per-frame horse audio while mounted. Hoofsteps fire on a speed-derived cadence
258
+ // (faster + louder at gallop); at rest, occasional breathing / snort / whinny.
259
+ _audio(dt, grounded) {
260
+ const a = this.game.audio; if (!a) return;
261
+ const spd = Math.abs(this.speed);
262
+ if (spd > 0.6 && grounded) {
263
+ this._idleT = 3 + Math.random() * 4;
264
+ this._stepT -= dt;
265
+ if (this._stepT <= 0) {
266
+ this._stepT = clamp(2.6 / Math.max(spd, 1.5), 0.2, 0.55) * (0.9 + Math.random() * 0.2);
267
+ a.sample('step' + (1 + Math.floor(Math.random() * 5)), { gain: clamp(0.22 + spd * 0.03, 0.25, 0.6), rate: 0.92 + Math.random() * 0.16 });
268
+ }
269
+ } else {
270
+ this._stepT = 0;
271
+ this._idleT -= dt;
272
+ if (this._idleT <= 0) {
273
+ this._idleT = 5 + Math.random() * 7;
274
+ // weighted idle vocalisations — mostly soft breathing, rarer whinny/neigh calls
275
+ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
276
+ const r = Math.random();
277
+ if (r < 0.4) a.sample(pick(['breath1', 'breath2']), { gain: 0.5 });
278
+ else if (r < 0.6) a.sample('snort', { gain: 0.5 });
279
+ else if (r < 0.75) a.sample('headshake', { gain: 0.5 });
280
+ else if (r < 0.92) a.sample(pick(['whinny1', 'whinny2', 'whinny3']), { gain: 0.45 });
281
+ else a.sample(pick(['neigh', 'neigh2', 'neigh3']), { gain: 0.4 });
282
+ }
283
+ }
284
+ }
285
+
286
+ // One-time: load the Biped rider rig + clips, bake the seated pose + climb clips
287
+ // onto the player's rig and mixer. Call after the player model is loaded.
288
+ async setup() {
289
+ const player = this.player.model;
290
+ const [riderRig, rClips] = await Promise.all([
291
+ loadModel('/assets/horse/Rider.fbx'),
292
+ loadClips(RIDER_CLIPS),
293
+ ]);
294
+ for (const b of realBonesOf(player)) this.pbone[b.name] = b; // first-occurrence bones (modular dupes)
295
+ this.seatedPose = computeSeatedPose(riderRig, rClips.rideIdle, this.player.root);
296
+ const mixer = this.player.animator.mixer;
297
+ for (const [side, clip] of [['L', rClips.mountL], ['R', rClips.mountR]]) {
298
+ const r = retargetClipWorldSpace(clip, riderRig, this.player.root);
299
+ const a = mixer.clipAction(r.clip);
300
+ a.setLoop(THREE.LoopOnce, 1); a.clampWhenFinished = true;
301
+ this.mountFps = r.fps;
302
+ if (side === 'L') { this.mountActionL = a; this.mountYawL = r.rootYaw; }
303
+ else { this.mountActionR = a; this.mountYawR = r.rootYaw; }
304
+ }
305
+ this.ready = true;
306
+ }
307
+
308
+ get mounted() { return this.mode !== 'foot'; }
309
+
310
+ // +1 = horse's right, -1 = left, from where the player stands relative to the horse.
311
+ climbSide() {
312
+ const h = this.horse;
313
+ return ((this.player.position.x - h.position.x) * Math.cos(h.heading) - (this.player.position.z - h.position.z) * Math.sin(h.heading)) >= 0 ? 1 : -1;
314
+ }
315
+
316
+ besidePos(out) {
317
+ const h = this.horse;
318
+ out.set(h.position.x + this.mountSide * Math.cos(h.heading) * 1.4, 0, h.position.z - this.mountSide * Math.sin(h.heading) * 1.4);
319
+ // walkSurfaceAt, not groundAt: dismounting ON a deck (the station platform) must land on the
320
+ // PLANKS, not the dirt 0.9 m under them (Nick stepped off at Dustwater Station and stood under
321
+ // the platform). A one-shot placement — exactly the seating class that oracle exists for; the
322
+ // per-frame capsule takes over the moment he is on his feet.
323
+ out.y = this.game.world.walkSurfaceAt(out.x, out.z);
324
+ }
325
+
326
+ startClimb(side) {
327
+ this.mountSide = side;
328
+ this.mountAction = side > 0 ? this.mountActionR : this.mountActionL;
329
+ this.mountYaw = side > 0 ? this.mountYawR : this.mountYawL;
330
+ this.player.animator.stopAll();
331
+ this.player.busy = false; // stopAll orphans a pending once-clip onDone — don't strand busy
332
+ this.mountAction.reset().play(); this.mountAction.paused = true;
333
+ }
334
+
335
+ yawAt(t) {
336
+ if (!this.mountYaw) return 0;
337
+ const i = clamp(Math.round(t * this.mountFps), 0, this.mountYaw.length - 1);
338
+ return this.mountYaw[i];
339
+ }
340
+
341
+ climbYaw(up) {
342
+ const ref = this.yawAt(up ? MOUNT_RANGE[1] : DISMOUNT_RANGE[0]);
343
+ return this.horse.heading + YAW_SIGN * (this.yawAt(this.mountAction.time) - ref);
344
+ }
345
+
346
+ applySeatedPose() {
347
+ for (const name in this.seatedPose) { const b = this.pbone[name]; if (b) b.quaternion.copy(this.seatedPose[name]); }
348
+ this.ikHandsToReins();
349
+ }
350
+
351
+ ikHandsToReins() {
352
+ const h = this.horse; if (!h.reinL || !h.reinR) return;
353
+ this.player.root.updateMatrixWorld(true);
354
+ // The hands DON'T MOVE (Nick). The rein bones bob with the head, so IK-ing to them every frame
355
+ // made the hands bounce into the neck. Capture the rein positions ONCE in the horse's own frame
356
+ // (position + heading, not the bobbing bones), lifted clear of the neck, then hold the hands
357
+ // exactly there every frame. They follow the horse's body/turning but never bob with the head.
358
+ if (!this._reinLoc) {
359
+ h.reinL.getWorldPosition(_tl).add(REIN_OFF);
360
+ h.reinR.getWorldPosition(_tr).add(REIN_OFF);
361
+ if (h.neckBone) {
362
+ const floorY = h.neckBone.getWorldPosition(_neck).y + REIN_CLEAR;
363
+ if (_tl.y < floorY) _tl.y = floorY;
364
+ if (_tr.y < floorY) _tr.y = floorY;
365
+ }
366
+ this._reinLoc = { L: this._toHorseLocal(_tl), R: this._toHorseLocal(_tr) };
367
+ }
368
+ this._fromHorseLocal(this._reinLoc.L, _tl);
369
+ this._fromHorseLocal(this._reinLoc.R, _tr);
370
+ twoBoneIK(this.pbone.Shoulder_L, this.pbone.Elbow_L, this.pbone.Hand_L, _tl); // modular arm chain
371
+ twoBoneIK(this.pbone.Shoulder_R, this.pbone.Elbow_R, this.pbone.Hand_R, _tr);
372
+ }
373
+
374
+ // Horse-frame (position + heading only, NOT the bobbing bones) ↔ world, for holding the hands
375
+ // steady relative to the horse's body.
376
+ _toHorseLocal(w) {
377
+ const h = this.horse, ch = Math.cos(h.heading), sh = Math.sin(h.heading);
378
+ const rx = w.x - h.position.x, rz = w.z - h.position.z;
379
+ return { r: rx * ch - rz * sh, f: rx * sh + rz * ch, y: w.y - h.position.y };
380
+ }
381
+
382
+ _fromHorseLocal(loc, out) {
383
+ const h = this.horse, ch = Math.cos(h.heading), sh = Math.sin(h.heading);
384
+ return out.set(h.position.x + loc.r * ch + loc.f * sh, h.position.y + loc.y, h.position.z - loc.r * sh + loc.f * ch);
385
+ }
386
+
387
+ saddlePos(out) { return this.horse.saddlePos(out, SADDLE); }
388
+
389
+ // Player must be near a mountable (riderless) horse.
390
+ mount(horse) {
391
+ if (!this.ready || this.mounted || horse.rider) return false;
392
+ this.horse = horse; horse.rider = this.player;
393
+ // the last horse he throws a leg over is HIS: it trails him afoot and rides the map/compass/minimap.
394
+ // The one he left behind goes feral again (back to grazing with the herd).
395
+ if (this.game.ownHorse && this.game.ownHorse !== horse) this.game.ownHorse.owner = null;
396
+ this.game.ownHorse = horse; horse.owner = this.player;
397
+ this.mode = 'mounting'; this.mountT = 0; this.speed = 0;
398
+ this._loadSfx(); this.game.audio?.sample('snort', { gain: 0.5 }); // silent the very first mount (samples still decoding)
399
+ this.startClimb(this.climbSide());
400
+ this.besidePos(this.player.position);
401
+ this.player.root.rotation.y = horse.heading;
402
+ return true;
403
+ }
404
+
405
+ dismount() {
406
+ if (this.mode !== 'mounted') return;
407
+ this.mode = 'dismounting'; this.mountT = 0; this.speed = 0;
408
+ this.horse.vy = 0;
409
+ this.startClimb(this.mountSide);
410
+ }
411
+
412
+ // Instantly drop to foot WITHOUT the climb-off animation and WITHOUT moving the player
413
+ // (used on Load Game — the loaded position must stick, not be overwritten by the saddle).
414
+ hardReset() {
415
+ const horse = this.horse;
416
+ if (horse) { horse.rider = null; horse.speed = 0; }
417
+ this.mountAction?.stop?.(); // a Load mid-climb left the clamped mount clip blended in (audit m1)
418
+ this._seatQuat = null; this._reinLoc = null;
419
+ this.horse = null; this.mode = 'foot';
420
+ this.player.vy = 0; this.player._capGrounded = false;
421
+ this.player.animator?.play('idle', { fade: 0.1 });
422
+ }
423
+
424
+ // Called from player.update each frame while mounted/transitioning. Drives the
425
+ // horse + seated pose + camera; the player's mixer was already advanced upstream.
426
+ update(dt, input, camera) {
427
+ const player = this.player, horse = this.horse;
428
+ if (!horse) { this.mode = 'foot'; return; } // horse gone (unlinked/removed) — drop to foot, never crash
429
+ const inMenu = this.game.ui?.menuOpen || this.game.ui?.dialogueOpen || this.game.ui?.worldMap?.visible || this.game.ui?.cinematic;
430
+
431
+ if (this.mode === 'mounting' || this.mode === 'dismounting') {
432
+ const up = this.mode === 'mounting';
433
+ const [t0, t1] = up ? MOUNT_RANGE : DISMOUNT_RANGE;
434
+ this.mountT += dt;
435
+ const e = Math.min(1, this.mountT / (t1 - t0));
436
+ this.mountAction.time = t0 + e * (t1 - t0); this.mountAction.paused = true;
437
+ this.saddlePos(_sp); this.besidePos(_beside);
438
+ player.root.position.lerpVectors(_beside, _sp, up ? e : 1 - e);
439
+ player.root.rotation.set(0, this.climbYaw(up), 0); // full reset — clears any saddle tilt from mounted mode
440
+ this.ikHandsToReins();
441
+ if (e >= 1) {
442
+ this.mountAction.stop();
443
+ if (up) this.mode = 'mounted';
444
+ else this._endDismount();
445
+ }
446
+ } else { // mounted
447
+ let fwd = 0, turn = 0, sprint = false, jump = false;
448
+ if (!inMenu) {
449
+ const st = input.moveStick;
450
+ if (st && (st.x || st.y)) {
451
+ // ANALOG REIN. Left/right is the turn, and it curves like the aim stick: a squared
452
+ // response, so a gentle push is a gentle lean and only the far edge is a hard turn
453
+ // (Nick: "wider the stick the heavier the turn", and it was snapping to a full turn on
454
+ // any nudge). Forward from −y.
455
+ fwd = -st.y;
456
+ turn = -st.x * Math.abs(st.x); // sign(x)·x² — gentle near centre, heavy at the edge
457
+ } else {
458
+ if (input.down('KeyW')) fwd += 1;
459
+ if (input.down('KeyS')) fwd -= 1;
460
+ if (input.down('KeyA')) turn += 1;
461
+ if (input.down('KeyD')) turn -= 1;
462
+ }
463
+ sprint = input.down('ShiftLeft');
464
+ jump = input.hit('Space');
465
+ }
466
+ const grounded = !horse.airborne;
467
+ // A real leap, not a hop: ~2 m of height (impulse 9 vs gravity 20) and forward drive so it
468
+ // carries over ground, not straight up (Nick: "it's not like a leaping jump"). From a canter
469
+ // the leap keeps the canter's speed; from a standstill it still springs forward at 5 m/s.
470
+ if (jump && grounded) { horse.jump(9); this.jumpSpeed = Math.max(Math.abs(this.speed), 5); }
471
+ if (horse.airborne) {
472
+ this.speed = this.jumpSpeed; // leap carries momentum
473
+ } else {
474
+ // Reverse needs a near-FULL pull-back (fwd < −0.5), not a nudge — a slight back on the
475
+ // stick just stops (Nick: "slightly back and it reverses; should need full on backwards").
476
+ // THE STICK'S THROW IS THE GAIT (Nick: "the less we move it the slower — slower = walk not
477
+ // jog"): a gentle push walks, half throw trots, the far edge canters, sprint gallops. BANDS,
478
+ // not a linear scale — a man holds a steady trot with a steady thumb, and each band's speed
479
+ // sits mid-clip so the legs match the ground. Keyboard W is a full push and rides as before.
480
+ const mag = Math.min(1, Math.abs(fwd));
481
+ const cruise = mag < 0.35 ? 1.8 : mag < 0.75 ? 3.4 : 9; // walk · trot · canter (must beat on-foot sprint 8.06)
482
+ const target = fwd > 0.05 ? (sprint ? 13 : cruise) : fwd < -0.5 ? -1.6 : 0;
483
+ const decel = Math.abs(target) < Math.abs(this.speed);
484
+ this.speed += (target - this.speed) * Math.min(1, dt * (decel ? 10 : 3));
485
+ if (target === 0 && Math.abs(this.speed) < 0.4) this.speed = 0;
486
+ }
487
+ horse.driveMove(this.speed, turn * 1.4, dt); // was 1.8 — a horse leans into a turn, it doesn't spin
488
+ // Advance the horse's gait NOW so the seat reads the CURRENT skeleton pose, not
489
+ // last frame's — otherwise the saddle bone's fore/aft surge + tilt reach the rider
490
+ // a frame late and read as "the saddle moves but we don't". horse.update skips its
491
+ // own tick this frame (see _mixerTicked there).
492
+ horse.mixer.update(dt); horse._mixerTicked = true;
493
+ this._audio(dt, grounded);
494
+ this.saddlePos(player.root.position);
495
+ // Glue the rider to the saddle bone's FULL orientation (pitch/roll + heading yaw),
496
+ // so we tilt and surge with the saddle instead of sitting bolt upright. The fixed
497
+ // local seat offset is captured once on the first mounted frame.
498
+ const bone = horse.saddleBone;
499
+ if (bone) {
500
+ bone.getWorldQuaternion(_qBone);
501
+ if (!this._seatQuat) {
502
+ this._seatQuat = new THREE.Quaternion();
503
+ _qBone2.setFromEuler(_qSeatEul.set(0, horse.heading, 0));
504
+ this._seatQuat.copy(_qBone).invert().multiply(_qBone2);
505
+ }
506
+ _qBone2.copy(_qBone).multiply(this._seatQuat); // full saddle-bone orientation
507
+ _qBase.setFromEuler(_qSeatEul.set(0, horse.heading, 0)); // upright, facing heading
508
+ player.root.quaternion.copy(_qBase).slerp(_qBone2, SEAT_TILT); // follow a FRACTION → subtle lean, not thrashing
509
+ } else {
510
+ player.root.rotation.y = horse.heading;
511
+ }
512
+ player.heading = horse.heading; // track the mount's facing so the minimap arrow + compass rotate while riding
513
+ this.applySeatedPose();
514
+ }
515
+
516
+ if (this.horse) this._mountCamera(dt, input, camera, inMenu); // _endDismount() above can null the horse this frame
517
+ }
518
+
519
+ _endDismount() {
520
+ const horse = this.horse;
521
+ this.besidePos(this.player.root.position);
522
+ this.player.heading = horse.heading; this.player.root.rotation.set(0, horse.heading, 0); // clear saddle tilt
523
+ this._seatQuat = null; this._reinLoc = null; // recapture seat + rein hands on the next mount
524
+ this.game.audio?.sample('snort', { gain: 0.45 });
525
+ horse.rider = null; horse.speed = 0;
526
+ this.horse = null; this.mode = 'foot';
527
+ this.player.vy = 0; this.player._capGrounded = false;
528
+ this.player.animator.play('idle', { fade: 0.1 });
529
+ }
530
+
531
+ // RDR2-style chase cam: trails behind the horse's heading and auto-recenters when
532
+ // you're not free-looking; free-look (mouse/stick) overrides and holds briefly
533
+ // before easing back. Pulls back a touch at speed. Reuses the player's
534
+ // camYaw/Pitch/Dist so the on-foot↔mounted transition is seamless.
535
+ _mountCamera(dt, input, camera, inMenu) {
536
+ const p = this.player, horse = this.horse;
537
+ const aiming = p.aiming || p._gunRecoilT > 0;
538
+ let looking = false;
539
+ if (!inMenu) {
540
+ const sens = p.camSensitivity, inv = p.invertY ? -1 : 1;
541
+ p.camYaw -= input.mouse.dx * 0.0028 * sens + input.stickLook.x * p.padCamSpeed * sens * dt;
542
+ // Pitch: normally mostly-down (chase cam); while aiming it's the symmetric aim cone so you can
543
+ // aim UP as well as down (Nick: "aims down but not up").
544
+ const pMin = aiming ? -AIM_PITCH_ARC : -0.2, pMax = aiming ? AIM_PITCH_ARC : 1.1;
545
+ p.camPitch = clamp(p.camPitch + input.mouse.dy * 0.0022 * sens * inv + input.stickLook.y * p.padCamSpeed * sens * inv * dt, pMin, pMax);
546
+ // While aiming the CAMERA itself is held inside the ±40° arc — you can't twist further than the
547
+ // gun can point (Nick: "lets us twist the aiming camera more than 40° from front").
548
+ if (aiming) {
549
+ const fwd = horse.heading + Math.PI;
550
+ p.camYaw = fwd + clamp(Math.atan2(Math.sin(p.camYaw - fwd), Math.cos(p.camYaw - fwd)), -AIM_YAW_ARC, AIM_YAW_ARC);
551
+ }
552
+ // no wheel zoom — camDist stays at the fixed on-foot follow distance (Nick: "get rid of all these zooms")
553
+ looking = Math.abs(input.mouse.dx) > 0.01 || Math.abs(input.mouse.dy) > 0.01 || Math.abs(input.stickLook.x) > 0.05 || Math.abs(input.stickLook.y) > 0.05;
554
+ }
555
+ // auto-follow: ease the camera behind the horse's heading once free-look settles — but NOT while
556
+ // aiming, when the camera is the aim and must stay where you point it.
557
+ if (looking || aiming) this._lookHold = 1.1;
558
+ else if (this._lookHold > 0) this._lookHold -= dt;
559
+ if (this._lookHold <= 0) p.camYaw = angleLerp(p.camYaw, horse.heading + Math.PI, Math.min(1, dt * 3));
560
+
561
+ // Over-the-shoulder: slide the framing to the side so the rider isn't dead-centre (you have to see
562
+ // past him down the sights), harder when aiming; and pull the camera in for ADS. Both eased.
563
+ const shTarget = aiming ? MOUNTED_SHOULDER : MOUNTED_SHOULDER_IDLE;
564
+ this._shNow = this._shNow == null ? shTarget : this._shNow + (shTarget - this._shNow) * Math.min(1, dt * 8);
565
+ const wide = p.camDist + Math.min(Math.abs(horse.speed) * 0.12, 1.6); // widen at gallop
566
+ const distTarget = aiming ? MOUNTED_ADS_DIST : wide;
567
+ this._camDistNow = this._camDistNow == null ? distTarget : this._camDistNow + (distTarget - this._camDistNow) * Math.min(1, dt * 6);
568
+ const dist = this._camDistNow;
569
+ this.saddlePos(_head); _head.y += 1.1; // rider torso/head height above the saddle
570
+ _head.x += Math.cos(p.camYaw) * this._shNow; // shoulder offset — to the right of the view line
571
+ _head.z -= Math.sin(p.camYaw) * this._shNow;
572
+ const cx = _head.x + Math.sin(p.camYaw) * Math.cos(p.camPitch) * dist;
573
+ const cz = _head.z + Math.cos(p.camYaw) * Math.cos(p.camPitch) * dist;
574
+ let cy = _head.y + Math.sin(p.camPitch) * dist;
575
+ const groundCam = this.game.world.groundAt(cx, cz) + 0.4;
576
+ if (cy < groundCam) cy = groundCam;
577
+ camera.position.lerp(_camTarget.set(cx, cy, cz), Math.min(1, dt * 10));
578
+ camera.lookAt(_head);
579
+ }
580
+ }