sindicate 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/engine.js +284 -0
- package/src/index.js +13 -1
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/enemy.js +442 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/player.js +1619 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/train.js +1957 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,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);
|