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