sindicate 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/renderer.md +115 -0
  10. package/docs/api/retarget.md +149 -0
  11. package/docs/api/shatter.md +121 -0
  12. package/docs/api/utils-decals.md +188 -0
  13. package/docs/api/water.md +143 -0
  14. package/docs/api/wind-weather-uniforms.md +105 -0
  15. package/docs/contracts/render.md +32 -0
  16. package/docs/contracts/time-feel.md +52 -0
  17. package/package.json +1 -1
  18. package/src/core/engine.js +284 -0
  19. package/src/index.js +13 -1
  20. package/src/systems/aim.js +114 -0
  21. package/src/systems/campfire.js +150 -0
  22. package/src/systems/casino/blackjack.js +187 -0
  23. package/src/systems/casino/cards3d.js +55 -0
  24. package/src/systems/casino/coins3d.js +75 -0
  25. package/src/systems/casino/table3d.js +130 -0
  26. package/src/systems/climbing.js +136 -0
  27. package/src/systems/coach.js +1034 -0
  28. package/src/systems/combat.js +586 -0
  29. package/src/systems/crime.js +582 -0
  30. package/src/systems/deadeye.js +332 -0
  31. package/src/systems/encounters.js +123 -0
  32. package/src/systems/enemy.js +442 -0
  33. package/src/systems/entity.js +524 -0
  34. package/src/systems/fistCurl.js +38 -0
  35. package/src/systems/footsteps.js +161 -0
  36. package/src/systems/glass.js +67 -0
  37. package/src/systems/herd.js +277 -0
  38. package/src/systems/horse.js +361 -0
  39. package/src/systems/loot.js +303 -0
  40. package/src/systems/missions.js +418 -0
  41. package/src/systems/mountedTraveller.js +518 -0
  42. package/src/systems/nav.js +343 -0
  43. package/src/systems/npc.js +880 -0
  44. package/src/systems/pathFollow.js +107 -0
  45. package/src/systems/platform.js +484 -0
  46. package/src/systems/player.js +1619 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/roadGraph.js +264 -0
  49. package/src/systems/satchel.js +500 -0
  50. package/src/systems/scenes.js +105 -0
  51. package/src/systems/seatedRider.js +396 -0
  52. package/src/systems/shop.js +515 -0
  53. package/src/systems/skybirds.js +129 -0
  54. package/src/systems/train.js +1957 -0
  55. package/src/systems/trainCamera.js +414 -0
  56. package/src/systems/traveller.js +254 -0
  57. package/src/systems/tumbleweeds.js +92 -0
  58. package/src/systems/vat.js +327 -0
  59. package/src/systems/vfx.js +472 -0
  60. package/src/world/blobShadows.js +109 -0
  61. package/src/world/clouds.js +61 -0
  62. package/src/world/flora.js +87 -0
  63. package/src/world/footprints.js +117 -0
  64. package/src/world/lampField.js +58 -0
  65. package/src/world/lampGlow.js +92 -0
  66. package/src/world/scatter.js +1077 -0
  67. package/src/world/sky.js +363 -0
  68. package/src/world/tileManager.js +197 -0
  69. package/src/world/walkHumps.js +74 -0
  70. package/src/world/weather.js +312 -0
