sindicate 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ // ─────────────────────────────────────────────────────────────────────────────────────────