sindicate 0.4.0 → 0.5.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.
- package/package.json +1 -1
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
|
@@ -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
|
+
}
|