@@ -0,0 +1,880 @@
1
+ // NPC — the townsfolk of Dustwater. Two behaviours, chosen by the def (data/npcs.js):
2
+ //
3
+ // POSTED (def.post) the barkeep behind his counter, the piano player at the keys, a hand
4
+ // of poker that never ends, the sheriff watching the street. Holds a
5
+ // mark and a heading, drifts through a small pool of role idles, clocks
6
+ // the player when he comes near, and walks back if he gets shoved off it.
7
+ // WANDER (def.wander) strolls a route of street points, lingering at each on a social idle,
8
+ // and occasionally strikes up a STAGED TWO-PERSON CONVERSATION with
9
+ // another free wanderer (NC.convTakes: matched A/B takes played in sync
10
+ // while the pair stand facing each other, turn-taking for 3–5 exchanges).
11
+ //
12
+ // Villagers are NOT COMBATANTS: faction 'villager' is deliberately absent from enemy.js's TEAM
13
+ // map, so foes() never returns true for them and no outlaw will ever shoot one. But they ARE
14
+ // SHOOTABLE — combat.js's bullet loop and the melee arc test game.npcs alongside the outlaws
15
+ // (they expose the same .position/.alive/.maxHealth/.takeDamage contract, plus .civilian). A man
16
+ // who takes a round cries out, reels, and RUNS; a man who takes the last one goes down where he
17
+ // stands and stays there. Killing him is a MURDER (crime.js) and spills his purse (loot.js) —
18
+ // the town does not forget, and the town does not stand still and watch.
19
+ //
20
+ // PERF CONTRACT (main.js:118): every NPC is built ONCE on the loading screen and stays resident
21
+ // and scene-attached for life. Nothing here ever adds, removes, or toggles a skinned mesh —
22
+ // visibility is only ever driven by Character.update's own distance/frustum cull, which is the
23
+ // mechanism the renderer already expects. Do not "spawn" an NPC during play.
24
+ import { Character } from './entity.js';
25
+ // NPC content is GAME data — register at boot, before populateTown/County:
26
+ // setNpcContent({ clipSubset, villagerClips, socialIdles, convTakes, convClips,
27
+ // getCharacter, npcDefs, wanderRoutes, countyDefs })
28
+ let NC = {
29
+ clipSubset: (c) => c, villagerClips: [], socialIdles: [], convTakes: [], convClips: [],
30
+ getCharacter: () => null, npcDefs: [], wanderRoutes: {}, countyDefs: [],
31
+ };
32
+ export function setNpcContent(c) { NC = { ...NC, ...c }; }
33
+ import { dist2D, tmpV1 } from '../core/utils.js';
34
+ import { steerCarrot } from './pathFollow.js';
35
+
36
+ const _fDir = tmpV1.clone(); // mission-follow steering scratch (own vector — tmpV1 is shared)
37
+ const _fAim = { x: 0, z: 0 }; // the carrot point steerCarrot hands a following NPC
38
+
39
+ const CONV_DIST = 1.5; // comfortable centre-to-centre talking distance for a staged conversation
40
+ const CONV_MAX = 3; // cap on simultaneous conversations — a lively town, not one frozen in pairs
41
+ const AI_RANGE = 60; // beyond this the AI itself sleeps (Character.update has already hidden them)
42
+ // A child's pace, and the cadence that keeps his boots on the ground with it — see the `child` branch
43
+ // below for the arithmetic. An adult townsman walks at 1.7.
44
+ const CHILD_SPEED = 0.85;
45
+ const CHILD_CADENCE = 0.81;
46
+ const TALK_R = 3.0; // within this the nearest civilian becomes the E-to-talk target (main.js reads
47
+ // game._talkCand). Sits just inside the 3.2 m mount reach so a man and his horse
48
+ // both offer a prompt and the nearer one wins.
49
+ const FLEE_R = 13; // an engaged outlaw this close sends the unarmed running
50
+ const PANIC_SECS = 7; // a man who has just been SHOT runs for this long — twice as long as a
51
+ // man who merely heard a shot. Being hit is not the same as being startled.
52
+ const WITNESS_R = 12; // and everyone who saw it happen runs with him
53
+ const ROAM_PTS = 9; // wander points sampled inside a roam disc — enough that a ranch hand's
54
+ // circuit doesn't read as a loop, few enough that the navgrid can bake it
55
+
56
+ export class NPC extends Character {
57
+ constructor(game, def, clips) {
58
+ const body = NC.getCharacter(def.char);
59
+ if (!body) throw new Error(`[npc] unknown character '${def.char}' for '${def.id}'`);
60
+
61
+ // Clip budget per NPC: the shared villager set, plus whatever situational idles THIS
62
+ // townsperson actually uses (the drunk's sway, the priest's prayer), plus — for wanderers
63
+ // only — the 18 paired conversation takes. A posted barkeep never talks to anyone, so he
64
+ // doesn't pay for them.
65
+ // CONVERSATIONS ARE RATIONED TO THE TWO TOWNS. The 18 paired takes are 18 more AnimationActions
66
+ // on a mixer, and a talking pair can never be a VAT instance (its clip isn't in the bake) — so
67
+ // a chatting man is a real skinned rig for as long as he chats. Dustwater's street and
68
+ // Perdition's ask for them (def.talk); a miner at Bonebreak does not.
69
+ const talks = !!(def.wander || def.talk);
70
+ const extra = [def.idle, ...(def.idles ?? [])].filter(Boolean);
71
+ const names = [...new Set([
72
+ ...NC.villagerClips,
73
+ ...extra,
74
+ ...(talks ? NC.convClips : []),
75
+ // a GUNHAND (the ride-along crew) carries the fighting clips: the aim/fire pair for the
76
+ // ally-shoot, the boxing guard for flavour. Costs a few AnimationActions on six mixers.
77
+ ...(def.gunhand ? ['gunAim', 'gunFire', 'punchIdle'] : []),
78
+ ])];
79
+
80
+ super(game, {
81
+ model: body.src,
82
+ texture: def.tex ?? body.tex, // a def may pick a recolour: the model cache is keyed url|texture|scale,
83
+ // so the same body in a second atlas is a second-looking man for a clone
84
+ clips: NC.clipSubset(clips, names, { strip: true, kid: !!body.child }), // strip: no finger/toe/eye tracks. kid: rescale the pelvis (clips.js)
85
+ speed: 1.7,
86
+ health: 60,
87
+ faction: 'villager', // NOT in enemy.js's TEAM map → never a valid combat target
88
+ name: def.name,
89
+ keep: body.keep, // multi-body western FBX → base load() keep-filters + atlases it.
90
+ // A LIST for a child: he is five meshes (characters.js).
91
+ texFlipY: true, // FBX UVs (characters.js: "load with flipY = true")
92
+ // THE THREE THINGS A CHILD NEEDS AND AN ADULT DOES NOT. All three are undefined on an adult
93
+ // def, so this costs the other 127 souls nothing. See characters.js's kid() for what each is.
94
+ collapse: !!body.collapse, // 5,082 bones and 100 'Head's → one 51-bone skeleton
95
+ hair: body.hair, // his hair or his hat — an adult's is baked into his body mesh
96
+ faceTex: body.faceTex, // the FACIAL atlas: his mouth
97
+ freckleTex: body.freckleTex,
98
+ });
99
+
100
+ this.def = def;
101
+ this.fleeT = 0;
102
+ this.posted = !!def.post;
103
+ this.civilian = true; // the SHOOTABLE-PEOPLE contract: combat/crime tell these from outlaws by this flag
104
+
105
+ // A CHILD CANNOT BE HARMED, AND THAT IS A PROPERTY OF THE ENTITY — not a special case
106
+ // sprinkled through combat.js. `noTarget` is honoured at every place a body is GATHERED
107
+ // (aim.js nearestBody, which serves BOTH the reticle and the shot convergence; combat.js's
108
+ // bullet loop and melee arc; deadeye's tag list), and takeDamage() below REFUSES outright.
109
+ // takeDamage is the only path to `health -=` and the only caller of die() (entity.js), and
110
+ // the purse drop lives inside die() — so a child who refuses damage cannot lose health,
111
+ // cannot die, cannot be looted and cannot be finished. If someone writes a sixth gathering
112
+ // site next year without reading this, the entity still says no.
113
+ // A CHILD IS HALF THE SPEED OF A GROWN MAN, AND HIS LEGS GO AT A CHILD'S CADENCE.
114
+ //
115
+ // He was set to 2.1 — FASTER than the adults' 1.7 — so the county's children outran their own
116
+ // parents down the street. Half of 1.7 is 0.85, and that is what he gets.
117
+ //
118
+ // But halving the ground speed alone would moonwalk him: the clip is authored for an adult's
119
+ // stride, and this rig's legs are shorter (KID_HIP = 0.62 — clips.js rescales the pelvis by it),
120
+ // so at playback rate 1.0 a kid's stride already covers only 0.62 of an adult's ground. That
121
+ // means his no-slide speed at rate 1.0 is 1.7 x 0.62 = 1.05 m/s, and to walk at 0.85 without his
122
+ // boots skating he must play at 0.85 / 1.05 = 0.81. Set on the MIXER, not the clip, so his idle,
123
+ // his walk and his run all slow together — a child who ambles but panics at adult speed is a
124
+ // puppet with two operators.
125
+ if (body.child) {
126
+ this.child = true;
127
+ this.noTarget = true;
128
+ this.speed = CHILD_SPEED;
129
+ this._animRate = CHILD_CADENCE;
130
+ }
131
+
132
+ // Idle pool: the poses this one drifts between while standing. A def that names an `idle`
133
+ // but no `idles` holds that single pose (a barkeep is a barkeep); a bare wanderer gets a
134
+ // random personal handful of the NC.socialIdles so the street isn't full of clones.
135
+ if (def.idles?.length) this._idlePool = def.idles.slice();
136
+ else if (def.idle) this._idlePool = [def.idle];
137
+ else {
138
+ const pool = NC.socialIdles.slice();
139
+ this._idlePool = [];
140
+ for (let i = 0; i < 3 && pool.length; i++) this._idlePool.push(pool.splice((Math.random() * pool.length) | 0, 1)[0]);
141
+ }
142
+ this._idleClip = this._idlePool[0];
143
+ this._idleT = 3 + Math.random() * 7;
144
+
145
+ if (this.posted) {
146
+ const p = def.post;
147
+ this.post = { x: p.x, z: p.z, y: p.y ?? null, face: p.face ?? null };
148
+ // An authored y is an EXACT indoor floor datum (the saloon's 1.051 world floorboards, measured):
149
+ // keep it, and let settle() leave it untouched. Everyone else takes the walk surface at his mark
150
+ // — terrain, boardwalk deck or building floor (world.walkSurfaceAt / town.js plinthAt).
151
+ this._authoredY = p.y != null;
152
+ this.setPosition(p.x, p.y ?? game.world.walkSurfaceAt(p.x, p.z), p.z);
153
+ // start ON the post's heading — never caught slowly swinging round when the player first walks in
154
+ if (p.face != null) { this.heading = p.face; this.root.rotation.y = p.face; }
155
+ } else {
156
+ // ROAM (def.roam = {x, z, r}): the county's other fourteen places have no authored street
157
+ // route and do not need one — a ranch hand's beat is "the yard", a soldier's is "the parade
158
+ // ground". Sample points on the disc and hand them to the SAME wanderer that walks Dustwater:
159
+ // wanderUpdate already jitters each point, validates it on the navgrid, and discards anything
160
+ // unreachable, so a point only has to be roughly right. sqrt() spreads them by AREA, not by
161
+ // radius, or everyone crowds the middle.
162
+ this._wanderPts = def.roam
163
+ ? Array.from({ length: ROAM_PTS }, () => {
164
+ const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * def.roam.r;
165
+ return { x: def.roam.x + Math.cos(a) * d, z: def.roam.z + Math.sin(a) * d };
166
+ })
167
+ : NC.wanderRoutes[def.wander] ?? NC.wanderRoutes.street ?? [];
168
+ this.canTalk = talks; // only these carry NC.convClips — see the ctor note
169
+ this.convo = null; // active staged conversation (shared object) or null
170
+ this.convoCooldown = 6 + Math.random() * 24; // stagger first attempts so the town doesn't pair up at once
171
+ this._convoScanT = Math.random();
172
+ this.wanderPause = Math.random() * 3;
173
+ const p = this._wanderPts[(Math.random() * this._wanderPts.length) | 0] ?? { x: 0, z: 0 };
174
+ const x = p.x + (Math.random() - 0.5) * 3, z = p.z + (Math.random() - 0.5) * 3;
175
+ this.setPosition(x, game.world.walkSurfaceAt(x, z), z);
176
+ this.heading = Math.random() * Math.PI * 2;
177
+ this.root.rotation.y = this.heading;
178
+ }
179
+ }
180
+
181
+ // Seat onto whatever we're actually standing on — the terrain, a boardwalk deck, or a building's
182
+ // floorboards — by a VERTICAL query (world.walkSurfaceAt = max(groundAt, plinthAt)). Run ONCE after
183
+ // load(), on the loading screen.
184
+ //
185
+ // This USED to drop the 0.45 m controller capsule and let it fall onto the BVH. That capsule's
186
+ // radius caught up-facing edges BESIDE the feet, not just under them — so a man authored a pace off
187
+ // the church plinth was hauled up onto its lip and left hanging half a metre in the air off the edge
188
+ // of it (Nick: "hovering people"). A column query cannot: on the floor it seats him on the floor,
189
+ // off it he stands on the dirt, and nothing lifts him onto a plinth he is not over.
190
+ settle() {
191
+ this.vy = 0;
192
+ this._capGrounded = true;
193
+ // A POST INSIDE A PROP IS A WAR THE MAN CANNOT WIN: postedUpdate walks him back onto the mark
194
+ // every frame while the collider shoves him off it, and he stands embedded forever (the soak
195
+ // monitor caught the Otts and Bram Coe doing exactly this at the farms). If the authored mark
196
+ // itself corrects, adopt the corrected point as the mark — once, here, for every post in the
197
+ // county, present and future.
198
+ if (this.post) {
199
+ const c = this.game.world.collide(this.post.x, this.post.z, 0.4, 0, this.position.y);
200
+ if (c && Math.hypot(c.x - this.post.x, c.z - this.post.z) > 0.05) {
201
+ this.post.x = c.x; this.post.z = c.z;
202
+ this.position.x = c.x; this.position.z = c.z;
203
+ }
204
+ } else {
205
+ // roamers too: a roam ANCHOR inside a farm building leaves the man embedded-from-boot,
206
+ // frozen there until the player walks close enough to wake his AI (the soak kept finding
207
+ // them at the far farms). Correct the spawn once, here, where every NPC passes.
208
+ const c = this.game.world.collide(this.position.x, this.position.z, 0.4, 0, this.position.y);
209
+ if (c && Math.hypot(c.x - this.position.x, c.z - this.position.z) > 0.05) {
210
+ this.position.x = c.x; this.position.z = c.z;
211
+ }
212
+ }
213
+ // an authored indoor y (the measured saloon floorboards) is exact — never re-derive it
214
+ if (this.post && this._authoredY) return;
215
+ this.position.y = this.game.world.walkSurfaceAt(this.position.x, this.position.z);
216
+ if (this.post) this.post.y = this.position.y;
217
+ }
218
+
219
+ // Nav home (Character._navRegion): wanderers centre their grid on their route's centroid.
220
+ // POSTED NPCs never path (they stand, or take two steps back to their mark) → no grid, no bake.
221
+ _navHome() {
222
+ if (!this._wanderPts?.length) return null;
223
+ let sx = 0, sz = 0;
224
+ for (const p of this._wanderPts) { sx += p.x; sz += p.z; }
225
+ return { x: sx / this._wanderPts.length, z: sz / this._wanderPts.length };
226
+ }
227
+
228
+ // Drift between this NPC's personal ambient idles so they aren't statues.
229
+ socialIdle(dt) {
230
+ this._idleT -= dt;
231
+ if (this._idleT <= 0) {
232
+ let n; do { n = this._idlePool[(Math.random() * this._idlePool.length) | 0]; }
233
+ while (this._idlePool.length > 1 && n === this._idleClip);
234
+ this._idleClip = n;
235
+ this._idleT = 5 + Math.random() * 10;
236
+ }
237
+ this.play(this._idleClip);
238
+ }
239
+
240
+ // Play a clip, falling back to 'idle' if this NPC's subset doesn't carry it (a def naming a
241
+ // situational idle that never made it into CLIP_URLS must not wedge the animator).
242
+ play(name, opts) {
243
+ this.animator?.play(this.animator.actions[name] ? name : 'idle', opts);
244
+ }
245
+
246
+ // Something frightened them (a gunfight, an alarm). Runs for wanderers only —
247
+ // the sheriff and the saloon staff hold their ground (def.flee === false, or a post).
248
+ //
249
+ // `force` is what a BULLET does. A barkeep is posted so that he doesn't abandon his counter
250
+ // because two men are arguing in the street — not so that he keeps polishing glasses while a
251
+ // man is shot dead in front of him. So force overrides the POST (he leaves it, and postedUpdate
252
+ // walks him back once he stops running) but never overrides def.flee === false: the sheriff
253
+ // holds his ground on purpose, and he holds it through this too.
254
+ scare(x, z, secs = 2.5, force = false) {
255
+ if (!this.alive) return;
256
+ if (this.def.flee === false) return;
257
+ if (this.posted && !force) return;
258
+ this._fleeFrom = { x, z };
259
+ this.fleeT = Math.max(this.fleeT, secs);
260
+ if (this.convo) this._endConversation();
261
+ }
262
+
263
+ // SHOT, OR PUNCHED. He has no fight in him — Character.takeDamage does the arithmetic, the
264
+ // stagger and the 'hit' clip exactly as it does for an outlaw, and then he does the only thing
265
+ // an unarmed man does: he cries out and he RUNS. (Reuses the flee above — forced, so a man shot
266
+ // behind his own counter leaves the counter.)
267
+ //
268
+ // opts.attacker is who pulled the trigger (combat.js sets it). It is the ONLY thing that decides
269
+ // whether this is a crime: an outlaw's stray round is not the player's murder. Unknown attacker
270
+ // is treated as the player, because today nothing else in the world can hurt a townsman.
271
+ takeDamage(amount, fromPos = null, opts = {}) {
272
+ if (!this.alive) return;
273
+ const g = this.game;
274
+ // THE BACKSTOP. Nothing in this game can gather a child as a target (see the ctor), but this
275
+ // is the line that makes it IMPOSSIBLE rather than merely unlikely: no health arithmetic runs,
276
+ // so a child cannot be hurt, cannot die, cannot drop a purse and cannot be finished — whatever
277
+ // any future caller does. He is frightened by whatever tried, and he runs.
278
+ if (this.child) {
279
+ const f = fromPos ?? g.player?.position ?? this.position;
280
+ this.scare(f.x, f.z, PANIC_SECS, true);
281
+ alarmTown(g, this.position.x, this.position.z, WITNESS_R, 4);
282
+ return;
283
+ }
284
+ // PLOT ARMOR. A campaign principal (def.plot — stamped at registration in main.js) cannot DIE:
285
+ // a stray round in a street fight killing Sheriff Bracken would softlock every mission that
286
+ // ever talks to him, forever, in the save. He takes the hit, reels, bleeds to a knee's worth of
287
+ // health — everything a body does short of dying — and the crime is still a crime.
288
+ if (this.def?.plot && this.health - amount <= 0) {
289
+ amount = Math.max(0, this.health - 1);
290
+ if (!amount) { // already at the floor: flinch and flee, no arithmetic
291
+ const f = fromPos ?? g.player?.position ?? this.position;
292
+ this.scare(f.x, f.z, PANIC_SECS, true);
293
+ return;
294
+ }
295
+ }
296
+ this._killer = opts.attacker ?? null; // die() runs INSIDE super.takeDamage — stash it first
297
+ super.takeDamage(amount, fromPos, opts);
298
+ if (!this.alive) return; // he died of it: die() below did the reporting
299
+ g.audio?.play('hurt');
300
+ const f = fromPos ?? g.player?.position ?? this.position;
301
+ this.scare(f.x, f.z, PANIC_SECS, true);
302
+ if (this._byPlayer()) {
303
+ g.crime?.report('assault', this.position.clone());
304
+ g.encounters?.provokeGroup(this); // a drover's outfit does not stand and watch (encounters.js)
305
+ }
306
+ alarmTown(g, this.position.x, this.position.z, WITNESS_R, 4); // and everyone who saw it goes with him
307
+ }
308
+
309
+ _byPlayer() { return this._killer == null || this._killer === this.game.player; }
310
+
311
+ // DOWN. Character.die() plays the villager 'death' clip (NC.villagerClips carries one) and stops
312
+ // the AI dead — NPC.update returns on !alive — and the body STAYS: nothing here ever removes a
313
+ // skinned mesh from the scene (main.js:118). A corpse in the street is the point.
314
+ die() {
315
+ if (!this.alive) return;
316
+ const g = this.game;
317
+ // let go of a conversation FIRST: _endConversation plays 'idle' on both partners, and on a
318
+ // dead man that idle would stomp the death clip he is about to be given.
319
+ if (this.convo) this._endConversation();
320
+ this.fleeT = 0;
321
+ super.die();
322
+ const at = this.position.clone();
323
+ g.audio?.play('hurt');
324
+ if (this._byPlayer()) {
325
+ g.crime?.report('murder', at);
326
+ // WHAT HE WAS ACTUALLY CARRYING. This used to drop a flat [2,14] on everybody, so a
327
+ // stationmaster, a ranch hand and a card player all fell with the same money in their
328
+ // pockets — and purse.js's whole ALIAS table, written for exactly this, was never reached
329
+ // from the NPC path. `def.purse` overrides; otherwise the def's id resolves through it.
330
+ g.loot?.dropFor?.(this.def.purse ?? this.def.id, at);
331
+ g.encounters?.provokeGroup(this);
332
+ }
333
+ // A BODY IN THE STREET. Close enough to have watched it happen and even the posted men run
334
+ // (forced — the saloon empties); further out, the street clears the ordinary way.
335
+ alarmTown(g, at.x, at.z, 9, 7, true);
336
+ alarmTown(g, at.x, at.z, 26, 5);
337
+ }
338
+
339
+ // Is a fight happening on top of us? (No morality system in the west — fear is just proximity
340
+ // to an outlaw who has stopped wandering and started hunting.)
341
+ // WHAT ACTUALLY FRIGHTENS A MAN: someone HUNTING, not someone standing about.
342
+ //
343
+ // This used to run from any enemy whose state was not literally 'wander' — and an outlaw who has
344
+ // not been provoked yet is 'passive', not 'wander'. So from the moment the game loaded, the four
345
+ // outlaws posted around town terrified every townsman within FLEE_R, and you walked into a
346
+ // Dustwater whose entire population was already running away from nothing. The street was empty
347
+ // before a shot had been fired.
348
+ //
349
+ // A man is frightened by a man who is CHASING, SHOOTING or SWINGING at someone. An outlaw leaning
350
+ // on a fence is just an outlaw leaning on a fence.
351
+ _danger() {
352
+ for (const e of this.game.combat?.enemies ?? []) {
353
+ if (!e.alive || e.dead || e.passive) continue;
354
+ if (e.state !== 'chase' && e.state !== 'attack' && e.state !== 'shoot') continue;
355
+ if (dist2D(this.position.x, this.position.z, e.position.x, e.position.z) < FLEE_R) return e.position;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ // ---- Staged two-person conversations (the paired NC.convTakes) ------------------------------
361
+ // Two lingering wanderers settle to a comfortable distance facing each other, then play a
362
+ // MATCHED PairA/PairB take in sync — one talks while the other listens, gestures interlock —
363
+ // alternating the speaker for 3–5 exchanges, then part and wander on.
364
+
365
+ _seekConversation() {
366
+ const g = this.game;
367
+ if (!this.canTalk) return;
368
+ if ((g._activeConvos ?? 0) >= CONV_MAX) return;
369
+ let best = null, bd = 2.8;
370
+ for (const n of g.npcs) {
371
+ if (n === this || !n.alive || !n.canTalk || n.convo) continue;
372
+ if (n.convoCooldown > 0 || !(n.wanderPause > 0) || n.busy || n.fleeT > 0) continue;
373
+ const d = dist2D(this.position.x, this.position.z, n.position.x, n.position.z);
374
+ if (d < bd && d > 0.9) { bd = d; best = n; }
375
+ }
376
+ if (best) this._startConversation(best);
377
+ }
378
+
379
+ // Seat both of us at CONV_DIST either side of our midpoint, then approach → talk.
380
+ _startConversation(partner) {
381
+ const mx = (this.position.x + partner.position.x) / 2;
382
+ const mz = (this.position.z + partner.position.z) / 2;
383
+ let dx = partner.position.x - this.position.x, dz = partner.position.z - this.position.z;
384
+ const dl = Math.hypot(dx, dz) || 1; dx /= dl; dz /= dl;
385
+ const h = CONV_DIST / 2;
386
+ const convo = {
387
+ master: this, a: this, b: partner, phase: 'approach', t: 0, talkDur: 8,
388
+ take: null, exchanges: 3 + ((Math.random() * 3) | 0), done: 0, prevLead: null,
389
+ spotA: { x: mx - dx * h, z: mz - dz * h },
390
+ spotB: { x: mx + dx * h, z: mz + dz * h },
391
+ };
392
+ this.convo = convo; partner.convo = convo;
393
+ this.wanderTarget = null; partner.wanderTarget = null;
394
+ this.game._activeConvos = (this.game._activeConvos ?? 0) + 1;
395
+ }
396
+
397
+ // Alternate the active speaker so it reads as turn-taking: open on a both-engaged take,
398
+ // then A-leads / B-leads flip each exchange.
399
+ _nextTake() {
400
+ const c = this.convo;
401
+ const want = c.prevLead == null ? 'both'
402
+ : c.prevLead === 'a' ? 'b'
403
+ : c.prevLead === 'b' ? 'a'
404
+ : (Math.random() < 0.5 ? 'a' : 'b'); // after a 'both', pick a side to lead
405
+ let pool = NC.convTakes.filter((t) => t.lead === want);
406
+ if (!pool.length) pool = NC.convTakes;
407
+ let take;
408
+ do { take = pool[(Math.random() * pool.length) | 0]; } while (pool.length > 1 && take === c.take);
409
+ c.prevLead = take.lead;
410
+ return take;
411
+ }
412
+
413
+ // One exchange = one matched take, played on both partners in sync.
414
+ _playExchange() {
415
+ const c = this.convo;
416
+ c.take = this._nextTake();
417
+ c.t = 0;
418
+ c.a.play(c.take.a, { once: true, fade: 0.3 });
419
+ c.b.play(c.take.b, { once: true, fade: 0.3 });
420
+ const dur = c.a.animator.actions[c.take.a]?.getClip().duration ?? 6;
421
+ c.talkDur = Math.min(dur, 12);
422
+ }
423
+
424
+ // Master locks both partners face-to-face, then kicks off the first exchange.
425
+ _beginTalk() {
426
+ const c = this.convo;
427
+ c.phase = 'talk';
428
+ for (const [npc, spot, other] of [[c.a, c.spotA, c.b], [c.b, c.spotB, c.a]]) {
429
+ const hd = Math.atan2(other.position.x - spot.x, other.position.z - spot.z);
430
+ npc.heading = hd; npc.root.rotation.y = hd; // lock facing; the clip animates relative to it
431
+ }
432
+ this._playExchange();
433
+ }
434
+
435
+ updateConversation(dt) {
436
+ const c = this.convo;
437
+ const partner = c.a === this ? c.b : c.a;
438
+ if (!partner.alive || this.busy || this.fleeT > 0 || partner.fleeT > 0) { this._endConversation(); return; }
439
+ const mySpot = c.a === this ? c.spotA : c.spotB;
440
+
441
+ if (c.phase === 'approach') {
442
+ const d = dist2D(this.position.x, this.position.z, mySpot.x, mySpot.z);
443
+ if (d > 0.16) {
444
+ const dir = tmpV1.set(mySpot.x - this.position.x, 0, mySpot.z - this.position.z).normalize();
445
+ this.move(dir, Math.min(this.speed, 1.2), dt);
446
+ this.faceToward(mySpot.x, mySpot.z, dt);
447
+ this.play('walk');
448
+ } else {
449
+ this.faceToward(partner.position.x, partner.position.z, dt, 6);
450
+ this.socialIdle(dt);
451
+ }
452
+ // the master owns the phase clock: talk once both are ~in place (or after a short grace)
453
+ if (this === c.master) {
454
+ c.t += dt;
455
+ const inPlace = dist2D(c.a.position.x, c.a.position.z, c.spotA.x, c.spotA.z) < 0.3 &&
456
+ dist2D(c.b.position.x, c.b.position.z, c.spotB.x, c.spotB.z) < 0.3;
457
+ if (inPlace || c.t > 2.5) this._beginTalk();
458
+ }
459
+ return;
460
+ }
461
+
462
+ // 'talk': heading was locked to the partner at talk-start — let the baked clip drive the
463
+ // body (a faceToward here would fight the take's own gestures). The master chains the
464
+ // exchanges and ends the conversation after the last one.
465
+ if (this === c.master) {
466
+ c.t += dt;
467
+ if (c.t >= c.talkDur) {
468
+ c.done += 1;
469
+ if (c.done >= c.exchanges) this._endConversation();
470
+ else this._playExchange();
471
+ }
472
+ }
473
+ }
474
+
475
+ // Release both partners back to wandering (idempotent — guarded on this.convo).
476
+ _endConversation() {
477
+ const c = this.convo;
478
+ if (!c) return;
479
+ this.game._activeConvos = Math.max(0, (this.game._activeConvos ?? 1) - 1);
480
+ for (const npc of [c.a, c.b]) {
481
+ if (npc.convo !== c) continue;
482
+ npc.convo = null;
483
+ npc.convoCooldown = 25 + Math.random() * 30; // don't immediately re-pair
484
+ npc.wanderPause = 0.5 + Math.random();
485
+ npc.wanderTarget = null;
486
+ npc.play('idle', { fade: 0.3 });
487
+ }
488
+ }
489
+
490
+ // A neighbour who's also lingering → they turn to face each other, so a knot of stopped
491
+ // wanderers reads as a conversation even without the paired clips.
492
+ _chatMate() {
493
+ let best = null, bd = 3.2;
494
+ for (const n of this.game.npcs) {
495
+ if (n === this || !n.alive || !n._wanderPts || n.convo || !(n.wanderPause > 0)) continue;
496
+ const d = dist2D(this.position.x, this.position.z, n.position.x, n.position.z);
497
+ if (d < bd && d > 0.8) { bd = d; best = n; }
498
+ }
499
+ return best;
500
+ }
501
+
502
+ update(dt) {
503
+ super.update(dt);
504
+ if (!this.alive) return;
505
+
506
+ const player = this.game.player;
507
+ if (!player) return;
508
+ const pd = dist2D(this.position.x, this.position.z, player.position.x, player.position.z);
509
+
510
+ // NEAREST TALKABLE — the cheap way (WESTERN SEAMS §3). main.js zeroes game._talkCand right before
511
+ // this loop; every NPC already holds `pd` here, so offering himself as an E-to-talk target is a
512
+ // couple of compares, not a second O(169) scan. THE ARRAY IS THE FACTION TEST: outlaws and the
513
+ // law-when-hostile are Enemy instances in combat.enemies / crime.lawmen, never in game.npcs, so a
514
+ // man iterated here can never be one. A fleeing, reeling or dead man is not offered; a child IS
515
+ // (the boy asking whether you've ever shot anyone is the whole point).
516
+ { const g = this.game;
517
+ if (this.alive && this.civilian && !this.busy && this.fleeT <= 0 && pd < TALK_R && pd < (g._talkCandD ?? Infinity)) {
518
+ g._talkCandD = pd; g._talkCand = this;
519
+ } }
520
+ // FAR NPCs SLEEP ENTIRELY — no capsule sweep, no steering probe, no navgrid. That is what makes
521
+ // a hundred and fifty of them affordable: a sleeper costs the two distanceTo calls it took to
522
+ // find out he was asleep (this one and Character.update's), and 90-odd sleepers together are
523
+ // ~0.02 ms of the measured 2.0 ms npcs phase.
524
+ //
525
+ // Park him on idle on the way out, though, and this is not cosmetic pedantry: AI_RANGE is 60 m
526
+ // and the entity cull is 45 / 70 / 110 by quality (core/quality.js). At medium and high the body
527
+ // between those two lines is STILL DRAWN while its AI is asleep — so a man frozen mid-stride
528
+ // keeps his walk cycle turning over and moonwalks on the spot. Once, on the transition; never
529
+ // again until he wakes. (At LOW the cull is nearer than AI_RANGE, which is why fourteen
530
+ // townsfolk in a town you only ever saw at low quality never showed it.)
531
+ //
532
+ // …and `aiRange` is the ONE way that 60 can be argued with. A ROAD TRAVELLER (traveller.js) is
533
+ // drawn out to 140 m because there is nothing in front of him but desert, and a man DRAWN but
534
+ // ASLEEP is a statue standing in the middle of an empty road — the exact moonwalk bug above, in
535
+ // its worst form. So he raises this to his own cull distance and walks for as long as he is seen.
536
+ // Every other NPC in the county leaves it undefined and gets 60.
537
+ // MISSION FOLLOW (missions.js stage `follow:`): on the player's heel — an escortee, a recruit
538
+ // being walked home. BEFORE the far-sleep gate, so a man you're escorting never freezes when you
539
+ // pull ahead; A* around statics via steerCarrot (the riders' pattern), whiskers for the crowd.
540
+ if (this._follow) {
541
+ this._asleep = false;
542
+ // ALLY-SHOOT — a GUNHAND on your heel fights: nearest AWAKE hostile engaged near the player
543
+ // (never aggroing the county on his own), inside 30m with a clear line. He stands his ground,
544
+ // faces it, and fires on a lazy cooldown with an honest miss rate — support fire, not an
545
+ // aimbot; your fight stays yours.
546
+ if (this.def.gunhand) {
547
+ this._allyScanT = (this._allyScanT ?? 0) - dt;
548
+ if (this._allyScanT <= 0) {
549
+ this._allyScanT = 0.4;
550
+ let best = null, bd = 30;
551
+ for (const e of this.game.combat?.enemies ?? []) {
552
+ if (!e.alive || e.faction === 'ally' || e.passive) continue;
553
+ const de = dist2D(this.position.x, this.position.z, e.position.x, e.position.z);
554
+ const dp = dist2D(e.position.x, e.position.z, player.position.x, player.position.z);
555
+ if (de < bd && dp < 32) { bd = de; best = e; }
556
+ }
557
+ this._allyTarget = best;
558
+ }
559
+ const t = this._allyTarget;
560
+ if (t?.alive && !this.game.world.losBlocked?.(this.position, t.position)) {
561
+ this.heading = Math.atan2(t.position.x - this.position.x, t.position.z - this.position.z);
562
+ this.root.rotation.y = this.heading;
563
+ this._allyFireT = (this._allyFireT ?? 0.9) - dt;
564
+ if (this._allyFireT <= 0) {
565
+ this._allyFireT = 1.7 + Math.random() * 0.9;
566
+ this.animator?.play?.('gunFire', { once: true, fade: 0.08, onDone: () => {} });
567
+ const mz = tmpV1.set(this.position.x, this.position.y + 1.35, this.position.z);
568
+ this.game.vfx?.muzzleFlash?.(mz, null);
569
+ this.game.audio?.play('gunshot_far');
570
+ if (Math.random() < 0.38) t.takeDamage(12, this.position, { attacker: this });
571
+ } else if (!this.animator?.actions?.gunFire?.isRunning?.()) this.play('gunAim');
572
+ return; // standing his ground — no follow-move this frame
573
+ }
574
+ }
575
+ if (pd > 2.6) {
576
+ let ax = player.position.x, az = player.position.z;
577
+ if (steerCarrot(this, this.game, player.position, this.position.x, this.position.z, ax, az, _fAim,
578
+ { dt, arrive: 1.4, lookahead: 2.5 })) { ax = _fAim.x; az = _fAim.z; }
579
+ const dx = ax - this.position.x, dz = az - this.position.z, L = Math.hypot(dx, dz) || 1;
580
+ const sd = this.walkDir(_fDir.set(dx / L, 0, dz / L), dt);
581
+ this.heading = Math.atan2(sd.x, sd.z);
582
+ this.root.rotation.y = this.heading;
583
+ const spd = pd > 9 ? 5.2 : Math.min(this.speed ?? 2, 2.2); // jog to catch up, walk at heel
584
+ this.move(sd, spd, dt);
585
+ this.play(spd > 3 ? 'run' : 'walk');
586
+ } else this.play(this._idleClip ?? 'idle');
587
+ return;
588
+ }
589
+ if (pd > (this.aiRange ?? AI_RANGE)) {
590
+ if (!this._asleep) { this._asleep = true; this.play('idle', { fade: 0.4 }); }
591
+ return;
592
+ }
593
+ this._asleep = false;
594
+
595
+ // BEING SPOKEN TO. ui/dialogue.js owns the panel and the talk camera; the NPC's whole side of
596
+ // the contract is three lines — turn to the lens, hold an attentive idle, and stop wandering off
597
+ // mid-sentence. (ui.currentNPC is set on open, nulled on close.) Hoisted ABOVE flee/wander so a
598
+ // posted man obeys it too, and so a gunfight two streets over can't walk him out of frame while
599
+ // you talk; a bullet still ends it the other way — player.js closes the panel when he's hit.
600
+ const ui = this.game.ui;
601
+ if (ui?.dialogueOpen && ui.currentNPC === this) {
602
+ this.faceToward(player.position.x, player.position.z, dt, 6);
603
+ this.wanderTarget = null;
604
+ if (this.convo) this._endConversation();
605
+ this.socialIdle(dt);
606
+ this.applyDescentSmoothing(dt);
607
+ return;
608
+ }
609
+
610
+ // fear: an outlaw hunting nearby sends the unarmed running (posted NPCs and the sheriff hold)
611
+ if (this.fleeT <= 0 && !this.posted && this.def.flee !== false) {
612
+ const d = this._danger();
613
+ if (d) this.scare(d.x, d.z);
614
+ }
615
+ if (this.fleeT > 0) {
616
+ this.fleeT -= dt;
617
+ // A MAN WHO HAS JUST BEEN HIT REELS BEFORE HE RUNS. takeDamage set busy and started the 'hit'
618
+ // clip; playing 'run' on the same frame threw that reaction straight in the bin — he was
619
+ // sprinting away before the bullet had visibly landed. Hold still until the reel is done.
620
+ if (this.busy) { this.applyDescentSmoothing(dt); return; }
621
+ const from = this._fleeFrom ?? player.position;
622
+ const dir = tmpV1.set(this.position.x - from.x, 0, this.position.z - from.z);
623
+ if (dir.lengthSq() < 1e-6) dir.set(1, 0, 0);
624
+ dir.normalize();
625
+ const sd = this.walkDir(dir, dt);
626
+ this.move(sd, 3.6, dt);
627
+ this.faceToward(this.position.x + sd.x, this.position.z + sd.z, dt);
628
+ this.play('run');
629
+ this.applyDescentSmoothing(dt);
630
+ if (this.fleeT <= 0) { this.wanderTarget = null; this._navPath = null; this.wanderPause = 1 + Math.random(); }
631
+ return;
632
+ }
633
+
634
+ if (this.posted) this.postedUpdate(dt, player, pd);
635
+ else if (this.convo) this.updateConversation(dt);
636
+ else this.wanderUpdate(dt, player, pd);
637
+
638
+ this.applyDescentSmoothing(dt);
639
+ }
640
+
641
+ // POSTED: hold the mark. The player can physically shove NPCs around (player.separateFromPeople),
642
+ // so a barkeep CAN be pushed off his spot — walk back to it rather than serving from the middle
643
+ // of the room forever.
644
+ postedUpdate(dt, player, pd) {
645
+ // MID YARD-TALK (main.js schedules the takes on the housed crew): stand your ground, face your
646
+ // partner, let the take run — both the walk-back-to-post branch and socialIdle would stomp it.
647
+ if (this._yardChat) { this.faceToward(this._yardChat.x, this._yardChat.z, dt, 4); return; }
648
+ const p = this.post;
649
+ const d = dist2D(this.position.x, this.position.z, p.x, p.z);
650
+ if (d > 0.4) {
651
+ const dir = tmpV1.set(p.x - this.position.x, 0, p.z - this.position.z).normalize();
652
+ this.move(dir, Math.min(this.speed, 1.3), dt); // short, slow steps back — no pathing indoors
653
+ this.faceToward(p.x, p.z, dt, 6);
654
+ this.play('walk');
655
+ return;
656
+ }
657
+ // A NOD FOR THE PASSER-BY. Now and then a posted townsperson greets the man walking past —
658
+ // a raised hand, nothing said — the small-town reflex every western street runs on. Rare
659
+ // (a per-soul cooldown measured in minutes), never from a seated man, and the wave holds
660
+ // via the same guard the yard-talk uses so socialIdle can't stomp it a frame in.
661
+ this._greetCd = Math.max(0, (this._greetCd ?? Math.random() * 60) - dt);
662
+ if (pd < 3.2 && !this.def.seatedNoTurn && this._greetCd <= 0 && this.animator?.actions?.idleWave) {
663
+ this._greetCd = 90 + Math.random() * 120;
664
+ this._yardChat = { x: player.position.x, z: player.position.z };
665
+ this.animator.play('idleWave', { once: true, fade: 0.2, onDone: () => { this._yardChat = null; } });
666
+ return; // the fall-through would hand the frame straight to socialIdle and stomp the wave
667
+ }
668
+ // On the mark: clock the player when he's close, else hold the post's authored heading
669
+ // (the sheriff keeps watching the street; the barkeep keeps facing his room). A seatedNoTurn
670
+ // man (the pianist at the keys) never swings round — his legs are under the piano — so he
671
+ // always holds his authored heading no matter how close you stand.
672
+ if (pd < 4.5 && !this.def.seatedNoTurn) this.faceToward(player.position.x, player.position.z, dt, 3);
673
+ else if (p.face != null) this.faceToward(this.position.x + Math.sin(p.face), this.position.z + Math.cos(p.face), dt, 3);
674
+ this.socialIdle(dt);
675
+ }
676
+
677
+ // WANDER: roam the route, lingering at each point; occasionally stage a real conversation.
678
+ wanderUpdate(dt, player, pd) {
679
+ if (this.convoCooldown > 0) this.convoCooldown -= dt;
680
+
681
+ if (this.wanderPause > 0) {
682
+ this.wanderPause -= dt;
683
+ // NEVER STAND INSIDE A THING. Every pause path funnels through here, and a stood man runs no
684
+ // capsule move to push him out of a prop edge he drifted into — corrected once per stand
685
+ // (the soak kept catching farm roamers wintering in their own barn walls).
686
+ if (!this._standFixed) {
687
+ this._standFixed = true;
688
+ const c0 = this.game.world.collide(this.position.x, this.position.z, 0.4, 0, this.position.y);
689
+ if (c0 && Math.hypot(c0.x - this.position.x, c0.z - this.position.z) > 0.02) {
690
+ this.position.x = c0.x; this.position.z = c0.z;
691
+ }
692
+ }
693
+ // Polite step-aside: steering only runs while WALKING, so two NPCs who came to rest inside
694
+ // each other would stay there forever. A paused NPC someone is standing on takes a step apart.
695
+ this._sepT = (this._sepT ?? Math.random() * 0.3) - dt;
696
+ if (this._sepT <= 0) {
697
+ this._sepT = 0.3;
698
+ for (const n of this.game.npcs) {
699
+ if (n === this || !n.alive) continue;
700
+ const dx = this.position.x - n.position.x, dz = this.position.z - n.position.z;
701
+ const d2 = dx * dx + dz * dz;
702
+ if (d2 < 0.45) { // < ~0.67m — inside each other's bodies
703
+ const d = Math.sqrt(d2) || 1;
704
+ const stacked = d2 < 1e-4; // perfectly overlapped → stable per-entity pick, or they'd both step the same way
705
+ this._sepDir = stacked ? { x: (this.root.id & 1) ? 1 : -1, z: 0.3 } : { x: dx / d, z: dz / d };
706
+ this._sepWalk = 0.5;
707
+ break;
708
+ }
709
+ }
710
+ }
711
+ if (this._sepWalk > 0) {
712
+ this._sepWalk -= dt;
713
+ tmpV1.set(this._sepDir.x, 0, this._sepDir.z).normalize();
714
+ this.move(tmpV1, 1.2, dt);
715
+ this.faceToward(this.position.x + tmpV1.x, this.position.z + tmpV1.z, dt, 6);
716
+ this.play('walk');
717
+ return;
718
+ }
719
+ // occasionally strike up a full staged conversation with a nearby free idler
720
+ this._convoScanT -= dt;
721
+ if (this._convoScanT <= 0) {
722
+ this._convoScanT = 0.5;
723
+ if (this.convoCooldown <= 0 && Math.random() < 0.5) { this._seekConversation(); if (this.convo) return; }
724
+ }
725
+ const mate = this._chatMate();
726
+ if (mate) this.faceToward(mate.position.x, mate.position.z, dt, 3); // stop and pass the time of day
727
+ else if (pd < 4) this.faceToward(player.position.x, player.position.z, dt, 3);
728
+ this.socialIdle(dt);
729
+ return;
730
+ }
731
+
732
+ if (!this.wanderTarget) {
733
+ const pts = this._wanderPts;
734
+ if (!pts.length) { this.wanderPause = 5; return; }
735
+ // DON'T PICK A POINT WE JUST FAILED TO REACH. Off the nav grids (a farm, a fenced field)
736
+ // reachability can't be pre-checked, so a fence across the roam disc made an NPC walk into
737
+ // the same corner, stall, give up, and pick the same doomed point forever (Nick saw her).
738
+ // Recent failures (the no-progress give-up below records them) poison a 6m radius for a
739
+ // couple of minutes; a pick that can't avoid them takes the LONGEST pause instead of a walk.
740
+ const fails = (this._wanderFails ??= []);
741
+ const now = this.game._playSeconds ?? 0;
742
+ while (fails.length && now - fails[0].t > 120) fails.shift();
743
+ let p = null;
744
+ for (let tries = 0; tries < 6 && !p; tries++) {
745
+ const cand = pts[(Math.random() * pts.length) | 0];
746
+ if (!fails.some((f) => dist2D(f.x, f.z, cand.x, cand.z) < 6)) p = cand;
747
+ }
748
+ if (!p) { this.wanderPause = 6 + Math.random() * 6; return; } // everywhere near here is poisoned — stand a while
749
+ // Personal offset around the shared point — several NPCs picking the SAME point would
750
+ // otherwise finish standing INSIDE each other. VALIDATE the offset on the NAVGRID (which
751
+ // knows about the BVH: crates, water troughs, porch posts), falling back to the circle
752
+ // collider test while the grid is still baking.
753
+ const grd = this._navGrid();
754
+ let tx = p.x, tz = p.z;
755
+ for (let i = 0; i < 4; i++) {
756
+ const cx = p.x + (Math.random() - 0.5) * 2.4, cz = p.z + (Math.random() - 0.5) * 2.4;
757
+ let ok;
758
+ if (grd) ok = grd.walkable(cx, cz) === true;
759
+ else {
760
+ const cor = this.game.world.collide(cx, cz, 0.45, 0, this.position.y);
761
+ ok = !cor || (Math.abs(cor.x - cx) < 1e-6 && Math.abs(cor.z - cz) < 1e-6);
762
+ }
763
+ if (ok) { tx = cx; tz = cz; break; }
764
+ }
765
+ this.wanderTarget = { x: tx, z: tz };
766
+ this._navPath = this._requestPath(this.wanderTarget); // A* around the statics (null until the grid bakes)
767
+ this._navI = 0;
768
+ // grid ready but no route → the goal is unreachable (a walled yard, a sealed pocket):
769
+ // DISCARD it. Walking blind at an unreachable goal is how you get an NPC nose-first
770
+ // into a wall for the rest of the game.
771
+ if (!this._navPath && grd) { this.wanderTarget = null; this.wanderPause = 0.3; return; }
772
+ this._wanderCheck = 0;
773
+ this._wanderLastX = this.position.x;
774
+ this._wanderLastZ = this.position.z;
775
+ }
776
+
777
+ const t = this.wanderTarget;
778
+ const td = dist2D(this.position.x, this.position.z, t.x, t.z);
779
+ // arrive EARLY when the goal is close but the way ahead reads blocked (the porch the point
780
+ // sits beside): stopping 2.4m out reads as "reached the store", not as walking into its wall.
781
+ if (this._steerBlockAhead && td <= 2.4) {
782
+ // same don't-settle-INSIDE check as the true arrival below — this early-out was the one way
783
+ // to come to rest embedded in a prop edge (the soak caught seven farmhands doing it)
784
+ const cor0 = this.game.world.collide(this.position.x, this.position.z, 0.4, 0, this.position.y);
785
+ if (cor0 && (Math.abs(cor0.x - this.position.x) > 1e-6 || Math.abs(cor0.z - this.position.z) > 1e-6)) {
786
+ this.position.x = cor0.x; this.position.z = cor0.z;
787
+ }
788
+ this.wanderPause = 2 + Math.random() * 4; this.wanderTarget = null; return;
789
+ }
790
+ if (td <= 1.5) {
791
+ // don't settle INSIDE an obstacle — collide() returns the CORRECTED position (always
792
+ // truthy); "inside" means the point MOVED. Shuffle on to a fresh spot instead.
793
+ const cor = this.game.world.collide(this.position.x, this.position.z, 0.4, 0, this.position.y);
794
+ if (cor && (Math.abs(cor.x - this.position.x) > 1e-6 || Math.abs(cor.z - this.position.z) > 1e-6)) { this.wanderTarget = null; return; }
795
+ this.wanderPause = 2 + Math.random() * 5; // linger at the boardwalk / the rail / the door
796
+ this.wanderTarget = null;
797
+ return;
798
+ }
799
+
800
+ // Give up on a destination we're making no progress toward, rather than shoving into it forever.
801
+ this._wanderCheck += dt;
802
+ if (this._wanderCheck > 1) {
803
+ const moved = dist2D(this.position.x, this.position.z, this._wanderLastX, this._wanderLastZ);
804
+ this._wanderCheck = 0;
805
+ this._wanderLastX = this.position.x;
806
+ this._wanderLastZ = this.position.z;
807
+ if (moved < 0.4) {
808
+ // record WHERE we were headed when we stalled — the picker refuses it for a couple of
809
+ // minutes, which is what breaks the fence-corner loop (walk, stall, same point, forever)
810
+ const fails = (this._wanderFails ??= []);
811
+ fails.push({ x: this.wanderTarget.x, z: this.wanderTarget.z, t: this.game._playSeconds ?? 0 });
812
+ if (fails.length > 4) fails.shift();
813
+ this.wanderTarget = null; this._navPath = null; this.wanderPause = 1.2 + Math.random() * 2; return;
814
+ }
815
+ }
816
+ if (!this._navPath) {
817
+ this._navRetryT = (this._navRetryT ?? 0) - dt; // the grid may still be baking — retry occasionally
818
+ if (this._navRetryT <= 0) {
819
+ this._navRetryT = 1.5;
820
+ this._navPath = this._requestPath(t); this._navI = 0;
821
+ if (!this._navPath && this._navGrid()) { this.wanderTarget = null; this.wanderPause = 0.3; return; }
822
+ }
823
+ }
824
+ this._standFixed = false; // walking again — the next stand re-checks its ground
825
+ const wp = this._pathWaypoint(t);
826
+ const dir = tmpV1.set(wp.x - this.position.x, 0, wp.z - this.position.z).normalize();
827
+ const sd = this.walkDir(dir, dt); // whiskers: DYNAMIC avoidance (people) — the statics are pre-avoided by the path
828
+ this.move(sd, this.speed * (this._steerSlow ? 0.55 : 1), dt);
829
+ this.faceToward(this.position.x + sd.x, this.position.z + sd.z, dt);
830
+ this.play('walk');
831
+ }
832
+ }
833
+
834
+ // Alarm every townsperson within `r` of a point — a gunshot, a brawl, a body in the street.
835
+ // Called from combat.fireBullet (every shot fired anywhere scatters the street), from a townsman's
836
+ // own death, and from the coach when its driver is shot off the box. `force` drags even the posted
837
+ // men off their marks — see scare(): keep it for things that happen ON TOP of somebody.
838
+ // Cheap: ~13 townsfolk, and only ever on an EVENT (a shot, a death), never per frame.
839
+ export function alarmTown(game, x, z, r = 18, secs = 3, force = false) {
840
+ for (const n of game.npcs ?? []) {
841
+ if (!n.alive) continue;
842
+ if (dist2D(n.position.x, n.position.z, x, z) < r) n.scare(x, z, secs, force);
843
+ }
844
+ }
845
+
846
+ // THE WHOLE COUNTY, built ONCE on the loading screen — every NPC resident and scene-attached for
847
+ // life (adding or toggling skinned meshes during play is a WebGPU pipeline build and a
848
+ // multi-second freeze). Mirrors combat.spawn(): construct → await load() → scene.add → push.
849
+ //
850
+ // BATCHED, not serial. The old populateTown awaited each body in turn — fine for 14, and 0.9s of
851
+ // dead wall-clock for 170 (a marginal body from an already-cached pack costs 2.5-3.5 ms, measured
852
+ // off the boot sink). The loads share ONE model cache, so N in flight cost roughly the slowest,
853
+ // not the sum. 16 at a time keeps the FBX parses from stampeding each other on the first pack.
854
+ const BATCH = 16;
855
+ export async function populateCounty(game, clips, defs = NC.countyDefs) {
856
+ game.npcs ??= [];
857
+ game.children ??= []; // the children, as their own list: player.js aims a separate ray at it
858
+ let failed = 0;
859
+ for (let i = 0; i < defs.length; i += BATCH) {
860
+ await Promise.all(defs.slice(i, i + BATCH).map(async (def) => {
861
+ try {
862
+ const npc = new NPC(game, def, clips);
863
+ await npc.load();
864
+ npc.settle(); // drop onto the terrain / the deck / the saloon floorboards
865
+ game.scene.add(npc.root);
866
+ game.npcs.push(npc);
867
+ if (npc.child) game.children.push(npc);
868
+ } catch (e) {
869
+ failed++;
870
+ console.warn('[npc] spawn failed', def.id, e); // one bad def must never take the boot down
871
+ }
872
+ }));
873
+ }
874
+ const kids = game.children.length;
875
+ console.log(`[npc] the county is peopled: ${game.npcs.length} souls (${kids} children)${failed ? ` — ${failed} FAILED` : ''}`);
876
+ return game.npcs;
877
+ }
878
+
879
+ // Dustwater only — kept for anything that still wants just the town (and for a quick A/B).
880
+ export const populateTown = (game, clips, defs = NC.npcDefs) => populateCounty(game, clips, defs);