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.
- 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/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/enemy.js +442 -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/loot.js +303 -0
- package/src/systems/missions.js +418 -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/player.js +1619 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/train.js +1957 -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;
|
|
@@ -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
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────
|