sindicate 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1619 @@
1
+ // The Gunslinger: third-person controller, camera, locomotion FSM, and the
2
+ // revolver/rifle gun state machine.
3
+ //
4
+ // CONTROLS — the RDR2 model. There is no "bullet mode" to enter:
5
+ // LMB fire ONE shot. Holstered? the click draws first and is BUFFERED across the
6
+ // draw gate, so the shot lands the instant the leather clears. Never dropped.
7
+ // RMB OPTIONAL ADS (hold): tighter over-shoulder cam, tighter spread, reticle.
8
+ // You never have to hold it to shoot — hip-fire just scatters more.
9
+ // R reload · Q weapon cycle · Alt dodge · E mount.
10
+ // Ctrl is NOT bound here — it belongs to Dead Eye.
11
+ //
12
+ // WHERE THE GUN POINTS is a procedural IK (applyAimIK), not heading math. The pack's aim
13
+ // clips are BLADED (the body is angled off the aim line), so no amount of rotating the root
14
+ // can make the muzzle track the reticle — the old code tried (heading = camYaw + PI plus an
15
+ // _aimTwist spine twist) and that is exactly what made the player "face slightly right" and
16
+ // the torso "spin around" on a wrap. Now the root just faces the camera on the shortest arc
17
+ // and the right-arm chain is rotated in world space onto the camera ray.
18
+ import * as THREE from 'three/webgpu';
19
+ import { aimPoint, nearestBody, rayCapsule, shotDir } from './aim.js';
20
+ import { Character } from './entity.js';
21
+
22
+ import { instantiate, resolveModelUrl } from '../core/assets.js';
23
+ import { clamp, lerp, angleLerp, tmpV1 } from '../core/utils.js';
24
+
25
+
26
+ // Player content is GAME data — register at boot, before the Player is constructed:
27
+ // setPlayerContent({ model, playerClips, clipSubset, weapons, weaponMesh, weaponTex,
28
+ // attachments, clipTiming, worldSize })
29
+ // WEAPONS is a registered live export: satchel/shop/benches read it as they always did.
30
+ export const WEAPONS = {};
31
+ let PC = {
32
+ clipSubset: (c) => c, playerClips: [], weaponMesh: {}, weaponTex: null,
33
+ model: { model: null, keep: null, texture: null, texFlipY: true, playerLook: false, outfit: null,
34
+ speed: 5.2, health: 100, faction: 'player', name: 'Player' },
35
+ attachments: {}, clipTiming: {}, worldSize: 4320,
36
+ };
37
+ export function setPlayerContent(c = {}) {
38
+ if (c.weapons) Object.assign(WEAPONS, c.weapons);
39
+ PC = { ...PC, ...c };
40
+ }
41
+ import { Riding } from './riding.js';
42
+ import { Climbing } from './climbing.js';
43
+ import { Footsteps } from './footsteps.js';
44
+ import { collectFistJoints, applyFist } from './fistCurl.js';
45
+
46
+ const _camTarget = new THREE.Vector3(); // reused each frame for the camera lerp
47
+ const _camDir = new THREE.Vector3(), _camRay = new THREE.Ray(); // camera-wall raycast scratch
48
+ const _rbQ1 = new THREE.Quaternion(), _rbQ2 = new THREE.Quaternion(); // torso re-blade scratch
49
+ // aim-IK scratch (no per-frame allocation — this runs inside the render loop)
50
+ const _ikA = new THREE.Vector3(), _ikB = new THREE.Vector3(), _ikDir = new THREE.Vector3();
51
+ const _ikQ1 = new THREE.Quaternion(), _ikQ2 = new THREE.Quaternion(), _ikQ3 = new THREE.Quaternion();
52
+
53
+
54
+ // WeaponData (contract): rate = seconds between shots, recoil scales the camera
55
+ // kick, range caps the bullet flight. Reserve ammo is infinite in v1.
56
+ // A BULLET DOES NOT STOP BECAUSE IT IS TIRED.
57
+ // `range` is only the distance at which the round is finally swept up (combat.js: ttl = range /
58
+ // speed) — it exists so a stray shot into the sky doesn't live forever, NOT to define how far you
59
+ // can kill a man. It used to be 60m on the revolver and 120m on the rifle, which meant a round
60
+ // aimed at somebody further away simply evaporated in mid-air: the reticle was on him, the gun went
61
+ // off, and nothing happened, with no impact and no miss to explain it. Real guns carry to the
62
+ // horizon and so do these. What punishes a long shot is the SPREAD (and your hand), which is where
63
+ // the difficulty belongs — the world is ~2km across, so these numbers are "as far as there is".
64
+
65
+ // recoil kick: peak camPitch deflection = RECOIL_KICK × weapon.recoil (rad), snapped
66
+ // up in ~60ms then eased back over the rest of RECOIL_TIME — code, not clip.
67
+ const RECOIL_KICK = 0.055;
68
+ const BASE_FADE = 0.18; // Animator.play's default fade — overlay fade-outs MUST match it
69
+ const RECOIL_TIME = 0.3;
70
+ const FIRE_BUF = 0.35; // LMB buffer (same idea as _atkBuf) — FROZEN while a draw is in flight
71
+ const DRAW_GATE = 0.5; // seconds of gunDraw before the gun can fire (leather-to-level)
72
+ const _aimFrom = new THREE.Vector3(), _aimDir = new THREE.Vector3(), _aimTo = new THREE.Vector3();
73
+ const _aimFrom2 = new THREE.Vector3();
74
+ const MENACE_R = 25; // a child this close sees the gun you are pointing at him. Beyond it he doesn't.
75
+ const _fireFrom = new THREE.Vector3(), _fireAim = new THREE.Vector3(), _fireDir = new THREE.Vector3();
76
+ const _fireRight = new THREE.Vector3(), _fireUp = new THREE.Vector3();
77
+ // The body, as the AIM sees it — the same capsule the bullets use (combat.js BODY_LO/HI/R), a hair
78
+ // wider so the crosshair is forgiving where the round is not. If these two ever disagree, the
79
+ // reticle lights green on a shot that misses, which is worse than no reticle at all.
80
+ const AIM_LO = 0.25, AIM_HI = 1.80, AIM_R = 0.50;
81
+ const AUTO_RELOAD_WAIT = 0.35; // the beat between the empty click and his hand going to his belt
82
+ const HOLSTER_IDLE = 6; // unaimed/unfired seconds before the gun goes back to leather
83
+ const ADS_CAM_DIST = 1.2; // aim (RMB / L1) pulls the camera in from camDist (4.0) — a tight over-the-shoulder for a lined-up shot (Nick: zoom closer)
84
+ // FALL DAMAGE (GTA V / RDR2 model): measured on NET DESCENT from the ground you took off from — a flat
85
+ // jump nets ~0 and never hurts; only a real drop off a ledge/ladder past FALL_SAFE_M does, scaled linearly.
86
+ const FALL_SAFE_M = 2; // a drop up to 2 m is free
87
+ const FALL_DMG_PER_M = 12; // HP per metre past the safe drop → a ~10 m fall is about lethal (100 HP)
88
+ // Over-the-shoulder framing. SHOULDER_IDLE is how much of it you get just WALKING AROUND (this
89
+ // is an over-the-shoulder game, not a centred one that lurches sideways whenever you shoot);
90
+ // aiming takes it to the full SHOULDER_OFF. The gap between them is deliberately small.
91
+ const SHOULDER_OFF = 0.58; // metres to the camera's right, at full ADS
92
+ const SHOULDER_IDLE = 0.8; // fraction of it you ride with by default (≈0.46m)
93
+ // SPREAD — radians, half-angle. It is ZERO, and that is a decision, not an oversight.
94
+ //
95
+ // It used to be 0.045 at the hip, doubled again on the move: 5.7 degrees, which is plus or minus
96
+ // two and a half METRES at 25m. You put the sights on a man, pulled the trigger, and the round went
97
+ // somewhere else — and because a miss looks exactly like a bug, every miss felt like one. The gun
98
+ // was not inaccurate, it was random, and random is not difficulty: it is the game overruling you
99
+ // after you have already done the hard part.
100
+ //
101
+ // Verified on /shoottest.html with the dice removed: crosshair on the head → 100% hits and 100%
102
+ // HEADSHOTS at every range from 10m to 500m, standing or walking. So the round now goes exactly
103
+ // where you point it, and the difficulty lives where it belongs — in whether YOU can put the
104
+ // crosshair on a moving man while he shoots back at you.
105
+ const SPREAD = { ads: 0, hip: 0, moveMul: 1 };
106
+ // Aim IK. AIM_IK_SPINE = the share of the correction Spine_02 takes (the rest goes to the
107
+ // shoulder) — a lean, so the arm isn't dislocated from the torso.
108
+ // AIM_IK_MAX caps ONE frame's correction so a wild transient can't wrench the arm. It is
109
+ // MEASURED, not guessed: the aim clip's shoulder→hand line sits 69°–94° off the camera ray
110
+ // (that IS the "points slightly to the right / at the floor" bug), so a cap below ~1.65 rad
111
+ // SATURATES and leaves a permanent residual — 1.2 rad left 7–9° of error at negative pitch.
112
+ // 1.9 rad clears the real ask with margin and still refuses to swing the arm halfway round
113
+ // the body in a single frame.
114
+ const AIM_IK_SPINE = 0; // spine share = 0: rotating the SPINE toward the aim bent the player forward. The arm alone reaches the ray.
115
+ const AIM_IK_MAX = 1.9;
116
+ const MOUNTED_ARC = 1.4; // rad — aim zone is ±80° off the horse's facing (Nick). Can't twist further in the saddle.
117
+ const MOUNTED_PITCH = 0.5; // rad — and ±~28° up/down: the aim is a cone off forward, not a free look.
118
+ const MOUNTED_HOLSTER = 0.4; // s after the aim is released before the gun goes back and the hand returns to the rein
119
+
120
+ // FIST CURL lives in fistCurl.js (shared with the brawler enemies) — the tuned numbers, the joint
121
+ // collector, and applyFist(). player.js only adds the weapon-mode gating around it (applyFistCurl below).
122
+
123
+ export class Player extends Character {
124
+ constructor(game, clips) {
125
+ super(game, {
126
+ ...PC.model, // the game's registered player body/dress/stats (see setPlayerContent)
127
+ clips: PC.clipSubset(clips, PC.playerClips),
128
+ });
129
+ this.stamina = 100;
130
+ // CHARACTER LEVEL — a stub, pinned at 1, until the XP/level system is built. It exists now so
131
+ // that anything which should GROW with the character (the satchel's carry cap today; health,
132
+ // Dead Eye, aim tomorrow) reads one field and follows it the day levelling lands, instead of
133
+ // being wired to a constant that then has to be hunted down. Raise this and the bag gets bigger.
134
+ this.level = 1;
135
+ this.capsuleSteps = 5; // smoother capsule stepping for the player (stairs)
136
+
137
+ // ---- gun state (HUD contract: weapon/aiming/ammo/ammoMax/reloading/deadEye/drawn)
138
+ this.weapon = 'revolver'; // 'revolver' | 'rifle' | 'fists'
139
+ this.aiming = false; // RMB held — ADS. NOT required to fire.
140
+ this.drawn = false; // gun in hand vs in leather (HUD contract)
141
+ this.reloading = false;
142
+ this.deadEye = 1; // 0..1 meter — OWNED by game/deadeye.js (Ctrl). HUD contract.
143
+ this.deadEyeActive = false; // slow-mo + tagging running — set by deadeye.js. HUD contract.
144
+ this._mags = { revolver: WEAPONS.revolver.mag, rifle: WEAPONS.rifle.mag }; // per-weapon mag persists across Q cycling
145
+ this.ammo = this._mags.revolver;
146
+ this.ammoMax = WEAPONS.revolver.mag;
147
+ this._drawT = 0; // gunDraw one-shot input gate
148
+ this._holsterT = 0; // unaimed seconds until the gun goes back to leather
149
+ this._holstering = false; // holster clip in flight — the gun is still in hand
150
+ this._gunFireT = 0; // per-weapon rate limit
151
+ this._gunRecoilT = 0; // brief post-shot window that keeps the aim pose + shoulder cam up
152
+ this._muzzleFlip = 0; // procedural barrel rise on the shot (radians), eased out
153
+ this._recoilT = 0; this._recoilAmp = 0; this._recoilOff = 0; // camera kick envelope
154
+ this._fireBuf = 0; // buffered LMB — survives the draw so one click is always one shot
155
+ this._ikW = 0; // aim-IK blend weight (0 holstered · 0.6 gun in hand · 1 ADS)
156
+
157
+ // melee (fists) — combat.playerMelee drives these
158
+ this.lockTarget = null;
159
+ this.comboStep = 0;
160
+ this.comboWindow = 0;
161
+ this.attackCooldown = 0;
162
+ this.dodgeT = 0;
163
+ this.aboard = null; // the coach he is riding, if any (coach.js board/alight)
164
+ this.airborne = false;
165
+ this.vy = 0;
166
+
167
+ // ON TOP OF SOMETHING THAT MOVES — the coach roof (game/platform.js). Not the same thing as
168
+ // `aboard`: aboard is a seat and it takes his legs away, this is a man STANDING on a moving
169
+ // floor, with his capsule, his locomotion, his gun and his jump all still his own. Only the
170
+ // player is rideable; nobody else queries the registry, so the NPCs and the horses pay nothing.
171
+ this.rideable = true;
172
+ this.platform = null; // the Ride record while he is up there
173
+ this._airVX = 0; // inherited horizontal momentum: the ONLY persistent one he has.
174
+ this._airVZ = 0; // hx/hz are recomputed from the keys every frame, so without it a
175
+ // man who jumps off a galloping coach stops dead in mid-air.
176
+ this._mantleT = 0; // ledge-catch lock-out
177
+ this._skipCols = null; // his platform's own collider entries, ignored while he rides it
178
+ this._skipT = 0;
179
+
180
+ // camera rig — start looking north up the main street (player spawns south of centre)
181
+ this.camYaw = 0;
182
+ this.camPitch = 0.35;
183
+ this.camDist = 3.0; // fixed close-follow (Nick's call)
184
+ this.heading = Math.PI; // back to the camera
185
+
186
+ // camera tuning (persisted) + gamepad look rate
187
+ this.camSensitivity = clamp(parseFloat(localStorage.getItem('av_camSens')) || 1, 0.25, 3);
188
+ this.invertY = localStorage.getItem('av_invertY') === '1';
189
+ this.padCamSpeed = 2.6; // right-stick turn rate (rad/s) at full deflection
190
+
191
+ // input buffers (seconds) so presses a few frames early still land
192
+ this._atkBuf = 0;
193
+ this._heavyBuf = 0;
194
+ this._jumpBuf = 0;
195
+ this._combatT = 0;
196
+
197
+ this.riding = new Riding(this); // mount/ride/dismount (set up after load)
198
+ this.climbing = new Climbing(this); // ladder climb controller (game/climbing.js)
199
+ this.footsteps = new Footsteps(this.game); // surface-aware foot SFX
200
+ }
201
+
202
+ async load() {
203
+ await super.load();
204
+ // body-split clips: leg clips drive only the lower body while the gun pose rides the
205
+ // torso/arms as a masked overlay — strafe-aiming. Hips goes with the LEGS so the whole
206
+ // lower body is one consistent walk (pelvis faces travel); the aim clips pose the spine
207
+ // for THEIR bladed pelvis, so applyUpperReblade() re-aims the torso on top.
208
+ const LOWER = /^(Hips|UpperLeg|LowerLeg|Thigh|calf|shin|Foot|Ball|Toes|Pelvis|ik_foot)/i;
209
+ this.animator.registerMasked('walkLower', 'walk', (b) => LOWER.test(b));
210
+ this.animator.registerMasked('idleLower', 'idle', (b) => LOWER.test(b));
211
+ // directional strafe/backpedal LEGS for aim-locked movement: while aiming the travel
212
+ // direction is body-relative, so play the clip that matches (back/left/right).
213
+ this.animator.registerMasked('walkBackLower', 'walkBack', (b) => LOWER.test(b));
214
+ this.animator.registerMasked('strafeLLower', 'strafeL', (b) => LOWER.test(b));
215
+ this.animator.registerMasked('strafeRLower', 'strafeR', (b) => LOWER.test(b));
216
+ // upper-body gun poses (masked complements of the leg splits):
217
+ // gunAimUpper/rifleAimUpper — ADS hold, pinned at the clip's aim frame each frame
218
+ // gunFireUpper — the shot one-shot (a single fan-beat of gunFire)
219
+ // gunDrawUpper/gunReloadUpper — draw/reload one-shots over walking legs
220
+ this.animator.registerMasked('gunAimUpper', 'gunAim', (b) => !LOWER.test(b));
221
+ this.animator.registerMasked('gunFireUpper', 'gunFire', (b) => !LOWER.test(b));
222
+ this.animator.registerMasked('rifleAimUpper', 'rifleAim', (b) => !LOWER.test(b));
223
+ this.animator.registerMasked('gunDrawUpper', 'gunDraw', (b) => !LOWER.test(b));
224
+ this.animator.registerMasked('gunReloadUpper', 'gunReload', (b) => !LOWER.test(b));
225
+ this.animator.registerMasked('gunHolsterUpper', 'gunHolster', (b) => !LOWER.test(b));
226
+ // reference pelvis pose of the aim clip (frame 0), for applyUpperReblade(): its spine
227
+ // tracks are authored against THIS pelvis, not the walk pelvis.
228
+ const hipsRef = (name) => {
229
+ const t = this.animator.actions[name]?.getClip().tracks.find((tr) => tr.name === 'Hips.quaternion');
230
+ return t ? new THREE.Quaternion().fromArray(t.values, 0) : null;
231
+ };
232
+ this._gunHipsRef = hipsRef('gunAim');
233
+ this._rifleHipsRef = hipsRef('rifleAim'); // rifle spine tracks are authored against ITS pelvis
234
+ this._rebladeW = 0;
235
+ // find attachment bones. Holster contract: revolver on Hips (right hip), rifle on Spine_03.
236
+ this.handBone = null;
237
+ this.handBoneL = null;
238
+ this.spineBone = null;
239
+ this.hipsBone = null;
240
+ this.model.traverse((o) => {
241
+ if (!o.isBone) return;
242
+ if (o.name === 'Hand_R') this.handBone = o;
243
+ if (o.name === 'Hand_L') this.handBoneL = o;
244
+ if (/^spine_03$/i.test(o.name)) this.spineBone = o;
245
+ if (/^hips$/i.test(o.name)) this.hipsBone = o;
246
+ });
247
+ // FIST joints for the bare-knuckle curl (applyFistCurl), captured HERE at the imported rest pose —
248
+ // before the mixer ever poses the fingers, so the curl is ADDED to an open hand as tuned in the bench.
249
+ this._fistBones = collectFistJoints(this.model);
250
+ await this.equipWeaponModel();
251
+ return this;
252
+ }
253
+
254
+ async equipWeaponModel() {
255
+ if (!this.handBone) return;
256
+ // Coalesce concurrent calls (same guard as the old sword path): only one build runs;
257
+ // a call arriving mid-build re-runs once after.
258
+ if (this._equipWeaponBusy) { this._equipWeaponQueued = true; return; }
259
+ this._equipWeaponBusy = true;
260
+ try {
261
+ for (const w of ['revolver']) { // rifle parked for now
262
+ const key = `${w}Obj`;
263
+ if (this[key]) continue;
264
+ const url = PC.weaponMesh[w];
265
+ this[key] = await instantiate(url, { texture: PC.weaponTex });
266
+ // hand bones live in the FBX's centimetre space: a raw cm FBX sits right at scale 1,
267
+ // but a GLB twin is METRES → ×100 back to cm or the gun renders 1cm long
268
+ this[key].scale.setScalar(/\.glb$/i.test(await resolveModelUrl(url, { texture: PC.weaponTex })) ? 100 : 1);
269
+ }
270
+ this.placeWeapons();
271
+ } catch (e) { console.warn('weapon model failed', e); }
272
+ this._equipWeaponBusy = false;
273
+ if (this._equipWeaponQueued) { this._equipWeaponQueued = false; return this.equipWeaponModel(); }
274
+ }
275
+
276
+ // Drawn gun → hand; everything else → holsters (revolver right hip, rifle across the back).
277
+ placeWeapons() {
278
+ const put = (obj, bone, slot) => {
279
+ if (!obj || !bone) return;
280
+ this.gripNodeFor(bone).add(obj); // grip node normalises bone world-scale for cm props
281
+ const a = PC.attachments[slot]; if (!a) return;
282
+ obj.position.set(a.pos[0], a.pos[1], a.pos[2]);
283
+ obj.rotation.set(a.rot[0], a.rot[1], a.rot[2]);
284
+ };
285
+ const hip = this.hipsBone ?? this.spineBone ?? this.handBone;
286
+ const back = this.spineBone ?? this.handBone;
287
+ const held = this.drawn ? this.weapon : null;
288
+ put(this.revolverObj, held === 'revolver' ? this.handBone : hip, held === 'revolver' ? 'revolver' : 'revolverHip');
289
+ put(this.rifleObj, held === 'rifle' ? this.handBone : back, held === 'rifle' ? 'rifle' : 'rifleBack');
290
+ this.weaponObj = held === 'revolver' ? this.revolverObj : held === 'rifle' ? this.rifleObj : null;
291
+ }
292
+
293
+ // Kept for the fists flow + combat compat: "in combat" for a few seconds after a swing/hit.
294
+ enterCombatStance() { this._combatT = 4; }
295
+
296
+ // GREET (G) — the hat-tip. If a townsperson is close enough to see it, it counts: five distinct
297
+ // courtesies earn a point of the town's regard (missions stat 'reputation' — the same scale the
298
+ // shop counters and THE STANDING read). Each soul counts once; the town remembers who's civil,
299
+ // not who's repetitive.
300
+ greet() {
301
+ if (!this.alive || this.busy || this.riding?.mounted) return;
302
+ this.busy = true;
303
+ this.animator.play('hatTip', { once: true, fade: 0.12, onDone: () => { this.busy = false; } });
304
+ const n = this.game._talkCand, m = this.game.missions;
305
+ if (!n || n._greeted || Math.hypot(n.position.x - this.position.x, n.position.z - this.position.z) > 5) return;
306
+ n._greeted = true;
307
+ m?.addStat?.('greets', 1);
308
+ const g = m?.stat?.('greets') ?? 0;
309
+ if (g % 5 === 0) {
310
+ m?.addStat?.('reputation', 1);
311
+ this.game.ui?.toast?.(`Word gets around that the deputy has manners. The town's regard rises.`, 5000);
312
+ } else {
313
+ this.game.ui?.toast?.(`${n.name} returns the nod.`, 2600);
314
+ }
315
+ }
316
+
317
+ // WHISTLE for the horse (H). No two-finger-whistle clip ships, so this is the "over here" wave
318
+ // (idleWave, one-shot — you pause a beat to call) plus the whistle recording. The companion horse
319
+ // reads ownHorse._summoned and breaks into a gallop to reach you (horse.js _followOwner).
320
+ whistle() {
321
+ if (!this.alive || this.busy || this.riding?.mounted) return;
322
+ this.busy = true;
323
+ this.animator.play('idleWave', { once: true, fade: 0.12, onDone: () => { this.busy = false; } });
324
+ this.riding?._loadSfx?.(); // whistle sample loads lazily on first mount — pull it in
325
+ this.game.audio?.sample?.('whistle', { gain: 0.6 });
326
+ const h = this.game.ownHorse;
327
+ if (h && !h.rider && h.alive !== false) {
328
+ h._summoned = 3; // seconds of urgent gallop toward you
329
+ // LEAN-TO STABLE (upg_stable): the whistle reaches county-wide — a horse too far to hear you
330
+ // is brought around to the edge of earshot (Jesse's whole job) and gallops the rest itself.
331
+ const d = Math.hypot(h.position.x - this.position.x, h.position.z - this.position.z);
332
+ if (d > 150 && this.game.missions?.flag?.('upg_stable')) {
333
+ const a = Math.atan2(h.position.x - this.position.x, h.position.z - this.position.z);
334
+ const nx = this.position.x + Math.sin(a) * 80, nz = this.position.z + Math.cos(a) * 80;
335
+ h.root.position.set(nx, this.game.world.groundAt(nx, nz), nz);
336
+ this.game.ui?.toast?.(`Jesse had him saddled before the whistle died.`, 4000);
337
+ }
338
+ }
339
+ }
340
+
341
+ // Live grip tuning for the held gun — find the look in-game, then bake the
342
+ // numbers into data/attachments.js.
343
+ setGunRot(x, y, z) { this.weaponObj?.rotation.set(x, y, z); console.log('[gun] rot', x, y, z); }
344
+ setGunPos(x, y, z) { this.weaponObj?.position.set(x, y, z); console.log('[gun] pos', x, y, z); }
345
+
346
+ // Torso re-blade for strafe-aim. Hips lives in the LOWER mask, so while moving the
347
+ // pelvis faces travel — but the aim upper-body clip poses the spine for ITS (bladed)
348
+ // pelvis. Post-mixer, premultiply Spine_01 by (liveHips⁻¹ · clipHips): the torso lands
349
+ // where the full-body clip would put it. Planted the delta collapses to identity, so
350
+ // moving↔planted fades stay smooth. Runs after super.update (mixer).
351
+ applyUpperReblade(dt) {
352
+ // resolve the MIXER-BOUND Hips/Spine_01 lazily — only nodes in the mixer's property
353
+ // bindings actually animate (harmless with one skeleton, vital with duplicates)
354
+ if (!this._rebladeBones) {
355
+ let hips = null, spine = null;
356
+ for (const pm of this.animator.mixer._bindings ?? []) {
357
+ const n = pm.binding?.node;
358
+ if (!n) continue;
359
+ if (!hips && n.name === 'Hips') hips = n;
360
+ if (!spine && n.name === 'Spine_01') spine = n;
361
+ if (hips && spine) break;
362
+ }
363
+ if (!hips || !spine) return; // bindings appear once actions have played
364
+ this._rebladeBones = { hips, spine };
365
+ }
366
+ const { hips, spine } = this._rebladeBones;
367
+ // the aim CLIP is on the upper body whenever ADS is held OR the post-shot hold is up
368
+ const onAimClip = this.aiming || this._gunRecoilT > 0;
369
+ const ref = onAimClip ? (this.weapon === 'rifle' ? (this._rifleHipsRef ?? this._gunHipsRef) : this._gunHipsRef) : null;
370
+ if (ref) this._rebladeRef = ref; // remembered so the fade-out has a pose to ease from
371
+ this._rebladeW = lerp(this._rebladeW, ref ? 1 : 0, Math.min(1, dt * 10));
372
+ if (this._rebladeW < 0.001 || !this._rebladeRef) return;
373
+ _rbQ1.copy(hips.quaternion).invert().multiply(this._rebladeRef);
374
+ _rbQ2.identity().slerp(_rbQ1, this._rebladeW);
375
+ spine.quaternion.premultiply(_rbQ2);
376
+ // NOTE: the old _aimTwist term lived here — a world-yaw twist injected into the spine to
377
+ // drag the torso onto the aim line. It is GONE. Its wrap is what "spun the top half of the
378
+ // player around", and it could never point the muzzle anywhere precise anyway. applyAimIK
379
+ // now owns the aim direction; this function is back to its one honest job (re-seating the
380
+ // aim clip's spine on top of the walk pelvis).
381
+ }
382
+
383
+ // The CAMERA RAY, i.e. forward away from the lens. VERIFIED against the camera placement
384
+ // at the bottom of update(): the camera sits at head + (sinY·cosP, sinP, cosY·cosP)·dist and
385
+ // looks AT head, so forward = −(sinY·cosP, sinP, cosY·cosP). Positive camPitch = camera above
386
+ // = looking DOWN, hence the −sin(pitch) on Y. _fire() shoots along this same vector.
387
+ aimDir(out = _ikDir) {
388
+ let yaw = this.camYaw, pitch = this.camPitch;
389
+ // Mounted: the aim is a CONE off the horse's facing — ±40° across and ±~28° up/down (Nick). You
390
+ // can't twist round in the saddle, so the gun (and the shot, which reads this same vector) stops
391
+ // at the edge of the cone instead of pointing back through the rider or straight up/down.
392
+ if (this.riding.mounted && this.riding.horse) {
393
+ const fwd = this.riding.horse.heading + Math.PI; // camYaw that points along the horse
394
+ const d = Math.atan2(Math.sin(yaw - fwd), Math.cos(yaw - fwd));
395
+ yaw = fwd + clamp(d, -MOUNTED_ARC, MOUNTED_ARC);
396
+ pitch = clamp(pitch, -MOUNTED_PITCH, MOUNTED_PITCH);
397
+ }
398
+ const cp = Math.cos(pitch);
399
+ return out.set(-Math.sin(yaw) * cp, -Math.sin(pitch), -Math.cos(yaw) * cp);
400
+ }
401
+
402
+ // ---- PROCEDURAL AIM IK ------------------------------------------------------------------
403
+ // Rotate the right-arm chain so the SHOULDER→HAND line (and with it the gun, which is
404
+ // gripped on Hand_R) lands on the camera ray — yaw AND pitch. This is the fix for both
405
+ // "points slightly to the right" (the clip's bladed stance) and "aims at the floor"
406
+ // (the clip has no pitch at all). Runs AFTER the mixer and AFTER applyUpperReblade, and
407
+ // is overwritten by the mixer next frame — so it never accumulates.
408
+ //
409
+ // Every correction is applied the same way as the reblade: a WORLD-space corrective C is
410
+ // conjugated into the bone's parent space (parentWorld⁻¹ · C · parentWorld) and premultiplied
411
+ // onto the local quaternion, which rotates the bone's world orientation about its own origin.
412
+ // The hand therefore swings around the shoulder and the arm line rotates by exactly C.
413
+ applyAimIK(dt) {
414
+ // bones MUST come from the mixer's driven bindings — this model has duplicate bone names
415
+ // across skeletons and a scene traverse grabs an undriven orphan (see footsteps.js)
416
+ if (!this._ikBones) {
417
+ let sh = null, sp = null, hd = null;
418
+ for (const pm of this.animator.mixer._bindings ?? []) {
419
+ const n = pm.binding?.node;
420
+ if (!n) continue;
421
+ if (!sh && n.name === 'Shoulder_R') sh = n;
422
+ if (!sp && n.name === 'Spine_02') sp = n;
423
+ if (!hd && n.name === 'Hand_R') hd = n;
424
+ if (sh && sp && hd) break;
425
+ }
426
+ if (!sh || !hd) return; // bindings only appear once actions have played
427
+ this._ikBones = { sh, sp, hd };
428
+ }
429
+ const { sh, sp, hd } = this._ikBones;
430
+ // weight: ADS = 1 (the muzzle IS the reticle), gun merely in hand = 0.6 (hip-fire still
431
+ // tracks, just loosely), holstered/reloading/holstering/stunned/mounted = 0. Never snapped.
432
+ // DEAD EYE also pins it to 1: you're painting shots down the reticle, so the muzzle had
433
+ // better BE the reticle — and `dt` here is wall-clock (see update), so the arm keeps
434
+ // tracking at full speed while the world crawls.
435
+ // ONLY while genuinely aiming (ADS or Dead Eye). At hip-ready the gun-carry clips hang the
436
+ // arm at the side BY DESIGN — that is the pose. Running the IK there dragged the hand up to
437
+ // the camera ray and pushed a share of that correction into the spine, folding the player
438
+ // in half. The bench never showed it because the bench plays the clips raw, with no IK.
439
+ const want = (!WEAPONS[this.weapon] || !this.drawn || !this.alive || this.busy
440
+ || this.reloading || this._holstering || this.riding.mounted) ? 0
441
+ : (this.aiming || this.deadEyeActive) ? 1 : 0;
442
+ this._ikW = lerp(this._ikW, want, Math.min(1, dt * 10));
443
+ if (this._ikW < 0.001) return;
444
+
445
+ this.model.updateWorldMatrix(true, true); // bones moved this frame (mixer + reblade)
446
+ const dir = this.aimDir(_ikDir);
447
+ // pass 1: a little of the correction at the spine, for a lean instead of a dislocated arm
448
+ if (sp && this._solveArm(sh, hd, dir, AIM_IK_SPINE * this._ikW, sp)) {
449
+ sp.updateWorldMatrix(true, true); // refresh the arm's world transforms under the new spine
450
+ }
451
+ // pass 2: the shoulder takes the REMAINING error exactly — re-solved, so the two passes
452
+ // compose to land on the ray rather than over/under-shooting it
453
+ this._solveArm(sh, hd, dir, this._ikW, sh);
454
+ }
455
+
456
+ // One IK pass: measure the shoulder→hand line, build the world corrective that takes it to
457
+ // `dir`, clamp it, and apply `frac` of it at `bone`.
458
+ _solveArm(sh, hd, dir, frac, bone) {
459
+ if (frac < 0.001) return false;
460
+ sh.getWorldPosition(_ikA);
461
+ hd.getWorldPosition(_ikB);
462
+ _ikB.sub(_ikA);
463
+ const len = _ikB.length();
464
+ if (len < 1e-5) return false; // degenerate (bind pose not built yet)
465
+ _ikB.divideScalar(len);
466
+ // ANTI-PARALLEL GUARD: at dot ≈ −1 setFromUnitVectors has no defined axis — it picks an
467
+ // arbitrary perpendicular and swings 180°, which flips the arm inside out. Sit this frame
468
+ // out instead; the body heading is already easing round, so it resolves in a few frames.
469
+ if (_ikB.dot(dir) < -0.999) return false;
470
+ _ikQ1.setFromUnitVectors(_ikB, dir); // world-space corrective: arm line → camera ray
471
+ // CLAMP one frame's correction (see AIM_IK_MAX — the cap is above the real ask, so the
472
+ // steady-state solve lands exactly on the ray instead of saturating short of it).
473
+ const ang = 2 * Math.acos(Math.min(1, Math.abs(_ikQ1.w)));
474
+ if (ang > AIM_IK_MAX) _ikQ1.slerp(_ikQ2.identity(), 1 - AIM_IK_MAX / ang);
475
+ _ikQ3.identity().slerp(_ikQ1, Math.min(1, frac));
476
+ bone.parent.getWorldQuaternion(_ikQ2);
477
+ // local' = (parentWorld⁻¹ · C · parentWorld) · local ⇒ world' = C · world
478
+ _ikQ1.copy(_ikQ2).invert().multiply(_ikQ3).multiply(_ikQ2);
479
+ bone.quaternion.premultiply(_ikQ1);
480
+ return true;
481
+ }
482
+
483
+ // ---- FIST CURL --------------------------------------------------------------------------
484
+ // Close both hands into fists for the bare-knuckle clips. The pose itself (knuckle flex + thumb fold)
485
+ // and the joint set live in fistCurl.js (applyFist / collectFistJoints, shared with the brawlers).
486
+ // This method is just the WEAPON-MODE gating around it. Runs AFTER the mixer, so it wins over the clip.
487
+ //
488
+ // OWNERSHIP is the subtle part. This doubled-skeleton rig binds each finger clip-track to just ONE
489
+ // instance per name (~11), but _fistBones spans BOTH hands' joints (22) — so the mixer never re-poses
490
+ // the extra joints we curl. If we merely stopped curling, they'd stay stuck in a fist. So:
491
+ // • fists weapon mode → WE own the fingers: full fist on a punch* clip, open (bind) on the shared
492
+ // locomotion clips (unarmed run/jump), every frame — nothing drifts.
493
+ // • any other mode → the gun/rifle clips own the fingers (the grip). We keep hands off, but the
494
+ // first frame after leaving fists we reset every joint to bind ONCE, so the
495
+ // residual curl on the un-driven joints is cleared and both hands hand back open.
496
+ applyFistCurl() {
497
+ const bones = this._fistBones;
498
+ if (!bones || !bones.length) return;
499
+ if (this.weapon !== 'fists') {
500
+ if (this._fistActive) { applyFist(bones, false); this._fistActive = false; } // hand the fingers back open, once
501
+ return;
502
+ }
503
+ this._fistActive = true;
504
+ applyFist(bones, (this.animator?.currentName || '').startsWith('punch')); // fist on the punch clips, open otherwise
505
+ }
506
+
507
+ // ---- MOUNTED AIM ------------------------------------------------------------------------
508
+ // On foot the gunAim CLIP puts the hand in a firing grip (gun aligned with the arm) and then
509
+ // applyAimIK points that arm line at the reticle. Mounted there is no aim clip — the seated pose
510
+ // leaves the hand on the reins — so pointing the arm line alone rolls the gun up. Fix: copy the
511
+ // gunAim clip's OWN right-arm pose onto the arm first (so the gun is gripped for firing), THEN
512
+ // point the shoulder at the reticle. The left hand keeps the reins.
513
+ _captureGunArmPose() {
514
+ const clip = this.animator.actions['gunAim']?.getClip();
515
+ if (!clip) return;
516
+ const q = (name) => {
517
+ const t = clip.tracks.find((t) => t.name === name + '.quaternion');
518
+ if (!t) return null;
519
+ const o = t.values.length - 4; // last keyframe = the settled aim-hold pose
520
+ return new THREE.Quaternion(t.values[o], t.values[o + 1], t.values[o + 2], t.values[o + 3]);
521
+ };
522
+ const sh = q('Shoulder_R'), el = q('Elbow_R'), hd = q('Hand_R');
523
+ if (sh && el && hd) this._gunArmPose = { sh, el, hd };
524
+ }
525
+
526
+ applyMountedAim(dt) {
527
+ if (!this._gunArmPose) this._captureGunArmPose();
528
+ const gp = this._gunArmPose, pb = this.riding.pbone;
529
+ const sh = pb?.Shoulder_R, el = pb?.Elbow_R, hd = pb?.Hand_R;
530
+ if (!gp || !sh || !el || !hd) return;
531
+ // firing grip on the right arm, overriding the rein pose the seat just applied
532
+ sh.quaternion.copy(gp.sh); el.quaternion.copy(gp.el); hd.quaternion.copy(gp.hd);
533
+ sh.updateWorldMatrix(true, true);
534
+ // point the shoulder→hand line (and the gun on it) at the clamped aim ray
535
+ this._solveArm(sh, hd, this.aimDir(_ikDir), 1, sh);
536
+ }
537
+
538
+ // Ease the body heading to `target` along the SHORTEST arc. camYaw is unbounded (it just
539
+ // accumulates mouse deltas — it reads 16.2 rad after a few spins), so `heading = camYaw + PI`
540
+ // handed the root an unbounded angle and any lerp against it could take the long way round:
541
+ // that is the 2π wrap the user saw as the torso "spinning around". angleLerp wraps the DELTA
542
+ // into [−π, π] first, so no step can ever be a wrap.
543
+ _faceAngle(target, dt, rate = 9) {
544
+ this.heading = angleLerp(this.heading, target, Math.min(1, dt * rate));
545
+ this.root.rotation.y = this.heading;
546
+ }
547
+
548
+ // The held gun's grip follows the CURRENT pose: a per-clip override
549
+ // (ATTACHMENTS.<weapon>.clips, tuned in /attach.html) when one exists, else the
550
+ // base grip. Gun poses ride masked overlays, so derive the logical clip name
551
+ // instead of animator.currentName (which is a leg clip while aiming on the move).
552
+ updateWeaponPose() {
553
+ if (!this.weaponObj) return;
554
+ const slot = PC.attachments[this.weapon];
555
+ if (!slot) return;
556
+ const key = this.reloading ? 'gunReload'
557
+ : (this.aiming || this._gunRecoilT > 0) ? 'gunAim'
558
+ : this.animator?.currentName;
559
+ const ov = slot.clips?.[key];
560
+ const r = ov?.rot ?? slot.rot;
561
+ const p = ov?.pos ?? slot.pos;
562
+ this.weaponObj.rotation.set(r[0], r[1], r[2]);
563
+ this.weaponObj.position.set(p[0], p[1], p[2]);
564
+ }
565
+
566
+ // Live jump tuning: frac (0..1) skips into the clip past the ground wind-up.
567
+ setJumpStart(frac, scale) { this._jumpStart = frac; if (scale != null) this._jumpScale = scale; console.log('[jump] startFrac', frac, 'timeScale', this._jumpScale ?? 1.4); }
568
+
569
+ // Freeze on one clip pose so the gun grip can be tuned against the exact frame
570
+ // that looks wrong:
571
+ // game.player.holdAnim('gunAim', 0.0) // orbit camera to inspect
572
+ // game.player.setGunRot(...) / setGunPos(...) // dial it in
573
+ // game.player.unfreeze()
574
+ holdAnim(name, t = 0.5) {
575
+ const a = this.animator.play(name, { once: true, fade: 0 });
576
+ if (!a) { console.warn('[hold] no clip', name); return; }
577
+ this.busy = true;
578
+ a.paused = true;
579
+ a.time = a.getClip().duration * clamp(t, 0, 1);
580
+ this.animator.mixer.update(0);
581
+ this._heldAction = a;
582
+ console.log(`[hold] ${name} @ ${t} — tune the grip, then game.player.unfreeze()`);
583
+ }
584
+ unfreeze() { if (this._heldAction) { this._heldAction.paused = false; this._heldAction = null; } this.busy = false; }
585
+
586
+ get moveSpeed() { return this.speed; }
587
+ // bare-knuckle damage (fists mode + combat.playerMelee compat)
588
+ get fistDamage() { return 12; }
589
+ get meleeDamage() { return this.fistDamage; }
590
+
591
+ update(dt, input, camera) {
592
+ // A MODAL PANEL (pause menu, world map, satchel, shop) freezes the player SOLID — no mixer, no
593
+ // gravity, no movement — so he doesn't fall through his spawn or run in place while a screen is
594
+ // up. main.js already freezes the WORLD when a panel is open; the player was the one thing that
595
+ // kept ticking (mixer at super.update + gravity in moveCapsule below), which read as "still
596
+ // moving on pause". Dialogue is excluded — its two-shot needs him breathing and turning to the
597
+ // lens, and main.js ticks that path by hand.
598
+ const _ui = this.game.ui;
599
+ if (_ui && (_ui.menuOpen || _ui.mapOpen || _ui.satchelOpen || _ui.shopOpen || _ui.deathOpen)) return;
600
+ super.update(dt); // advances the mixer (mount climb clip, or nothing while seated)
601
+ if (!this.alive) return;
602
+ // DEAD EYE TIME SPLIT. `dt` is WORLD time — main.js has already multiplied it by the
603
+ // Dead Eye / hit-stop chain, so the mixer, the movement and the physics above and below
604
+ // all crawl in slow motion, which is the point. `rdt` is WALL-CLOCK time, and the two
605
+ // things that must NOT crawl read it instead: the camera (look + follow) and the aim IK.
606
+ // Aiming stays crisp inside a slow world — that IS Dead Eye. (Mouse look is already
607
+ // dt-free: it integrates per-event pixel deltas.)
608
+ const rdt = this.game.rawDt ?? dt;
609
+ // timers — up here (not the on-foot block) so they also tick while riding
610
+ this._combatT = Math.max(0, this._combatT - dt);
611
+ this._stamPause = Math.max(0, (this._stamPause ?? 0) - dt);
612
+ this._gunFireT = Math.max(0, this._gunFireT - dt);
613
+ this._drawT = Math.max(0, this._drawT - dt);
614
+ this._gunRecoilT = Math.max(0, this._gunRecoilT - dt);
615
+ this._mantleT = Math.max(0, this._mantleT - dt);
616
+ // the collider-skip grace outlives the platform by a few frames: he is still at roof height on
617
+ // the way off it, and a train car's box is banded all the way to its own roof
618
+ if (this._skipT > 0 && !this.platform) {
619
+ this._skipT -= dt;
620
+ if (this._skipT <= 0) this._skipCols = null;
621
+ }
622
+ if (this._stamPause <= 0) this.stamina = Math.min(100, this.stamina + dt * 22);
623
+ // slow out-of-combat mend
624
+ if (this._combatT <= 0 && this.health < this.maxHealth) this.health = Math.min(this.maxHealth, this.health + dt * 1.5);
625
+ // no outfit → no known feetDrop: ground the feet once the idle pose has settled
626
+ // (the model origin sits at the hips; measuring at load catches a half-faded pose)
627
+ if (!this._grounded && this.model) {
628
+ this._groundT = (this._groundT ?? 0) + dt;
629
+ if (this._groundT > 0.5) { this._grounded = true; this._groundFeet(); }
630
+ }
631
+ // a live aim/reload dies the moment anything steals the gun out from under it
632
+ // (menu/dialogue, mount, death handled in takeDamage) — otherwise the stale state
633
+ // fires a phantom pose/refill whenever gunInputs next runs
634
+ if (this.aiming || this.reloading || this._drawT > 0) {
635
+ const stolen = !WEAPONS[this.weapon] || this.riding.mode === 'mounting' || this.riding.mode === 'dismounting'
636
+ || this.game.ui?.menuOpen || this.game.ui?.dialogueOpen || this.game.ui?.cinematic;
637
+ if (stolen) this.cancelGun();
638
+ }
639
+ // an interrupt (dodge/hit sets busy, jump sets airborne) cancels the reload cleanly —
640
+ // the old ammo stands, no refill from the fading one-shot
641
+ // A RELOAD SURVIVES A HIT. `busy` goes up for the flinch as well as for a dodge, so the old
642
+ // guard (busy || airborne) threw away your reload the instant anyone clipped you — in the one
643
+ // situation where an empty gun actually kills you. Now only a DODGE or leaving the ground
644
+ // interrupts it: a man rolling out of the way is not thumbing rounds into a cylinder, but a
645
+ // man who just took a graze absolutely is. The flinch plays over the top.
646
+ if (this.reloading && (this.dodgeT > 0 || this.airborne)) this.cancelReload();
647
+ // ease the model's ground lift: bone-measured drop on foot, 0 while mounted (hips sit
648
+ // ON the saddle). Easing kills the pop — and the camera jump — on dismount.
649
+ const targetBase = this.riding.mounted ? 0 : (this._baseModelY ?? 0);
650
+ this._baseModelY2 = this._baseModelY2 == null ? targetBase : this._baseModelY2 + (targetBase - this._baseModelY2) * Math.min(1, dt * 6);
651
+ if (this.riding.mounted) {
652
+ // A MAN IN THE SADDLE IS NOT AIRBORNE — the horse is. The aboard-the-stage branch below has
653
+ // exactly this reset and this branch never did, so a jump that ended in a mount left
654
+ // `airborne` stale-true for the whole ride, and airborne gates BOTH starting a reload and
655
+ // keeping one alive (Nick: R1 on horseback did nothing).
656
+ this.airborne = false;
657
+ this.model.position.y = this._baseModelY2;
658
+ const inMenu = this.game.ui?.menuOpen || this.game.ui?.dialogueOpen || this.game.ui?.cinematic;
659
+ // Draw/aim/fire from the saddle (only while fully mounted — not mid climb). The right hand
660
+ // takes the gun; the left keeps the reins. Reins want the hand back, so holster promptly once
661
+ // the aim is released rather than after the on-foot 6 s idle (that block never runs mounted).
662
+ if (this.riding.mode === 'mounted' && WEAPONS[this.weapon] && !inMenu) {
663
+ this.gunInputs(input, dt);
664
+ if (this.drawn && !this.aiming && this._drawT <= 0 && this._gunFireT <= 0) {
665
+ this._mHolsterT = (this._mHolsterT ?? MOUNTED_HOLSTER) - dt;
666
+ if (this._mHolsterT <= 0) { this.holsterGun(); this._mHolsterT = null; }
667
+ } else this._mHolsterT = MOUNTED_HOLSTER;
668
+ } else if (this.drawn) {
669
+ this.holsterGun();
670
+ }
671
+ this.riding.update(dt, input, camera); // seated pose + rein IK (both hands)
672
+ if (this.drawn && this.riding.mode === 'mounted') { // override the right arm onto the gun
673
+ this.applyMountedAim(dt);
674
+ this.updateWeaponPose();
675
+ }
676
+ return;
677
+ }
678
+
679
+ // ABOARD THE STAGE. He is not walking, he is being CARRIED — so his position is written from
680
+ // the seat rather than integrated from his own legs. It is still written, every frame, and not
681
+ // faked by reparenting him onto the coach: collision, the camera, the aim and the law's line of
682
+ // sight all read player.position, and a player hidden inside a moving group is a player none of
683
+ // them can find. (The coach drives itself off this same tick — W/S/A/D go to it, not to him.)
684
+ if (this.aboard) {
685
+ const c = this.aboard;
686
+ c.seatWorld(this.position, c.rider?.kind ?? 'inside');
687
+ // A QUARTER TURN CLOCKWISE on the box. He faces across the coach otherwise; this is the turn
688
+ // that puts him along it, looking down the road with the reins in front of him.
689
+ this.heading = c.root.rotation.y - Math.PI / 2;
690
+ this.root.rotation.y = this.heading;
691
+ this.vy = 0;
692
+ this.airborne = false;
693
+ this.busy = false; // he can still be shot at, and still draw
694
+ // THE VISUAL-HEIGHT SMOOTHER DOES NOT RUN ON THIS PATH, so its state must not be left to rot.
695
+ // It carries `_visualY` — a lagged copy of your feet height — and the model is drawn at
696
+ // (_visualY - position.y) above the root to ease out micro-bumps. Skip the update while your
697
+ // position is being teleported onto a coach seat and that difference is no longer a bump, it
698
+ // is a stale number: measured at 24 METRES, which fired the player straight up into the sky
699
+ // and left an empty box seat rolling down the street. Keep it pinned.
700
+ this._visualY = this.position.y;
701
+ this.model.position.y = this._baseModelY2 ?? 0;
702
+ // SIT HIM DOWN. The animator is not ticked on this path, so nothing fights the pose: we
703
+ // re-seat it and solve his limbs onto the reins every frame, exactly as the coach does for
704
+ // its own driver (seatedRider.js). Costs four two-bone solves — no clip, no mixer.
705
+ this.game.coaches?.playerRider?.solve();
706
+ // A HOST MAY OWN THE LENS OUTRIGHT — the train does (game/trainCamera.js), and when it does, NONE
707
+ // of the block below runs: no look, no follow camera, and the mouse is dead, because a passenger
708
+ // on a train is watching a film and not steering a turret. It is filmed at the END of the tick
709
+ // instead, from aboardPost() — main.js moves the player at :664 and the TRAINS at :683, so every
710
+ // car transform read on this line is one frame old, and at 16 m/s against a frame time that
711
+ // jitters that is a camera bolted to a carriage that shivers. (Measured, riding at line speed, in
712
+ // the carriage's own frame: he sits still to within 0.5 mm a frame while the lens slid 234 mm.)
713
+ //
714
+ // A STAGECOACH OWNS NO CAMERA and answers none of this, so it keeps its look, its recentre and
715
+ // its follow camera, here, on this frame, exactly as it always has.
716
+ if (!c.camera) {
717
+ // LOOK AROUND — you are on a coach, not in a cutscene.
718
+ const rdt2 = this.game.rawDt ?? dt;
719
+ const moved = this.game.ui?.menuOpen ? false : this._look(input, rdt2);
720
+ if (moved) this._lookHoldT = 1.4; // your hand is on the mouse: leave him alone
721
+ else this._lookHoldT = Math.max(0, (this._lookHoldT ?? 0) - dt);
722
+ // ...and when you take your hand off, the camera drifts back behind her, as it does in every
723
+ // driving game there has ever been. Only while DRIVING: a passenger looks where he likes.
724
+ if (c.driven && this._lookHoldT <= 0) {
725
+ const want = c.root.rotation.y + Math.PI;
726
+ let d = want - this.camYaw;
727
+ while (d > Math.PI) d -= Math.PI * 2;
728
+ while (d < -Math.PI) d += Math.PI * 2;
729
+ this.camYaw += d * Math.min(1, dt * 1.6);
730
+ }
731
+ this._camera(dt, input, camera);
732
+ }
733
+ return;
734
+ }
735
+
736
+ // ON A LADDER. Like the mounted/aboard paths, the controller owns position + camera; the on-foot
737
+ // locomotion/gravity/capsule/combat below is fully suppressed. super.update(dt) above already ticked
738
+ // the mixer so the climb clips animate. The model rests at its base offset (the visual-Y line below
739
+ // is skipped). .active is a getter — forceOff() resets it on death/fast-travel/load (main.js).
740
+ if (this.climbing?.active) {
741
+ this.model.position.y = this._baseModelY2 ?? 0;
742
+ this.climbing.update(dt, input, camera);
743
+ return;
744
+ }
745
+
746
+ const ui = this.game.ui;
747
+ const inMenu = ui?.menuOpen || ui?.dialogueOpen || ui?.cinematic;
748
+
749
+ this.attackCooldown = Math.max(0, this.attackCooldown - dt);
750
+ if (this.comboWindow > 0) {
751
+ this.comboWindow -= dt;
752
+ if (this.comboWindow <= 0) this.comboStep = 0;
753
+ }
754
+ if (this.dodgeT > 0) this.dodgeT -= dt;
755
+ if (this._hitGraceT > 0) this._hitGraceT -= dt;
756
+ // dodge burst (see dodge()) — inMenu freezes it with the rest of the world
757
+ if (this._dodgeBurst && !inMenu) {
758
+ const b = this._dodgeBurst;
759
+ const step = Math.min(dt, b.left);
760
+ // ...on a coach roof it is a displacement across the DECK. Character.move() goes through
761
+ // moveCapsule, which knows nothing about decks and would drop him onto the road.
762
+ if (this.platform) this.game.platforms.step(this, b.dx * this.moveSpeed * 1.6 * step, b.dz * this.moveSpeed * 1.6 * step);
763
+ else this.move(tmpV1.set(b.dx, 0, b.dz), this.moveSpeed * 1.6, step);
764
+ b.left -= step;
765
+ if (b.left <= 0) this._dodgeBurst = null;
766
+ }
767
+ // never record a respawn point on a moving roof
768
+ if (!this.airborne && !this.platform) { const sp = this._lastSafePos ??= {}; sp.x = this.position.x; sp.y = this.position.y; sp.z = this.position.z; }
769
+ // TAKEOFF HEIGHT for fall damage: the ground you last stood on. A flat jump lands fast but this stays
770
+ // level with the landing (net 0), so only a real DROP past it hurts — a walk/hop off a ledge or ladder.
771
+ if (!this.airborne && !this.platform) this._airStartY = this.position.y;
772
+ this._sinceGrounded = this.airborne ? (this._sinceGrounded ?? 0) + dt : 0; // for coyote-time jumps
773
+
774
+ // ---- camera orbit
775
+ if (!inMenu) this._look(input, rdt);
776
+
777
+ // ---- recoil kick + settle (code, not clip): an attack/decay envelope feeds camPitch
778
+ // by DELTA so player look input composes. Negative pitch = muzzle climb (aim rises).
779
+ if (this._recoilT > 0 || this._recoilOff !== 0) {
780
+ this._recoilT = Math.max(0, this._recoilT - dt);
781
+ const t = this._recoilT / RECOIL_TIME; // 1 → 0 over the envelope
782
+ const env = t > 0.8 ? (1 - t) * 5 : t / 0.8; // ~60ms snap up, eased settle
783
+ const off = this._recoilAmp * env;
784
+ this.camPitch = clamp(this.camPitch - (off - this._recoilOff), -0.6, 1.1);
785
+ this._recoilOff = off;
786
+ }
787
+
788
+ // ---- movement intent (camera relative)
789
+ let mx = 0, mz = 0, moveMag = 0;
790
+ if (!inMenu) {
791
+ const st = input.moveStick;
792
+ if (st && (st.x || st.y)) {
793
+ // analog left stick: direction AND magnitude (a light push walks, a full push runs).
794
+ // stick +y is toward the player (down), so forward is −y.
795
+ mx = st.x; mz = -st.y; moveMag = Math.min(1, Math.hypot(mx, mz));
796
+ } else {
797
+ if (input.down('KeyW')) mz += 1;
798
+ if (input.down('KeyS')) mz -= 1;
799
+ if (input.down('KeyA')) mx -= 1;
800
+ if (input.down('KeyD')) mx += 1;
801
+ moveMag = (mx || mz) ? 1 : 0; // keyboard is always full throw
802
+ }
803
+ }
804
+ const moving = moveMag > 0.02 && !this.busy;
805
+ const sprinting = moving && input.down('ShiftLeft') && !this.aiming && this.stamina > 1;
806
+ if (sprinting) { this.stamina = Math.max(0, this.stamina - dt * 8); this._stamPause = Math.max(this._stamPause ?? 0, 0.6); }
807
+ let hx = 0, hz = 0; // horizontal delta for the capsule controller
808
+
809
+ if (moving) {
810
+ const len = Math.hypot(mx, mz);
811
+ mx /= len; mz /= len;
812
+ // camera sits at +(sin,cos)·camYaw from the player, so camera-forward
813
+ // (away from the lens) is -(sin,cos); camera-right is (cos,-sin).
814
+ const sin = Math.sin(this.camYaw), cos = Math.cos(this.camYaw);
815
+ const dir = tmpV1.set(mx * cos - mz * sin, 0, -mx * sin - mz * cos);
816
+ // wading: the creek drags you down at chest depth — only when actually IN the water
817
+ const surfW = this.game.world.waterSurfaceAt?.(this.position.x, this.position.z);
818
+ const immersed = surfW != null ? surfW - this.position.y : 0;
819
+ const wade = Math.max(0, Math.min(immersed, this.game.world.waterDepthAt?.(this.position.x, this.position.z) ?? 0));
820
+ const wadeK = wade > 0.15 ? Math.max(0.45, 1 - wade * 0.45) : 1;
821
+ const aimSlow = (this.aiming || this.reloading) ? 0.45 : 1;
822
+ // analog: a half-pushed stick walks, a full push runs. Keyboard holds moveMag at 1,
823
+ // so it is unaffected; sprint (Shift/L1) still overrides to the flat-out rate.
824
+ const speed = this.moveSpeed * (sprinting ? 1.55 : moveMag) * aimSlow * wadeK;
825
+ hx = dir.x * speed * dt; hz = dir.z * speed * dt;
826
+ this._hspeed = speed; // what he is ACTUALLY doing — the running jump reads this, not the keys
827
+ // face travel direction (or target when locked) — UNLESS aiming, which owns the heading
828
+ if (this.aiming) { /* handled below */ }
829
+ else if (this.lockTarget?.alive) {
830
+ this.faceToward(this.lockTarget.position.x, this.lockTarget.position.z, dt);
831
+ } else {
832
+ this._faceAngle(Math.atan2(dir.x, dir.z), dt, 10);
833
+ }
834
+ } else if (this.lockTarget?.alive && !this.busy && !this.aiming) {
835
+ this.faceToward(this.lockTarget.position.x, this.lockTarget.position.z, dt);
836
+ }
837
+ // ---- ADS body heading: EASE onto the camera direction (shortest arc, moving or planted).
838
+ // The body faces the shot, the legs strafe under it, and the arm IK does the precision —
839
+ // no snap, no wrap. (The old code hard-assigned heading = camYaw + PI when planted and
840
+ // twisted the spine when moving; both are gone.)
841
+ // Gun out = the body squares to the camera (faster while ADS). The character must face where
842
+ // you are LOOKING, or the shot — and the whole stance — reads sideways. Shortest arc, no wrap.
843
+ if (this.drawn && !this.busy && !moving) this._faceAngle(this.camYaw + Math.PI, dt, this.aiming ? 9 : 5);
844
+ else if (this.aiming && !this.busy) this._faceAngle(this.camYaw + Math.PI, dt, 9);
845
+
846
+ // ---- INHERITED MOMENTUM. Jump off a coach doing 10.5 m/s and you go with her: this is the
847
+ // speed she gave you, added ON TOP of your own air control (it never replaces it — hold W and
848
+ // you go further, let go and you still fly). It is OUTSIDE the moving block above on purpose:
849
+ // hx/hz are only ever assigned in there, and "let go of the keys in mid-air and stop dead" is
850
+ // precisely the bug this exists to kill. No drag while airborne — a jump is ballistics, which
851
+ // is what lets you hop straight up on a galloping coach and land back on her roof. On the
852
+ // ground it bleeds off in a quarter of a second: a stumble, not a skate.
853
+ if (this._airVX || this._airVZ) {
854
+ hx += this._airVX * dt; hz += this._airVZ * dt;
855
+ if (!this.airborne) {
856
+ const k = Math.exp(-dt / 0.25);
857
+ this._airVX *= k; this._airVZ *= k;
858
+ if (Math.hypot(this._airVX, this._airVZ) < 0.2) { this._airVX = 0; this._airVZ = 0; }
859
+ }
860
+ }
861
+
862
+ // ---- don't walk through people (the coach's crew are in none of its lists, so a roof rider
863
+ // has nobody to be separated from — and being shoved in WORLD x/z would fight the carry)
864
+ if (!this.platform) this.separateFromPeople();
865
+
866
+ // ---- vertical resolution: the capsule controller owns gravity, ground/stairs/slopes — unless
867
+ // he is standing on a moving floor, where the deck IS the floor (game/platform.js)
868
+ const wasAir = this.airborne;
869
+ const fallSpeed = -this.vy; // capture BEFORE the capsule zeroes it on touchdown
870
+ const prevFeetY = this.position.y; // for the swept landing test — his height at the top of the frame
871
+ const plat = this.game.platforms;
872
+ if (this.platform) {
873
+ plat.deckMove(this, hx, hz, dt);
874
+ } else {
875
+ this.game.world.moveCapsule(this, hx, hz, dt);
876
+ // STANDING-STILL Y LATCH. Measured on the store roof (Nick's vibrating screen): two
877
+ // overlapping roof surfaces 19 cm apart under one capsule made the resolver alternate
878
+ // between them — the player physically bounced at breathing rate while standing dead still,
879
+ // and every smoother downstream just softened it into a shimmer. A man with NO input does
880
+ // not need his feet re-negotiated every frame: latch the height he stood down at, and hold
881
+ // it while the capsule still calls him grounded and his height stays within a step of the
882
+ // latch. Any real movement — input, a shove off the edge (grounded drops), a dodge, a jump —
883
+ // releases it.
884
+ const still = Math.abs(hx) < 1e-4 && Math.abs(hz) < 1e-4 && !this.airborne && this.vy <= 0 && this.dodgeT <= 0;
885
+ if (still && this._standLatch != null && Math.abs(this.position.y - this._standLatch) < 0.3) {
886
+ this.position.y = this._standLatch;
887
+ this.vy = 0;
888
+ } else {
889
+ this._standLatch = still ? this.position.y : null;
890
+ }
891
+ plat?.tryLand(this, prevFeetY, wasAir); // ...did he just come down on a coach roof?
892
+ if (!this.platform) plat?.hullPush(this); // ...and if he missed it, he hit the side of her
893
+ }
894
+ // keep the player ~20m inside the world edge (a clamp on a roof would desync his deck coords —
895
+ // and no coach route goes within a kilometre of the edge)
896
+ if (!this.platform) {
897
+ const EDGE = PC.worldSize / 2 - 20;
898
+ this.position.x = clamp(this.position.x, -EDGE, EDGE);
899
+ this.position.z = clamp(this.position.z, -EDGE, EDGE);
900
+ }
901
+ if (wasAir && !this.airborne) {
902
+ const fell = (this._airStartY ?? this.position.y) - this.position.y; // NET descent from takeoff
903
+ if (fell > FALL_SAFE_M && this.alive && !this._resting) {
904
+ // FALL DAMAGE — a drop past the safe height hurts, scaled by how far past; a big fall is lethal.
905
+ // takeDamage plays the hit reel + damage flash + shake and handles death (fromPos null = no reel dir).
906
+ this.takeDamage(Math.round((fell - FALL_SAFE_M) * FALL_DMG_PER_M), null);
907
+ } else if (!moving && fallSpeed > 8.5) {
908
+ // land-lock on a hard-but-safe touchdown (> a normal jump's ~7.2 m/s) — the soft land clip, no damage
909
+ this.busy = true;
910
+ this.animator.play('land', { once: true, fade: 0.08, onDone: () => { this.busy = false; } });
911
+ }
912
+ }
913
+
914
+ // ---- locomotion animation
915
+ // NO MASKS, NO OVERLAYS. The gunslinger pack is a COMPLETE locomotion set (Idle_w_Revolver,
916
+ // Walk_wRevolver, Walk_wRevolverAimed, Run_w_Gun, Fulldraw_Idle, Idle_Reload...), so every
917
+ // gun state has a real FULL-BODY clip. The old code grafted a gun TORSO (masked overlay)
918
+ // onto SYNTY LEGS (masked base) — but the gun clips are authored against their own BLADED
919
+ // pelvis, so bolting that torso onto the Synty idle's hips rotated it ~90deg off the legs:
920
+ // the body-split after every shot. Masking also created the whole T-pose class (any weight
921
+ // gap fills from the bind pose). Full-body clips remove both. The gun is pointed by the
922
+ // arm-only aim IK, which is now the only thing that touches a bone after the mixer.
923
+ const gunW = WEAPONS[this.weapon];
924
+ // "the gun is in use": aiming, reloading, mid-draw, or still settling from a shot. The
925
+ // auto-holster below counts idle seconds against it. (This local survived the move to
926
+ // full-body clips — the holster timer still reads it, so it must still exist.)
927
+ const gunPose = !!gunW && (this.aiming || this.reloading || this._drawT > 0 || this._gunRecoilT > 0);
928
+ if (!this.busy && !this.airborne) {
929
+ if (this.animator._overlay) this.animator.setOverlay(null, { fade: BASE_FADE });
930
+ if (this.drawn && gunW) {
931
+ if (this.reloading) this.animator.play(moving ? 'gunReloadWalk' : 'gunReload');
932
+ else if (this._holstering) this.animator.play(moving ? 'gunWalkHolster' : 'gunHolster');
933
+ else if (this.aiming) this.animator.play(moving ? 'gunWalkAimed' : 'gunAim');
934
+ else if (moving && sprinting) this.animator.play('gunRun');
935
+ else if (moving) this.animator.play('gunWalk');
936
+ else this.animator.play('gunIdle');
937
+ } else if (moving && sprinting) this.animator.play('sprint');
938
+ else if (moving) this.animator.play('run');
939
+ else if (this.weapon === 'fists') this.animator.play('punchIdle');
940
+ else this.animator.play('idle');
941
+ }
942
+
943
+ // ---- footsteps: surface-aware step SFX on a distance-stride timer (on foot only)
944
+ this.footsteps.update(dt, moving, sprinting);
945
+
946
+ // ---- combat inputs (buffers cleared while a menu/dialogue is open so a press
947
+ // made just before it opened can't fire a phantom shot/jump on close)
948
+ if (!inMenu && this.alive) this.combatInputs(input, dt);
949
+ else { this._atkBuf = 0; this._heavyBuf = 0; this._jumpBuf = 0; }
950
+
951
+ // ---- auto-holster: ~6s without aiming/firing/reloading sends the gun back to leather
952
+ if (this.drawn && !gunPose) {
953
+ this._holsterT -= dt;
954
+ if (this._holsterT <= 0) this.holsterGun();
955
+ }
956
+
957
+ // ---- POST-MIXER pose fixes, in order: re-seat the aim clip's spine on the walk pelvis
958
+ // (reblade), THEN swing the arm onto the camera ray (IK), THEN the grip — the grip reads
959
+ // bone poses, so the arm has to be final before it runs.
960
+ // REBLADE DISABLED. It was built for the bow: it premultiplies Spine_01 by
961
+ // (liveHips⁻¹ · clipHipsRef) to re-aim a torso whose clip was authored against a different
962
+ // pelvis. With the gunslinger clips that difference is large, so it LEANED THE PLAYER OVER.
963
+ // The bench plays the clips raw — no reblade — and looks correct; the game did not. The aim
964
+ // IK (arm only) is what points the gun now, so the reblade has no job left.
965
+ // this.applyUpperReblade(dt);
966
+ // AIM IK: arm only (AIM_IK_SPINE = 0), and only while genuinely aiming. It is what puts the
967
+ // muzzle ON the reticle — without it the gun points wherever the clip happens to point, so
968
+ // you aim down the street and he fires sideways. It does NOT touch the posture: the pose is
969
+ // measurably identical to /guntest.html (tilt 8.2 deg, lean 0.099m) with it off or on.
970
+ this.applyAimIK(rdt);
971
+ this.applyFistCurl(); // bare-knuckle: curl the fingers into fists on the punch clips (post-mixer)
972
+ this.updateWeaponPose();
973
+ // muzzle flip: the gun rises on the shot and settles. Runs AFTER updateWeaponPose, which
974
+ // rewrites rotation from the grip every frame — so this is an OFFSET on a fresh value, not
975
+ // an accumulating rotate (rotateX() on an unreset transform spins the gun like a propeller).
976
+ if (this._muzzleFlip > 0.0001 && this.weaponObj) {
977
+ this.weaponObj.rotateX(-this._muzzleFlip); // safe: rotation was just reset above
978
+ this._muzzleFlip = Math.max(0, this._muzzleFlip - dt * 4.5);
979
+ }
980
+
981
+ // ---- smooth the VISUAL height on the way DOWN over micro-bumps so descents
982
+ // aren't choppy. Physics stay exact; ascents/jumps track 1:1.
983
+ // ON A MOVING FLOOR neither this nor the camera below may run here: his position is still the
984
+ // one the coach had LAST frame (she moves after him — main.js), so both would be a frame stale.
985
+ // platform.js's postUpdate does the same two jobs at the end of the tick, once she has moved.
986
+ if (!this.platform) {
987
+ const py = this.position.y;
988
+ if (this.airborne || this._visualY === undefined || Math.abs(py - this._visualY) > 1.5) {
989
+ this._visualY = py;
990
+ } else if (py < this._visualY) {
991
+ this._visualY += (py - this._visualY) * Math.min(1, dt * 12); // ease down
992
+ } else {
993
+ this._visualY = py;
994
+ }
995
+ this.model.position.y = this._baseModelY2 + (this._visualY - py);
996
+
997
+ this._camera(dt, input, camera);
998
+ }
999
+ }
1000
+
1001
+ // THE LOOK, on its own — for the same reason the camera is: a man who has climbed onto a coach has
1002
+ // not gone blind. It was buried in the middle of update(), below the early-out for riding, so the
1003
+ // moment you took the reins your mouse stopped turning your head.
1004
+ // Returns true if the player actually MOVED the look this frame, which is what tells a vehicle
1005
+ // camera to stop recentring and leave him alone (RDR2/GTA: look freely, and it drifts back behind
1006
+ // the vehicle only once you take your hand off).
1007
+ _look(input, rdt) {
1008
+ const sens = this.camSensitivity;
1009
+ const inv = this.invertY ? -1 : 1;
1010
+ const dx = input.mouse.dx, dy = input.mouse.dy;
1011
+ const sx = input.stickLook.x, sy = input.stickLook.y;
1012
+ // mouse: per-event deltas are already framerate-independent
1013
+ this.camYaw -= dx * 0.0028 * sens;
1014
+ this.camPitch = clamp(this.camPitch + dy * 0.0022 * sens * inv, -0.6, 1.1);
1015
+ // gamepad right stick: rate-based so the turn speed doesn't scale with fps — and on
1016
+ // WALL-CLOCK dt, so a pad player's look doesn't slow to a crawl inside Dead Eye
1017
+ this.camYaw -= sx * this.padCamSpeed * sens * rdt;
1018
+ this.camPitch = clamp(this.camPitch + sy * this.padCamSpeed * sens * inv * rdt, -0.6, 1.1);
1019
+ return Math.abs(dx) + Math.abs(dy) + Math.abs(sx) + Math.abs(sy) > 0.001;
1020
+ }
1021
+
1022
+ // THE VEHICLE CAMERA, AFTER THE VEHICLE HAS MOVED. main.js calls this at the end of the tick, beside
1023
+ // platform.js's postUpdate, which exists for exactly this reason — once the coaches and the trains
1024
+ // have run and train.js's host.sync() has put him back on his bench on the car where it NOW is.
1025
+ // Only a host that OWNS its camera comes through here; the stagecoach is filmed in update(), as before.
1026
+ aboardPost(dt, input, camera) {
1027
+ const c = this.aboard;
1028
+ if (!c?.camera) return;
1029
+ // The rig answers false the moment it must not be filming — he aimed, he drew, he took a round, a
1030
+ // fight started, or he pressed V — and then the ordinary follow camera takes the lens straight
1031
+ // back, with the mouse live again.
1032
+ if (c.camera(dt, camera, this, input)) return;
1033
+ const rdt = this.game.rawDt ?? dt;
1034
+ if (!this.game.ui?.menuOpen) this._look(input, rdt);
1035
+ this._camera(dt, input, camera);
1036
+ }
1037
+
1038
+ // THE FOLLOW CAMERA, on its own so anything that takes over the player's legs (a horse, the box
1039
+ // seat of a stagecoach) can still hand him a camera. Riding is not the same as being gone.
1040
+ _camera(dt, input, camera) {
1041
+ const rdt = this.game.rawDt ?? dt;
1042
+ // ---- camera position. RMB (ADS) also pulls the camera IN — eased, so the transition
1043
+ // reads as a lean rather than a cut. Note this.camDist stays the user's follow distance.
1044
+ // (every ease in this block runs on rdt — WALL-CLOCK. On world dt the follow cam would
1045
+ // lag a third of a second behind the mouse inside Dead Eye, which reads as input lag.)
1046
+ const head = tmpV1.set(this.position.x, this._visualY + 1.55, this.position.z);
1047
+ // A COACH IS BIGGER THAN A MAN. The on-foot follow distance puts the lens inside the roof rack.
1048
+ // ...but a vehicle you sit INSIDE is smaller than the follow distance: a railway carriage's saloon
1049
+ // is 2.98 m wide and 9 m of camera lands outside the planking, looking at the outside of it. So a
1050
+ // host may say how far back its lens goes (train.js: makeSeat). A stagecoach says nothing and gets
1051
+ // the 9 m it has always had.
1052
+ const wantDist = this.aboard ? (this.aiming ? (this.aboard.camAdsDist ?? 6.0) : (this.aboard.camDist ?? 9.0)) : (this.aiming ? ADS_CAM_DIST : this.camDist);
1053
+ this._camDistNow = this._camDistNow == null ? this.camDist : lerp(this._camDistNow, wantDist, Math.min(1, rdt * 8));
1054
+ const dist = this._camDistNow;
1055
+ let cx = head.x + Math.sin(this.camYaw) * Math.cos(this.camPitch) * dist;
1056
+ let cz = head.z + Math.cos(this.camYaw) * Math.cos(this.camPitch) * dist;
1057
+ let cy = head.y + Math.sin(this.camPitch) * dist;
1058
+ // OVER THE SHOULDER — BY DEFAULT, NOT ONLY TO SHOOT.
1059
+ // This used to snap sideways the instant you aimed OR fired (the shot's recoil window kept
1060
+ // it there for 0.28s) and snap back after: the camera lurched on every single trigger pull,
1061
+ // which Nick rightly called a sickening sensation. A framing you only adopt to shoot is a
1062
+ // framing that lurches every time you shoot. So the game simply IS an over-the-shoulder
1063
+ // game: the player rides off to the left of frame all the time, the centre of the screen is
1064
+ // always clear of his body, and aiming merely DEEPENS the offset a little. Nothing snaps,
1065
+ // because nothing has to travel.
1066
+ // (Parallel shift: camera AND look-target move together, so the framing pans rather than
1067
+ // orbiting — the aim ray out of the screen centre is unchanged.)
1068
+ const wantShoulder = (this.aiming || this._gunRecoilT > 0) ? 1 : SHOULDER_IDLE;
1069
+ this._shoulderT = lerp(this._shoulderT ?? SHOULDER_IDLE, wantShoulder, Math.min(1, rdt * 8));
1070
+ if (this._shoulderT > 0.001) {
1071
+ const off = SHOULDER_OFF * this._shoulderT; // metres to the camera's right
1072
+ const rx = Math.cos(this.camYaw) * off, rz = -Math.sin(this.camYaw) * off;
1073
+ head.x += rx; head.z += rz; cx += rx; cz += rz; // shift target + camera together → player goes left
1074
+ }
1075
+ const groundCam = this.game.world.groundAt(cx, cz) + 0.4;
1076
+ if (cy < groundCam) cy = groundCam;
1077
+ // ...and a vehicle with a ROOF on it keeps the lens indoors. The wall ray below only knows about
1078
+ // buildings — its BVH is baked out of the town at world build, and a moving train is not in it —
1079
+ // so a host that has an inside says where the lens may go, in its own frame (train.js: makeSeat).
1080
+ // Swing the camera round to look out of the left-hand window and it presses against the right-hand
1081
+ // wall instead of standing a metre outside it staring at the planking.
1082
+ if (this.aboard?.camConfine) { const p = this.aboard.camConfine(cx, cy, cz); cx = p.x; cy = p.y; cz = p.z; }
1083
+ // pull the camera in if a wall is between it and the player, so inside rooms you
1084
+ // keep facing the player instead of looking through the wall from outside
1085
+ const W = this.game.world;
1086
+ const camBvhs = [W.buildingBVH];
1087
+ _camDir.set(cx - head.x, cy - head.y, cz - head.z);
1088
+ const want = _camDir.length();
1089
+ let wallClipped = false;
1090
+ if (want > 0.01) {
1091
+ _camDir.divideScalar(want);
1092
+ _camRay.origin.copy(head); _camRay.direction.copy(_camDir);
1093
+ let nearest = want;
1094
+ for (const bvh of camBvhs) {
1095
+ if (!bvh || !bvh.raycastFirst) continue;
1096
+ try { const hit = bvh.raycastFirst(_camRay, THREE.DoubleSide); if (hit && hit.distance < nearest) nearest = hit.distance; }
1097
+ catch (e) { if (!this._wallRayWarned) { console.error('[cam wall ray]', e); this._wallRayWarned = true; } }
1098
+ }
1099
+ // THE CLIP DISTANCE IS HELD, NOT RE-DECIDED EVERY FRAME. Standing on a roof, the ray to the
1100
+ // desired camera spot GRAZES the parapet under your own heels — hit one frame, miss the next —
1101
+ // and the old instant-cut-in / eased-out pair turned that into a standing VIBRATION (Nick).
1102
+ // A hit pulls the held distance in at once; a miss lets it breathe back out slowly. The hard
1103
+ // CUT only fires when a wall bites meaningfully DEEPER than what is already held — walking
1104
+ // into a room still cuts, a grazing flicker no longer does.
1105
+ // GLIDE, NOT POP. Measured on the store roof (player amplitude 0.000, camera 0.53 m): the
1106
+ // wall ray GRAZES the roof edge and the idle head-sway flips it hit/miss forever — any rule
1107
+ // that pops the distance on a state change is a vibration with that as its clock. So: a wall
1108
+ // biting a full METRE inside the camera's current stance cuts instantly (walking into a
1109
+ // room); everything nearer GLIDES in; release starts only after the ray has been clean for
1110
+ // 0.6 s and eases; and a re-bite mid-breathe just glides back down. No edge, no pop.
1111
+ const eff = this._camWallD ?? want;
1112
+ if (nearest < want) {
1113
+ if (nearest < eff - 1.0) wallClipped = true;
1114
+ this._camWallD = wallClipped ? nearest : Math.min(eff, eff + (nearest - eff) * Math.min(1, rdt * 6));
1115
+ this._camWallT = 0;
1116
+ } else if (this._camWallD != null) {
1117
+ // release only after the ray has been clean LONGER than the idle sway that drives the
1118
+ // flicker (~1-2 s breathing period, measured at steep pitch on the store roof — a 0.6 s
1119
+ // window released mid-flicker and the 2 m hit/miss gap pumped the camera every cycle)
1120
+ this._camWallT = (this._camWallT ?? 0) + rdt;
1121
+ if (this._camWallT > 2.0) {
1122
+ this._camWallD += (want - this._camWallD) * Math.min(1, rdt * 2.5);
1123
+ if (this._camWallD > want - 0.02) this._camWallD = null;
1124
+ }
1125
+ }
1126
+ if (this._camWallD != null && this._camWallD < want) {
1127
+ const s = Math.max(0.4, this._camWallD - 0.35);
1128
+ cx = head.x + _camDir.x * s; cy = head.y + _camDir.y * s; cz = head.z + _camDir.z * s;
1129
+ }
1130
+ }
1131
+ // cut in INSTANTLY when a wall newly constrains the camera, ease normally relaxing out.
1132
+ // ...and _camSnap is the same instant, asked for from outside: a cinematic camera handing the lens
1133
+ // back (trainCamera.js) is standing sixty metres away, and easing home from there takes half a
1134
+ // second — half a second of a man with a gun in his hand watching the world swoop past him while
1135
+ // somebody shoots at him. It CUTS. One flag, consumed the frame it is read.
1136
+ const snap = wallClipped || this._camSnap;
1137
+ this._camSnap = false;
1138
+ camera.position.lerp(_camTarget.set(cx, cy, cz), snap ? 1 : Math.min(1, rdt * 10));
1139
+ camera.lookAt(head);
1140
+ }
1141
+
1142
+ combatInputs(input, dt) {
1143
+ const combat = this.game.combat;
1144
+
1145
+ // toggle weapon: revolver → rifle → fists → revolver. Cancels any live aim/reload
1146
+ // (the release guard — old weapon keeps its part-filled mag).
1147
+ if (input.hit('KeyQ') && !this.busy) {
1148
+ this.cancelGun();
1149
+ this.weapon = this.weapon === 'revolver' ? 'fists' : 'revolver'; // rifle parked — untuned sling, sorted later
1150
+ const w = WEAPONS[this.weapon];
1151
+ this.ammoMax = w?.mag ?? 0;
1152
+ this.ammo = w ? this._mags[this.weapon] : 0;
1153
+ this.drawn = false; // fresh weapon starts holstered — first fire/aim plays the draw
1154
+ this._holstering = false;
1155
+ this.placeWeapons();
1156
+ this.game.audio?.play('ui');
1157
+ }
1158
+ // WHISTLE (H) — call the companion horse over. On foot only (this whole block never runs mounted).
1159
+ if (input.hit('KeyH') && !this.busy) this.whistle();
1160
+ // GREET (G) — tip the hat to whoever's near. Manners are cheap and the town keeps books.
1161
+ if (input.hit('KeyG') && !this.busy && !this.drawn) this.greet();
1162
+ // lock-on (fists brawling aid — guns aim with the camera)
1163
+ if (input.hit('Tab')) {
1164
+ this.lockTarget = combat?.nearestEnemy?.(this.position, 18, this.lockTarget) ?? null;
1165
+ if (this.lockTarget) this.enterCombatStance();
1166
+ }
1167
+ if (this.lockTarget && (!this.lockTarget.alive || this.lockTarget.position.distanceTo(this.position) > 25)) {
1168
+ this.lockTarget = null;
1169
+ }
1170
+
1171
+ // ---- input buffers: stamp presses, then decay EVERY frame so a press a few frames
1172
+ // early still lands. update() zeroes them while a menu is open.
1173
+ const canMelee = this.weapon === 'fists';
1174
+ if (canMelee && input.mouseHit(0) && input.acting) this._atkBuf = 0.35;
1175
+ if (canMelee && input.hit('KeyF')) {
1176
+ // finisher first (if combat offers one): a staggered foe in front eats the execute
1177
+ if (!this.busy && combat?.finishTarget && combat.executeFinisher?.()) { this._heavyBuf = 0; this._atkBuf = 0; }
1178
+ else this._heavyBuf = 0.35;
1179
+ }
1180
+ if (input.hit('Space')) this._jumpBuf = 0.12;
1181
+ this._atkBuf = Math.max(0, this._atkBuf - dt);
1182
+ this._heavyBuf = Math.max(0, this._heavyBuf - dt);
1183
+ this._jumpBuf = Math.max(0, this._jumpBuf - dt);
1184
+
1185
+ // Alt = DODGE (the only dodge key). Space jumps in the open and dodges when locked on.
1186
+ // Ctrl is deliberately UNBOUND in this file — it belongs to Dead Eye.
1187
+ if ((input.hit('AltLeft') || input.hit('AltRight')) && !this.busy && this.dodgeT <= 0 && !this.airborne) {
1188
+ this.dodge(input);
1189
+ return;
1190
+ }
1191
+ // coyote time: a jump pressed just after walking off a ledge (<0.12s) is honoured
1192
+ if (this._jumpBuf > 0 && !this.busy && (!this.airborne || (this._sinceGrounded < 0.12 && this.vy <= 0))) {
1193
+ this._jumpBuf = 0;
1194
+ if (this.lockTarget?.alive && this.dodgeT <= 0) this.dodge(input);
1195
+ else this.jump(input);
1196
+ return;
1197
+ }
1198
+
1199
+ if (this.weapon === 'fists') {
1200
+ if (!this.busy && this.attackCooldown <= 0) {
1201
+ if (this._heavyBuf > 0) { this.enterCombatStance(); combat?.playerMelee?.(true, true); this._heavyBuf = 0; this._atkBuf = 0; }
1202
+ else if (this._atkBuf > 0) { this.enterCombatStance(); combat?.playerMelee?.(false, true); this._atkBuf = 0; }
1203
+ }
1204
+ } else {
1205
+ this.gunInputs(input, dt);
1206
+ }
1207
+ }
1208
+
1209
+ // THE GUN STATE MACHINE — RDR2 rules. There is no mode to enter.
1210
+ // LMB : ONE shot, rate-limited. Holstered? draw, and the click is BUFFERED across the
1211
+ // draw gate so it fires the moment the gun is level. Empty chamber → dryfire.
1212
+ // RMB : OPTIONAL ADS while held. Tighter cam, tighter spread, reticle. Also draws.
1213
+ // R : reload. Ctrl : NOT BOUND (Dead Eye owns it).
1214
+ gunInputs(input, dt) {
1215
+ const w = WEAPONS[this.weapon];
1216
+ if (!w) return;
1217
+ const movin = input.down('KeyW') || input.down('KeyA') || input.down('KeyS') || input.down('KeyD');
1218
+
1219
+ // ---- RMB = optional ADS (never required to fire)... and DEAD EYE AIMS TOO. Ctrl puts him in
1220
+ // the same shoulder-aim as RMB, because a man picking his shots is a man looking down the barrel,
1221
+ // not one marking them from the hip. The gun POSE already knew this (the aim overlay reads
1222
+ // `this.aiming || this.deadEyeActive` above) — it was the camera and the reticle that did not, so
1223
+ // Dead Eye looked like a different, sloppier stance than the one it borrows its animation from.
1224
+ const deadEyeAim = this.deadEyeActive && !this.busy && !this.airborne; // Ctrl aims on its own — no mouse held
1225
+ const adsHeld = (input.held(2) && input.acting && !this.busy && !this.airborne) || deadEyeAim;
1226
+ if (adsHeld && this._holstering) this._abortHolster(); // re-aim aborts the stow
1227
+ if (adsHeld && !this.drawn && this._drawT <= 0) this._startDraw();
1228
+ const wasAiming = this.aiming;
1229
+ this.aiming = adsHeld && this.drawn && this._drawT <= 0 && !this._holstering;
1230
+ // aim released: normally the locomotion block hands the upper body back to a full-body
1231
+ // base in ONE frame (matched fades, sum stays 1). It's skipped while busy/airborne, so
1232
+ // only THEN do we drop the overlay here — otherwise it would strand a masked base.
1233
+ if (wasAiming && !this.aiming && (this.busy || this.airborne)) {
1234
+ this.animator.setOverlay(null, { fade: BASE_FADE });
1235
+ }
1236
+
1237
+ // ---- LMB. THE FIRST CLICK DRAWS. THE SECOND ONE SHOOTS.
1238
+ // A holstered man does not shoot: he draws. The click that clears leather is the click that
1239
+ // puts you IN gunfighting stance — the gun comes up, the reticle appears, you can see what
1240
+ // you would be shooting at — and only the NEXT click sends a round. It used to hold the fire
1241
+ // buffer THROUGH the draw, so a single click on a holstered gun drew and fired in one motion,
1242
+ // at whatever happened to be under a reticle you had not seen yet.
1243
+ // (Once drawn, the buffer behaves as before: click during the rate-limit or a stow and the
1244
+ // shot still lands the instant the gate clears — you never lose an intended shot.)
1245
+ if (input.mouseHit(0) && input.acting) this._fireBuf = FIRE_BUF;
1246
+ if (this._drawT <= 0) this._fireBuf = Math.max(0, this._fireBuf - dt);
1247
+ if (this._fireBuf > 0 && !this.busy && !this.airborne && !this.reloading) {
1248
+ if (this._holstering) this._abortHolster(); // shoot through a stow
1249
+ if (!this.drawn) {
1250
+ if (this._drawT <= 0) { this._startDraw(); this._fireBuf = 0; } // this click only DRAWS
1251
+ } else if (this._drawT <= 0 && this._gunFireT <= 0) {
1252
+ this._fireBuf = 0; // consumed — one click, one shot
1253
+ this._holsterT = HOLSTER_IDLE;
1254
+ if (this.ammo > 0) this._fire(w, movin);
1255
+ else { this.game.audio?.play('dryfire'); this._gunFireT = 0.25; }
1256
+ }
1257
+ }
1258
+
1259
+ if (this.drawn) this.game.ui?.aimTarget(this._aimingAtBody());
1260
+ if (this.drawn) this._menaceCheck(dt);
1261
+ if (this.aiming || this.reloading || this._drawT > 0) this._holsterT = HOLSTER_IDLE;
1262
+ if (this.drawn) this.game.hint?.('gun', 'Left-click to fire — hold right-click to aim down the sights. R reloads.');
1263
+
1264
+ // R = reload when the mag isn't full
1265
+ if (input.hit('KeyR') && !this.reloading && this._drawT <= 0 && !this.busy && !this.airborne && this.ammo < this.ammoMax) {
1266
+ this._startReload();
1267
+ }
1268
+
1269
+ // AN EMPTY GUN RELOADS ITSELF. Nobody in a gunfight stands there dry-firing at a man because
1270
+ // they forgot a keybind — the character knows his revolver is empty. There is a short beat
1271
+ // first (AUTO_RELOAD_WAIT) so the last shot's recoil reads and the click of an empty chamber
1272
+ // still lands: it goes click, THEN he reloads, which is the sound of running out. Hitting R
1273
+ // early skips the beat, and a fresh trigger-pull does not (a dry gun cannot be hurried).
1274
+ // (mounted too — a rider's revolver knows it is empty the same as a walker's; the reload is
1275
+ // sound + timer in the saddle, the seat pose holds)
1276
+ if (this.drawn && this.ammo <= 0 && !this.reloading && this._drawT <= 0 && !this.airborne
1277
+ && this.ammoMax > 0) {
1278
+ this._autoReloadT = (this._autoReloadT ?? AUTO_RELOAD_WAIT) - dt;
1279
+ if (this._autoReloadT <= 0 && !this.busy) { this._autoReloadT = null; this._startReload(); }
1280
+ } else {
1281
+ this._autoReloadT = null;
1282
+ }
1283
+ }
1284
+
1285
+ _startDraw() {
1286
+ this.drawn = true;
1287
+ this.placeWeapons(); // the gun appears in the hand as the draw plays
1288
+ this._drawT = DRAW_GATE; // input gate ≈ the draw's leather-to-level beat
1289
+ this._holsterT = HOLSTER_IDLE;
1290
+ this._overlayOnce('gunDrawUpper');
1291
+ }
1292
+
1293
+ // Abort a stow in flight (a fresh aim or a fresh shot beats the holster). Clearing the
1294
+ // flag is the whole abort: the locomotion block stops picking the holster take next
1295
+ // frame, and the pending stow timer checks the flag and stands down.
1296
+ _abortHolster() {
1297
+ this._holstering = false;
1298
+ }
1299
+
1300
+ _startReload() {
1301
+ this.reloading = true;
1302
+ if (!this.drawn) { this.drawn = true; this.placeWeapons(); }
1303
+ if (this._holstering) this._abortHolster();
1304
+ this._holsterT = HOLSTER_IDLE;
1305
+ this.game.audio?.play('reload');
1306
+ const done = () => {
1307
+ if (!this.reloading) return; // cancelled — the old ammo stands
1308
+ this.reloading = false;
1309
+ this.ammo = this._mags[this.weapon] = this.ammoMax;
1310
+ };
1311
+ // FULL-BODY reload: the locomotion block owns the pose while `reloading` is up (standing
1312
+ // take planted, walking take on the move — both loop). The old gunReloadUpper overlay
1313
+ // could never finish here: the no-masks locomotion block DROPS overlays every frame, so
1314
+ // its onDone never fired and the reload looped forever. Completion is a TIMER cut to the
1315
+ // standing take's length; the clip may be missing in dev, so fall back to a fixed beat.
1316
+ const dur = this.animator.actions.gunReload?.getClip().duration;
1317
+ this.game.after(dur ? dur / (PC.clipTiming.gunReload?.speed ?? 1) : 1.2, done);
1318
+ }
1319
+
1320
+ // Everything a round can hit, as one list. The reticle and the shot BOTH walk it, so the light
1321
+ // that says "this will connect" and the lead that connects can never be looking at different men.
1322
+ // (The children are in game.npcs too, and aim.js's nearestBody drops anything carrying
1323
+ // `noTarget` — so they are in this generator and yet unreachable through it, by construction.)
1324
+ * _targets() {
1325
+ for (const e of this.game.combat?.enemies ?? []) yield e;
1326
+ for (const n of this.game.npcs ?? []) yield n;
1327
+ for (const c of this.game.coaches?.coaches ?? []) for (const r of c.crew ?? []) yield r;
1328
+ }
1329
+
1330
+ _rayBody(origin, dir) {
1331
+ return nearestBody(origin, dir, this._targets(), { skip: this });
1332
+ }
1333
+
1334
+ // The point the crosshair is over — see aim.js for why this is not the same line as the shot.
1335
+ _aimPoint(out) {
1336
+ return aimPoint(out, this.game.camera.position, this.aimDir(_aimDir), this._targets(), { skip: this });
1337
+ }
1338
+
1339
+ // IS THERE A MAN IN THE LINE OF FIRE?
1340
+ // The same camera ray the bullet takes (aimDir), minus the spread — a cone of luck is not
1341
+ // something to promise the player. A body is a capsule about his spine; we take the NEAREST one
1342
+ // the ray crosses, and only if the world isn't in the way (shooting a man through a wall is not
1343
+ // a shot that connects). Cheap: a dot product per candidate, and the LOS check only for the one
1344
+ // that wins, so a crowded street costs a handful of multiplies, not a raycast per townsman.
1345
+ // POINT A GUN AT A CHILD AND HE RUNS — AND THE STREET THINKS THE WORSE OF YOU.
1346
+ // A child is unreachable through _targets() (aim.js drops `noTarget`), so the reticle never
1347
+ // lights on one and the round never converges on one. That is the safety. This is the REACTION,
1348
+ // and it needs its own ray against its own list — game.children, which populateCounty keeps.
1349
+ //
1350
+ // MENACE is a crime like any other, so it goes through crime.report() and is weighted by
1351
+ // witnesses exactly as a murder is: on Perdition's main street at noon with five people watching
1352
+ // it is nine heat and a black look; alone in a barn it is one heat and nobody cares, because
1353
+ // nobody saw. Scanned at 5 Hz (it is a ray against ~21 children county-wide, and a fright is not
1354
+ // a thing that needs 120 Hz), and charged once per aiming episode.
1355
+ _menaceCheck(dt) {
1356
+ const kids = this.game.children;
1357
+ if (!kids?.length) return;
1358
+ this._menaceT = (this._menaceT ?? 0) - dt;
1359
+ this._menaceCool = Math.max(0, (this._menaceCool ?? 0) - dt);
1360
+ if (this._menaceT > 0) return;
1361
+ this._menaceT = 0.2;
1362
+ // aim.js's nearestBody refuses `noTarget` and every child carries it — which is exactly the
1363
+ // guarantee we want everywhere else and useless here. So ask rayCapsule directly: the same
1364
+ // body model, the same camera ray, past the flag.
1365
+ const cam = this.game.camera.position, dir = this.aimDir(_aimDir);
1366
+ let best = null, bestT = Infinity;
1367
+ for (const k of kids) {
1368
+ if (!k.alive || k.position.distanceTo(this.position) > MENACE_R) continue;
1369
+ const r = rayCapsule(cam.x, cam.y, cam.z, dir.x, dir.y, dir.z, k.position, 0.55);
1370
+ if (!r || r.t >= bestT) continue;
1371
+ best = k; bestT = r.t;
1372
+ }
1373
+ if (!best) return;
1374
+ _aimTo.set(best.position.x, best.position.y + 1.0, best.position.z);
1375
+ if (this.game.world.segmentBlocked?.(this._muzzle(_aimFrom2), _aimTo)) return;
1376
+ best.scare(this.position.x, this.position.z, 6, true); // and he keeps running while you hold it on him
1377
+ // The STREET's reaction is one event per episode, not a five-a-second siren: crime.report()
1378
+ // already empties the street round the spot (14 m, alarmTown, see crime.js), and firing that
1379
+ // at 5 Hz meant walking all 150 souls 750 times a second to re-panic the same dozen men.
1380
+ if (this._menaceCool <= 0) {
1381
+ this._menaceCool = 4;
1382
+ this.game.crime?.report('menace', best.position.clone());
1383
+ }
1384
+ }
1385
+
1386
+ _aimingAtBody() {
1387
+ if (!this.drawn) return false;
1388
+ const hit = this._rayBody(this.game.camera.position, this.aimDir(_aimDir));
1389
+ if (!hit) return false;
1390
+ // ...and the world must not be in the way, checked along the round's ACTUAL path (muzzle →
1391
+ // him), not along the camera's. Shooting a man through a wall is not a shot that connects.
1392
+ const from = this._muzzle(_aimFrom2);
1393
+ _aimTo.set(hit.e.position.x, hit.e.position.y + 1.0, hit.e.position.z);
1394
+ return !this.game.world.segmentBlocked?.(from, _aimTo);
1395
+ }
1396
+
1397
+ _fire(w, moving) {
1398
+ this._gunFireT = w.rate;
1399
+ // FROM the muzzle, AT what the crosshair is over (_aimPoint) — the two are not the same line
1400
+ // and pretending they are is what put every round below where you were aiming.
1401
+ const from = this._muzzle(_fireFrom);
1402
+ const aim = this._aimPoint(_fireAim);
1403
+ const dir = _fireDir;
1404
+ // ADS is TIGHT; hip-fire is not — that is the whole point of RMB being optional. Moving
1405
+ // scatters either further. The jitter goes in the plane ACROSS the shot, so a spread of x
1406
+ // radians means x radians off the line you actually took, whichever way you happen to face.
1407
+ const spread = (this.aiming ? SPREAD.ads : SPREAD.hip) * (moving ? SPREAD.moveMul : 1);
1408
+ shotDir(dir, from, aim, spread);
1409
+ this._shootAlong(w, dir.clone(), from.clone());
1410
+ }
1411
+
1412
+ // Muzzle = the held gun's world position; fallback = chest height, a little down the aim ray.
1413
+ _muzzle(out) {
1414
+ if (this.weaponObj) this.weaponObj.getWorldPosition(out);
1415
+ else out.set(this.position.x, this.position.y + 1.4, this.position.z).addScaledVector(this.aimDir(_ikDir), 0.4);
1416
+ return out;
1417
+ }
1418
+
1419
+ // THE SHOT: one bullet down `dir`, plus everything that sells it. This is the ONLY place a
1420
+ // round is spent — LMB comes through _fire (camera ray + spread) and DEAD EYE comes through
1421
+ // deadEyeShot (straight at a painted body point), so the two land IDENTICAL damage, blood,
1422
+ // impacts, ammo and recoil by construction, not by two copies agreeing with each other.
1423
+ _shootAlong(w, dir, from = this._muzzle(new THREE.Vector3())) {
1424
+ this.ammo = this._mags[this.weapon] = Math.max(0, this.ammo - 1);
1425
+ this.game.combat?.fireBullet?.({ from, dir, damage: w.damage, speed: 90, range: w.range, friendly: true, shooter: this });
1426
+ this.game.vfx?.muzzleFlash?.(from, dir);
1427
+ this.game.audio?.play('gunshot');
1428
+ // camera kick (RECOIL_KICK × recoil, eased settle — see update)
1429
+ this._recoilAmp = RECOIL_KICK * w.recoil;
1430
+ this._recoilT = RECOIL_TIME;
1431
+ // THE SHOT IS PROCEDURAL. The pack ships no plain 'shoot': its firing clip is Fanning —
1432
+ // a HIP-LEVEL hammer-fan that starts from a hip stance, so playing it yanked the gun out
1433
+ // of the aim and fired from the hip. So we never swap the pose: the aim overlay HOLDS and
1434
+ // the shot is sold by a muzzle-flip on the gun + the camera kick (both already procedural,
1435
+ // and they track wherever you're actually aiming, which a canned clip can't).
1436
+ this._gunRecoilT = 0.28;
1437
+ this._muzzleFlip = 0.55 * (w.recoil ?? 1); // radians of barrel rise, eased out in update()
1438
+ // VISIBLE SHOT. Removing the masked overlays also removed the fire motion — the gun fired
1439
+ // but the man stood stock still, so the click read as "nothing happened". Play the pack's
1440
+ // own fire clip FULL-BODY (never masked: masking a gun torso onto Synty legs is what
1441
+ // twisted him). While ADS the aim holds instead — the canned fan would yank the gun off
1442
+ // the reticle — and the camera kick + muzzle flip sell that shot.
1443
+ // The full-body fire clip is a hip-fan stance — on the horse it wrenches the seated rider off
1444
+ // the saddle to the side (Nick: "on shot it moves the player to the side of the horse"). Mounted,
1445
+ // never play it: the aim pose holds and the muzzle flash sells the shot, same as ADS on foot.
1446
+ if (!this.aiming && !this.riding.mounted) this.animator.play('gunFire', { once: true, fade: 0.05, onDone: () => {} });
1447
+ this._combatT = Math.max(this._combatT, 4);
1448
+ }
1449
+
1450
+ // DEAD EYE (game/deadeye.js) looses one queued tag: fired at the exact body point that was
1451
+ // painted — no spread (you already aimed, in slow motion) and no rate limit (the volley
1452
+ // owns its own ~0.12s cadence). Returns false on an empty chamber, which ends the volley.
1453
+ deadEyeShot(point) {
1454
+ const w = WEAPONS[this.weapon];
1455
+ if (!w || !this.drawn || this.ammo <= 0) return false;
1456
+ const from = this._muzzle(new THREE.Vector3());
1457
+ const dir = new THREE.Vector3().subVectors(point, from);
1458
+ if (dir.lengthSq() < 1e-8) return false;
1459
+ this._shootAlong(w, dir.normalize(), from);
1460
+ return true;
1461
+ }
1462
+
1463
+ // One-shot masked overlay with restart (setOverlay alone won't reset an action that's
1464
+ // already the overlay — repeat fire). Returns the action, or null when the clip is
1465
+ // missing (loadClips tolerates absent gun FBXs).
1466
+ _overlayOnce(name, { startFrac = 0, onDone = null } = {}) {
1467
+ const a = this.animator.actions[name];
1468
+ if (!a) return null;
1469
+ a.timeScale = 1; // a previous reversed play must not leak in
1470
+ a.enabled = true;
1471
+ a.setEffectiveWeight(1); // a finished one-shot's weight decays -> deficit -> T-POSE
1472
+ // setOverlay does reset -> loop -> weight -> play in the right order and crossfades from
1473
+ // the outgoing overlay of the SAME mask (weights sum to 1 — no T-pose fill). Calling
1474
+ // reset() after it, as this used to, destroyed the fade interpolant and hard-snapped.
1475
+ this.animator.setOverlay(name, { once: true, restart: true, fade: 0.08 });
1476
+ a.paused = false;
1477
+ if (startFrac > 0) a.time = a.getClip().duration * startFrac;
1478
+ a._onceDone = onDone;
1479
+ return a;
1480
+ }
1481
+
1482
+ // Cancel any live aim/draw/reload WITHOUT firing — for interruptions (menu, mount,
1483
+ // weapon swap, death). Whatever took over owns the pose; we just clear state.
1484
+ cancelGun() {
1485
+ this.aiming = false;
1486
+ this._drawT = 0;
1487
+ this._fireBuf = 0; // a buffered click must not survive the interruption
1488
+ this.cancelReload();
1489
+ this.animator.setOverlay(null, { fade: BASE_FADE }); // bare drop = zero upper weight = T-pose
1490
+ }
1491
+
1492
+ // Reload interrupt: old ammo stands. The reload's pending timer callback checks
1493
+ // `this.reloading` before refilling, so clearing the flag is the whole cancel.
1494
+ cancelReload() {
1495
+ if (!this.reloading) return;
1496
+ this.reloading = false;
1497
+ this.animator.setOverlay(null, { fade: BASE_FADE });
1498
+ }
1499
+
1500
+ // Holster with the real motion: the gun stays IN HAND while the clip plays and only drops
1501
+ // to leather on its last frame (stowing it up-front made the holster look like a no-op —
1502
+ // the weapon teleported to the hip and the animation had nothing left to show).
1503
+ holsterGun() {
1504
+ if (!this.drawn || this._holstering) return;
1505
+ this.cancelGun();
1506
+ const stow = () => { if (!this._holstering) return; this._holstering = false; this.drawn = false; this.placeWeapons(); };
1507
+ this._holstering = true;
1508
+ // FULL-BODY holster, same pattern as the reload: the locomotion block plays the take
1509
+ // (standing or walking) while _holstering is up, and the gun drops to leather on a TIMER
1510
+ // cut to the standing take's length. The old gunHolsterUpper overlay was dropped by the
1511
+ // no-masks locomotion block on the very next frame, so its onDone never fired —
1512
+ // _holstering stuck true forever and the gun could never be stowed (measured live).
1513
+ const dur = this.animator.actions.gunHolster?.getClip().duration;
1514
+ if (dur) this.game.after(dur / (PC.clipTiming.gunHolster?.speed ?? 1), stow);
1515
+ else stow(); // no clip in dev → instant
1516
+ }
1517
+
1518
+ jump(input) {
1519
+ this.airborne = true;
1520
+ // A RUNNING JUMP GOES HIGHER. You cannot convert forward speed into height with a standing
1521
+ // take-off, but a man at a dead run can — he plants and drives up, and every platformer since
1522
+ // the beginning has let him. So the take-off scales with how fast you are ACTUALLY travelling
1523
+ // (not with which key is held: a sprint into a wall is not a run-up), from a standing 7.2 up to
1524
+ // a third higher flat out. Height goes as v², so a third more speed is ~78% more air.
1525
+ const moving = input.down('KeyW') || input.down('KeyA') || input.down('KeyS') || input.down('KeyD');
1526
+ const top = (this.speed ?? 5) * 1.55; // flat-out sprint
1527
+ const run = moving ? Math.min(1, (this._hspeed ?? 0) / top) : 0;
1528
+ this.vy = 7.2 * (1 + 0.33 * run);
1529
+ this.busy = true; // suppress locomotion overrides; cleared on landing
1530
+ this.game.audio?.play('whoosh');
1531
+ // start/speed/end come from CLIP_TIMING (tuned in /anim.html)
1532
+ this.animator.play(moving ? 'jumpRun' : 'jumpIdle', { once: true, fade: 0.03 });
1533
+ this.busy = false; // allow air control; anim continues as one-shot
1534
+ }
1535
+
1536
+ // Soft-body separation so the gunslinger can't walk through townsfolk or outlaws.
1537
+ separateFromPeople() {
1538
+ let moved = false;
1539
+ const push = (o) => {
1540
+ if (!o || o === this || o.alive === false) return;
1541
+ const dx = this.position.x - o.position.x, dz = this.position.z - o.position.z;
1542
+ const d = Math.hypot(dx, dz);
1543
+ const min = this.radius + (o.radius ?? 0.45);
1544
+ if (d < min && d > 0.0001) {
1545
+ const k = (min - d) / d;
1546
+ this.position.x += dx * k;
1547
+ this.position.z += dz * k;
1548
+ moved = true;
1549
+ }
1550
+ };
1551
+ for (const o of this.game.npcs ?? []) push(o);
1552
+ for (const o of this.game.combat?.enemies ?? []) push(o);
1553
+ for (const o of this.game.horses ?? []) push(o); // solid horse bodies — no walking through them on foot
1554
+ // re-resolve against walls so a shove can't push us into a building
1555
+ if (moved) {
1556
+ const c = this.game.world.collide(this.position.x, this.position.z, this.radius, 0, this.position.y);
1557
+ this.position.x = c.x; this.position.z = c.z;
1558
+ }
1559
+ }
1560
+
1561
+ dodge(input) {
1562
+ if (this.stamina < 20) { this.game.audio?.play('whoosh'); return; } // too winded to roll
1563
+ this.stamina -= 20;
1564
+ this._stamPause = 1;
1565
+ let anim = 'dodgeF';
1566
+ if (input.down('KeyS')) anim = 'dodgeB';
1567
+ else if (input.down('KeyA')) anim = 'dodgeL';
1568
+ else if (input.down('KeyD')) anim = 'dodgeR';
1569
+ this.busy = true;
1570
+ this.dodgeT = 0.7;
1571
+ this.game.audio?.play('whoosh');
1572
+ this.animator.play(anim, { once: true, fade: 0.08, timeScale: 1.25, onDone: () => { this.busy = false; } });
1573
+ // BURST OF MOVEMENT — WHERE YOU ARE RUNNING, not where the lens is pointed.
1574
+ //
1575
+ // This used to roll along `heading`, and heading is NOT your travel direction while the gun is
1576
+ // out: aiming plants it at camYaw + PI so he can strafe with the revolver up (see the aim block
1577
+ // above). So a man sprinting sideways with his gun drawn dived at the camera instead of the way
1578
+ // he was going, which is exactly the moment a dive is supposed to save you.
1579
+ //
1580
+ // Take the direction he is ACTUALLY travelling — the same camera-relative wish vector the
1581
+ // locomotion builds from the keys — and fall back to his facing only when he is standing still,
1582
+ // where "the way he is going" has no meaning and the way he is looking is the honest answer.
1583
+ let mx = (input.down('KeyD') ? 1 : 0) - (input.down('KeyA') ? 1 : 0);
1584
+ let mz = (input.down('KeyS') ? 1 : 0) - (input.down('KeyW') ? 1 : 0);
1585
+ let dx, dz;
1586
+ if (mx || mz) {
1587
+ const l = Math.hypot(mx, mz); mx /= l; mz /= l;
1588
+ const s = Math.sin(this.camYaw), c = Math.cos(this.camYaw);
1589
+ dx = mx * c - mz * s;
1590
+ dz = -mx * s - mz * c;
1591
+ } else {
1592
+ dx = Math.sin(this.heading); dz = Math.cos(this.heading);
1593
+ }
1594
+ // burst over a fixed ~0.23s window, ticked from update() on GAME dt — frozen by pause,
1595
+ // same distance at 60Hz or 144Hz
1596
+ this._dodgeBurst = { dx, dz, left: 14 / 60 };
1597
+ }
1598
+
1599
+ takeDamage(amount, fromPos, opts) {
1600
+ if (!this.alive) return; // already dead — don't re-fire onPlayerDeath
1601
+ if (this.game.ui?.dialogueOpen) this.game.ui.closeDialogue?.(); // getting shot ends the conversation
1602
+ if (this.dodgeT > 0.25) return; // i-frames during roll
1603
+ if (this._hitGraceT > 0) return; // mercy window after a real hit — no chain-stagger lock
1604
+ amount *= this.game.dmgTakenMul ?? 1; // DIFFICULTY scales the hurt (Greenhorn 0.55× … Dead Man 1.7×)
1605
+ this.game.shake(0.2 + amount * 0.008); // getting hit should be felt, not just seen
1606
+ this._hitGraceT = 0.6;
1607
+ super.takeDamage(amount, fromPos, opts);
1608
+ this._combatT = Math.max(this._combatT, 4); // struck → stay in combat
1609
+ this.game.noteDamageTaken?.();
1610
+ this.game.ui?.flashDamage();
1611
+ if (!this.alive) {
1612
+ // dying mid-aim/mid-reload: cancel the gun so the corpse doesn't fall with the aim
1613
+ // overlay blended over the death clip, and a cancelled reload can't refill on respawn
1614
+ // (update() early-returns while dead, so the central 'stolen' check never runs)
1615
+ this.cancelGun();
1616
+ this.game.onPlayerDeath();
1617
+ }
1618
+ }
1619
+ }