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