sindicate 0.2.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.
Files changed (70) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/casino/blackjack.js +187 -0
  23. package/src/systems/casino/cards3d.js +55 -0
  24. package/src/systems/casino/coins3d.js +75 -0
  25. package/src/systems/casino/table3d.js +130 -0
  26. package/src/systems/climbing.js +136 -0
  27. package/src/systems/coach.js +1034 -0
  28. package/src/systems/combat.js +586 -0
  29. package/src/systems/crime.js +582 -0
  30. package/src/systems/deadeye.js +332 -0
  31. package/src/systems/encounters.js +123 -0
  32. package/src/systems/enemy.js +442 -0
  33. package/src/systems/entity.js +524 -0
  34. package/src/systems/fistCurl.js +38 -0
  35. package/src/systems/footsteps.js +161 -0
  36. package/src/systems/glass.js +67 -0
  37. package/src/systems/herd.js +277 -0
  38. package/src/systems/horse.js +361 -0
  39. package/src/systems/loot.js +303 -0
  40. package/src/systems/missions.js +418 -0
  41. package/src/systems/mountedTraveller.js +518 -0
  42. package/src/systems/nav.js +343 -0
  43. package/src/systems/npc.js +880 -0
  44. package/src/systems/pathFollow.js +107 -0
  45. package/src/systems/platform.js +484 -0
  46. package/src/systems/player.js +1619 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/roadGraph.js +264 -0
  49. package/src/systems/satchel.js +500 -0
  50. package/src/systems/scenes.js +105 -0
  51. package/src/systems/seatedRider.js +396 -0
  52. package/src/systems/shop.js +515 -0
  53. package/src/systems/skybirds.js +129 -0
  54. package/src/systems/train.js +1957 -0
  55. package/src/systems/trainCamera.js +414 -0
  56. package/src/systems/traveller.js +254 -0
  57. package/src/systems/tumbleweeds.js +92 -0
  58. package/src/systems/vat.js +327 -0
  59. package/src/systems/vfx.js +472 -0
  60. package/src/world/blobShadows.js +109 -0
  61. package/src/world/clouds.js +61 -0
  62. package/src/world/flora.js +87 -0
  63. package/src/world/footprints.js +117 -0
  64. package/src/world/lampField.js +58 -0
  65. package/src/world/lampGlow.js +92 -0
  66. package/src/world/scatter.js +1077 -0
  67. package/src/world/sky.js +363 -0
  68. package/src/world/tileManager.js +197 -0
  69. package/src/world/walkHumps.js +74 -0
  70. 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;
