sindicate 0.1.0 → 0.4.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/quality.md +159 -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/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -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/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/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -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,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
|
+
}
|