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,582 @@
1
+ // THE LAW — Dustwater's four-star wanted system.
2
+ //
3
+ // A counter is not a wanted system. What makes it a game is that the town has EYES: shoot a man
4
+ // on Main Street in front of six people and the sheriff knows your face within the minute; shoot
5
+ // the same man out in the creosote with nobody inside a hundred yards and it is a rumour. So heat
6
+ // is scored per crime and then WEIGHTED BY WITNESSES (living townsfolk with line of sight), and
7
+ // it only cools while nobody — no lawman, no townsman — can see you. That is the whole loop:
8
+ // break contact, get out of town, wait it out.
9
+ //
10
+ // THE LAWMEN ARE PRE-BUILT. PERF LAW: a skinned mesh may never enter or leave the scene during
11
+ // play (WebGPU pipeline builds → multi-second freezes). So the posse — 4 deputies + 2 marshals —
12
+ // is spawned ONCE on the loading screen through combat.spawn() and parked at Fort Copperhead's
13
+ // parade ground (OUTPOSTS 'fort', 470m north: past the entity cull, so they are invisible, their
14
+ // animators frozen, their AI striding at 1/4 rate — they cost nothing). "Dispatching" a lawman is
15
+ // moving a man who already exists to a muster point and provoking him; "the heat dying" is walking
16
+ // him back to the fort. Nothing is ever created, added, or removed.
17
+ //
18
+ // They are ordinary Enemy instances running the ordinary Enemy FSM (wander→chase→shoot) — no new
19
+ // AI. Two things follow from that and are deliberate:
20
+ // * faction 'hostile', NOT 'ally'. combat.js's friendly-bullet loop SKIPS faction 'ally'
21
+ // (combat.js:404) — an "ally" lawman would be unshootable, and a wanted system whose police
22
+ // cannot be shot is not a wanted system. 'hostile' also means lawmen and outlaws ignore each
23
+ // other (same TEAM in enemy.js); the law is here for YOU, and teaching foes() a third team
24
+ // would mean editing enemy.js, which this system does not own.
25
+ // * their ENEMY_TYPES defs are REGISTERED from here (below). enemy.js keeps its defs inline and
26
+ // there is no data/enemies.js; registering into the exported table is how the law gets bodies
27
+ // without forking the Enemy class.
28
+ import * as THREE from 'three/webgpu';
29
+ import { ENEMY_TYPES } from './enemy.js'; // read-only here: the game registers law tiers with the roster
30
+ import { alarmTown } from './npc.js';
31
+ // where the off-screen law bench parks is game data: setLawBench({ x, z })
32
+ let BENCH = { x: 40, z: -470 };
33
+ export function setLawBench(p) { if (p) BENCH = p; }
34
+ import { dist2D } from '../core/utils.js';
35
+
36
+ const _v = new THREE.Vector3(); // module-local temp: tmpV1 is shared with combat's bullet loop
37
+
38
+ // ── the bodies ──────────────────────────────────────────────────────────────────────────────
39
+ // Same idiom as enemy.js's `outlaw()`/`townOutlaw()` helpers: one multi-char FBX, keep-filter a
40
+ // body, the pack's atlas, texFlipY (FBX UVs). The Sheriff body is the one characters.js reserves
41
+ // for the law ("keep out of the outlaw roster"); the marshals ride on the frontier pack's Hunter
42
+ // body so a posse doesn't read as six clones of the same man.
43
+ // Law-tier defs are registered with the rest of the game's roster (data/enemyTypes.js).
44
+
45
+ // ── the ledger ──────────────────────────────────────────────────────────────────────────────
46
+ // Heat per crime, BEFORE witnesses. A scuffle is a scuffle; a body in the street is a hanging
47
+ // offence; the coach is the county's money and brings the law down hard; a dead lawman is worse
48
+ // than all of it — one killing puts you straight to three stars even unseen.
49
+ const HEAT = {
50
+ assault: 7,
51
+ murder: 26,
52
+ robbery: 12, // going through a dead man's pockets
53
+ theft: 14, // pulling a man off his horse and taking it — a hanging offence out here
54
+ coachRobbery: 34,
55
+ lawman: 55,
56
+ // POINTING A GUN AT A CHILD. Cannot hurt him — he takes no damage, ever (npc.js) — but it
57
+ // frightens him and the street sees you do it. Deliberately just under a star's worth at five
58
+ // witnesses (5 x 1.88 = 9.4 against the 10 a star costs): do it once on Main Street and you get
59
+ // a black look; do it twice and the law wants a word. Unseen it is 1.1 heat and nothing happens,
60
+ // because nothing was seen to happen.
61
+ menace: 5,
62
+ };
63
+ // heat → stars. Derived, never set: the level can only move because heat moved.
64
+ const STARS = [10, 30, 60, 100, 150];
65
+ const MAX_HEAT = 200; // headroom above 5 stars so it doesn't drop the instant you stop
66
+
67
+ // Cooling, per star. Nothing cools while you are SEEN (see _spotted). Higher levels cool slower —
68
+ // 1 star is gone in ~10s of quiet, 4 stars takes ~100s of unbroken evasion (it decays through the
69
+ // lower tiers on the way down, each at its own rate).
70
+ // ONE ENTRY PER STAR, AND THERE ARE FIVE. These are indexed BY LEVEL, so a five-star level reading
71
+ // a four-long table gets `undefined` — and `x > undefined` is false, so the heat would simply never
72
+ // cool again. Permanently wanted, with no way to tell why. An off-by-one in a tuning table is
73
+ // invisible until it is the whole game.
74
+ const COOL_DELAY = [0, 4, 6, 9, 13, 18]; // seconds of not-seen, not-offending before heat moves
75
+ const COOL_RATE = [0, 4.5, 2.4, 1.3, 0.75, 0.5]; // heat per second once it does
76
+
77
+ // Who comes. Level 1 is one deputy taking an interest; level 4 is a posse with rifles.
78
+ const RESPONSE = [
79
+ { deputies: 0, marshals: 0 },
80
+ { deputies: 1, marshals: 0 },
81
+ { deputies: 2, marshals: 0 },
82
+ { deputies: 3, marshals: 1 },
83
+ { deputies: 4, marshals: 2 },
84
+ { deputies: 4, marshals: 2 }, // five stars: everything the county has, and it comes for you
85
+ ];
86
+ const DISPATCH_GAP = [0, 9, 7, 5, 3.5, 2.5]; // seconds between men leaving the jail
87
+ const POOL = { law_deputy: 4, law_marshal: 2 };
88
+
89
+ // WITNESSES. 26m is about as far as a townsman will swear to a face; line of sight is the real
90
+ // gate (world.losBlocked — the same test combat uses so you can't be shot through a wall).
91
+ const WITNESS_R = 26;
92
+ const TATTLE_R = 18; // a witness this close keeps pointing you out: heat won't cool
93
+
94
+ // WHERE THE LAW COMES FROM. Real places, so nobody materialises at your elbow: the two ends of the
95
+ // jail's boardwalk (jail_1 sits at 15.96,-11.99, its door facing west onto the street) and the two
96
+ // ends of Main Street, for men riding in. MIN_MUSTER keeps a spawn off-camera-ish; MAX_MUSTER is
97
+ // the point past which "from the jail" becomes a five-minute walk and we muster a posse near the
98
+ // player instead (they rode out after you — that is what a posse IS).
99
+ const LAW_POSTS = [
100
+ { x: 12.6, z: -14.6 }, // outside the jail, south end of its deck
101
+ { x: 12.6, z: -9.4 }, // outside the jail, north end
102
+ { x: 1, z: -46 }, // north end of Main Street
103
+ { x: -1, z: 46 }, // south end of Main Street
104
+ ];
105
+ const MIN_MUSTER = 22; // never closer to the player than this
106
+ const MAX_MUSTER = 150; // past this the jail is useless — ride a posse out to him
107
+ const POSSE_R = 58; // and put them down this far out, behind him if we can
108
+
109
+
110
+
111
+ export class Crime {
112
+ constructor(game) {
113
+ this.game = game;
114
+ this.heat = 0;
115
+ this.level = 0;
116
+ this.lawmen = []; // the resident pool (Enemy instances, all in combat.enemies for life)
117
+ this._coolT = 0;
118
+ this._dispatchT = 0;
119
+ this._retargetT = 0;
120
+ this._lastKnown = new THREE.Vector3(); // where the law thinks you are — only refreshed while they can see you
121
+ this._lawKillT = 0; // de-dup window: this system self-detects lawman deaths (see report)
122
+ this._buildHUD();
123
+ }
124
+
125
+ // Built on the LOADING SCREEN. Must run after combat.populate() (it needs combat.clips) — see
126
+ // the wiring block at the bottom of this file.
127
+ async build() {
128
+ const c = this.game.combat;
129
+ if (!c) { console.warn('[crime] no combat system — the law will never ride'); return this; }
130
+ let i = 0;
131
+ for (const [type, count] of Object.entries(POOL)) {
132
+ for (let n = 0; n < count; n++, i++) {
133
+ const a = (i / 6) * Math.PI * 2;
134
+ const x = BENCH.x + Math.sin(a) * 6, z = BENCH.z + Math.cos(a) * 6;
135
+ try {
136
+ // leashR 3 + passive: benched men mill about the parade ground and never aggro. They are
137
+ // 470m from town — past quality.entityCull, so Character.update hides them and freezes
138
+ // their animator on frame one. (If the player rides all the way to the fort he'll find
139
+ // the garrison standing about. They won't start anything. Shooting one is a crime.)
140
+ const e = await c.spawn(type, x, z, { leashR: 3, passive: true });
141
+ e.lawman = true; // other systems can tell a badge from an outlaw
142
+ this.lawmen.push(e);
143
+ } catch (err) { console.warn('[crime] lawman failed to muster', type, err); }
144
+ }
145
+ }
146
+ return this;
147
+ }
148
+
149
+ // ── the public ledger ─────────────────────────────────────────────────────────────────────
150
+ // kind: 'assault' | 'murder' | 'robbery' | 'coachRobbery' | 'lawman'
151
+ report(kind, position) {
152
+ const base = HEAT[kind];
153
+ if (base == null) return;
154
+ // Killing a badge is self-detected below (a lawman leaving combat.enemies alive-ledger), so a
155
+ // combat.js that ALSO reports it can't double-charge you for the same corpse.
156
+ if (kind === 'lawman') {
157
+ if (this._lawKillT > 0) return;
158
+ this._lawKillT = 0.6;
159
+ }
160
+ const at = position ?? this.game.player?.position;
161
+ if (!at) return;
162
+
163
+ const w = this._witnesses(at);
164
+ // Unseen ≠ free: a body still turns up. But it is a quarter of the price, and that gap is the
165
+ // entire reason to think about WHERE you do a thing.
166
+ let mult = w > 0 ? 1 + 0.22 * Math.min(w - 1, 4) : 0.22;
167
+ // Two exceptions to "nobody saw it": the law already hunting you knows your face (no quiet
168
+ // murders once the posse is out), and a dead lawman is always missed by his own.
169
+ if (this.level > 0 || kind === 'lawman') mult = Math.max(mult, 1);
170
+
171
+ const before = this.level;
172
+ this.heat = Math.min(MAX_HEAT, this.heat + base * mult);
173
+ this._coolT = 0;
174
+ this._syncLevel(before);
175
+
176
+ // the street empties: a gunshot and a scream carry further than a face does
177
+ const small = kind === 'assault' || kind === 'menace';
178
+ if (kind !== 'robbery') alarmTown(this.game, at.x, at.z, small ? 14 : 22, small ? 2.5 : 4.5);
179
+ }
180
+
181
+ clear() {
182
+ this.heat = 0;
183
+ this._coolT = 0;
184
+ const before = this.level;
185
+ this.level = 0;
186
+ for (const e of this.lawmen) this._bench(e, true); // force: called on death/reload, nobody's watching
187
+ if (before) this._paintStars();
188
+ }
189
+
190
+ isLawman(e) { return !!e?.lawman; }
191
+
192
+ // ── frame ─────────────────────────────────────────────────────────────────────────────────
193
+ update(dt) {
194
+ const p = this.game.player;
195
+ if (!p) return;
196
+ // Wall-clock-ish time, not the scaled dt: Dead Eye's slow-mo must not slow the cooldown to a
197
+ // crawl (the same reasoning main.js uses to tick deadEye on rawDt).
198
+ const rt = this.game.rawDt ?? dt;
199
+ if (this._lawKillT > 0) this._lawKillT -= rt;
200
+
201
+ this._tickBodies(rt);
202
+
203
+ const before = this.level;
204
+ if (this.heat > 0) {
205
+ // THROTTLED TO 4 Hz, and this is not a micro-optimisation. _spotted() walks every living
206
+ // soul in the county and fires a BVH line-of-sight ray at each one inside TATTLE_R — at
207
+ // fourteen townsfolk that was free, and at a hundred and fifty, standing in a crowded
208
+ // street, it is a fistful of raycasts EVERY FRAME just to ask whether anyone is looking.
209
+ // COOL_DELAY is 4-18 seconds and COOL_RATE is at most 4.5 heat/s, so a quarter-second of
210
+ // granularity is worth at most one heat and is invisible.
211
+ this._seenT = (this._seenT ?? 0) - rt;
212
+ if (this._seenT <= 0) { this._seenT = 0.25; this._seenC = this._spotted(); }
213
+ const seen = this._seenC;
214
+ if (seen) this._coolT = 0; else this._coolT += rt;
215
+ if (this._coolT > COOL_DELAY[this.level]) {
216
+ this.heat = Math.max(0, this.heat - COOL_RATE[this.level] * rt);
217
+ this._syncLevel(before);
218
+ }
219
+ }
220
+ this._dispatchUpdate(rt);
221
+ this._paintStars();
222
+ }
223
+
224
+ // ── heat → stars ──────────────────────────────────────────────────────────────────────────
225
+ _syncLevel(before) {
226
+ let lv = 0;
227
+ while (lv < 5 && this.heat >= STARS[lv]) lv++;
228
+ if (lv === this.level) return;
229
+ this.level = lv;
230
+ if (lv > before) {
231
+ this.game.audio?.play('aggro');
232
+ // the grace beat between "they know" and the first badge appearing — short at high stars
233
+ this._dispatchT = lv >= 3 ? 1.2 : 2.5;
234
+ const line = ['', 'A deputy is asking after you.', 'The sheriff has men on the street.',
235
+ 'Marshals. They are hunting you now.', 'Every badge in the county wants you.',
236
+ 'They have stopped trying to arrest you.'][lv];
237
+ if (lv === 4) {
238
+ this.game.ui?.questBanner?.('A POSSE RIDES');
239
+ this.game.ui?.toast?.('Every badge in the county wants you dead.', 5000);
240
+ } else if (line) this.game.ui?.toast?.(line, 3400);
241
+ } else if (lv === 0) {
242
+ this.game.ui?.toast?.('The law has lost your trail.', 3000);
243
+ }
244
+ }
245
+
246
+ // ── witnesses ─────────────────────────────────────────────────────────────────────────────
247
+ // Living townsfolk (game.npcs) and any coach crew who can SEE the spot. Coach riders are read
248
+ // defensively: they only became shootable people this wave and may not carry .position yet.
249
+ _witnesses(at) {
250
+ let n = 0;
251
+ for (const t of this.game.npcs ?? []) {
252
+ if (!t.alive) continue;
253
+ if (dist2D(t.position.x, t.position.z, at.x, at.z) > WITNESS_R) continue;
254
+ if (this.game.world.losBlocked(t.position, at)) continue;
255
+ n++;
256
+ }
257
+ // A NEUTRAL OUTFIT'S GUN IS A WITNESS TOO. He is an Enemy by mechanism (so that he can shoot
258
+ // back) and a civilian by law (enemy.js `civilian`), and he is standing four feet from the man
259
+ // you just shot. Without this line the drover's own partner does not see his murder.
260
+ for (const e of this.game.combat?.enemies ?? []) {
261
+ if (!e.alive || !e.civilian) continue;
262
+ if (dist2D(e.position.x, e.position.z, at.x, at.z) > WITNESS_R) continue;
263
+ if (this.game.world.losBlocked(e.position, at)) continue;
264
+ n++;
265
+ }
266
+ for (const c of this.game.coaches?.coaches ?? []) {
267
+ for (const r of c.riders ?? []) {
268
+ if (!r?.alive || !r.position || r.pivot?.visible === false) continue;
269
+ if (dist2D(r.position.x, r.position.z, at.x, at.z) > WITNESS_R) continue;
270
+ if (this.game.world.losBlocked(r.position, at)) continue;
271
+ n++;
272
+ }
273
+ }
274
+ return n;
275
+ }
276
+
277
+ // Are you being watched? A lawman inside his aggro range with a clear line, or a townsman close
278
+ // enough to keep pointing. Either one freezes the cooldown — this is "break line of sight, get
279
+ // out of town", made literal.
280
+ _spotted() {
281
+ const p = this.game.player;
282
+ if (!p.alive) return false;
283
+ for (const e of this.lawmen) {
284
+ if (!e.alive || !e._onDuty) continue;
285
+ const d = dist2D(e.position.x, e.position.z, p.position.x, p.position.z);
286
+ if (d > e.def.aggroR) continue;
287
+ if (this.game.world.losBlocked(e.position, p.position)) continue;
288
+ this._lastKnown.copy(p.position); // the law only ever knows where it last SAW you
289
+ return true;
290
+ }
291
+ for (const t of this.game.npcs ?? []) {
292
+ if (!t.alive) continue;
293
+ if (dist2D(t.position.x, t.position.z, p.position.x, p.position.z) > TATTLE_R) continue;
294
+ if (this.game.world.losBlocked(t.position, p.position)) continue;
295
+ return true;
296
+ }
297
+ return false;
298
+ }
299
+
300
+ // ── the pool: corpses, recycling, and the one perf trap ───────────────────────────────────
301
+ // combat.js EVICTS a body 6 seconds after death (scene.remove + splice out of combat.enemies).
302
+ // For an outlaw that's fine; for a pooled lawman it would tear a resident skinned mesh out of the
303
+ // scene — and putting him back would be exactly the pipeline stall the perf law exists to stop.
304
+ // So a dead lawman never reaches that flag: his deathT is held below the 6s eviction while the
305
+ // player is anywhere near (the corpse lies in the street, lootable, as a corpse should), and once
306
+ // the player is well clear he is quietly recycled back to the fort — indistinguishable from the
307
+ // corpse eviction the player already sees for outlaws.
308
+ _tickBodies(dt) {
309
+ const p = this.game.player;
310
+ for (let i = this.lawmen.length - 1; i >= 0; i--) {
311
+ const e = this.lawmen[i];
312
+ if (e.dead) { this.lawmen.splice(i, 1); continue; } // combat got him first (a dt spike) — he's gone; the county is short a man
313
+ if (e.alive) continue;
314
+ if (!e._deathSeen) {
315
+ e._deathSeen = true;
316
+ // SELF-DETECTED: killing a badge is a crime, and this system is the only one that knows
317
+ // which enemy was a badge. combat.js does not need to know about the law at all.
318
+ this.report('lawman', e.position.clone());
319
+ }
320
+ const d = dist2D(e.position.x, e.position.z, p.position.x, p.position.z);
321
+ if (d > 70) this._bench(e, true);
322
+ else if (e.deathT > 4.5) e.deathT = 4.5; // hold the body: never let combat's 6s eviction fire
323
+ }
324
+ }
325
+
326
+ // ── dispatch ──────────────────────────────────────────────────────────────────────────────
327
+ _dispatchUpdate(dt) {
328
+ const p = this.game.player;
329
+ const want = RESPONSE[this.level];
330
+ let onDuty = 0, dep = 0, mar = 0;
331
+ for (const e of this.lawmen) {
332
+ if (!e._onDuty || !e.alive) continue;
333
+ onDuty++;
334
+ if (e.type === 'law_marshal') mar++; else dep++;
335
+ }
336
+
337
+ // Keep the hunt honest: while they're on you, re-home the chase on your last known position so
338
+ // enemy.js's leash can't yank a chasing man back to the muster point mid-street. When they lose
339
+ // you they converge on that spot and cast about — which is what makes running work.
340
+ this._retargetT -= dt;
341
+ if (this._retargetT <= 0 && onDuty) {
342
+ this._retargetT = 1.0;
343
+ for (const e of this.lawmen) {
344
+ if (!e._onDuty || !e.alive) continue;
345
+ e.home.x = this._lastKnown.x; e.home.z = this._lastKnown.z;
346
+ if (e.state === 'wander' && this.level > 0) e.provoke(); // reacquire — he's not done with you
347
+ }
348
+ }
349
+
350
+ // Stand men down as the heat falls — but only ones far enough out that nobody sees a deputy
351
+ // wink out of existence.
352
+ const surplus = onDuty - (want.deputies + want.marshals);
353
+ if (surplus > 0) {
354
+ let n = surplus;
355
+ for (const e of this.lawmen) {
356
+ if (n <= 0) break;
357
+ if (!e._onDuty || !e.alive) continue;
358
+ if (this._bench(e, false)) n--;
359
+ }
360
+ }
361
+
362
+ if (this.level === 0) return;
363
+ this._dispatchT -= dt;
364
+ if (this._dispatchT > 0) return;
365
+ const needM = want.marshals - mar, needD = want.deputies - dep;
366
+ if (needM <= 0 && needD <= 0) return;
367
+ const type = needM > 0 ? 'law_marshal' : 'law_deputy';
368
+ const man = this.lawmen.find((e) => e.type === type && e.alive && !e._onDuty)
369
+ ?? this.lawmen.find((e) => e.alive && !e._onDuty); // out of marshals? send whoever's left
370
+ if (!man) return;
371
+ this._dispatchT = DISPATCH_GAP[this.level];
372
+ const spot = this._muster();
373
+ this._send(man, spot);
374
+ }
375
+
376
+ // Where the next man appears. The jail first (it's where lawmen ARE), unless the player is
377
+ // standing on top of it or two counties away; then a posse rides out to him — behind him if the
378
+ // camera allows, never in his face.
379
+ // WHERE THE LAW GOES — the last place it SAW you, not the place you actually are.
380
+ //
381
+ // This used to muster on the player's live position, and it made the wanted level inescapable:
382
+ // every new man rode out to wherever you were standing at that instant, arrived, saw you, and
383
+ // reset the cooldown — so the heat could never fall, no matter how far or how cleverly you ran.
384
+ // Measured: two stars, six hundred metres of empty desert, not a witness alive for half a mile,
385
+ // and the heat sat at 52 for a solid minute. The law was not hunting you; it was teleporting to
386
+ // you. A posse converges on where the trail went cold, and if you are not there, they cast about
387
+ // and lose you. That is the whole game of running.
388
+ _muster() {
389
+ const p = this._lastKnown ?? this.game.player.position;
390
+ let best = null, bestD = Infinity;
391
+ for (const s of LAW_POSTS) {
392
+ const d = dist2D(s.x, s.z, p.x, p.z);
393
+ if (d < MIN_MUSTER || d > MAX_MUSTER) continue;
394
+ const off = this._offCamera(s.x, s.z) ? 0 : 35; // an out-of-shot post is worth 35m of walking
395
+ if (d + off < bestD) { bestD = d + off; best = s; }
396
+ }
397
+ if (best) return best;
398
+
399
+ // the posse: ring the player at POSSE_R, prefer off-camera and standing on open ground
400
+ let pick = null, pickScore = -Infinity;
401
+ for (let i = 0; i < 8; i++) {
402
+ const a = (i / 8) * Math.PI * 2;
403
+ const x = p.x + Math.sin(a) * POSSE_R, z = p.z + Math.cos(a) * POSSE_R;
404
+ let score = this._offCamera(x, z) ? 100 : 0;
405
+ // don't put a man inside a wall: world.collide returns the CORRECTED point, so "blocked"
406
+ // means it MOVED (the trap entity.js documents at _probeSteer)
407
+ const c = this.game.world.collide(x, z, 0.5, 0, this.game.world.groundAt(x, z));
408
+ if (Math.abs(c.x - x) > 1e-6 || Math.abs(c.z - z) > 1e-6) score -= 500;
409
+ score -= dist2D(x, z, 0, 0) * 0.02; // all else equal, come from the town side
410
+ if (score > pickScore) { pickScore = score; pick = { x, z }; }
411
+ }
412
+ return pick ?? LAW_POSTS[0];
413
+ }
414
+
415
+ _offCamera(x, z) {
416
+ const f = this.game._camFrustum;
417
+ if (!f) return true;
418
+ _v.set(x, this.game.world.groundAt(x, z) + 1.0, z);
419
+ return !f.containsPoint(_v);
420
+ }
421
+
422
+ _send(e, spot) {
423
+ e.setPosition(spot.x, this.game.world.groundAt(spot.x, spot.z), spot.z);
424
+ e.home = { x: spot.x, z: spot.z };
425
+ e.leashR = 70; // long rein — the chase re-homes on _lastKnown every second
426
+ e.wanderTarget = null;
427
+ e._navPath = null;
428
+ e.target = null;
429
+ e._onDuty = true;
430
+ e.provoke(); // passive off, straight into chase
431
+ e.faceToward(this.game.player.position.x, this.game.player.position.z);
432
+ this._lastKnown.copy(this.game.player.position);
433
+ }
434
+
435
+ // Back to the fort. `force` bypasses the "is anyone watching" test (death recycle, clear()).
436
+ // Returns true if he actually went.
437
+ _bench(e, force) {
438
+ const p = this.game.player;
439
+ if (!force && e.alive) {
440
+ const d = dist2D(e.position.x, e.position.z, p.position.x, p.position.z);
441
+ if (d < 50 && !this._offCamera(e.position.x, e.position.z)) return false; // not while he's in shot
442
+ }
443
+ const i = this.lawmen.indexOf(e);
444
+ const a = (Math.max(0, i) / 6) * Math.PI * 2;
445
+ const x = BENCH.x + Math.sin(a) * 6, z = BENCH.z + Math.cos(a) * 6;
446
+ // full reset: a recycled corpse is a fresh man at the fort. Everything here is state the
447
+ // Enemy/Character FSM owns — clear all of it or he comes back mid-death-animation, staggered,
448
+ // with a swing still pending on someone who is now 500m away.
449
+ e.alive = true;
450
+ e.dead = false;
451
+ e.deathT = 0;
452
+ e.health = e.maxHealth;
453
+ e.busy = false;
454
+ e.staggerT = 0;
455
+ e._poiseT = 0;
456
+ e._hitBusyT = 0;
457
+ e._swing = null;
458
+ e._deathSeen = false;
459
+ e._onDuty = false;
460
+ e.passive = true;
461
+ e.state = 'wander';
462
+ e.target = null;
463
+ e.wanderTarget = null;
464
+ e._navPath = null;
465
+ e._token = undefined;
466
+ e.setPosition(x, this.game.world.groundAt(x, z), z);
467
+ e.home = { x, z };
468
+ e.leashR = 3;
469
+ e.animator?.play('idle');
470
+ return true;
471
+ }
472
+
473
+ // ── the HUD: four stars, top right ────────────────────────────────────────────────────────
474
+ // ui.js is not ours to edit, so the wanted block builds its own DOM into #hud — same idiom the
475
+ // rest of the HUD uses (one <style>, cached nodes, and EVERY DOM write behind a change check;
476
+ // this runs every frame). Dark empty stars, gold when lit, and the whole strip blinks while the
477
+ // heat is actually cooling — the GTA tell that says "keep it up and they're gone".
478
+ _buildHUD() {
479
+ if (document.getElementById('wanted')) return;
480
+ const css = document.createElement('style');
481
+ css.textContent = `
482
+ /* UNDER the money badge, not UNDERNEATH IT. Both this and the money counter were written to
483
+ the same corner (top:74px right:24px) by two hands that never met, so the stars rendered
484
+ behind the cash and you could not see them at all. The money block is 30px tall from 74,
485
+ so the law starts below it. */
486
+ #wanted { position:absolute; top:214px; right:26px; display:flex; flex-direction:column; align-items:flex-end; gap:3px;
487
+ opacity:0; transition:opacity 0.35s; filter:drop-shadow(0 2px 4px rgba(0,0,0,0.6)); }
488
+ #wanted.on { opacity:1; }
489
+ #wanted .wlbl { font-size:10px; letter-spacing:4px; color:#c9a24a; text-shadow:0 1px 3px #000; }
490
+ #wanted .wstars { display:flex; gap:2px; }
491
+ #wanted .wstar { width:24px; height:24px; background:center/contain no-repeat;
492
+ /* the real sprite out of INTERFACE_Military_Combat_HUD (Badge_Element_Star_01_Gold) — the
493
+ previous path pointed at a fantasy-warrior icon that does not exist in this project, so
494
+ every star was an empty box and a 404. It is already gold, so the LIT state does nothing
495
+ to it and the unlit state is the same star with the life drained out of it. */
496
+ background-image:url(/assets/ui/star_gold.png);
497
+ filter:grayscale(1) brightness(0.4); opacity:0.5; transition:filter 0.25s, opacity 0.25s, transform 0.25s; }
498
+ #wanted .wstar.lit { opacity:1;
499
+ filter:brightness(1.08) drop-shadow(0 0 6px rgba(240,200,90,0.8)); }
500
+ #wanted .wstar.new { animation: wstarpop 0.45s ease-out; }
501
+ @keyframes wstarpop { 0% { transform:scale(2.1) rotate(-25deg); } 60% { transform:scale(0.92); } 100% { transform:scale(1); } }
502
+ /* cooling: the law is losing you — the stars flutter (same beat as the Dead Eye pulse) */
503
+ #wanted.cooling .wstar.lit { animation: wstarblink 0.85s ease-in-out infinite; }
504
+ @keyframes wstarblink { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
505
+ `;
506
+ document.head.appendChild(css);
507
+ const el = document.createElement('div');
508
+ el.id = 'wanted';
509
+ el.innerHTML = `<span class="wlbl">WANTED</span><div class="wstars">${'<i class="wstar"></i>'.repeat(5)}</div>`;
510
+ (document.getElementById('hud') ?? document.getElementById('app'))?.appendChild(el);
511
+ this._hud = el;
512
+ this._stars = [...el.querySelectorAll('.wstar')];
513
+ this._hudLvl = 0;
514
+ this._hudCool = false;
515
+ }
516
+
517
+ _paintStars() {
518
+ if (!this._hud) return;
519
+ if (this.level !== this._hudLvl) {
520
+ const rising = this.level > this._hudLvl;
521
+ this._hudLvl = this.level;
522
+ this._hud.classList.toggle('on', this.level > 0);
523
+ for (let i = 0; i < 5; i++) {
524
+ const lit = i < this.level;
525
+ this._stars[i].classList.toggle('lit', lit);
526
+ this._stars[i].classList.remove('new');
527
+ if (lit && rising && i === this.level - 1) {
528
+ void this._stars[i].offsetWidth; // restart the keyframe on a re-lit star
529
+ this._stars[i].classList.add('new');
530
+ }
531
+ }
532
+ }
533
+ const cooling = this.level > 0 && this._coolT > COOL_DELAY[this.level];
534
+ if (cooling !== this._hudCool) {
535
+ this._hudCool = cooling;
536
+ this._hud.classList.toggle('cooling', cooling);
537
+ }
538
+ }
539
+ }
540
+
541
+ // ── WIRING (main.js — this file does not touch it) ───────────────────────────────────────────
542
+ //
543
+ // 1. import, with the other game systems at the top of main.js:
544
+ //
545
+ // import { Crime } from './game/crime.js';
546
+ //
547
+ // 2. CONSTRUCT + BUILD on the loading screen, immediately after `this.deadEye = new DeadEye(this);`
548
+ // (main.js ~line 210). It MUST come after `await this.combat.populate(clips)` (the pool spawns
549
+ // through combat.spawn, which needs combat.clips) and after `this.ui = new UI(this)` (the star
550
+ // HUD attaches to #hud). Both already happen above that line:
551
+ //
552
+ // this.crime = await new Crime(this).build(); // THE LAW: wanted level + the resident posse
553
+ //
554
+ // (`build()` is async because it loads six skinned bodies — that is the whole point of doing it
555
+ // here, on the loading screen, and never again.)
556
+ //
557
+ // 3. TICK it once per frame inside the `if (!paused)` block, next to the other entity systems —
558
+ // e.g. right before `_fz.ent = performance.now();` (main.js ~line 619), after combat/npcs:
559
+ //
560
+ // this.crime.update(dt);
561
+ //
562
+ // 4. WIPE it when the run resets — in newGame() (main.js ~441) and wherever player death is
563
+ // handled (onPlayerDeath):
564
+ //
565
+ // this.crime?.clear();
566
+ //
567
+ // 5. REPORTING (combat.js's job, not main.js's). combat.js must call, from the friendly-bullet /
568
+ // melee paths, when a CIVILIAN (townsfolk NPC or coach rider — anything with .civilian) is hit:
569
+ //
570
+ // this.game.crime?.report('assault', e.position); // hurt a civilian
571
+ // this.game.crime?.report('murder', e.position); // killed a civilian
572
+ // this.game.crime?.report('coachRobbery', e.position); // killed a coach driver/mate/fare
573
+ //
574
+ // and loot.js, when a corpse is robbed:
575
+ //
576
+ // game.crime?.report('robbery', corpse.position);
577
+ //
578
+ // Killing a LAWMAN needs NO call — Crime watches its own pool and reports it itself. (If
579
+ // combat.js reports it anyway, a 0.6s de-dup window in report() swallows the double.)
580
+ //
581
+ // NOTHING ELSE. No enemy.js edit is needed (the law rides as faction 'hostile'); no ui.js edit is
582
+ // needed (the stars build their own DOM); no scene churn ever happens after boot.
@@ -54,7 +54,7 @@ export class Encounters {
54
54
 
55
55
  // ── the neutral outfits. The men are real NPCs (civilians: shootable, lootable, witnesses);
56
56
  // the one gun in the outfit is a passive Enemy who never starts anything on his own.
57
- for (const o of [...OUTFITS]) {
57
+ for (const o of [...ET.outfits]) {
58
58
  const grp = { id: o.id, name: o.name, kind: 'outfit', x: o.x, z: o.z, members: [], guns: [] };
59
59
  this.groups.set(o.id, grp);
60
60
  await Promise.all(o.men.map(async (m, i) => {