@@ -0,0 +1,303 @@
1
+ // MONEY + LOOT. A man who falls spills his purse where he fell; the player walks over it and
2
+ // it's his. That's the whole loop — no inventory screen, no loot table UI, no "press E to open".
3
+ //
4
+ // Three decisions worth writing down:
5
+ //
6
+ // 1. WALK-OVER, NOT PROMPT. E is already contested (mount / dismount / doors / the finisher
7
+ // outranks all of them — main.js:642-669), and a purse you have to stop and interact with
8
+ // turns a gunfight into paperwork. Walk within 1.2m and it's yours, with a short magnet
9
+ // so you never have to hunt for the exact pixel.
10
+ //
11
+ // 2. THE PURSES ARE POOLED. PERF LAW is about SKINNED meshes — a sack is a static prop and
12
+ // shatter.js adds and removes those freely — but a drop happens at the worst possible
13
+ // moment (mid-firefight, four men down at once), and instantiate() on the hot path is an
14
+ // await plus a clone plus a first-sight pipeline/texture upload. So the whole pool is
15
+ // built ON THE LOADING SCREEN, added to the scene once, and "dropping" is making a
16
+ // resident sack visible — the same trick the coach uses for its fares.
17
+ //
18
+ // 3. THE HUD LIVES HERE. ui.js owns the hearts, the clock and the ammo pips; the money
19
+ // counter is one <div> that only this file ever writes, so it ships with the system it
20
+ // belongs to instead of adding a limb to UI. It is styled off the clock's hex plate so it
21
+ // reads as part of the same HUD, it is parented to #hud (so it hides when the HUD hides),
22
+ // and — house law — every DOM write is behind a change check.
23
+ import * as THREE from 'three/webgpu';
24
+ import { instantiate } from '../core/assets.js';
25
+ // purse bands, drop tables and the goods catalogue are GAME data:
26
+ // setLootContent({ purseFor, purseKind, dropItemsFor, items })
27
+ let LC = { purseFor: () => 0, purseKind: () => 'default', dropItemsFor: () => null, items: {} };
28
+ export function setLootContent(c) { LC = { ...LC, ...c }; }
29
+
30
+ const ATLAS = '/assets/textures/atlas_western.png';
31
+ // Four sack shapes so a wiped stagecoach doesn't leave four identical props in the dust.
32
+ const SACKS = ['SM_Prop_Sack_01', 'SM_Prop_Sack_02', 'SM_Prop_Sack_03', 'SM_Prop_Sack_04'];
33
+
34
+ const START_MONEY = 5; // the player rides in with a few dollars — enough to feel like money exists
35
+ const POOL = 16; // 16 purses on the ground at once; a full coach (6) plus a street brawl
36
+ const PURSE_H = 0.30; // metres tall. The pack's sacks are grain-sized; a purse is a hand's-worth,
37
+ // and normalising by measured bbox means the FBX/GLB-twin scale can't bite us.
38
+ const PICKUP_R = 1.2; // walk-over radius (2D — you can grab a purse from the boardwalk above it)
39
+ const MAGNET_R = 2.6; // beyond pickup, the purse slides to you. Hunting for the exact pixel is not fun.
40
+ const MAGNET_V = 3.4; // m/s it slides
41
+ const LIFE = 120; // seconds before it's gone. Otherwise the desert silts up with sacks.
42
+ const FADE = 3; // last seconds shrink away, so the vanish doesn't look like a bug
43
+ const MERGE_R = 0.7; // a second body falling on the same spot tops up the purse already there,
44
+ // instead of burning a pool slot and stacking two sacks in each other
45
+ const BOB_H = 0.06;
46
+ const SPIN = 1.1; // rad/s — slow enough to read as loot, not as a pickup from another genre
47
+
48
+ export class Loot {
49
+ constructor(game) {
50
+ this.game = game;
51
+ this.money = START_MONEY;
52
+ this.purses = []; // the pool — every entry resident and scene-attached for life
53
+ this._live = 0; // how many are out (cheap early-out in update)
54
+ this._t = 0;
55
+ this._bump = false; // set on a gain, consumed by the HUD next frame
56
+ this._shown = -1; // last value written to the DOM (change-gate)
57
+ this._buildHUD();
58
+ // Kicked off in the constructor, awaited by boot: `await this.loot.ready`. A drop before
59
+ // the sacks exist is survivable (the money is simply awarded on the spot) but it should
60
+ // never happen — the pool is up before the loading screen comes down.
61
+ this.ready = this._buildPool().catch((e) => { console.warn('[loot] purse pool failed to build', e); });
62
+ }
63
+
64
+ // ── MONEY ────────────────────────────────────────────────────────────────────────────────
65
+
66
+ add(n) {
67
+ n = Math.round(n);
68
+ if (!n) return this.money;
69
+ this.money = Math.max(0, this.money + n);
70
+ if (n > 0) { this._bump = true; this.game.audio?.play('coin'); } // synth.js has a 'coin' case
71
+ return this.money;
72
+ }
73
+
74
+ // For the shops/bribes that don't exist yet. Returns false and takes nothing if he's short.
75
+ spend(n) {
76
+ n = Math.round(n);
77
+ if (n > this.money) return false;
78
+ this.money -= n;
79
+ this._shown = -1; // a spend must repaint even though _bump stays false
80
+ return true;
81
+ }
82
+
83
+ // What a given man is carrying — see data/purse.js. `id` is an npc def id, an ENEMY_TYPES
84
+ // key, or a coach seat role ('driver' / 'mate' / 'fare'); unknown ids get the default band.
85
+ amountFor(id, mult = 1) { return LC.purseFor(id, mult); }
86
+
87
+ // The convenience the other systems actually want: "this one just died, spill him".
88
+ // dropFor('driver', rider.position) — one call, no purse arithmetic at the call site.
89
+ // ...and now his THINGS as well as his cash: dropItemsFor rolls data/drops.js by the same id, so
90
+ // every caller that already spilled a purse (combat.js, npc.js) starts dropping items for free.
91
+ dropFor(id, position, mult = 1) { return this.drop(position, LC.purseFor(id, mult), LC.dropItemsFor(id)); }
92
+
93
+ // ── THE PURSE ON THE GROUND ──────────────────────────────────────────────────────────────
94
+
95
+ // `position` is at the FEET (the codebase's entity convention) — the corpse's own y, NOT
96
+ // groundAt(), because a man shot on the saloon boardwalk must not drop his purse through
97
+ // the deck and into the dirt below it.
98
+ drop(position, amount = LC.purseFor('default'), items = null) {
99
+ amount = Math.max(1, Math.round(amount));
100
+ // top up a purse already lying here rather than stacking sacks (a coach crew dies in a heap)
101
+ for (const p of this.purses) {
102
+ if (p.t < 0) continue;
103
+ const dx = p.root.position.x - position.x, dz = p.root.position.z - position.z;
104
+ if (dx * dx + dz * dz < MERGE_R * MERGE_R && Math.abs(p.baseY - position.y) < 1.2) {
105
+ p.amount += amount;
106
+ p.items = mergeItems(p.items, items); // ...and its THINGS too, or a top-up silently loses them
107
+ p.t = 0; // and its clock restarts — the pile is fresh again
108
+ return p;
109
+ }
110
+ }
111
+ const p = this._take();
112
+ if (!p) { this.add(amount); this._giveItems(items); return null; } // pool not built yet: pay him rather than lose it
113
+ p.amount = amount;
114
+ p.items = items;
115
+ p.t = 0;
116
+ p.phase = Math.random() * 6.28;
117
+ p.baseY = position.y + 0.02;
118
+ // scatter a little so two drops a metre apart still read as two purses
119
+ p.root.position.set(position.x + (Math.random() - 0.5) * 0.3, p.baseY, position.z + (Math.random() - 0.5) * 0.3);
120
+ p.root.rotation.y = Math.random() * 6.28;
121
+ p.root.scale.setScalar(1);
122
+ p.root.visible = true;
123
+ this._live++;
124
+ return p;
125
+ }
126
+
127
+ update(dt) {
128
+ if (!this._live) { this._syncHUD(); return; }
129
+ dt = Math.min(dt, 0.05);
130
+ this._t += dt;
131
+ const player = this.game.player;
132
+ const alive = player?.alive !== false;
133
+ for (const p of this.purses) {
134
+ if (p.t < 0) continue;
135
+ p.t += dt;
136
+ // expire — shrink out over the last few seconds so it doesn't just blink off
137
+ if (p.t > LIFE) { this._park(p); continue; }
138
+ const left = LIFE - p.t;
139
+ if (left < FADE) p.root.scale.setScalar(left / FADE);
140
+ p.root.rotation.y += SPIN * dt;
141
+ p.root.position.y = p.baseY + 0.14 + Math.sin(this._t * 2.4 + p.phase) * BOB_H;
142
+ if (!alive) continue;
143
+ const px = player.position.x, pz = player.position.z;
144
+ const dx = px - p.root.position.x, dz = pz - p.root.position.z;
145
+ const d2 = dx * dx + dz * dz;
146
+ if (d2 > MAGNET_R * MAGNET_R) continue;
147
+ if (d2 > PICKUP_R * PICKUP_R) {
148
+ const d = Math.sqrt(d2) || 1;
149
+ p.root.position.x += (dx / d) * MAGNET_V * dt;
150
+ p.root.position.z += (dz / d) * MAGNET_V * dt;
151
+ continue;
152
+ }
153
+ // TAKEN. The float reads like a damage number because that's the beat it lands on —
154
+ // showDamage() takes any {position} and any string (combat.js passes 'HEADSHOT').
155
+ this.add(p.amount);
156
+ this.game.ui?.showDamage({ position: p.root.position }, `$${p.amount}`);
157
+ this._giveItems(p.items); // BEFORE _park clears the slot — the money floats, the things get a toast
158
+ this._park(p);
159
+ }
160
+ this._syncHUD();
161
+ }
162
+
163
+ // ── internals ────────────────────────────────────────────────────────────────────────────
164
+
165
+ _take() {
166
+ let oldest = null;
167
+ for (const p of this.purses) {
168
+ if (p.t < 0) return p;
169
+ if (!oldest || p.t > oldest.t) oldest = p;
170
+ }
171
+ // Pool exhausted (16 uncollected purses lying about). Recycle the OLDEST — the one he's
172
+ // had the longest to pick up and didn't. Its cash is forfeit; that's the price of greed.
173
+ if (oldest) this._live--;
174
+ return oldest;
175
+ }
176
+
177
+ _park(p) {
178
+ p.t = -1;
179
+ p.items = null; // a recycled slot must never carry the last body's things
180
+ p.root.visible = false;
181
+ this._live = Math.max(0, this._live - 1);
182
+ }
183
+
184
+ // Merge a dropped body's THINGS into the satchel and name them in a toast. Cash floats its own
185
+ // number (showDamage) the frame it lands; the items get a line so the standout — a watch, a bar —
186
+ // reads. Guarded on the satchel: a drop before the UI is up simply awards nothing (never in play).
187
+ _giveItems(items) {
188
+ if (!items?.length) return;
189
+ const sat = this.game.ui?.satchel;
190
+ if (!sat) return;
191
+ const names = [];
192
+ for (const it of items) {
193
+ const got = sat.add(it.id, it.n);
194
+ if (got > 0) { const nm = LC.items[it.id]?.name ?? it.id; names.push(got > 1 ? `${got}× ${nm}` : nm); }
195
+ }
196
+ if (names.length) this.game.ui?.toast?.(`Picked up ${names.join(', ')}`);
197
+ }
198
+
199
+ async _buildPool() {
200
+ const box = new THREE.Box3();
201
+ for (let i = 0; i < POOL; i++) {
202
+ const mesh = await instantiate(`/assets/western/${SACKS[i % SACKS.length]}.fbx`, { texture: ATLAS });
203
+ // Normalise off the MEASURED height, not a magic scalar: instantiate() hands back a
204
+ // cm-authored FBX at 0.01 or its metre-scale GLB twin at 1:1 (assets.js decides), and a
205
+ // hardcoded scale would silently be 100× wrong the day the twin manifest changes.
206
+ box.setFromObject(mesh);
207
+ const h = Math.max(1e-3, box.max.y - box.min.y);
208
+ const s = PURSE_H / h;
209
+ mesh.scale.multiplyScalar(s);
210
+ mesh.position.y = -box.min.y * s; // base sits on the group origin, so bobbing is about the ground
211
+ const root = new THREE.Group();
212
+ root.name = 'purse';
213
+ root.add(mesh);
214
+ root.visible = false;
215
+ this.game.scene.add(root); // added ONCE, here, on the loading screen. Never during play.
216
+ this.purses.push({ root, amount: 0, t: -1, baseY: 0, phase: 0, items: null });
217
+ }
218
+ }
219
+
220
+ // ── HUD: "$123", top-right under the clock ───────────────────────────────────────────────
221
+
222
+ _buildHUD() {
223
+ const css = document.createElement('style');
224
+ // Lifted straight off #clock (ui.js) — same hex plate, same parchment gold, same drop
225
+ // shadow — so the counter looks like it was always there. Font comes from #hud.
226
+ css.textContent = `
227
+ #money { position:absolute; top:74px; right:24px; height:30px; display:flex; align-items:center; justify-content:center; gap:5px; padding:0 20px; filter:drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
228
+ #money .mhex { position:absolute; inset:0; z-index:0;
229
+ background:linear-gradient(180deg,rgba(96,68,34,0.82),rgba(42,28,12,0.86));
230
+ -webkit-mask:url(/assets/ui/fw/SPR_HUD_FantasyWarrior_Mask_Hexagon_01.png) center/100% 100% no-repeat;
231
+ mask:url(/assets/ui/fw/SPR_HUD_FantasyWarrior_Mask_Hexagon_01.png) center/100% 100% no-repeat; }
232
+ #money .mcoin { position:relative; z-index:1; width:15px; height:15px; object-fit:contain; filter:drop-shadow(0 1px 1px rgba(0,0,0,0.6)); }
233
+ #money .mtxt { position:relative; z-index:1; color:#f0e2c0; font-size:14px; font-weight:600; letter-spacing:1px; text-shadow:0 1px 3px #000; white-space:nowrap; transform-origin:center; }
234
+ #money .mtxt.bump { animation: moneybump 0.4s ease-out; }
235
+ @keyframes moneybump { 0% { transform:scale(1.4); color:#fff4c8; } 100% { transform:scale(1); color:#f0e2c0; } }
236
+ `;
237
+ document.head.appendChild(css);
238
+ this._el = null; // #hud may not exist yet (construction order) — _syncHUD adopts it late
239
+ }
240
+
241
+ _syncHUD() {
242
+ if (!this._el) {
243
+ const hud = document.getElementById('hud');
244
+ if (!hud) return; // UI not built — try again next frame, cost is one getElementById
245
+ const div = document.createElement('div');
246
+ div.id = 'money';
247
+ div.innerHTML = '<i class="mhex"></i><img class="mcoin" src="/assets/ui/fw/icons/ICON_FantasyWarrior_Inventory_Currency_01_Clean.png" alt=""><span class="mtxt"></span>';
248
+ hud.appendChild(div);
249
+ this._el = div.querySelector('.mtxt');
250
+ }
251
+ if (this.money === this._shown) return; // called every frame — only touch the DOM on change
252
+ const gained = this.money > this._shown && this._bump;
253
+ this._shown = this.money;
254
+ this._el.textContent = `$${this.money}`;
255
+ this._bump = false;
256
+ if (gained) {
257
+ // restart the animation: drop the class, force a reflow, put it back
258
+ this._el.classList.remove('bump');
259
+ void this._el.offsetWidth;
260
+ this._el.classList.add('bump');
261
+ }
262
+ }
263
+ }
264
+
265
+ // Fold one drop's item list into another's (a purse top-up on the same spot), summing stacks of the
266
+ // same id. Either side may be null; the result is null only when both are empty.
267
+ function mergeItems(a, b) {
268
+ if (!a?.length) return b?.length ? b.slice() : null;
269
+ if (!b?.length) return a.slice();
270
+ const out = a.map((it) => ({ ...it }));
271
+ for (const it of b) {
272
+ const hit = out.find((o) => o.id === it.id);
273
+ if (hit) hit.n += it.n; else out.push({ ...it });
274
+ }
275
+ return out;
276
+ }
277
+
278
+ // Re-exported so a caller that already has `game.loot` doesn't need a second import to ask
279
+ // "what kind of purse is this man carrying".
280
+ export const purseFor = (...a) => LC.purseFor(...a);
281
+ export const purseKind = (...a) => LC.purseKind(...a);
282
+
283
+ // ─────────────────────────────────────────────────────────────────────────────────────────
284
+ // WIRING — what main.js must do (this file touches nothing else).
285
+ //
286
+ // 1. import, with the other game systems near the top:
287
+ // import { Loot } from './game/loot.js';
288
+ //
289
+ // 2. construct in boot(), right after `this.deadEye = new DeadEye(this);` (main.js:209) —
290
+ // after audio/ui because it drives both — and await the purse pool BEFORE the loading
291
+ // screen comes down (the sacks are static props, but they are built once, not per drop):
292
+ // this.loot = new Loot(this);
293
+ // await this.loot.ready; // the purse pool: 16 sacks, built on the loading screen
294
+ //
295
+ // 3. tick it inside the `if (!paused)` block, next to vfx/shatter (main.js:617-620), so it
296
+ // freezes with the pause menu:
297
+ // this.loot.update(dt);
298
+ //
299
+ // 4. no other hooks. Whoever kills a man calls it:
300
+ // game.loot.dropFor('driver', rider.position) // rolls the band from data/purse.js
301
+ // game.loot.drop(pos, 12) // or an explicit sum
302
+ // and the pickup, the HUD and the coin sound take care of themselves.
303
+ // ─────────────────────────────────────────────────────────────────────────────────────────