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.
Files changed (65) 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/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,282 @@
1
+ // Retargeting from the ANIMATION-pack rig (PolygonSyntyCharacter:
2
+ // Root/Hips/Spine_01/Shoulder_L…) onto Synty POLYGON character rigs.
3
+ //
4
+ // The rigs share structure and proportions, but bone names differ
5
+ // (Pelvis/spine_01/UpperArm_L…) and the bone-local frames differ
6
+ // (Unreal-style axis convention). We retarget with bind-pose deltas:
7
+ //
8
+ // Δb = Wb_animBind⁻¹ · Wb_targetBind (world-rotation delta)
9
+ // q'b(t) = Δparent(b)⁻¹ · qb(t) · Δb (per quaternion key)
10
+ //
11
+ // which makes the target's world deformation match the source clip
12
+ // exactly, regardless of local frame conventions.
13
+ import * as THREE from 'three/webgpu';
14
+
15
+ // ANIMATION rig bone -> POLYGON rig bone. Identity entries are implied
16
+ // for rigs (e.g. Knights pack) that reuse the source names.
17
+ const ANIM_TO_POLYGON = {
18
+ Hips: 'Pelvis',
19
+ Spine_01: 'spine_01', Spine_02: 'spine_02', Spine_03: 'spine_03',
20
+ Neck: 'neck_01', Head: 'head', Eyes: 'eyes', Eyebrows: 'eyebrows',
21
+ Clavicle_L: 'clavicle_l', Shoulder_L: 'UpperArm_L', Elbow_L: 'lowerarm_l', Hand_L: 'Hand_L',
22
+ Clavicle_R: 'clavicle_r', Shoulder_R: 'UpperArm_R', Elbow_R: 'lowerarm_r', Hand_R: 'Hand_R',
23
+ Thumb_01: 'thumb_01_l', Thumb_02: 'thumb_02_l', Thumb_03: 'thumb_03_l',
24
+ IndexFinger_01: 'indexFinger_01_l', IndexFinger_02: 'indexFinger_02_l',
25
+ IndexFinger_03: 'indexFinger_03_l', IndexFinger_04: 'indexFinger_04_l',
26
+ Finger_01: 'finger_01_l', Finger_02: 'finger_02_l', Finger_03: 'finger_03_l', Finger_04: 'finger_04_l',
27
+ Thumb_01_1: 'thumb_01_r', Thumb_02_1: 'thumb_02_r', Thumb_03_1: 'thumb_03_r',
28
+ IndexFinger_01_1: 'indexFinger_01_r', IndexFinger_02_1: 'indexFinger_02_r',
29
+ IndexFinger_03_1: 'indexFinger_03_r', IndexFinger_04_1: 'indexFinger_04_r',
30
+ Finger_01_1: 'finger_01_r', Finger_02_1: 'finger_02_r', Finger_03_1: 'finger_03_r', Finger_04_1: 'finger_04_r',
31
+ UpperLeg_L: 'Thigh_L', LowerLeg_L: 'calf_l', Ankle_L: 'Foot_L', Ball_L: 'ball_l', Toes_L: 'toes_l',
32
+ UpperLeg_R: 'Thigh_R', LowerLeg_R: 'calf_r', Ankle_R: 'Foot_R', Ball_R: 'ball_r', Toes_R: 'toes_r',
33
+ };
34
+
35
+ // Named SOURCE RIGS. Clips are authored against a specific skeleton's BIND POSE, and a clip
36
+ // only plays correctly on a rig with that same bind. Unity hides this — its Humanoid/Mecanim
37
+ // avatar retargets muscle-space, so any humanoid clip plays on any humanoid rig. three.js has
38
+ // no such layer: it applies raw bone quaternions. So a pack whose bind differs (the Wild West
39
+ // gunslinger pack's PreviewRig is ~90-107 deg off on Hips/Spine vs the Synty anim rig, despite
40
+ // IDENTICAL bone names) must declare its own source bind, or it plays as a folded-in-half mess.
41
+ // One global source bind was the bug: every clip was assumed to be authored on the Synty rig.
42
+ const sourceRigs = new Map(); // key -> { bind, map }
43
+ let defaultRigKey = null;
44
+
45
+ // Malbers "Human" rig -> the ANIMATION rig. Used for the climb/ladder clips, which are authored on the
46
+ // Malbers skeleton, not the Synty animation rig. We map onto ANIM-rig names (Hips/Spine_01/Shoulder_L…);
47
+ // the existing ANIM_TO_POLYGON step in mapName() then carries it onto polygon characters automatically, so
48
+ // ONE map serves both anim- and polygon-rigged targets. NOTE: FBXLoader sanitizes spaces->underscores, so
49
+ // the bones load as R_L_Clavicle (not "R_L Clavicle").
50
+ export const MALBERS_TO_ANIM = {
51
+ R_CG: 'Root', // Malbers root (above pelvis) -> anim Root; without it the pelvis loses parent compensation and the body pitches 90°
52
+ R_Pelvis: 'Hips', R_Spine: 'Spine_01', R_Spine1: 'Spine_02', R_Spine2: 'Spine_03',
53
+ R_Neck: 'Neck', R_Head: 'Head',
54
+ R_L_Clavicle: 'Clavicle_L', R_L_UpperArm: 'Shoulder_L', R_L_Forearm: 'Elbow_L', R_L_Hand: 'Hand_L',
55
+ R_R_Clavicle: 'Clavicle_R', R_R_UpperArm: 'Shoulder_R', R_R_Forearm: 'Elbow_R', R_R_Hand: 'Hand_R',
56
+ R_L_Thigh: 'UpperLeg_L', R_L_Calf: 'LowerLeg_L', R_L_Foot: 'Ankle_L',
57
+ R_R_Thigh: 'UpperLeg_R', R_R_Calf: 'LowerLeg_R', R_R_Foot: 'Ankle_R',
58
+ };
59
+
60
+ const _bindP = new THREE.Vector3(), _bindS = new THREE.Vector3();
61
+ const _posV = new THREE.Vector3();
62
+
63
+ function captureBind(root) {
64
+ root.updateMatrixWorld(true);
65
+ // TRUE bind-pose world rotations from the SkinnedMesh inverse-bind matrices (boneInverses[i]⁻¹
66
+ // IS the bone's world matrix at bind), which are pose-independent ground truth. Reading the
67
+ // live getWorldQuaternion() instead silently poisons every delta if the model is captured
68
+ // mid-animation — e.g. a shared master that got posed before this clone (which is exactly why
69
+ // the SK_Chr_Soldier_* guards held their arms out: their arm deltas were ~30° off true bind).
70
+ const bindQ = new Map(); // bone name -> world quaternion at bind
71
+ root.traverse((o) => {
72
+ if (!o.isSkinnedMesh || !o.skeleton) return;
73
+ const { bones, boneInverses } = o.skeleton;
74
+ for (let i = 0; i < bones.length; i++) {
75
+ const b = bones[i];
76
+ if (!b || !boneInverses[i] || bindQ.has(b.name)) continue;
77
+ const q = new THREE.Quaternion();
78
+ boneInverses[i].clone().invert().decompose(_bindP, q, _bindS);
79
+ bindQ.set(b.name, q);
80
+ }
81
+ });
82
+ // OBJECT-CHAIN ROTATION. boneInverses are expressed in the SKELETON's container space —
83
+ // NOT world. FBXLoader parks the Z-up->Y-up conversion on the container Object3Ds for some
84
+ // packs (the Wild West gunslinger rig: root Group -90deg X, PreviewRig Group -180deg Z) and
85
+ // on a Root BONE for others (Synty: containers identity). Comparing a bind captured in one
86
+ // frame against a bind captured in the other tilts every delta by that rotation — the
87
+ // character animates lying on his back. Fold the container's world rotation in so both rigs
88
+ // are captured in the MODEL-ROOT frame. Identity for Synty-family rigs (no behaviour change);
89
+ // it is a static transform, so this stays safe for posed shared masters (unlike reading
90
+ // getWorldQuaternion off a live bone).
91
+ root.updateMatrixWorld(true);
92
+ let firstBone = null;
93
+ root.traverse((o) => { if (!firstBone && o.isBone) firstBone = o; });
94
+ let rootBone = firstBone;
95
+ while (rootBone?.parent?.isBone) rootBone = rootBone.parent;
96
+ const container = rootBone?.parent ?? null;
97
+ // ...MEASURED RELATIVE TO `root`, NOT TO THE WORLD. getWorldQuaternion() also picks up
98
+ // wherever the character happens to be STANDING — and a Character's model hangs under a root
99
+ // Group that is rotated to its HEADING. So the bind captured for a townsman facing south had
100
+ // his heading folded into it, and every delta computed from it was off by exactly that angle:
101
+ // measured across the town, chainQ came out at precisely |heading| for every NPC (Silas at
102
+ // heading π captured a 180° "bind" and stood with his arms straight up; the player and the
103
+ // barkeep face heading 0, which is the only reason they ever looked right). The container's
104
+ // rotation is only meaningful in the MODEL-ROOT frame — which is what this always meant to be.
105
+ const rootQinv = root.getWorldQuaternion(new THREE.Quaternion()).invert();
106
+ const chainQ = container
107
+ ? rootQinv.multiply(container.getWorldQuaternion(new THREE.Quaternion()))
108
+ : new THREE.Quaternion();
109
+
110
+ const map = new Map();
111
+ map.chainQ = chainQ; // container rotation — position tracks live in this frame too
112
+ root.traverse((o) => {
113
+ if (!o.isBone) return;
114
+ const bq = bindQ.get(o.name);
115
+ const worldQ = bq
116
+ ? chainQ.clone().multiply(bq) // container space -> model-root frame
117
+ : o.getWorldQuaternion(new THREE.Quaternion()); // fallback: bones with no skin weight
118
+ map.set(o.name, {
119
+ worldQ,
120
+ localP: o.position.clone(),
121
+ parentName: o.parent?.isBone ? o.parent.name : null,
122
+ });
123
+ });
124
+ return map;
125
+ }
126
+
127
+ // Register a source rig by key (its loaded pose = its bind pose). The FIRST one registered
128
+ // becomes the default for clips that don't name a rig.
129
+ export function addSourceRig(key, root, map = null) {
130
+ sourceRigs.set(key, { bind: captureBind(root), map });
131
+ if (!defaultRigKey) defaultRigKey = key;
132
+ }
133
+
134
+ // Back-compat: the original single-source API — registers the 'anim' (Synty) rig.
135
+ export function setSourceRig(root, map = null) {
136
+ addSourceRig('anim', root, map);
137
+ defaultRigKey = 'anim';
138
+ }
139
+
140
+ export function detectRig(root) {
141
+ let rig = 'anim';
142
+ root.traverse((o) => { if (o.isBone && o.name === 'Pelvis') rig = 'polygon'; });
143
+ return rig;
144
+ }
145
+
146
+ // Per-character retarget context: bone deltas keyed by SOURCE bone name.
147
+ // Characters sharing a rig signature can share a context, but contexts are
148
+ // cheap; we just build one per loaded character master.
149
+ export function buildRetargetContext(targetRoot, rigKey = null) {
150
+ const key = rigKey ?? defaultRigKey;
151
+ const srcRig = sourceRigs.get(key);
152
+ if (!srcRig) throw new Error(`source rig '${key}' not registered — call setSourceRig()/addSourceRig() first`);
153
+ const sourceBind = srcRig.bind, sourceMap = srcRig.map;
154
+ const rig = detectRig(targetRoot);
155
+ const targetBind = captureBind(targetRoot);
156
+ const deltas = new Map(); // source bone name -> THREE.Quaternion
157
+ const mapName = (src) => {
158
+ let n = sourceMap ? (sourceMap[src] || src) : src; // foreign source -> anim-rig name
159
+ if (rig === 'polygon' && ANIM_TO_POLYGON[n]) return ANIM_TO_POLYGON[n]; // ...then anim -> polygon for polygon targets
160
+ return n; // identity mapping (Knights-style rigs, or shared names)
161
+ };
162
+ for (const [srcName, src] of sourceBind) {
163
+ const tgtName = mapName(srcName);
164
+ const tgt = targetBind.get(tgtName);
165
+ if (!tgt) continue;
166
+ const delta = src.worldQ.clone().invert().multiply(tgt.worldQ);
167
+ // ROOT FIX. Each bone is compensated through its PARENT's delta. A source bone with no
168
+ // parent (packs whose root bone is Hips — no Root — e.g. the Wild West gunslinger rig)
169
+ // has no parent delta, and the old code fell back to IDENTITY. But the TARGET's Hips
170
+ // hangs under a Root bone carrying its own bind rotation, which then never cancels:
171
+ // want W_tgt(t) = W_src(t) · Δb , and W_tgt = W_parent · q_local
172
+ // so q_local = W_parent⁻¹ · q_src(t) · Δb
173
+ // i.e. the fallback must be the TARGET PARENT's inverse bind world rotation, not identity.
174
+ // With identity the whole character renders upside down / rolled by the root's bind.
175
+ let parentFix = null;
176
+ if (!src.parentName) {
177
+ const tp = tgt.parentName ? targetBind.get(tgt.parentName) : null;
178
+ if (tp) parentFix = tp.worldQ.clone().invert();
179
+ }
180
+ deltas.set(srcName, { delta, targetName: tgtName, parentFix });
181
+ }
182
+
183
+ // Root translation tracks are authored in the source rig's units (cm).
184
+ // Packs authored in metres need them rescaled or the pelvis flies 87m up.
185
+ const srcHips = sourceBind.get('Hips');
186
+ const tgtRootName = rig === 'polygon' ? 'Pelvis' : 'Hips';
187
+ const tgtRoot = targetBind.get(tgtRootName);
188
+ let posScale = 1;
189
+ if (srcHips && tgtRoot && Math.abs(srcHips.localP.y) > 0.001 && Math.abs(tgtRoot.localP.y) > 0.001) {
190
+ posScale = tgtRoot.localP.y / srcHips.localP.y;
191
+ }
192
+ // A TARGET whose Hips sits at its parent's origin (localP.y === 0 — true of the western
193
+ // Synty rigs, where Root and Hips are coincident) used to yield posScale = 0, which
194
+ // multiplied every Hips.position key by ZERO: the pelvis got pinned to the root and the
195
+ // character sank into the ground. Only rescale when BOTH hip heights are real; else 1:1.
196
+ if (!(posScale > 0.01 && posScale < 100)) posScale = 1;
197
+
198
+ // Same bone NAMES don't guarantee the same bind POSE: the Fantasy Rivals bosses each carry a
199
+ // custom rest stance (bent legs, etc.) on anim-rig bone names. The clip can only be used as-is
200
+ // when the bind pose ALSO matches — otherwise their legs break. bindMatches = every bone's
201
+ // bind-delta is ~identity (|w|≈1).
202
+ // Also fold a hash of the bind-deltas into the cache key: every custom-posed boss otherwise
203
+ // produces the SAME key (same rig/boneCount), so they'd share ONE retargeted clip — the first
204
+ // one computed — and wear each other's skeleton. The hash makes each distinct bind pose unique
205
+ // while still letting identical rigs (e.g. many goblins) share the cache.
206
+ let bindMatches = true, h = 0;
207
+ for (const e of deltas.values()) {
208
+ const d = e.delta;
209
+ if (Math.abs(Math.abs(d.w) - 1) > 0.002) bindMatches = false;
210
+ h = (Math.imul(h, 31) + ((d.x * 1e3) | 0) + Math.imul((d.y * 1e3) | 0, 7) + Math.imul((d.z * 1e3) | 0, 13)) | 0;
211
+ }
212
+
213
+ const needsRootFix = [...deltas.values()].some((e) => e.parentFix);
214
+ // position tracks are VECTORS in the source container's frame — convert them the same way
215
+ const posQ = (targetBind.chainQ ?? new THREE.Quaternion()).clone().invert()
216
+ .multiply(sourceBind.chainQ ?? new THREE.Quaternion());
217
+ return { rig, deltas, posScale, bindMatches, needsRootFix, posQ, sourceBind, rigKey: key, key: `${key}:${rig}:${posScale.toFixed(3)}:${deltas.size}:${bindMatches ? 1 : 0}:${h}` };
218
+ }
219
+
220
+ const clipCache = new Map(); // `${clip.uuid}:${ctx.key}` -> retargeted clip
221
+
222
+ // Retarget a clip recorded on the ANIMATION rig to the target context.
223
+ export function retargetClip(clip, ctx) {
224
+ // Identity rig with full bone coverage AND matching units (cm): use clip as-is.
225
+ // A metre-scale rig (e.g. the Modular Fantasy Hero) needs the root-position rescale below,
226
+ // so don't short-circuit it even though its bone names already match the source.
227
+ if (ctx.rig === 'anim' && ctx.bindMatches && !ctx.needsRootFix && Math.abs(ctx.posScale - 1) < 0.05) return clip;
228
+ const cacheKey = `${clip.uuid}:${ctx.key}`;
229
+ if (clipCache.has(cacheKey)) return clipCache.get(cacheKey);
230
+
231
+ const qa = new THREE.Quaternion();
232
+ const tracks = [];
233
+ for (const track of clip.tracks) {
234
+ const dot = track.name.lastIndexOf('.');
235
+ const node = track.name.slice(0, dot);
236
+ const prop = track.name.slice(dot + 1);
237
+ const entry = ctx.deltas.get(node);
238
+ if (!entry) continue; // not a character bone (e.g. bow-prop joints) -> drop
239
+
240
+ if (prop === 'quaternion') {
241
+ const src = ctx.sourceBind.get(node);
242
+ const parentEntry = src.parentName ? ctx.deltas.get(src.parentName) : null;
243
+ // parented bone → cancel the parent's delta; source-root bone → cancel the TARGET
244
+ // parent's bind world rotation (see ROOT FIX in buildRetargetContext)
245
+ const dParentInv = parentEntry ? parentEntry.delta.clone().invert()
246
+ : (entry.parentFix ? entry.parentFix.clone() : null);
247
+ const dBone = entry.delta;
248
+ const values = new Float32Array(track.values.length);
249
+ for (let i = 0; i < track.values.length; i += 4) {
250
+ qa.fromArray(track.values, i);
251
+ if (dParentInv) qa.premultiply(dParentInv);
252
+ qa.multiply(dBone);
253
+ qa.toArray(values, i);
254
+ }
255
+ const t = new THREE.QuaternionKeyframeTrack(`${entry.targetName}.${prop}`, Array.from(track.times), Array.from(values));
256
+ tracks.push(t);
257
+ } else if (prop === 'position') {
258
+ // Keep only the root (Hips) translation — drives bob/crouch —
259
+ // rescaled into the target rig's units.
260
+ if (node !== 'Hips') continue;
261
+ const t = track.clone();
262
+ t.name = `${entry.targetName}.${prop}`;
263
+ // The hips translation is a VECTOR in the source container's frame. Rotating only the
264
+ // quaternion tracks left it in the pack's Z-up space: the pelvis got driven to the
265
+ // floor (hipsY 0) while the body above it posed correctly. Rotate, then rescale.
266
+ const v = t.values;
267
+ const identityQ = Math.abs(ctx.posQ.w) > 0.999999;
268
+ if (!identityQ) {
269
+ for (let i = 0; i < v.length; i += 3) {
270
+ _posV.set(v[i], v[i + 1], v[i + 2]).applyQuaternion(ctx.posQ);
271
+ v[i] = _posV.x; v[i + 1] = _posV.y; v[i + 2] = _posV.z;
272
+ }
273
+ }
274
+ if (ctx.posScale !== 1) for (let i = 0; i < v.length; i++) v[i] *= ctx.posScale;
275
+ tracks.push(t);
276
+ }
277
+ // scale tracks dropped
278
+ }
279
+ const out = new THREE.AnimationClip(clip.name, clip.duration, tracks);
280
+ clipCache.set(cacheKey, out);
281
+ return out;
282
+ }
package/src/index.js CHANGED
@@ -9,18 +9,35 @@
9
9
  // facet contracts) land in later steps; step 1 files are byte-identical to
