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,524 @@
1
+ // Character: base for player, NPCs and enemies — a loaded Synty character
2
+ // with an Animator, health, faction and simple physics on the terrain.
3
+ import * as THREE from 'three/webgpu';
4
+ import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
5
+ import { instantiate, loadTexture, applyAtlas, applyFaceAtlas, resolveModelUrl } from '../core/assets.js';
6
+ import { Animator } from '../core/anim.js';
7
+ // A game may register a dress pass run on the player model after load
8
+ // (western: face surgery + modular beard). setCharacterDressHook(async (model) => …)
9
+ let dressHook = null;
10
+ export function setCharacterDressHook(fn) { dressHook = fn; }
11
+ import { angleLerp, dist2D } from '../core/utils.js';
12
+
13
+ const _gripV = new THREE.Vector3();
14
+ const _cullSphere = new THREE.Sphere(new THREE.Vector3(), 2.2); // body sphere for the off-screen frustum test
15
+ const _steerV = new THREE.Vector3(); // walkDir's steered output (shared tmp)
16
+ const _steerA = new THREE.Vector3(), _steerB = new THREE.Vector3(); // whisker segment endpoints
17
+ const _WHISK_A = [0, 0.61, -0.61, 1.22, -1.22]; // whisker fan: 0°, ±35°, ±70°
18
+ const _WHISK_L = [2.3, 1.9, 1.9, 1.5, 1.5]; // centre feeler longest — earliest warning
19
+
20
+ // Collapse a character's visible body parts into ONE SkinnedMesh.
21
+ // Synty modular characters ship the body as 6–10 separate SkinnedMeshes (head, torso, arms,
22
+ // legs, hands…). They already share ONE skeleton and ONE atlas texture, so merging their
23
+ // geometry is lossless but turns ~8 draw calls per character into 1 — and, crucially, ONE
24
+ // shadow-caster instead of 8 (the shadow pass re-draws every caster). At 25 on-screen NPCs
25
+ // that's ~200 draws → ~25. The bones/skeleton are untouched so the Animator keeps driving it.
26
+ // Robust: any failure leaves the original separate meshes in place (never breaks a character).
27
+ // NOT for the player — gear swaps re-run applyOutfit, which needs the individual part meshes.
28
+ export function mergeCharacterBody(model) {
29
+ try {
30
+ const meshes = [];
31
+ model.traverse((o) => { if (o.isSkinnedMesh && o.visible) meshes.push(o); });
32
+ if (meshes.length < 2) return false;
33
+ const skeleton = meshes[0].skeleton;
34
+ if (!meshes.every((m) => m.skeleton === skeleton)) return false; // don't merge across skeletons
35
+ // A merged mesh carries ONE material, so split by transparency (ghost enemies are translucent).
36
+ const groups = new Map();
37
+ for (const m of meshes) {
38
+ const mat = Array.isArray(m.material) ? m.material[0] : m.material;
39
+ const key = mat && mat.transparent ? 't' : 'o';
40
+ if (!groups.has(key)) groups.set(key, []);
41
+ groups.get(key).push(m);
42
+ }
43
+ const parent = meshes[0].parent;
44
+ const bindMatrix = meshes[0].bindMatrix;
45
+ let mergedAny = false;
46
+ for (const grp of groups.values()) {
47
+ if (grp.length < 2) continue;
48
+ let merged;
49
+ try { merged = BufferGeometryUtils.mergeGeometries(grp.map((m) => m.geometry), false); }
50
+ catch { continue; } // attribute mismatch (e.g. some parts have vertex colors) → leave as-is
51
+ if (!merged) continue;
52
+ const proto = Array.isArray(grp[0].material) ? grp[0].material[0] : grp[0].material;
53
+ const mesh = new THREE.SkinnedMesh(merged, proto);
54
+ mesh.bind(skeleton, bindMatrix);
55
+ mesh.frustumCulled = false;
56
+ mesh.castShadow = grp[0].castShadow;
57
+ mesh.receiveShadow = grp[0].receiveShadow;
58
+ mesh.name = 'Chr_merged';
59
+ parent.add(mesh);
60
+ for (const m of grp) m.removeFromParent();
61
+ mergedAny = true;
62
+ }
63
+ return mergedAny;
64
+ } catch (e) {
65
+ console.warn('[mergeCharacterBody] failed, keeping separate parts', e);
66
+ return false;
67
+ }
68
+ }
69
+
70
+ export class Character {
71
+ constructor(game, { model, texture, clips, scale = null, speed = 3, health = 50, faction = 'neutral', name = '', outfit = null, keep = null, attach = null, texFlipY = false, collapse = false, hair = null, faceTex = null, freckleTex = null, playerLook = false }) {
72
+ this.game = game;
73
+ this.modelUrl = model;
74
+ this.textureUrl = texture;
75
+ this.clipSet = clips;
76
+ // Model scale by source units: fbx2gltf/Blender GLBs are metres (load 1:1); Synty FBX are cm
77
+ // (× 0.01 → metres). Default is URL-aware so a model can be swapped .fbx↔.glb without touching
78
+ // its scale, EXCEPT where an explicit cm tweak was given (those get ×100'd at the call site).
79
+ this._rawScale = scale; // null = derive from the RESOLVED url at load (twin-aware)
80
+ this.scale = scale != null ? scale : (/\.glb$/i.test(model || '') ? 1 : 0.01);
81
+ this.outfit = outfit; // modular character: { gender, parts:{slot:meshName} } or null
82
+ this.keep = keep; // packed multi-char source: keep only this body mesh (dwarves, the western packs).
83
+ // A LIST for a CHILD — he is five meshes, and keep[0] is the body (see assets.js).
84
+ this.attach = attach; // head props to seat on the Head bone (beard/hair GLB names)
85
+ this.texFlipY = texFlipY; // atlas V-flip for the keep path: dwarf GLB=false, the fbx2gltf goblin GLB=true
86
+ this.collapse = collapse; // rebind the whole pack onto ONE skeleton before cloning (POLYGON Kids: 5082 bones → 51)
87
+ this.hair = hair; // kid-pack hair/hat FBX names, hung off the Head bone
88
+ this.faceTex = faceTex; // the FACIAL atlas — the mouth decal. A second texture on the same model.
89
+ this.playerLook = playerLook; // PLAYER only: shave the built-in beard + seat the modular one (playerLook.js)
90
+ this.freckleTex = freckleTex; // the freckle sheet (its own UVs — see applyFaceAtlas). null = no freckles.
91
+ this.name = name;
92
+ this.faction = faction; // 'player' | 'villager' | 'guard' | 'hostile'
93
+ this.speed = speed;
94
+ this.maxHealth = health;
95
+ this.health = health;
96
+ this.alive = true;
97
+ this.root = new THREE.Group(); // moved by gameplay; model hangs under it
98
+ this.heading = 0; // facing angle (radians, around Y)
99
+ this.velocity = new THREE.Vector3();
100
+ this.radius = 0.45;
101
+ this.capsuleSteps = 2; // capsule substeps (cheap for NPCs; player ups it)
102
+ this.busy = false; // one-shot anim in progress (attack, hit...)
103
+ this.staggerT = 0;
104
+ this.dead = false;
105
+ }
106
+
107
+ async load() {
108
+ if (this.keep) {
109
+ // packed multi-character GLB (Dungeon Realms dwarves): extract one body, seat head
110
+ // attachments, and apply the atlas with flipY=false (glTF UV) PER INSTANCE so A/B/C
111
+ // outfit recolours vary between dwarves. (The FBX-model path below keeps its own atlas.)
112
+ this.model = await instantiate(this.modelUrl, {
113
+ keep: this.keep, attach: this.attach, collapse: this.collapse, hair: this.hair,
114
+ });
115
+ // ONE atlas over the whole model — clothes, pupils, eyebrows, AND the hair hanging off the
116
+ // head bone (applyAtlas walks a bone's children too, which is exactly how a child's hair gets
117
+ // its colour). The MOUTH is the one thing this map is wrong for; the face atlas lands below.
118
+ if (this.textureUrl) applyAtlas(this.model, await loadTexture(this.textureUrl, { flipY: this.texFlipY }));
119
+ } else {
120
+ // TWIN-AWARE SCALE (the 1.8cm travellers): an EXPLICIT ctor scale means the caller
121
+ // tuned for this exact format (MountedTraveller's cm FBX seat origin) — forwarding
122
+ // it BLOCKS the twin redirect, preserving that choice. A defaulted scale re-sniffs
123
+ // units from the RESOLVED url: .fbx defs that redirect to metre twins load at 1
124
+ // (the unresolved sniff gave them ×0.01 → microscopic walkers).
125
+ const resolved = await resolveModelUrl(this.modelUrl, { texture: this.textureUrl, scale: this._rawScale ?? undefined });
126
+ if (this._rawScale == null) this.scale = /\.glb$/i.test(resolved) ? 1 : 0.01;
127
+ this.model = await instantiate(this.modelUrl, { texture: this.textureUrl, ...(this._rawScale != null ? { scale: this._rawScale } : {}) });
128
+ }
129
+ this.model.scale.setScalar(this.scale);
130
+ if (this.outfit) applyOutfit(this.model, this.outfit); // show only the equipped part meshes
131
+ this.model.traverse((o) => {
132
+ if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; }
133
+ });
134
+ // PLAYER face: shave the merged beard/moustache + seat the chosen modular beard on the Head bone.
135
+ // After the traverse (so the beard mesh it adds inherits castShadow) and before grounding (which
136
+ // measures bones only, so the beard never shifts the feet). CLONES the body geometry, so NPC cowboys
137
+ // keep theirs. Awaited: it fetches a 26 KB beard glb.
138
+ if (this.playerLook) await dressHook?.(this.model);
139
+ // THE SECOND TEXTURE, and it goes on AFTER that traverse on purpose: the mouth decal must NOT
140
+ // cast a shadow (a flat plane inside the head's own silhouette — its shadow can only ever be
141
+ // wrong), and the line above would have switched it back on. applyFaceAtlas clears it again.
142
+ if (this.faceTex) {
143
+ applyFaceAtlas(this.model,
144
+ await loadTexture(this.faceTex, { flipY: this.texFlipY }),
145
+ this.freckleTex ? await loadTexture(this.freckleTex, { flipY: this.texFlipY }) : null);
146
+ }
147
+ // Body-part merge is OFF by default: most Synty characters here are already a single skinned
148
+ // mesh, so it rarely helps, and merging skinned geometry risks a bind-matrix distortion. Opt in
149
+ // with ?mergebody only for content that ships multi-part bodies. (The real character win is the
150
+ // frustum/off-screen cull, not fewer parts.)
151
+ if (this.faction !== 'player' && new URLSearchParams(location.search).has('mergebody')) {
152
+ mergeCharacterBody(this.model);
153
+ }
154
+ this.root.add(this.model);
155
+ // lift the model so its feet sit on the ground from frame 1 (origin is at the hips). Known
156
+ // value → no spawn pop; falls back to the deferred measure if no feetDrop is given.
157
+ if (this.outfit?.feetDrop != null) { this.model.position.y = this.outfit.feetDrop; this._baseModelY = this.model.position.y; this._grounded = true; }
158
+ else { this._groundFeet(); } // instant approximate from bind pose — the settle re-measure only corrects millimetres, so no sink-then-pop
159
+ this.animator = new Animator(this.model, this.clipSet);
160
+ // A CADENCE, if this body has one. The mixer's own rate, so EVERY clip it will ever play slows
161
+ // together — a child who ambles down the street but panics at a grown man's frame rate is a
162
+ // puppet with two operators. Set by npc.js for children (0.81: see the arithmetic there).
163
+ if (this._animRate && this.animator.mixer) this.animator.mixer.timeScale = this._animRate;
164
+ this.animator.play('idle');
165
+ return this;
166
+ }
167
+
168
+ // Offset the model so its lowest bone sits at the root origin (the ground), measured from
169
+ // the bind pose via bones (skinned-mesh bounding boxes are unreliable).
170
+ _groundFeet() {
171
+ this.model.updateWorldMatrix(true, true);
172
+ let minY = Infinity; const v = new THREE.Vector3();
173
+ this.model.traverse((o) => { if (o.isBone) { o.getWorldPosition(v); minY = Math.min(minY, v.y); } });
174
+ if (isFinite(minY)) { this.model.position.y -= minY - this.root.position.y; this._baseModelY = this.model.position.y; }
175
+ }
176
+
177
+ // Attachment parent for a bone that compensates for the model scale, so cm-scale props
178
+ // (tuned for the 0.01 peasant rig) sit correctly on a metre-scale rig (the modular hero).
179
+ // At ~0.01 scale the factor is 1 → attach straight to the bone (unchanged for cm rigs).
180
+ gripNodeFor(bone) {
181
+ bone.updateWorldMatrix(true, false);
182
+ const ws = bone.getWorldScale(_gripV).x || 1;
183
+ const g = 0.01 / ws; // cm-tuned props → metres, whatever the rig's actual bone world-scale is
184
+ if (Math.abs(g - 1) < 0.02) return bone;
185
+ if (!bone.userData.gripNode) { bone.userData.gripNode = new THREE.Group(); bone.add(bone.userData.gripNode); }
186
+ bone.userData.gripNode.scale.setScalar(g);
187
+ return bone.userData.gripNode;
188
+ }
189
+
190
+ // Attach a held prop (a guard's spear, a tool…) to a hand bone via the grip node so it
191
+ // sits right on any rig. KitForge GLB pieces carry no usable atlas, so texture them the
192
+ // KitForge way (flipY-true) when a texture is passed. Returns the prop (also on .propObj).
193
+ async equipProp(url, { texture, texFlipY = false, pos, rot, scale, bone = 'Hand_R' } = {}) {
194
+ let b = null;
195
+ this.model.traverse((o) => { if (!b && o.isBone && o.name === bone) b = o; });
196
+ if (!b) return null;
197
+ const obj = await instantiate(url);
198
+ if (texture) {
199
+ // CLONE the cached atlas before touching flipY/colorSpace — loadTexture returns a SHARED
200
+ // instance, and mutating it corrupts every other mesh that uses the same atlas (a wrong-flipY
201
+ // clone once V-flipped the whole PolygonFantasyKingdom set — people, buildings, props).
202
+ const tex = (await loadTexture(texture)).clone();
203
+ tex.flipY = texFlipY; tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true;
204
+ obj.traverse((o) => { if (o.isMesh && o.material) { o.material.map = tex; o.material.needsUpdate = true; } });
205
+ }
206
+ obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
207
+ this.gripNodeFor(b).add(obj);
208
+ if (scale != null) Array.isArray(scale) ? obj.scale.set(scale[0], scale[1], scale[2]) : obj.scale.setScalar(scale);
209
+ if (pos) obj.position.set(pos[0], pos[1], pos[2]);
210
+ if (rot) obj.rotation.set(rot[0], rot[1], rot[2]);
211
+ this.propObj = obj;
212
+ return obj;
213
+ }
214
+
215
+ get position() { return this.root.position; }
216
+
217
+ setPosition(x, y, z) { this.root.position.set(x, y, z); }
218
+
219
+ faceToward(x, z, dt = 1, rate = 12) {
220
+ const target = Math.atan2(x - this.position.x, z - this.position.z);
221
+ this.heading = angleLerp(this.heading, target, Math.min(1, dt * rate));
222
+ this.root.rotation.y = this.heading;
223
+ }
224
+
225
+ // Move with terrain-follow + static collision. dir is normalized XZ.
226
+ // While airborne (jumping), Y is left to the jump physics.
227
+ // Capsule-vs-world-BVH movement, so people collide with buildings/rocks/props
228
+ // /stairs too. Cull-frozen far entities never reach here (entity.update returns
229
+ // early), so this only runs for nearby ones.
230
+ move(dir, speed, dt) {
231
+ this.game.world.moveCapsule(this, dir.x * speed * dt, dir.z * speed * dt, dt);
232
+ }
233
+
234
+ // ---- Human-ish walking: steer around obstacles + other walkers instead of barging ----
235
+ // Whisker model (Nick's spec): probe FORWARD and ±45°; blocked ahead → swing toward the
236
+ // clear side; plus a soft lateral push away from nearby walkers/horses/the player, with
237
+ // head-on encounters passing on the LEFT (this is alt-Britain).
238
+ // PERF CONTRACT (must not eat fps): the probe (3 short whisker checks + one neighbour scan)
239
+ // runs every ~0.2s per walker, phase-staggered so only a few probe on any given frame, and
240
+ // ONLY within 45m of the player (beyond that they're culled/invisible anyway). Between
241
+ // probes the cached angle is smoothed — the per-frame cost is two multiplies.
242
+ walkDir(dir, dt) {
243
+ const g = this.game, pl = g.player;
244
+ if (!pl) return dir;
245
+ const dx = this.position.x - pl.position.x, dz = this.position.z - pl.position.z;
246
+ if (dx * dx + dz * dz > 45 * 45) { this._steerAng = 0; this._steerTarget = 0; return dir; }
247
+ this._steerT = (this._steerT ?? Math.random() * 0.15) - dt; // random phase → staggered probes
248
+ if (this._steerT <= 0) { this._steerT = 0.15; this._steerTarget = this._probeSteer(dir); }
249
+ const curAng = this._steerAng ?? 0;
250
+ this._steerAng = curAng + Math.min(1, dt * 7) * ((this._steerTarget ?? 0) - curAng);
251
+ if (Math.abs(this._steerAng) < 0.02) return dir;
252
+ const c = Math.cos(this._steerAng), s = Math.sin(this._steerAng); // +angle = left turn
253
+ return _steerV.set(dir.x * c + dir.z * s, 0, dir.z * c - dir.x * s);
254
+ }
255
+
256
+ _probeSteer(dir) {
257
+ const g = this.game, px = this.position.x, pz = this.position.z, py = this.position.y;
258
+ // Separation ALWAYS applies (v1 only ran it when the whiskers were clear — in the stall-packed
259
+ // market everything reads blocked, so walkers never separated and still overlapped at 0m).
260
+ let push = 0, headOn = false;
261
+ const consider = (o) => {
262
+ if (o === this || !o?.position || o.dead) return;
263
+ const ox = o.position.x - px, oz = o.position.z - pz;
264
+ const d2 = ox * ox + oz * oz;
265
+ if (d2 > 6.76) return; // only within 2.6m
266
+ if (d2 < 0.01) { push += (this.root.id & 1) ? 0.9 : -0.9; return; } // standing INSIDE someone → force apart (stable per-entity side)
267
+ if (ox * dir.x + oz * dir.z < -0.2) return; // behind us — ignore
268
+ const side = dir.z * ox - dir.x * oz; // + = they're to our left
269
+ push -= Math.sign(side || 1) * 0.9 * (1 - Math.sqrt(d2) / 2.6); // steer away — strong enough to visibly swerve
270
+ if (o.heading !== undefined && (Math.sin(o.heading) * dir.x + Math.cos(o.heading) * dir.z) < -0.5) headOn = true;
271
+ };
272
+ for (const n of g.npcs) consider(n);
273
+ if (g.travellers) for (const t of g.travellers) consider(t);
274
+ if (g.horses) for (const h of g.horses) consider(h);
275
+ consider(g.player);
276
+ if (headOn) push += 0.4; // pass on the LEFT — we're English
277
+ // CONTEXT WHISKERS (v3 — Nick: "every move they sense and adjust, not only when they walk
278
+ // into something"): five feelers (0°, ±35°, ±70°), the CENTRE one long (2.3m ≈ 1.3s of
279
+ // warning at walk pace) so walkers start CURVING well before contact. Each feeler scores
280
+ // graded openness (blocked-near 0 / blocked-far 0.5 / clear 1) minus a bias away from the
281
+ // desired line, and the walker leans toward the most-open direction — so an obstacle ahead
282
+ // bends the path early and smoothly instead of stopping it at the last step. If even the
283
+ // best direction reads blocked-near (boxed in — stall maze), _steerSlow flags the callers
284
+ // to drop to a hesitant pace while the turn happens: nobody barges at full speed.
285
+ // NB: world.collide() returns the CORRECTED position ({x,z}) — ALWAYS truthy. Blocked
286
+ // means the point MOVED. (Treating it as a boolean made every whisker read blocked
287
+ // everywhere → zero steering — the "not working at all" bug.)
288
+ const circleHit = (qx, qz) => {
289
+ const h = g.world.collide(qx, qz, 0.3, 0, py);
290
+ return !!h && (Math.abs(h.x - qx) > 1e-6 || Math.abs(h.z - qz) > 1e-6);
291
+ };
292
+ const probe = (a, L) => { // graded by DISTANCE to first hit (0..1) — dense areas keep a gradient
293
+ const c = Math.cos(a), s = Math.sin(a);
294
+ const wx = dir.x * c + dir.z * s, wz = dir.z * c - dir.x * s;
295
+ if (circleHit(px + wx * L * 0.35, pz + wz * L * 0.35)) return 0;
296
+ _steerA.set(px, py + 0.9, pz); _steerB.set(px + wx * L * 0.5, py + 0.9, pz + wz * L * 0.5);
297
+ if (g.world.segmentBlocked(_steerA, _steerB)) return 0.15; // BVH near half
298
+ if (circleHit(px + wx * L * 0.7, pz + wz * L * 0.7)) return 0.4;
299
+ if (circleHit(px + wx * L, pz + wz * L)) return 0.7;
300
+ _steerB.set(px + wx * L, py + 0.9, pz + wz * L);
301
+ return g.world.segmentBlocked(_steerA, _steerB) ? 0.7 : 1; // BVH full
302
+ };
303
+ const scores = _WHISK_A.map((a, i) => probe(a, _WHISK_L[i]));
304
+ const centreScore = scores[0];
305
+ this._steerBlockAhead = centreScore < 0.35; // callers: treat a close goal as reached
306
+ let bestA = 0, bestScore = -9;
307
+ for (let i = 0; i < 5; i++) {
308
+ // NEVER pick "straight in": when the centre reads blocked-near, it is excluded — the walker
309
+ // MUST commit to a side (the old equal-scores tie kept centre → walked into things first).
310
+ if (i === 0 && centreScore < 0.35) continue;
311
+ const score = scores[i] - Math.abs(_WHISK_A[i]) * 0.14; // mild preference for the desired line
312
+ if (score > bestScore) { bestScore = score; bestA = _WHISK_A[i]; } // ties keep the EARLIER whisker — left sits before right in the fan, so ties pass LEFT
313
+ }
314
+ if (centreScore < 0.35 && bestScore < 0.1) {
315
+ // boxed in hard: turn to whichever ±70° side is less bad (tie → LEFT, we're English)
316
+ bestA = scores[3] >= scores[4] ? _WHISK_A[3] : _WHISK_A[4];
317
+ }
318
+ this._steerSlow = centreScore < 0.4; // hesitate whenever contact is near
319
+ // whisker lean + people separation, together. Centre clear = no lean (bestA 0).
320
+ return Math.max(-1.35, Math.min(1.35, bestA + Math.max(-0.6, Math.min(0.6, push))));
321
+ }
322
+
323
+ // ---- shared navgrid access — EVERYONE routes on the grids (townsfolk, knights, goblins…) ----
324
+ // Subclasses provide _navHome() (settlement centre / camp home); null = no pathing for this
325
+ // character. Grids bake lazily per region; until ready, callers fall back to direct+whiskers.
326
+ _navHome() { return null; }
327
+
328
+ _navRegion() {
329
+ if (this._navCx !== undefined) return;
330
+ const h = this._navHome();
331
+ if (!h) { this._navCx = null; return; }
332
+ this._navCx = h.x; this._navCz = h.z;
333
+ this._navKey = `r${Math.round(h.x / 80)}_${Math.round(h.z / 80)}`; // ~one shared grid per area
334
+ }
335
+
336
+ _navGrid() {
337
+ const nav = this.game.nav;
338
+ if (!nav) return null;
339
+ this._navRegion();
340
+ if (this._navCx == null) return null;
341
+ const grd = nav.gridFor(this._navKey, this._navCx, this._navCz);
342
+ return grd.ready ? grd : null;
343
+ }
344
+
345
+ _requestPath(to) {
346
+ const nav = this.game.nav;
347
+ if (!nav) return null;
348
+ this._navRegion();
349
+ if (this._navCx == null) return null;
350
+ return nav.path(this._navKey, this._navCx, this._navCz, this.position, to);
351
+ }
352
+
353
+ // Current waypoint along the active path (advances as we reach each), else the raw goal.
354
+ _pathWaypoint(t) {
355
+ if (this._navPath && this._navI < this._navPath.length) {
356
+ let wp = this._navPath[this._navI];
357
+ if (dist2D(this.position.x, this.position.z, wp.x, wp.z) <= 0.8) {
358
+ this._navI++;
359
+ wp = this._navI < this._navPath.length ? this._navPath[this._navI] : t;
360
+ }
361
+ return wp;
362
+ }
363
+ return t;
364
+ }
365
+
366
+ // Smooth the VISUAL height on the way DOWN over micro-bumps (cobbles/steps/
367
+ // slopes), so descents aren't choppy from snap-to-ground stepping each frame.
368
+ // Same logic the player runs in player.update — but MUST be called AFTER the
369
+ // entity has moved this frame (so position.y is final), else the move re-adds
370
+ // the raw delta and the smoothing does nothing. Physics stay exact; ascents
371
+ // and big jumps (spawn/teleport/fall) track 1:1. The player does its own (it
372
+ // also drives the camera from _visualY), so this is for NPCs and enemies.
373
+ applyDescentSmoothing(dt) {
374
+ if (!this.model) return;
375
+ const py = this.position.y;
376
+ if (this.airborne || this._visualY === undefined || Math.abs(py - this._visualY) > 1.5) {
377
+ this._visualY = py;
378
+ } else if (py < this._visualY) {
379
+ this._visualY += (py - this._visualY) * Math.min(1, dt * 12); // ease down
380
+ } else {
381
+ this._visualY = py;
382
+ }
383
+ this._baseModelY ??= this.model.position.y;
384
+ this.model.position.y = this._baseModelY + (this._visualY - py);
385
+ }
386
+
387
+ takeDamage(amount, fromPos = null, opts = {}) {
388
+ if (!this.alive) return;
389
+ const { knockback = 0.5, softHit = false } = opts; // `opts` also carries .power (heavy/flourish) — read below
390
+ this.health -= amount;
391
+ // softHit: damage only (a blocked hit chips health but must not break the guard).
392
+ // _poiseT: stagger cooldown — damage always lands, but mashing can't stun-lock a
393
+ // character; they get a swing off between staggers.
394
+ // poise tier (enemy.js def.poise): 0 staggers on any hit, 1 needs a heavy/
395
+ // flourish (opts.power 1), 2 shrugs everything — heavies finally have a job
396
+ // opts.parried: a timed deflect ALWAYS staggers, through any poise tier and any
397
+ // cooldown — the parry is the answer to armoured enemies, so it must never whiff
398
+ const interrupt = opts.parried || (!softHit && !(this._poiseT > 0) && (opts.power ?? 1) >= (this.poise ?? 0));
399
+ if (interrupt) {
400
+ this.staggerT = opts.parried ? 1.1 : 0.35; // a parried enemy reels — the punish window
401
+ // 1.6s: long enough for a full windup+swing before the next stagger can land — the
402
+ // old 0.9s left ~0.5s of freedom, which light-attack spam ate entirely ("I hit them
403
+ // with sword and they never get to hit me"). First hit still staggers (feel), then
404
+ // they FIGHT THROUGH the combo (threat) — the AC pressure loop, phase 1.
405
+ this._poiseT = 1.6;
406
+ this._swing = null; // a hit interrupts their in-progress swing (game-time pending swing, enemy.js)
407
+ if (fromPos) {
408
+ const dx = this.position.x - fromPos.x, dz = this.position.z - fromPos.z;
409
+ const d = Math.hypot(dx, dz) || 1;
410
+ // Displace via the capsule controller so knockback DEPENETRATES against building/
411
+ // castle/goblin-camp/Ironkeep BVH walls and stays on the MESH floor it's standing
412
+ // on (bridge deck, castle walk, Ironkeep upper storey). The old path used the 2D
413
+ // collide() (trees/fences only) and then HARD-SET y to the terrain heightfield —
414
+ // teleporting entities through walls and dropping them through mesh floors.
415
+ this.game.world.moveCapsule(this, (dx / d) * knockback, (dz / d) * knockback, 1 / 60);
416
+ }
417
+ }
418
+ if (this.health <= 0) {
419
+ this.die();
420
+ } else if (interrupt && this.animator?.actions.hit) {
421
+ this.busy = true;
422
+ this._hitBusyT = 0.55; // failsafe: creature rigs alias 'hit' to their IDLE clip — busy must not lock for its full length (audit m9)
423
+ this.animator.play('hit', { once: true, fade: 0.08, onDone: () => { this.busy = false; } });
424
+ }
425
+ }
426
+
427
+ die() {
428
+ if (!this.alive) return;
429
+ this.alive = false;
430
+ this.busy = true;
431
+ if (this.animator?.actions.death) {
432
+ this.animator.play('death', { once: true, fade: 0.1 });
433
+ }
434
+ this.deathT = 0;
435
+ }
436
+
437
+ update(dt) {
438
+ if (!this.alive) {
439
+ this.deathT = (this.deathT ?? 0) + dt;
440
+ if (this.deathT > 6 && !this.dead) {
441
+ this.dead = true; // flagged for removal by the entity manager
442
+ }
443
+ }
444
+ if (this.staggerT > 0) this.staggerT -= dt;
445
+ if (this._hitBusyT > 0) { this._hitBusyT -= dt; if (this._hitBusyT <= 0 && this.busy && this.alive) this.busy = false; }
446
+ if (this._poiseT > 0) this._poiseT -= dt;
447
+
448
+ const g = this.game;
449
+ // VAT proxy (vatRoster.js): while DEMOTED this character renders as a roster instance —
450
+ // the rig stays hidden and the Animator never runs (the two real per-character costs).
451
+ // PROMOTE to the rig within 8m of the player, or the moment the AI plays a clip that
452
+ // isn't in the baked ambient set (attack/hit/death/chat/sit — mappable() says no), or on
453
+ // death/stagger. DEMOTE back past 10m when ambient again (hysteresis so nothing flickers).
454
+ // The subclass AI runs after this either way — demoted characters keep moving/scheduling.
455
+ if (this.vatProxy?.ready && g.vatRoster && this.faction !== 'player' && g.player) { // v1 ships no VAT roster — guard, don't assume
456
+ const roster = g.vatRoster;
457
+ const d = this.position.distanceTo(g.player.position);
458
+ const clipName = this.animator?.currentName ?? 'idle';
459
+ const ambient = this.alive && !this.busy && this.staggerT <= 0 && roster.mappable(clipName);
460
+ const rIn = this.vatProxy.promoteR ?? 8, rOut = this.vatProxy.demoteR ?? 10;
461
+ const asInstance = this._vatPromoted ? (ambient && d > rOut) : (ambient && d >= rIn);
462
+ this._vatPromoted = !asInstance;
463
+ if (asInstance) {
464
+ roster.write(this.vatProxy, this.position.x, this.position.y, this.position.z, this.heading, clipName);
465
+ if (this.model?.visible) this.model.visible = false;
466
+ this._animPhase = 0;
467
+ return; // no frustum test, no anim stride, no animator — the instance is the character
468
+ }
469
+ roster.hide(this.vatProxy);
470
+ }
471
+
472
+ // distance-based animation culling: far characters freeze (hidden by
473
+ // fog anyway), mid-range ones animate at half rate. Player always full.
474
+ if (this.faction !== 'player' && g.player && g.quality) {
475
+ const d = this.position.distanceTo(g.player.position);
476
+ if (d > (this.cullDist ?? g.quality.entityCull)) { // road travellers raise cullDist so they don't pop out close
477
+ if (this.model) this.model.visible = false;
478
+ this._animPhase = 0;
479
+ return;
480
+ }
481
+ // Off-screen cull: characters are frustumCulled=false, so three draws every near character even
482
+ // behind the camera. Test the body sphere vs the shared per-frame frustum (built in main tick) —
483
+ // hide + skip the animator when off-screen. The subclass AI runs AFTER super.update, so the
484
+ // character keeps moving; it just isn't drawn or skinned until it re-enters view. No pop.
485
+ if (g._camFrustum) {
486
+ _cullSphere.center.set(this.position.x, this.position.y + 1, this.position.z);
487
+ if (!g._camFrustum.intersectsSphere(_cullSphere)) {
488
+ if (this.model) this.model.visible = false;
489
+ this._animPhase = 0;
490
+ return;
491
+ }
492
+ }
493
+ if (this.model && !this.model.visible) this.model.visible = true;
494
+ if (d > g.quality.animHalfRate) {
495
+ // Graded stride: 1/2 rate past animHalfRate, 1/3 past animMidRate, 1/4 in the outer band
496
+ // before the cull. _pendingDt accumulation keeps clip time exact, so gaits never drift —
497
+ // a far NPC just updates its pose less often (fog + distance hide the step).
498
+ const cull = this.cullDist ?? g.quality.entityCull;
499
+ const mid = g.quality.animMidRate ?? g.quality.animHalfRate * 1.5;
500
+ const stride = d > mid ? (d > cull * 0.85 ? 4 : 3) : 2;
501
+ this._animPhase = ((this._animPhase ?? 0) + 1) % stride;
502
+ if (this._animPhase) { this._pendingDt = (this._pendingDt ?? 0) + dt; return; }
503
+ dt += this._pendingDt ?? 0;
504
+ this._pendingDt = 0;
505
+ }
506
+ }
507
+ this.animator?.update(dt);
508
+ // ground the feet once the REAL idle pose has settled (model origin sits at the hips).
509
+ // Runs for EVERY rig without an explicit feetDrop — the western keep-extracts are
510
+ // hip-origined like the modular hero, so skipping them sank enemies to the waist.
511
+ if (!this._grounded && this.model) {
512
+ this._groundT = (this._groundT ?? 0) + dt;
513
+ if (this._groundT > 0.5) { this._grounded = true; this._groundFeet(); }
514
+ }
515
+ }
516
+ }
517
+
518
+ // Modular character: show only the equipped part meshes per slot, hide all the rest.
519
+ // Exported so the equipment system can re-apply after armour part swaps (visibility
520
+ // toggles on the one resident rig — never adds/removes meshes).
521
+ export function applyOutfit(model, outfit) {
522
+ const wanted = new Set(Object.values(outfit.parts || {}));
523
+ model.traverse((o) => { if (o.isMesh && o.name.startsWith('Chr_')) o.visible = wanted.has(o.name); });
524
+ }
@@ -0,0 +1,38 @@
1
+ // Bare-knuckle finger curl — the shared bit between the PLAYER (fists weapon mode, player.js) and the
2
+ // brawler ENEMIES (ENEMY_TYPES … brawler:true, enemy.js). The punch MoCap clips leave the fingers slack;
3
+ // a per-frame procedural curl — run AFTER the mixer so it wins — closes them into fists. Tuned live in
4
+ // /fighttest.html; this file is the single home for the tuned numbers, so a re-tune lands in one place.
5
+ //
6
+ // Each knuckle flexes about its own local −Z; the thumb (Thumb_01/Thumb_02) folds across on a compound
7
+ // euler with its tip (Thumb_03) left straight.
8
+ import * as THREE from 'three/webgpu';
9
+
10
+ export const FIST_CURL = 55 * Math.PI / 180; // per-knuckle flex, −Z
11
+ export const FIST_THUMB = { x: 66 * Math.PI / 180, y: -18 * Math.PI / 180, z: -23 * Math.PI / 180 }; // Thumb_01/02 euler (Nick-tuned)
12
+
13
+ const _z = new THREE.Vector3(0, 0, 1), _q = new THREE.Quaternion(), _e = new THREE.Euler();
14
+
15
+ // The finger JOINT bones = finger-named bones that HAVE a finger-named child. On the player/enemy DOUBLED
16
+ // skeleton these are the twin joints the skinning leaves ride on; on a COLLAPSED skeleton they're the
17
+ // single finger chain — either way, rotating them cascades the whole digit and the mesh follows. Call at
18
+ // REST (before the mixer poses the fingers); each entry keeps the bone's rest quaternion as `bind`.
19
+ export function collectFistJoints(model) {
20
+ const out = [], isFinger = (n) => /Thumb|Finger|Index/i.test(n);
21
+ model.traverse((b) => {
22
+ if (!b.isBone || !isFinger(b.name)) return;
23
+ if (b.children.some((c) => c.isBone && isFinger(c.name)))
24
+ out.push({ bone: b, bind: b.quaternion.clone(), thumb: /thumb/i.test(b.name), tip: b.name === 'Thumb_03' });
25
+ });
26
+ return out;
27
+ }
28
+
29
+ // Pose the joints for this frame: curled → fist, else → open (rest bind). Overwrites LOCAL quaternions, so
30
+ // it MUST run after the mixer. On the doubled skeleton the `false` branch also clears any residual curl on
31
+ // the un-driven twin joints (the mixer never re-poses those), so a hand never sticks in a fist.
32
+ export function applyFist(joints, curled) {
33
+ for (const f of joints) {
34
+ if (!curled || f.tip) f.bone.quaternion.copy(f.bind);
35
+ else if (f.thumb) f.bone.quaternion.copy(f.bind).multiply(_q.setFromEuler(_e.set(FIST_THUMB.x, FIST_THUMB.y, FIST_THUMB.z, 'XYZ')));
36
+ else f.bone.quaternion.copy(f.bind).multiply(_q.setFromAxisAngle(_z, -FIST_CURL));
37
+ }
38
+ }