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,472 @@
1
+ // VFX: legacy sprite bursts + a faithful interpreter for the POLYGON Particle FX
2
+ // pack's Unity ParticleSystem recipes (data/fxRecipes.js — extracted straight from
3
+ // the prefab YAML). Each recipe is 1-4 coordinated emitters: burst/rate emission
4
+ // from shaped volumes (sphere/hemisphere/cone/circle/edge/box), alpha+colour
5
+ // gradients and size curves over particle lifetime, gravity/damping/noise, and a
6
+ // renderer that is either a textured billboard or one of the pack's FX meshes
7
+ // (e.g. blood gouts are FX_Sphere mesh particles, stun stars an FX_Star mesh —
8
+ // not sprites).
9
+ import * as THREE from 'three/webgpu';
10
+ import { loadTexture, loadModel } from '../core/assets.js';
11
+ // Particle recipes are GAME data (Unity-pack-derived) — register at boot:
12
+ // registerFxRecipes(FX_RECIPES)
13
+ let FX_RECIPES = {};
14
+ export function registerFxRecipes(recipes) { FX_RECIPES = recipes ?? {}; }
15
+ import { DecalField } from '../core/decalField.js';
16
+
17
+ const P = '/assets/particles';
18
+ const GRAV = 9.81; // unity gravityModifier is a multiplier on physics gravity
19
+ const Z2 = [0, 0];
20
+
21
+ // Ground blood decals — four matte splats cut from the Biostart "Blood VFX Kit" atlas
22
+ // (imported via ~/Sites/unity-vfx-webgpu). Laid flat where a foe falls; fade after ~30s.
23
+ const BLOOD_DECALS = {
24
+ bloodSplat1: { tex: 'BloodSplat_1', sz: [0.65, 1.0], life: 32, fade: [0, 7], rot: 'rand', tint: [0.82, 0.72, 0.72, 0.92], yOffset: 0.04 },
25
+ bloodSplat2: { tex: 'BloodSplat_2', sz: [0.6, 0.9], life: 32, fade: [0, 7], rot: 'rand', tint: [0.82, 0.72, 0.72, 0.92], yOffset: 0.04 },
26
+ bloodSplat3: { tex: 'BloodSplat_3', sz: [0.8, 1.2], life: 32, fade: [0, 7], rot: 'rand', tint: [0.82, 0.72, 0.72, 0.92], yOffset: 0.04 },
27
+ bloodSplat4: { tex: 'BloodSplat_4', sz: [0.75, 1.1], life: 32, fade: [0, 7], rot: 'rand', tint: [0.82, 0.72, 0.72, 0.92], yOffset: 0.04 },
28
+ };
29
+ const BLOOD_KEYS = Object.keys(BLOOD_DECALS);
30
+ const _e = new THREE.Euler();
31
+ const _q = new THREE.Quaternion();
32
+ const _v = new THREE.Vector3();
33
+ const _v2 = new THREE.Vector3();
34
+
35
+ const rand = (r) => r[0] + Math.random() * (r[1] - r[0]);
36
+
37
+ // piecewise-linear gradient stops [[t, ...vals]] — Unity clamps outside the keys
38
+ function sampleStop(stops, t, ch) {
39
+ const first = stops[0], last = stops[stops.length - 1];
40
+ if (t <= first[0]) return first[ch];
41
+ if (t >= last[0]) return last[ch];
42
+ for (let i = 1; i < stops.length; i++) {
43
+ if (t <= stops[i][0]) {
44
+ const a = stops[i - 1], b = stops[i];
45
+ const u = (t - a[0]) / (b[0] - a[0] || 1);
46
+ return a[ch] + (b[ch] - a[ch]) * u;
47
+ }
48
+ }
49
+ return last[ch];
50
+ }
51
+
52
+ // 9-sample size-over-lifetime LUT (Hermite curve baked at extract time)
53
+ function sampleLut(lut, t) {
54
+ const x = Math.min(Math.max(t, 0), 1) * 8;
55
+ const i = Math.floor(x);
56
+ if (i >= 8) return lut[8];
57
+ return lut[i] + (lut[i + 1] - lut[i]) * (x - i);
58
+ }
59
+
60
+ export class VFX {
61
+ constructor(scene) {
62
+ this.scene = scene;
63
+ this.active = []; // legacy sprite bursts
64
+ this.playing = []; // live recipe instances (emission drivers)
65
+ this.rp = []; // live recipe particles
66
+ this.textures = {};
67
+ this.fxMesh = {}; // FX mesh name -> geometry at native metre scale
68
+ this.protoMats = new Map(); // (map,blend) -> prototype SpriteMaterial (see _spriteMat)
69
+ // vendored from ~/Sites/unity-vfx-webgpu — laid-flat ground decals (blood pools)
70
+ this.decals = new DecalField(scene, { loadTexture, textureBase: `${P}/blood`, max: 80 });
71
+ }
72
+
73
+ // Recipe billboards CLONE a boot-made prototype instead of constructing fresh
74
+ // materials mid-update — sprite materials born inside the update loop with the
75
+ // recipe's full constructor args never render under WebGPU (their opacity
76
+ // writes don't take), while clones of a plain proven material always do.
77
+ _spriteMat(map, blending) {
78
+ const key = (map?.uuid ?? 'none') + blending;
79
+ let proto = this.protoMats.get(key);
80
+ if (!proto) {
81
+ proto = new THREE.SpriteMaterial({ map, transparent: true, depthWrite: false, blending });
82
+ this.protoMats.set(key, proto);
83
+ }
84
+ return proto.clone();
85
+ }
86
+
87
+ async load() {
88
+ const names = ['star_06', 'flame_01', 'smoke_04', 'light_01', 'muzzle_01', 'spark_04', 'circle_05', 'twirl_01'];
89
+ for (const n of names) this.textures[n] = await loadTexture(`${P}/${n}.png`);
90
+ // Synty Particle FX pack sprites (legacy shorthand keys)
91
+ const synty = {
92
+ sSmoke: 'PolygonParticles_Smoke_01', sSparkle: 'PolygonParticles_Sparkle',
93
+ sSpot: 'PolygonParticles_Soft_Spot', sRing: 'PolygonParticles_Ring_02',
94
+ sBolt: 'PolygonParticles_Lightning_02', sFumes: 'PolygonParticles_Fumes_02',
95
+ sSwipe: 'PolygonParticles_Swipe_01', sCircle: 'PolygonParticles_Circle_01',
96
+ };
97
+ for (const [k, f] of Object.entries(synty)) {
98
+ try { this.textures[k] = await loadTexture(`${P}/synty/${f}.png`); } catch { /* optional */ }
99
+ }
100
+ // every texture + FX mesh the recipes reference, under their pack names
101
+ const texNames = new Set(), meshNames = new Set();
102
+ for (const emitters of Object.values(FX_RECIPES)) {
103
+ for (const e of emitters) {
104
+ if (e.tex && e.tex !== 'soft') texNames.add(e.tex);
105
+ if (e.mesh) meshNames.add(e.mesh);
106
+ }
107
+ }
108
+ this.textures.soft = this.textures.light_01; // unity Default-Particle stand-in
109
+ for (const n of texNames) {
110
+ if (this.textures[n]) continue;
111
+ try { this.textures[n] = await loadTexture(`${P}/synty/${n}.png`); } catch { console.warn('[vfx] missing tex', n); }
112
+ }
113
+ // FX meshes at NATIVE metre scale (loadModel applies the cm→m factor to the
114
+ // group; bake it into the geometry so unity's meshSize × particleSize holds).
115
+ // Pivots matter (beams grow from their base) — no recentring, no normalizing.
116
+ for (const n of meshNames) {
117
+ try {
118
+ const m = await loadModel(`/assets/fx/${n}.fbx`);
119
+ m.updateMatrixWorld(true);
120
+ let geo = null;
121
+ m.traverse((o) => { if (!geo && o.isMesh) geo = o.geometry.clone().applyMatrix4(o.matrixWorld); });
122
+ if (geo) this.fxMesh[n] = geo;
123
+ } catch { console.warn('[vfx] missing mesh', n); }
124
+ }
125
+ this._warm();
126
+ await this.decals.load(BLOOD_DECALS);
127
+ }
128
+
129
+ // one resident object per material/pipeline variant, parked under the map, so
130
+ // boot's compileAsync builds the WebGPU pipelines — not the first cast mid-fight
131
+ _warm() {
132
+ const g = new THREE.Group();
133
+ g.name = 'vfxwarm';
134
+ g.position.set(0, -800, -50);
135
+ const tex = this.textures.sSparkle ?? this.textures.star_06;
136
+ const geo = this.fxMesh.FX_Cylinder_01 ?? new THREE.PlaneGeometry(0.1, 0.1);
137
+ const mk = (mat) => { const o = mat.isSpriteMaterial ? new THREE.Sprite(mat) : new THREE.Mesh(geo, mat); o.scale.setScalar(0.01); g.add(o); };
138
+ mk(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
139
+ mk(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
140
+ // muzzle flash sprite variant — compiled at boot, never on the first shot
141
+ mk(new THREE.SpriteMaterial({ map: this.textures.muzzle_01 ?? tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
142
+ mk(new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false }));
143
+ mk(new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false }));
144
+ mk(new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false, side: THREE.DoubleSide, fog: false }));
145
+ this.scene.add(g);
146
+ }
147
+
148
+ // ---- recipe playback -------------------------------------------------------
149
+ // scale grows/shrinks the whole effect; color multiplies the authored colours
150
+ // (tier tints); speed is Unity's simulationSpeed (2 = plays in half the time);
151
+ // density multiplies particle counts; sizeMul fattens particles without
152
+ // widening the emitter. Returns a {stop} handle for looping recipes.
153
+ playFx(name, { pos, scale = 1, color = null, yaw = 0, loopFor = Infinity, speed = 1, density = 1, sizeMul = 1 } = {}) {
154
+ const def = FX_RECIPES[name];
155
+ if (!def) return null;
156
+ const inst = {
157
+ def, x: pos.x, y: pos.y, z: pos.z, scale, yaw, speed, den: density, szm: sizeMul,
158
+ col: color != null ? new THREE.Color(color) : null,
159
+ t: 0, stopAt: loopFor,
160
+ st: def.map(() => ({ fired: 0, acc: 0 })),
161
+ };
162
+ this.playing.push(inst);
163
+ return { stop: () => { inst.stopAt = 0; } };
164
+ }
165
+
166
+ _spawnR(e, inst) {
167
+ const sc = inst.scale;
168
+ const sh = e.shape;
169
+ // ---- sample the emitter shape: local position + outward direction ----
170
+ let px = 0, py = 0, pz = 0, dx = 0, dy = 0, dz = 1;
171
+ if (sh) {
172
+ const vol = sh.thick === 0 ? 1 : Math.cbrt(Math.random()); // 0 = shell only
173
+ switch (sh.t) {
174
+ case 'sphere': case 'sphereShell': {
175
+ _v.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize();
176
+ dx = _v.x; dy = _v.y; dz = _v.z;
177
+ const rr = (sh.r || 0) * vol;
178
+ px = dx * rr; py = dy * rr; pz = dz * rr;
179
+ break;
180
+ }
181
+ case 'hemisphere': case 'hemisphereShell': {
182
+ _v.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random()).normalize();
183
+ dx = _v.x; dy = _v.y; dz = Math.abs(_v.z);
184
+ const rr = (sh.r || 0) * vol;
185
+ px = dx * rr; py = dy * rr; pz = dz * rr;
186
+ break;
187
+ }
188
+ case 'cone': case 'coneShell': case 'coneVolume': {
189
+ const a = (sh.ang * Math.PI / 180) * Math.sqrt(Math.random());
190
+ const phi = Math.random() * Math.PI * 2;
191
+ dx = Math.sin(a) * Math.cos(phi); dy = Math.sin(a) * Math.sin(phi); dz = Math.cos(a);
192
+ const rr = (sh.r || 0) * Math.sqrt(Math.random());
193
+ const phi2 = Math.random() * Math.PI * 2;
194
+ px = Math.cos(phi2) * rr; py = Math.sin(phi2) * rr;
195
+ break;
196
+ }
197
+ case 'circle': case 'circleEdge': {
198
+ const phi = Math.random() * Math.PI * 2;
199
+ dx = Math.cos(phi); dy = Math.sin(phi); dz = 0;
200
+ const rr = (sh.r || 0) * (sh.thick === 0 ? 1 : Math.sqrt(Math.random()));
201
+ px = dx * rr; py = dy * rr;
202
+ break;
203
+ }
204
+ case 'edge': {
205
+ px = (Math.random() * 2 - 1) * (sh.r || 0);
206
+ dx = 0; dy = 1; dz = 0;
207
+ break;
208
+ }
209
+ case 'box': case 'boxShell': case 'boxEdge': {
210
+ const b = sh.box || [1, 1, 1];
211
+ px = (Math.random() - 0.5) * b[0]; py = (Math.random() - 0.5) * b[1]; pz = (Math.random() - 0.5) * b[2];
212
+ break;
213
+ }
214
+ default: {
215
+ _v.set(Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() * 2 - 1).normalize();
216
+ dx = _v.x; dy = _v.y; dz = _v.z;
217
+ }
218
+ }
219
+ const rot = sh.rot;
220
+ if (rot && (rot[0] || rot[1] || rot[2])) {
221
+ _e.set(rot[0] * Math.PI / 180, rot[1] * Math.PI / 180, rot[2] * Math.PI / 180);
222
+ _q.setFromEuler(_e);
223
+ _v.set(px, py, pz).applyQuaternion(_q); px = _v.x; py = _v.y; pz = _v.z;
224
+ _v.set(dx, dy, dz).applyQuaternion(_q); dx = _v.x; dy = _v.y; dz = _v.z;
225
+ }
226
+ }
227
+ if (inst.yaw) {
228
+ const c = Math.cos(inst.yaw), s = Math.sin(inst.yaw);
229
+ let t = px * c + pz * s; pz = -px * s + pz * c; px = t;
230
+ t = dx * c + dz * s; dz = -dx * s + dz * c; dx = t;
231
+ }
232
+ // ---- particle start values ----
233
+ const life = Math.max(0.05, rand(e.life));
234
+ const speed = rand(e.spd || Z2) * sc;
235
+ const size = rand(e.sz) * sc * inst.szm;
236
+ const sizeY = e.szY ? rand(e.szY) * sc * inst.szm : size;
237
+ const sizeZ = e.szZ ? rand(e.szZ) * sc * inst.szm : size; // beams stretch mesh particles along Z
238
+ let cr = e.col[0], cg = e.col[1], cb = e.col[2], ca = e.col[3];
239
+ if (e.col2) {
240
+ const m = Math.random();
241
+ cr += (e.col2[0] - cr) * m; cg += (e.col2[1] - cg) * m; cb += (e.col2[2] - cb) * m; ca += (e.col2[3] - ca) * m;
242
+ }
243
+ const tint = e.tint;
244
+ if (tint) { cr *= tint[0]; cg *= tint[1]; cb *= tint[2]; ca *= tint[3]; }
245
+ if (inst.col) { cr *= inst.col.r; cg *= inst.col.g; cb *= inst.col.b; }
246
+ const map = e.tex ? this.textures[e.tex] ?? null : null;
247
+ const blending = e.blend === 'a' ? THREE.AdditiveBlending : THREE.NormalBlending;
248
+ const isMesh = e.render === 'm';
249
+ // construct at the REAL first-frame alpha, never 0 — a sprite material born at
250
+ // opacity 0 inside the update loop never becomes visible under WebGPU (its
251
+ // later opacity writes don't take; a fresh material with the same values does)
252
+ const a0 = ca * (e.aK ? sampleStop(e.aK, 0, 1) : 1);
253
+ let o, mat;
254
+ if (isMesh) {
255
+ const geo = this.fxMesh[e.mesh];
256
+ if (!geo) return;
257
+ mat = new THREE.MeshBasicMaterial({
258
+ map, transparent: true, opacity: a0, depthWrite: false, blending,
259
+ side: THREE.DoubleSide, fog: false,
260
+ });
261
+ o = new THREE.Mesh(geo, mat);
262
+ // directional effects (yaw set) keep the emitter's axes so Z-stretched
263
+ // beam segments point down-range; radial ones get a random spin
264
+ o.rotation.y = e.rot0 ? rand(e.rot0) : (inst.yaw || Math.random() * Math.PI * 2);
265
+ o.scale.set(size, sizeY, sizeZ);
266
+ } else {
267
+ mat = this._spriteMat(map, blending);
268
+ mat.opacity = a0;
269
+ if (e.rot0) mat.rotation = rand(e.rot0);
270
+ o = new THREE.Sprite(mat);
271
+ o.scale.set(size, sizeY, 1);
272
+ }
273
+ mat.color.setRGB(cr, cg, cb);
274
+ const ep = e.p;
275
+ o.position.set(
276
+ inst.x + (ep ? ep[0] * sc : 0) + px * sc,
277
+ inst.y + (ep ? ep[1] * sc : 0) + py * sc,
278
+ inst.z + (ep ? ep[2] * sc : 0) + pz * sc
279
+ );
280
+ // unity renderer pivot (fractions of size) lifts e.g. the lightning bolt so
281
+ // it strikes DOWN onto the point instead of straddling it. sprite.center is
282
+ // a no-show under WebGPU, so it's a world-Y offset, kept in sync with the
283
+ // size curve in update(). Must come after position.set — it's additive.
284
+ if (!isMesh && e.pivot) o.position.y += e.pivot[1] * sizeY;
285
+ this.scene.add(o);
286
+ const vel = e.vel;
287
+ this.rp.push({
288
+ o, m: mat, isMesh, ts: inst.speed,
289
+ vx: dx * speed + (vel ? rand(vel[0]) * sc : 0),
290
+ vy: dy * speed + (vel ? rand(vel[1]) * sc : 0),
291
+ vz: dz * speed + (vel ? rand(vel[2]) * sc : 0),
292
+ age: 0, life, sz: size, szY: sizeY, szZ: sizeZ,
293
+ cr, cg, cb, ca,
294
+ aK: e.aK ?? null, cK: e.cK ?? null, szK: e.szK ?? null,
295
+ pvY: !isMesh && e.pivot ? e.pivot[1] : 0, pvOff: !isMesh && e.pivot ? e.pivot[1] * sizeY : 0,
296
+ rotV: e.rotV ? rand(e.rotV) : 0,
297
+ grav: (e.grav || 0) * GRAV * sc,
298
+ damp: e.damp || 0,
299
+ noise: e.noise ?? null, ph: Math.random() * 6.283,
300
+ });
301
+ }
302
+
303
+ // ---- legacy sprite bursts (hits, sparks, ambience one-liners) ---------------
304
+ spawn({ tex = 'star_06', pos, vel = null, count = 6, size = 0.5, life = 0.6, color = 0xffffff, gravity = 0, spread = 1.2, fade = true, additive = true }) {
305
+ for (let i = 0; i < count; i++) {
306
+ const mat = new THREE.SpriteMaterial({
307
+ map: this.textures[tex],
308
+ color,
309
+ transparent: true,
310
+ depthWrite: false,
311
+ blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
312
+ });
313
+ const s = new THREE.Sprite(mat);
314
+ s.position.copy(pos);
315
+ s.position.x += (Math.random() - 0.5) * 0.3;
316
+ s.position.y += (Math.random() - 0.5) * 0.3 + 0.2;
317
+ s.position.z += (Math.random() - 0.5) * 0.3;
318
+ const sc = size * (0.6 + Math.random() * 0.8);
319
+ s.scale.setScalar(sc);
320
+ const v = vel
321
+ ? vel.clone()
322
+ : new THREE.Vector3((Math.random() - 0.5) * spread, Math.random() * spread, (Math.random() - 0.5) * spread);
323
+ this.scene.add(s);
324
+ this.active.push({ s, v, life, age: 0, gravity, fade, size: sc });
325
+ }
326
+ }
327
+
328
+ // ---- game effects — pack recipes with game-tuned scale/tint -----------------
329
+ hit(pos) {
330
+ this.spawn({ tex: 'star_06', pos, count: 6, size: 0.45, life: 0.3, color: 0xffd080, spread: 3 });
331
+ this.playFx('impact_small', { pos, scale: 1.2, speed: 2 });
332
+ }
333
+ spark(pos, size = 0.6) {
334
+ this.spawn({ tex: 'spark_04', pos, count: 10, size, life: 0.35, color: 0xfff0a0, spread: 4 });
335
+ }
336
+ // hit SQUIRT: a vivid directional jet of blood — fat gouts + a spray of fine droplets,
337
+ // launched along `dir` (defaults up-and-out) and arcing down hard under gravity.
338
+ // `mult` scales the droplet count — a bladed slash sprays ~2× a bare-fist punch.
339
+ blood(pos, dir = null, mult = 1) {
340
+ const d = (dir && dir.lengthSq() > 1e-6) ? _v.copy(dir).normalize() : _v.set(0, 0.55, 0.5).normalize();
341
+ const perp = _v2.set(-d.z, 0, d.x).normalize();
342
+ const dx = d.x, dy = d.y, dz = d.z, px = perp.x, pz = perp.z;
343
+ const shoot = (n, spdMin, spdMax, szMin, szMax, spread, col) => {
344
+ for (let i = 0; i < n; i++) {
345
+ const spd = spdMin + Math.random() * (spdMax - spdMin);
346
+ const s1 = (Math.random() - 0.5) * spread, s2 = (Math.random() - 0.15) * spread;
347
+ const v = new THREE.Vector3(
348
+ dx * spd + px * s1 + (Math.random() - 0.5) * spread * 0.4,
349
+ dy * spd + s2,
350
+ dz * spd + pz * s1 + (Math.random() - 0.5) * spread * 0.4,
351
+ );
352
+ this.spawn({ tex: 'circle_05', pos, vel: v, count: 1, size: szMin + Math.random() * (szMax - szMin), life: 0.4 + Math.random() * 0.3, color: col, gravity: 13, additive: false });
353
+ }
354
+ };
355
+ shoot(Math.round(16 * mult), 1.3, 3.0, 0.03, 0.06, 1.4, 0xd11818); // fine droplets (~3-6cm), vivid red
356
+ shoot(Math.round(5 * mult), 0.8, 1.8, 0.06, 0.1, 0.8, 0xa81212); // fatter gouts (~6-10cm)
357
+ }
358
+ // a lasting pool of blood on the ground where a foe fell (normal = surface up)
359
+ bloodDecal(pos, normal = [0, 1, 0]) {
360
+ this.decals.add(BLOOD_KEYS[(Math.random() * BLOOD_KEYS.length) | 0], { pos, normal });
361
+ }
362
+
363
+ // muzzle flash (WESTERN_CONTRACT pin): a one-frame muzzle_01 star nudged down-range
364
+ // + a brief additive glow. ~0.06s, sprites only — NO point light (the light count
365
+ // never changes mid-session, so a flash light would need a resident pool for nothing).
366
+ muzzleFlash(pos, dir = null) {
367
+ const at = dir ? pos.clone().addScaledVector(dir, 0.1) : pos.clone();
368
+ const vel = dir ? dir.clone().multiplyScalar(0.6) : null;
369
+ this.spawn({ tex: 'muzzle_01', pos: at, vel, count: 1, size: 0.42, life: 0.06, color: 0xffdf9a, spread: 0 });
370
+ this.spawn({ tex: this.textures.sSpot ? 'sSpot' : 'light_01', pos: at, count: 1, size: 1.1, life: 0.08, color: 0xffb860, spread: 0 });
371
+ }
372
+
373
+ update(dt) {
374
+ this.decals.update(dt);
375
+ // ---- recipe instances drive emission ----
376
+ for (let i = this.playing.length - 1; i >= 0; i--) {
377
+ const inst = this.playing[i];
378
+ const idt = dt * (inst.speed || 1);
379
+ inst.t += idt;
380
+ let alive = false;
381
+ for (let ei = 0; ei < inst.def.length; ei++) {
382
+ const e = inst.def[ei], st = inst.st[ei];
383
+ const lt = inst.t - (e.delay || 0);
384
+ if (lt < 0) { alive = true; continue; }
385
+ const bursts = e.bursts;
386
+ if (bursts) {
387
+ for (let b = 0; b < bursts.length; b++) {
388
+ if (!(st.fired & (1 << b)) && lt >= bursts[b][0]) {
389
+ st.fired |= 1 << b;
390
+ const n0 = Math.round(bursts[b][1] * inst.den);
391
+ for (let n = 0; n < n0; n++) this._spawnR(e, inst);
392
+ }
393
+ }
394
+ if (st.fired !== (1 << bursts.length) - 1) alive = true;
395
+ }
396
+ if (e.rate) {
397
+ const emitting = e.loop ? inst.t < inst.stopAt : lt <= e.dur;
398
+ if (emitting) {
399
+ st.acc += e.rate * inst.den * idt;
400
+ while (st.acc >= 1) { st.acc -= 1; this._spawnR(e, inst); }
401
+ alive = true;
402
+ }
403
+ }
404
+ if (!e.loop && lt <= e.dur) alive = true;
405
+ }
406
+ if (!alive) this.playing.splice(i, 1);
407
+ }
408
+ // ---- recipe particles ----
409
+ for (let i = this.rp.length - 1; i >= 0; i--) {
410
+ const p = this.rp[i];
411
+ const pdt = dt * (p.ts || 1); // per-particle sim clock (playFx speed)
412
+ p.age += pdt;
413
+ if (p.age >= p.life) {
414
+ this.scene.remove(p.o);
415
+ p.m.dispose();
416
+ this.rp.splice(i, 1);
417
+ continue;
418
+ }
419
+ const t = p.age / p.life;
420
+ p.vy -= p.grav * pdt;
421
+ if (p.damp) {
422
+ const d = Math.max(0, 1 - p.damp * 3 * pdt);
423
+ p.vx *= d; p.vy *= d; p.vz *= d;
424
+ }
425
+ let mx = p.vx, my = p.vy, mz = p.vz;
426
+ if (p.noise) {
427
+ const w = p.noise[0], f = p.noise[1] * 7;
428
+ mx += Math.sin(p.age * f + p.ph) * w;
429
+ my += Math.sin(p.age * f * 0.83 + p.ph * 2.1) * w;
430
+ mz += Math.cos(p.age * f * 1.13 + p.ph) * w;
431
+ }
432
+ p.o.position.x += mx * pdt; p.o.position.y += my * pdt; p.o.position.z += mz * pdt;
433
+ p.m.opacity = p.ca * (p.aK ? sampleStop(p.aK, t, 1) : 1 - t);
434
+ if (p.cK) {
435
+ p.m.color.setRGB(
436
+ p.cr * sampleStop(p.cK, t, 1),
437
+ p.cg * sampleStop(p.cK, t, 2),
438
+ p.cb * sampleStop(p.cK, t, 3)
439
+ );
440
+ }
441
+ const s = p.szK ? sampleLut(p.szK, t) : 1;
442
+ if (p.isMesh) {
443
+ p.o.scale.set(p.sz * s, p.szY * s, p.szZ * s);
444
+ if (p.rotV) p.o.rotation.y += p.rotV * pdt;
445
+ } else {
446
+ p.o.scale.set(p.sz * s, p.szY * s, 1);
447
+ if (p.pvY) { // keep the pivot offset tracking the size curve
448
+ const off = p.pvY * p.szY * s;
449
+ p.o.position.y += off - p.pvOff;
450
+ p.pvOff = off;
451
+ }
452
+ if (p.rotV) p.m.rotation += p.rotV * pdt;
453
+ }
454
+ }
455
+ // ---- legacy sprites ----
456
+ for (let i = this.active.length - 1; i >= 0; i--) {
457
+ const p = this.active[i];
458
+ p.age += dt;
459
+ if (p.age >= p.life) {
460
+ this.scene.remove(p.s);
461
+ p.s.material.dispose();
462
+ this.active.splice(i, 1);
463
+ continue;
464
+ }
465
+ p.v.y -= p.gravity * dt;
466
+ p.s.position.addScaledVector(p.v, dt);
467
+ const t = p.age / p.life;
468
+ if (p.fade) p.s.material.opacity = 1 - t;
469
+ p.s.scale.setScalar(p.size * (1 + t * 0.6));
470
+ }
471
+ }
472
+ }
@@ -0,0 +1,109 @@
1
+ // Blob shadows: one InstancedMesh of soft dark discs under every nearby character.
2
+ // This REPLACES sun shadow mapping (retired 2026-07-10) — the shadow pass was a second
3
+ // full scene render every frame (~7.5ms CPU measured at Oakhollow; r184's ShadowNode
4
+ // ignores the renderer-level throttle flags, so it ran every frame despite the
5
+ // every-2nd-frame needsUpdate toggle). One draw call, no extra pass, resident for life
6
+ // (the WebGPU rule: never create/destroy meshes at runtime — parked slots are scale-0).
7
+ import * as THREE from 'three/webgpu';
8
+
9
+ const CAP = 160; // discs available per frame (player + everything close); extras just go discless
10
+ const RANGE = 55; // only characters this close get a disc — beyond that it's sub-pixel anyway
11
+ // WHO GETS THE DISCS WHEN THERE ARE NOT ENOUGH. The fill used to stop at the first CAP bodies in
12
+ // ARRAY ORDER — which was harmless with fourteen townsfolk and a disaster with a hundred and fifty:
13
+ // Dustwater's residents are registered first, so they would take every disc in the pool and the
14
+ // place you were actually standing in would have none. RANGE already drops everything far away;
15
+ // what is left is sorted NEAREST-FIRST, so the discs that get dropped are the ones behind you.
16
+ const _cand = []; // reused every frame — no per-frame allocation in the hot path
17
+
18
+ export class BlobShadows {
19
+ constructor(game) {
20
+ this.game = game;
21
+
22
+ // soft radial disc, generated — no asset, no loader
23
+ const S = 64;
24
+ const cnv = document.createElement('canvas');
25
+ cnv.width = cnv.height = S;
26
+ const ctx = cnv.getContext('2d');
27
+ const grad = ctx.createRadialGradient(S / 2, S / 2, S * 0.08, S / 2, S / 2, S / 2);
28
+ grad.addColorStop(0, 'rgba(0,0,0,0.40)');
29
+ grad.addColorStop(0.65, 'rgba(0,0,0,0.22)');
30
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
31
+ ctx.fillStyle = grad;
32
+ ctx.fillRect(0, 0, S, S);
33
+ const tex = new THREE.CanvasTexture(cnv);
34
+
35
+ const geo = new THREE.PlaneGeometry(1, 1);
36
+ geo.rotateX(-Math.PI / 2);
37
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false });
38
+ // hover the ground without z-fighting it (same trick as the road decals)
39
+ mat.polygonOffset = true;
40
+ mat.polygonOffsetFactor = -2;
41
+ mat.polygonOffsetUnits = -2;
42
+
43
+ const mesh = this.mesh = new THREE.InstancedMesh(geo, mat, CAP);
44
+ mesh.name = 'blobShadows';
45
+ mesh.frustumCulled = false; // instances follow the whole near field; RANGE is the cull
46
+ mesh.matrixAutoUpdate = false;
47
+ mesh.renderOrder = 2; // after terrain, before water foam/fx transparents
48
+ game.scene.add(mesh);
49
+
50
+ this._m = new THREE.Matrix4();
51
+ this._zero = new THREE.Matrix4().makeScale(0, 0, 0);
52
+ }
53
+
54
+ update() {
55
+ const g = this.game;
56
+ const p = g.player?.position;
57
+ if (!p) return;
58
+ const m = this._m, mesh = this.mesh;
59
+ _cand.length = 0;
60
+ // NO DISC WITHOUT A BODY OVER IT. RANGE is 55 m; the entity cull is 45 m at low quality
61
+ // (core/quality.js), so the ten metres between the two used to lay a shadow on the sand under a
62
+ // man the cull had already hidden. Fourteen townsfolk never caught it out; a hundred and fifty
63
+ // ring every settlement with ownerless shadows. Take whichever line is nearer.
64
+ const range = Math.min(RANGE, g.quality?.entityCull ?? RANGE);
65
+ const r2 = range * range;
66
+ const put = (pos, r) => {
67
+ if (!pos) return;
68
+ const dx = pos.x - p.x, dz = pos.z - p.z;
69
+ const d2 = dx * dx + dz * dz;
70
+ if (d2 > r2) return;
71
+ _cand.push(d2, pos, r);
72
+ };
73
+ for (const h of g.horses ?? []) put(h.position, 1.9);
74
+ // …AND A MAN IN A SADDLE THROWS NO DISC OF HIS OWN. A mounted traveller (game/mountedTraveller.js)
75
+ // is an ordinary NPC in game.npcs — that is what makes him shootable, lootable and a witness — but
76
+ // his `position` is the SADDLE, a metre and a half off the dirt, and `put` lays the disc at
77
+ // position.y + 4 cm. So he would hang a shadow in mid-air over a horse that is already throwing one.
78
+ for (const n of g.npcs ?? []) { if (!n.seated) put(n.position, n.child ? 0.62 : 0.95); } // a child throws a child's shadow
79
+ for (const e of g.combat?.enemies ?? []) { if (!e.dead) put(e.position, 0.95); }
80
+ for (const t of g.travellers ?? []) put(t.position, 0.95);
81
+
82
+ // the player always has one; the rest go nearest-first
83
+ let i = 0;
84
+ m.makeScale(1.05, 1, 1.05); m.setPosition(p.x, p.y + 0.04, p.z);
85
+ mesh.setMatrixAt(i++, m);
86
+ const n = _cand.length / 3;
87
+ if (n > CAP - 1) { // more bodies in range than discs: sort so the near ones win
88
+ const idx = (this._idx ??= []);
89
+ idx.length = n;
90
+ for (let k = 0; k < n; k++) idx[k] = k;
91
+ idx.sort((a, b) => _cand[a * 3] - _cand[b * 3]);
92
+ for (let k = 0; k < n && i < CAP; k++) {
93
+ const b = idx[k] * 3, pos = _cand[b + 1], r = _cand[b + 2];
94
+ m.makeScale(r, 1, r);
95
+ m.setPosition(pos.x, pos.y + 0.04, pos.z); // .position sits at foot height; +4cm clears the ground plane
96
+ mesh.setMatrixAt(i++, m);
97
+ }
98
+ } else {
99
+ for (let b = 0; b < _cand.length && i < CAP; b += 3) {
100
+ const pos = _cand[b + 1], r = _cand[b + 2];
101
+ m.makeScale(r, 1, r);
102
+ m.setPosition(pos.x, pos.y + 0.04, pos.z);
103
+ mesh.setMatrixAt(i++, m);
104
+ }
105
+ }
106
+ for (; i < CAP; i++) mesh.setMatrixAt(i, this._zero); // park the rest — scale-0 draws nothing
107
+ mesh.instanceMatrix.needsUpdate = true;
108
+ }
109
+ }
@@ -0,0 +1,61 @@
1
+ // PROCEDURAL CLOUD MATERIAL for the dome-anchored quads (sky.js owns the InstancedMesh + drift).
2
+ // Each quad is ONE soft cumulus built from FBM noise — three octaves of the shared tiling noise texture,
3
+ // shaped by a round envelope so it reads as a discrete puff, given lumpy self-shading for depth, and
4
+ // ADVECTED DOWNWIND on the global wind so the shapes stream and evolve instead of sitting dead. No two
5
+ // clouds repeat (each seeds its noise off its instance index). This replaces the old single radial-blob
6
+ // sprite that made all 52 clouds identical flat discs.
7
+ //
8
+ // `tint` (vec3 uniform) and `opacity` (float uniform) are written each frame by sky.js — the phase/weather
9
+ // colour (white by day, dusk hues at sunset, slate under a storm, dimmed at night) and the deck's alpha.
10
+ import * as THREE from 'three/webgpu';
11
+ import { uv, texture, float, vec2, mix, smoothstep, length, hash, instanceIndex, time, uniform, attribute } from 'three/tsl';
12
+ import { uCloudiness, uWindDir } from './weatherUniforms.js';
13
+ import { waterNoiseTexture } from './waterNoise.js';
14
+
15
+ export function buildCloudMaterial() {
16
+ const tint = uniform(new THREE.Color(1, 1, 1));
17
+ const opacity = uniform(0.6);
18
+ const noiseTex = waterNoiseTexture();
19
+ const mat = new THREE.MeshBasicNodeMaterial({ transparent: true, depthWrite: false, fog: false, side: THREE.DoubleSide });
20
+
21
+ const seed = hash(instanceIndex); // per-cloud offsets → no two puffs alike
22
+ const seed2 = hash(instanceIndex.add(37));
23
+ const nScale = hash(instanceIndex.add(7)).mul(0.8).add(0.55); // per-cloud NOISE FREQUENCY — kept low so the edge is LUMPY, not stringy
24
+ const wisp = hash(instanceIndex.add(23)).mul(0.12).sub(0.02); // per-cloud rim bias: − fuller, + softer/thinner rim
25
+ const p = uv(); // 0..1 across the quad
26
+ const r = length(p.sub(0.5)).mul(2.0); // 0 at the centre … ~1.4 at the corner
27
+
28
+ const drift = uWindDir.mul(time.mul(0.004)); // stream the noise field DOWNWIND (vec2)
29
+ const q = p.mul(nScale).add(vec2(seed, seed2)).add(drift);
30
+ const n1 = texture(noiseTex, q).r;
31
+ const n2 = texture(noiseTex, q.mul(2.3).add(vec2(seed2, seed))).r;
32
+ const n3 = texture(noiseTex, q.mul(4.9).add(vec2(seed, seed2).mul(2.0))).r;
33
+ const fbm = n1.mul(0.55).add(n2.mul(0.30)).add(n3.mul(0.15)); // ~0..1
34
+
35
+ const thick = uCloudiness.mul(0.28); // a storm deck grows bigger + fuller
36
+ // SOLID cotton body with a CAULIFLOWER ragged edge: the noise perturbs the silhouette RADIUS, it does
37
+ // NOT hollow out the middle. (Driving opacity by fbm across the whole cloud punched transparent holes
38
+ // through the body and left the thin stringy smears.) The internal fbm is kept only for the lumpy
39
+ // self-shading below, so the billows still catch light without the body going see-through.
40
+ const edge = r.add(fbm.sub(0.5).mul(0.5));
41
+ const body = smoothstep(float(0.98).add(wisp), float(0.46).sub(thick.mul(0.3)), edge);
42
+ // FLAT BASE: clip the rounded bottom off along a near-horizontal (faintly wavy) line, so each cloud
43
+ // reads as a real cumulus sitting on its condensation level rather than a cotton ball. p.y is the
44
+ // cloud's world-vertical (sky.js orients the quad's +Y to world-up), so low p.y = the base.
45
+ const baseCut = smoothstep(float(0.26), float(0.42), p.y.add(fbm.sub(0.5).mul(0.08)));
46
+ const dens = body.mul(baseCut);
47
+
48
+ // lumpy self-shading: denser core catches more light, the fluffed edges fall into soft shadow — reads
49
+ // as volume/depth without a per-quad light direction (the quads are billboarded + rolled every which way)
50
+ // DIRECTIONAL LIGHT. sky.js orients each quad so its +Y is WORLD-UP, so the TOP of the cloud is its
51
+ // sunlit CROWN and the bottom is the self-shadowed flat BASE; `aSun` (per cloud, −1 sun-left … +1
52
+ // sun-right) shifts the bright side toward the sun's azimuth. fbm ruffles the gradient so it reads as
53
+ // lumpy billows catching light, not a flat ramp. (Was a flat lumpiness with no sense of light direction.)
54
+ const aSun = attribute('aSun', 'float');
55
+ const crown = p.y.mul(1.1).add(fbm.sub(0.5).mul(0.5)).add(aSun.mul(p.x.sub(0.5)).mul(0.7)).sub(0.05).clamp(0, 1);
56
+ const shade = mix(float(0.5), float(1.2), crown);
57
+
58
+ mat.colorNode = tint.mul(shade);
59
+ mat.opacityNode = dens.mul(opacity);
60
+ return { material: mat, tint, opacity };
61
+ }