10
10
  // their proven originals on purpose.
11
11
 
12
- // core — math/utils, quality presets + auto-tuner, decals
12
+ // core — rendering, assets, animation, retargeting, input, quality, decals, utils
13
+ export { Engine } from './core/engine.js';
14
+ export { createRenderer, createPostProcessing, wantsTRAA } from './core/renderer.js';
15
+ export * from './core/assets.js';
16
+ export * from './core/anim.js';
17
+ export * from './core/retarget.js';
18
+ export { Input, DEFAULT_PAD_BINDINGS } from './core/input/index.js';
13
19
  export * from './core/utils.js';
14
20
  export { PRESETS, getQuality, getQualityName, setQualityName, applyQuality, AutoTuner } from './core/quality.js';
15
21
  export { DecalField } from './core/decalField.js';
16
22
 
17
- // world — collision (sync + worker BVH), water materials, grass, wind, shared weather uniforms
23
+ // world — streaming tiles, scatter/LOD, collision, sky/weather stack, lighting, ground dressing
18
24
  export * from './world/collision.js';
25
+ export * from './world/tileManager.js';
26
+ export * from './world/scatter.js';
27
+ export * from './world/sky.js';
28
+ export * from './world/weather.js';
29
+ export * from './world/clouds.js';
19
30
  export * from './world/water.js';
20
31
  export * from './world/grass.js';
21
32
  export * from './world/wind.js';
22
33
  export * from './world/weatherUniforms.js';
23
34
  export * from './world/waterNoise.js';
35
+ export * from './world/flora.js';
36
+ export * from './world/lampField.js';
37
+ export * from './world/lampGlow.js';
38
+ export * from './world/blobShadows.js';
39
+ export * from './world/walkHumps.js';
40
+ export * from './world/footprints.js';
24
41
 
25
42
  // systems — pooled debris shatter
26
43
  export * from './systems/shatter.js';
@@ -0,0 +1,114 @@
1
+ // WHERE THE ROUND ACTUALLY GOES — the one copy of the maths.
2
+ //
3
+ // This module exists because the same geometry was being written twice, in two files, by two hands,
4
+ // and the two copies disagreed. The reticle said one thing (player.js), the bullet did another
5
+ // (combat.js), and the argument between them was invisible: the green light came on, the gun went
6
+ // off, and the man did not fall. Now there is one body model and one intersection test, and the
7
+ // aim, the reticle and the round are all reading from it. /shoottest.html fires the same functions
8
+ // at a measured range, so what you tune there is what the game does.
9
+ //
10
+ // TWO FACTS THIS ENCODES, both learned the hard way:
11
+ //
12
+ // 1. A MAN IS A CAPSULE, NOT A BALL AT HIS STERNUM. The first version tested a sphere at chest
13
+ // height. A crosshair resting on his HEAD is 0.68m from that point — outside the tolerance — so
14
+ // the aim found nobody, converged on a default distance far behind him, and the round went under
15
+ // his chin. Headshots weren't failing to register; they were never headshots.
16
+ //
17
+ // 2. THE CROSSHAIR IS THE CAMERA'S RAY, AND THE BULLET LEAVES THE GUN. Those are two different
18
+ // lines. The camera sits over the player's shoulder — measured 4.45m behind and 2m above the
19
+ // muzzle — so firing "along the camera direction" from the muzzle produces a shot PARALLEL to
20
+ // the crosshair that never meets it, landing a fixed step low at every range. The round must be
21
+ // aimed AT the point the crosshair is over, not merely in the same direction.
22
+ import * as THREE from 'three/webgpu';
23
+
24
+ // The body, as the guns see it. Feet-relative: `position` is on the ground, by the convention every
25
+ // entity in this game already follows (combat.js's HEAD_Y test depends on it, and the coach
26
+ // publishes the datum a seated man's feet WOULD be on so the same numbers work on him).
27
+ export const BODY = {
28
+ lo: 0.25, // ankles
29
+ hi: 1.80, // crown
30
+ r: 0.42, // half a man's shoulders, and generous by a hair — a western is about hitting people
31
+ head: 1.50, // above this and it went in over the collar
32
+ };
33
+ // The AIM is a shade more forgiving than the bullet: the crosshair should never light green on a
34
+ // shot that misses, but it may stay dark on one that grazes. Err that way round.
35
+ export const AIM_R = 0.50;
36
+
37
+ const _v = new THREE.Vector3();
38
+
39
+ // Where a ray crosses a standing body. Returns the distance along the ray, or null.
40
+ // The body's axis is VERTICAL, so this is a 2D problem in x/z with the height riding along the ray
41
+ // — no general 3D segment-segment solve, and no allocation.
42
+ export function rayCapsule(ox, oy, oz, dx, dy, dz, feet, radius = BODY.r) {
43
+ if (!feet || !Number.isFinite(feet.x) || !Number.isFinite(feet.y) || !Number.isFinite(feet.z)) return null;
44
+ const ax = ox - feet.x, az = oz - feet.z;
45
+ const h = dx * dx + dz * dz;
46
+ const t = h > 1e-9 ? -(ax * dx + az * dz) / h : 0;
47
+ if (!(t > 0)) return null; // behind us (NaN-safe: !(NaN > 0) is true)
48
+ const hx = ax + dx * t, hz = az + dz * t;
49
+ if (!(hx * hx + hz * hz <= radius * radius)) return null; // the ray goes past him
50
+ const y = oy + dy * t - feet.y; // where it crosses, above his feet
51
+ if (!(y >= BODY.lo && y <= BODY.hi)) return null; // over his head, or into the dirt
52
+ return { t, hitY: y };
53
+ }
54
+
55
+ // The nearest body a ray crosses. `bodies` is any iterable of things with .position (at the feet)
56
+ // and .alive. Used by the reticle AND by the aim — so the light and the lead agree by construction.
57
+ //
58
+ // `e.noTarget` (a child, npc.js) is invisible to this function, and BECAUSE this one function
59
+ // serves both the light and the lead, that single word is what makes the reticle refuse to sit on
60
+ // a child AND the round refuse to converge on one. There is no second copy of this to forget.
61
+ export function nearestBody(origin, dir, bodies, { skip = null, maxT = 600, radius = AIM_R } = {}) {
62
+ let best = null, bestT = Infinity;
63
+ for (const e of bodies) {
64
+ if (!e || e === skip || e.alive === false || e.noTarget) continue;
65
+ const hit = rayCapsule(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, e.position, radius);
66
+ if (!hit || hit.t >= bestT || hit.t > maxT) continue;
67
+ best = e; bestT = hit.t;
68
+ }
69
+ return best ? { e: best, t: bestT, } : null;
70
+ }
71
+
72
+ // THE POINT THE CROSSHAIR IS OVER. A body if there is one, otherwise a long way down the ray.
73
+ // `fallback` matters more than it looks: it is the distance the shot converges at when you are
74
+ // aiming at nothing, and a shot fired at a target 25m away while converging on 140m still carries
75
+ // most of its parallax error. So: converge on the man when there is a man.
76
+ export function aimPoint(out, camPos, dir, bodies, { skip = null, fallback = 140 } = {}) {
77
+ const hit = nearestBody(camPos, dir, bodies, { skip });
78
+ const t = hit && Number.isFinite(hit.t) ? hit.t : fallback;
79
+ return out.copy(camPos).addScaledVector(dir, t);
80
+ }
81
+
82
+ // The direction a round leaves the gun on: from the MUZZLE, AT the aim point. Optionally scattered.
83
+ // The jitter goes in the plane ACROSS the shot, so `spread` radians means that much off the line
84
+ // you actually took, whichever way you happen to be facing.
85
+ export function shotDir(out, muzzle, aim, spread = 0, rnd = Math.random) {
86
+ out.copy(aim).sub(muzzle).normalize();
87
+ if (spread > 0) {
88
+ _v.set(0, 1, 0).cross(out).normalize(); // right, across the shot
89
+ const up = _v.clone().cross(out).normalize(); // and up, across it the other way
90
+ out.addScaledVector(_v, (rnd() - 0.5) * 2 * spread)
91
+ .addScaledVector(up, (rnd() - 0.5) * 2 * spread)
92
+ .normalize();
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // Where a bullet's FLIGHT SEGMENT (p1→p2, one frame of travel) crosses a body. Returns the height
98
+ // above his feet, or null. Segment-based on purpose: a round does 90m/s and we step at 1/60s, so it
99
+ // jumps 1.5 METRES between frames — a point test against a 1m ball can step clean over a man at
100
+ // range (that is the "I shot him and nothing happened" bug), and lands at the ball's near edge when
101
+ // it doesn't (that is the "my headshot did 25 damage" bug).
102
+ export function segmentCapsule(p1, p2, feet, radius = BODY.r) {
103
+ if (!feet || !Number.isFinite(feet.x)) return null;
104
+ const dx = p2.x - p1.x, dy = p2.y - p1.y, dz = p2.z - p1.z;
105
+ const ax = p1.x - feet.x, az = p1.z - feet.z;
106
+ const a = dx * dx + dz * dz;
107
+ let t = a > 1e-9 ? -(ax * dx + az * dz) / a : 0;
108
+ t = t < 0 ? 0 : t > 1 ? 1 : t; // stay on THIS frame's step
109
+ const hx = ax + dx * t, hz = az + dz * t;
110
+ if (!(hx * hx + hz * hz <= radius * radius)) return null;
111
+ const y = p1.y + dy * t - feet.y;
112
+ if (!(y >= BODY.lo - 0.15 && y <= BODY.hi + 0.15)) return null;
113
+ return { hitY: y, at: t };
114
+ }
@@ -0,0 +1,150 @@
1
+ // A lit campsite fire the player can REST at (walk up → E → sit → sleep → skip 5h + heal → wake).
2
+ // The prop is the real Synty campfire (SM_Prop_Campfire_01, frontier pack); the flame + smoke are
3
+ // the Synty-style emissive-orb VFX ported from Fable's torchfire.js. Everything is created ONCE at
4
+ // boot and resident for life (PERF LAW: no scene churn mid-play) — the light is toggled by INTENSITY
5
+ // only, never .visible (toggling a light's presence recompiles every lit material on WebGPU).
6
+ //
7
+ // The sit/sleep sequence itself lives in Game.startRest() (main.js) so it can reach the sky clock +
8
+ // player health + the fade overlay; this file owns the fire object and its look. Prototyped whole in
9
+ // /campfire.html (src/tools/campfire.js) — see [[survival-animset-retarget]].
10
+ import * as THREE from 'three/webgpu';
11
+ import { attribute } from 'three/tsl';
12
+ import { loadGLB, loadTexture } from '../core/assets.js';
13
+
14
+ const FIRE_STOPS = [
15
+ { t: 0.00, c: new THREE.Color(1.00, 0.97, 0.80) },
16
+ { t: 0.30, c: new THREE.Color(1.00, 0.68, 0.20) },
17
+ { t: 0.70, c: new THREE.Color(0.96, 0.30, 0.06) },
18
+ { t: 1.00, c: new THREE.Color(0.72, 0.12, 0.02) },
19
+ ];
20
+ const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _s = new THREE.Vector3(), _c = new THREE.Color();
21
+ function fireColor(t, out) {
22
+ for (let i = 1; i < FIRE_STOPS.length; i++) {
23
+ if (t <= FIRE_STOPS[i].t) { const a = FIRE_STOPS[i - 1], b = FIRE_STOPS[i]; return out.lerpColors(a.c, b.c, (t - a.t) / (b.t - a.t)); }
24
+ }
25
+ return out.copy(FIRE_STOPS[FIRE_STOPS.length - 1].c);
26
+ }
27
+
28
+ class CampFlame {
29
+ constructor(count = 46, size = 0.16) {
30
+ this.size = size; this._pool = [];
31
+ const geo = new THREE.IcosahedronGeometry(1, 0);
32
+ const mat = new THREE.MeshBasicNodeMaterial({ toneMapped: false });
33
+ this.mesh = new THREE.InstancedMesh(geo, mat, count);
34
+ this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3);
35
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
36
+ this.mesh.frustumCulled = false;
37
+ for (let i = 0; i < count; i++) { const f = this._spawn({}); f.age = Math.random() * f.life; this._pool.push(f); }
38
+ this.update(0);
39
+ }
40
+ _spawn(f) {
41
+ const z = this.size;
42
+ f.age = 0; f.life = 0.5 + Math.random() * 0.4;
43
+ f.x = (Math.random() - 0.5) * z * 2.4; f.z = (Math.random() - 0.5) * z * 2.4;
44
+ f.vy = z * (2.0 + Math.random() * 1.4); f.sz = z * (0.5 + Math.random() * 0.45);
45
+ return f;
46
+ }
47
+ update(dt, bright = 1) {
48
+ for (let i = 0; i < this._pool.length; i++) {
49
+ const f = this._pool[i]; f.age += dt;
50
+ if (f.age >= f.life) this._spawn(f);
51
+ const t = f.age / f.life;
52
+ _p.set(f.x * (1 - t * 0.6), t * f.vy, f.z * (1 - t * 0.6));
53
+ _s.setScalar(Math.max(0.001, f.sz * Math.sin(Math.min(1, t * 1.3) * Math.PI)));
54
+ this.mesh.setMatrixAt(i, _m.compose(_p, _q, _s));
55
+ this.mesh.setColorAt(i, fireColor(t, _c).multiplyScalar(bright));
56
+ }
57
+ this.mesh.instanceMatrix.needsUpdate = true;
58
+ if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
59
+ }
60
+ }
61
+
62
+ class CampSmoke {
63
+ constructor(count = 120, size = 0.16) {
64
+ this.size = size; this._pool = [];
65
+ const geo = new THREE.IcosahedronGeometry(1, 0);
66
+ const mat = new THREE.MeshBasicNodeMaterial({ transparent: true, depthWrite: false, toneMapped: false });
67
+ this.alpha = new THREE.InstancedBufferAttribute(new Float32Array(count), 1);
68
+ geo.setAttribute('iAlpha', this.alpha);
69
+ mat.opacityNode = attribute('iAlpha', 'float');
70
+ this.mesh = new THREE.InstancedMesh(geo, mat, count);
71
+ this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3);
72
+ this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
73
+ this.mesh.frustumCulled = false; this.mesh.renderOrder = 3;
74
+ for (let i = 0; i < count; i++) { const s = this._spawn({}); s.age = s.life; this._pool.push(s); this.alpha.array[i] = 0; } // start empty
75
+ this.update(0, 0);
76
+ }
77
+ _spawn(s) {
78
+ const z = this.size;
79
+ s.age = 0; s.life = 2.2 + Math.random() * 1.4;
80
+ s.x = (Math.random() - 0.5) * z * 0.5; s.z = (Math.random() - 0.5) * z * 0.5;
81
+ s.vy = z * (5.5 + Math.random() * 3.5); s.drift = (Math.random() - 0.5) * z * 1.2;
82
+ s.sz0 = z * 0.5; s.sz1 = z * (1.6 + Math.random() * 0.5); s.base = z * 4.0; // start above the flame tip
83
+ return s;
84
+ }
85
+ // emit = fire level (respawn rate); doused -> the column clears upward, no downward shrink.
86
+ update(dt, emit = 1, bright = 1) {
87
+ for (let i = 0; i < this._pool.length; i++) {
88
+ const s = this._pool[i]; s.age += dt;
89
+ if (s.age >= s.life) {
90
+ if (Math.random() < emit) this._spawn(s);
91
+ else { this.alpha.array[i] = 0; continue; }
92
+ }
93
+ const t = s.age / s.life;
94
+ _p.set(s.x + s.drift * t, s.base + t * s.vy, s.z + s.drift * t * 0.6);
95
+ _s.setScalar(Math.max(0.001, s.sz0 + (s.sz1 - s.sz0) * t));
96
+ this.mesh.setMatrixAt(i, _m.compose(_p, _q, _s));
97
+ this.alpha.array[i] = Math.max(0, Math.min(t * 6, (1 - t) * 10, 1)) * 0.34;
98
+ const g = (0.4 + t * 0.06) * bright;
99
+ this.mesh.setColorAt(i, _c.setRGB(g * 1.05, g, g * 0.97));
100
+ }
101
+ this.mesh.instanceMatrix.needsUpdate = true;
102
+ this.alpha.needsUpdate = true;
103
+ if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
104
+ }
105
+ }
106
+
107
+ export class Campfire {
108
+ // starts UNLIT — just the wood pile. The player's rest lights it (Game.startRest) and douses it on
109
+ // leaving; `level` eases toward `lit` so it catches + dies out over a few seconds.
110
+ constructor(game, x, z) { this.game = game; this.x = x; this.z = z; this.group = new THREE.Group(); this.t = 0; this.lit = false; this.level = 0; }
111
+ light() { this.lit = true; }
112
+ douse() { this.lit = false; }
113
+
114
+ async build() {
115
+ const g = this.game;
116
+ const y = g.world.groundAt?.(this.x, this.z) ?? 0;
117
+ this.group.position.set(this.x, y, this.z);
118
+ // real Synty prop (GLB is in METRES ~3.3m -> 0.3 ≈ 1m; its shipped map is a 1x1 stub, so apply the atlas)
119
+ try {
120
+ const prop = (await loadGLB('/assets/frontier/SM_Prop_Campfire_01.glb')).clone();
121
+ prop.scale.setScalar(0.3);
122
+ const atlas = await loadTexture('/assets/textures/atlas_westernfrontier.png', { flipY: false });
123
+ prop.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; if (o.material) { o.material.map = atlas; o.material.color?.setScalar(1); o.material.needsUpdate = true; } } });
124
+ this.group.add(prop);
125
+ } catch (e) { console.warn('[campfire] prop failed', e); }
126
+ this.flame = new CampFlame(); this.flame.mesh.position.y = 0.04; this.group.add(this.flame.mesh);
127
+ this.smoke = new CampSmoke(); this.group.add(this.smoke.mesh);
128
+ // warm light added once, toggled by intensity only (never .visible — that recompiles lit materials).
129
+ // NOTE: named warmLight, NOT `light` — that would clobber the light() method above.
130
+ this.warmLight = new THREE.PointLight(0xff7a2a, 0, 16, 2); this.warmLight.position.set(0, 0.35, 0); this.group.add(this.warmLight);
131
+ g.scene.add(this.group);
132
+ // walk-up-and-press-E REST prompt (same registry as doors/coach/NPCs)
133
+ g.interactables.push({ x: this.x, z: this.z, r: 2.6, label: 'E — Rest at the campfire', action: () => g.startRest(this) });
134
+ }
135
+
136
+ update(dt) {
137
+ if (!this.flame) return;
138
+ this.t += dt;
139
+ this.level += ((this.lit ? 1 : 0) - this.level) * Math.min(1, dt * (this.lit ? 0.5 : 2.2)); // catch slow, die quicker
140
+ const lvl = this.level;
141
+ const flick = 0.75 + 0.25 * Math.sin(this.t * 24) * Math.sin(this.t * 13.3 + 1.7);
142
+ this.warmLight.intensity = lvl * (7 + flick * 4);
143
+ this.flame.mesh.scale.setScalar(lvl < 0.02 ? 0 : 0.12 + 0.88 * lvl); // fully gone when unlit (else a tiny orb lingers)
144
+ // keep the unlit-tone-mapped flame a constant brightness through the night-exposure dimmer (torch trick)
145
+ const exp = this.game.renderer?.toneMappingExposure ?? 1;
146
+ const bright = Math.min(10, Math.max(1, 1 / exp));
147
+ this.flame.update(dt, bright);
148
+ this.smoke.update(dt, lvl, bright);
149
+ }
150
+ }