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,312 @@
1
+ // Dynamic weather: a slow state machine (clear → overcast → rain → storm) sets a global precipitation
2
+ // intensity; the TYPE is local — warm ground falls as rain (fast streaks + rain/thunder audio), cold/
3
+ // high ground as snow (slow drifting flakes, silent). Precipitation is ONE resident InstancedMesh of
4
+ // camera-following billboarded quads (NOT points — WebGPU point sprites compile pointUV→gl_PointCoord
5
+ // which doesn't exist in WGSL, so they render invisible & are locked to 1px). All motion is shader math
6
+ // off uTime/aSeed (no per-frame buffer churn). An `exposure` factor (0 underground/sheltered → 1 open
7
+ // sky) zeroes it in dungeons + muffles it indoors. Audio loops gaplessly via Web Audio (HTMLAudio loop
8
+ // gaps on AAC padding). Runs AFTER Sky.update so it overlays the time-of-day sky/fog.
9
+ // Design spec: webgpu-rain-research workflow (2026-06-21) — toneMapped:false (ACESFilmic else greys
10
+ // the streaks), manual linear fog in opacityNode (custom view-space vertexNode bypasses built-in fog).
11
+ import * as THREE from 'three/webgpu';
12
+ import { uniform, float, vec2, vec3, vec4, attribute, positionGeometry, modelViewMatrix, cameraProjectionMatrix, uv, smoothstep, oneMinus, abs, mod, floor, sin, cos, fract, length, mix, clamp, step, varying } from 'three/tsl';
13
+ import { uWet, uCover, uCloudiness, uRainT, uRain, uWindDir, uWindStr } from './weatherUniforms.js';
14
+
15
+ const COUNT = 10000; // instanced quads — density is what makes rain read
16
+ const BOX = 28; // disk radius around the player (m)
17
+ const TOP = 18, BOT = 5; // column extent above / below the player (drops pass through eye level)
18
+ const SPAN = TOP + BOT;
19
+ const FALL = 30; // rain fall speed (m/s); snow is ~7× slower
20
+ const SNOWLINE = 500; // terrain height (m) where precipitation turns to snow — above every mesa: it never snows on Dustwater
21
+ const STORM_GREY = new THREE.Color(0x4a4f57);
22
+
23
+ export class Weather {
24
+ constructor(scene, { shelterObjects = null, indoorAt = null } = {}) {
25
+ this._shelterObjects = shelterObjects; this._indoorAt = indoorAt;
26
+ this.scene = scene;
27
+ this.intensity = 0; // 0 clear … 1 storm (smoothed)
28
+ this.target = 0;
29
+ this.exposure = 1; // 0 underground/sheltered … 1 open sky (smoothed)
30
+ this._expTarget = 1;
31
+ this._snow = 0; // 0 rain … 1 snow (smoothed by ground coldness)
32
+ this._snowForce = null;
33
+ this._time = 0;
34
+ this._stateT = 0;
35
+ this._rollT = 0;
36
+ this._flash = 0;
37
+ this._nextBolt = 8;
38
+ this._thunderIn = -1;
39
+ this._boom = 0;
40
+ this._wet = 0; // ground wetness (rain) — eased, drives uWet
41
+ this._cover = 0; // ground snow cover — eased, drives uCover
42
+ this._forced = false;
43
+ this._muted = false; // weather SFX (rain/thunder) on with the audio pass; follows the master audio toggle (setMuted)
44
+ this._ray = new THREE.Raycaster(); this._ray.far = 14;
45
+ this._up = new THREE.Vector3(0, 1, 0);
46
+ this._origin = new THREE.Vector3();
47
+
48
+ this.uCenter = uniform(new THREE.Vector3());
49
+ this.uTime = uniform(0);
50
+ this.uAmount = uniform(0); // precipitation visual amount = intensity * exposure
51
+ this.uSnow = uniform(0); // 0 rain … 1 snow
52
+ this.uFogNear = uniform(60); // fed from scene.fog each frame so drops fade into the haze
53
+ this.uFogFar = uniform(240);
54
+ this.uRainSize = uniform(1); // live size multipliers (tunable via console)
55
+ this.uSnowSize = uniform(1);
56
+
57
+ this._buildPrecip();
58
+ this._buildAudio();
59
+ this._roll(true);
60
+ }
61
+
62
+ _buildPrecip() {
63
+ const geo = new THREE.PlaneGeometry(1, 1); // unit quad: positionGeometry ±0.5, uv 0..1
64
+ const seed = new Float32Array(COUNT * 4); // (offsetX, phase 0-1, offsetZ, rnd 0-1)
65
+ for (let i = 0; i < COUNT; i++) {
66
+ seed[i * 4] = (Math.random() * 2 - 1) * BOX;
67
+ seed[i * 4 + 1] = Math.random();
68
+ seed[i * 4 + 2] = (Math.random() * 2 - 1) * BOX;
69
+ seed[i * 4 + 3] = Math.random();
70
+ }
71
+ geo.setAttribute('aSeed', new THREE.InstancedBufferAttribute(seed, 4));
72
+ const mat = new THREE.MeshBasicNodeMaterial({
73
+ transparent: true, depthWrite: false, depthTest: true, side: THREE.DoubleSide,
74
+ forceSinglePass: true, fog: false, toneMapped: false,
75
+ });
76
+ const snow = this.uSnow;
77
+ const s = attribute('aSeed', 'vec4');
78
+ const rnd = s.w;
79
+ const near = step(rnd, float(0.5)); // 1 = near drop (rnd ≤ 0.5): tighter, faster, bigger, brighter
80
+
81
+ // fall: snow ~7× slower; per-drop jitter; near drops a touch faster. Wraps over the column.
82
+ const fall = float(FALL).mul(oneMinus(snow.mul(0.86))).mul(float(0.75).add(rnd.mul(0.5))).mul(mix(float(1.0), float(1.15), near));
83
+ const tt = this.uTime.mul(fall).add(s.y.mul(SPAN));
84
+ const cyc = floor(tt.div(float(SPAN)));
85
+ const yLocal = float(TOP).sub(tt.sub(cyc.mul(float(SPAN)))); // = TOP - mod(tt, SPAN)
86
+ // each fall cycle the drop reappears at a FRESH random spot in the disk, so rain doesn't fall in
87
+ // fixed vertical lines (the same column repeating). Hash the cycle number with the drop's seed.
88
+ const jx = fract(sin(cyc.mul(12.9898).add(s.x.mul(4.7)).add(s.z.mul(7.3))).mul(43758.5453)).mul(2.0).sub(1.0).mul(float(BOX));
89
+ const jz = fract(sin(cyc.mul(78.233).add(s.z.mul(3.1)).add(s.x.mul(2.6))).mul(43758.5453)).mul(2.0).sub(1.0).mul(float(BOX));
90
+ const wSlant = mix(uWindStr.mul(0.5).add(0.05), float(0.0), snow); // rain leans ON THE WIND; snow flutters
91
+ const driftX = sin(this.uTime.add(s.y.mul(6.283))).mul(mix(float(0.0), float(1.6), snow));
92
+ const driftZ = cos(this.uTime.mul(0.7).add(s.x.mul(0.3))).mul(mix(float(0.0), float(1.2), snow));
93
+ const radial = mix(float(1.0), float(0.55), near);
94
+ const worldPos = this.uCenter.add(vec3(
95
+ jx.mul(radial).add(yLocal.mul(uWindDir.x).mul(wSlant)).add(driftX), // streak leans the way the wind blows (XZ)
96
+ yLocal,
97
+ jz.mul(radial).add(yLocal.mul(uWindDir.y).mul(wSlant)).add(driftZ),
98
+ ));
99
+ // view-space billboard (mesh at origin → modelViewMatrix == view)
100
+ const viewCenter = modelViewMatrix.mul(vec4(worldPos, 1.0)).xyz;
101
+ const upView = modelViewMatrix.mul(vec4(0.0, 1.0, 0.0, 0.0)).xyz.normalize(); // world-up in view space
102
+ const pitch = abs(upView.z); // 0 level … 1 looking up/down
103
+ const fore = oneMinus(pitch.mul(0.85)); // foreshorten steep streaks
104
+ const baseW = mix(float(0.028).mul(this.uRainSize), float(0.032).mul(this.uSnowSize), snow);
105
+ const baseH = mix(float(0.42).mul(this.uRainSize), float(0.036).mul(this.uSnowSize), snow);
106
+ const sizeMul = mix(float(0.85), float(1.12), near).mul(float(0.8).add(rnd.mul(0.4)));
107
+ const W = baseW.mul(sizeMul);
108
+ const H = baseH.mul(sizeMul).mul(mix(fore, float(1.0), snow));
109
+ const longAxis = mix(vec3(0.0, 1.0, 0.0), upView, pitch.mul(0.6).mul(oneMinus(snow))); // rotate toward up when steep
110
+ const viewPos = viewCenter.add(vec3(positionGeometry.x.mul(W), float(0), float(0))).add(longAxis.mul(positionGeometry.y).mul(H));
111
+ mat.vertexNode = cameraProjectionMatrix.mul(vec4(viewPos, 1.0));
112
+
113
+ // carry per-drop scalars + camera distance to the fragment
114
+ const vCamDist = varying(length(viewCenter));
115
+ const vNear = varying(near);
116
+ const vRnd = varying(rnd);
117
+ // fade drops as they reach the ground (yLocal≈0 is the player's feet) so they LAND instead of
118
+ // punching through the terrain + flickering among the grass blades
119
+ const vGround = varying(smoothstep(float(-1.5), float(0.8), yLocal));
120
+
121
+ const u = uv();
122
+ // rain streak: thin core, soft tail, bright droplet head
123
+ const core = oneMinus(smoothstep(0.0, 0.5, abs(u.x.sub(0.5))));
124
+ const tail = smoothstep(0.0, 0.25, u.y).mul(oneMinus(smoothstep(0.55, 1.0, u.y)));
125
+ const head = smoothstep(0.7, 1.0, u.y);
126
+ const streak = core.mul(tail.add(head.mul(1.6)));
127
+ const flake = oneMinus(smoothstep(0.12, 0.5, length(u.sub(vec2(0.5)))));
128
+ const shape = mix(streak, flake, snow);
129
+ const tint = mix(vec3(0.78, 0.86, 1.0), vec3(0.95, 0.97, 1.0), snow);
130
+ const bright = mix(float(0.9), float(1.35), vNear).mul(float(0.7).add(vRnd.mul(0.6)));
131
+ // manual linear fog (custom vertexNode bypasses built-in fog): fade drops into the haze, don't paint over it
132
+ const fogged = oneMinus(smoothstep(this.uFogNear, this.uFogFar, vCamDist));
133
+ const nearFade = smoothstep(this.uFogNear.mul(0.04), this.uFogNear.mul(0.25), vCamDist); // no lens-plastered drops
134
+ const density = step(fract(vRnd.mul(11.3)), clamp(this.uAmount.mul(1.3), float(0.15), float(1.0))); // drizzle = fewer drops
135
+ mat.colorNode = tint.mul(bright);
136
+ mat.opacityNode = shape.mul(this.uAmount).mul(fogged).mul(nearFade).mul(density).mul(vGround).mul(mix(float(0.55), float(0.9), snow)).clamp(0.0, 1.0);
137
+
138
+ const precip = new THREE.InstancedMesh(geo, mat, COUNT);
139
+ const I = new THREE.Matrix4();
140
+ for (let i = 0; i < COUNT; i++) precip.setMatrixAt(i, I); // identity — positions come from the shader
141
+ precip.instanceMatrix.needsUpdate = true;
142
+ precip.frustumCulled = false;
143
+ precip.renderOrder = 10; // after opaque world
144
+ this.scene.add(precip);
145
+ this.precip = precip;
146
+ }
147
+
148
+ // ---- gapless audio via Web Audio (decoded buffers loop without the AAC padding gap) ----
149
+ _buildAudio() {
150
+ this._buf = {}; this._src = {}; this._gain = {}; this._actx = null;
151
+ this._load('rain', '/assets/audio/weather/rain.m4a');
152
+ this._load('thunder', '/assets/audio/weather/thunder.m4a');
153
+ const unlock = () => { if (this._actx?.state === 'suspended') this._actx.resume(); };
154
+ addEventListener('pointerdown', unlock); addEventListener('keydown', unlock);
155
+ }
156
+ _ctx() { if (!this._actx) this._actx = new (window.AudioContext || window.webkitAudioContext)(); return this._actx; }
157
+ async _load(key, url) {
158
+ try { const r = await fetch(url); this._buf[key] = await this._ctx().decodeAudioData(await r.arrayBuffer()); } catch (e) { /* leave silent */ }
159
+ }
160
+ _vol(key, vol) {
161
+ if (this._muted) vol = 0;
162
+ vol *= 0.28; // master weather volume cap — rain/thunder at full gain (1.0) was ear-splitting
163
+ const buf = this._buf[key]; if (!buf) return;
164
+ if (vol > 0.002) {
165
+ if (!this._src[key]) {
166
+ const ctx = this._ctx(); if (ctx.state === 'suspended') ctx.resume();
167
+ const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true;
168
+ const g = ctx.createGain(); g.gain.value = 0;
169
+ src.connect(g).connect(ctx.destination); src.start();
170
+ this._src[key] = src; this._gain[key] = g;
171
+ }
172
+ this._gain[key].gain.setTargetAtTime(Math.min(1, vol), this._actx.currentTime, 0.3); // smooth, no clicks
173
+ } else if (this._src[key]) {
174
+ try { this._src[key].stop(); } catch (e) {}
175
+ this._src[key] = null; this._gain[key] = null;
176
+ }
177
+ }
178
+
179
+ // A DISCRETE thunderclap fired on each bolt — a one-shot with a sharp attack + rolling decay.
180
+ // (The old scheme looped thunder as a bed and nudged its volume, but _vol's 0.3s smoothing
181
+ // smeared every clap into an inaudible swell, and the low rumble is sub-bass laptop speakers
182
+ // barely reproduce. A punchy transient reads as thunder on any speaker.)
183
+ _thunderClap(vol) {
184
+ const buf = this._buf.thunder; if (!buf || this._muted) return;
185
+ const ctx = this._ctx(); if (ctx.state === 'suspended') ctx.resume();
186
+ const src = ctx.createBufferSource(); src.buffer = buf; src.loop = false;
187
+ const off = Math.random() * Math.max(0, buf.duration - 5); // vary which part of the sample
188
+ const g = ctx.createGain(); const t = ctx.currentTime;
189
+ const peak = Math.min(0.6, vol);
190
+ g.gain.setValueAtTime(0.0001, t);
191
+ g.gain.exponentialRampToValueAtTime(peak, t + 0.03); // sharp crack
192
+ g.gain.exponentialRampToValueAtTime(0.0001, t + 4.0); // rolling boom decay
193
+ src.connect(g).connect(ctx.destination);
194
+ src.start(t, off, 4.2); try { src.stop(t + 4.3); } catch (e) {}
195
+ }
196
+
197
+ _roll(initial) {
198
+ if (initial) this.target = 0;
199
+ else { const r = Math.random(); this.target = r < 0.42 ? 0 : r < 0.68 ? 0.18 : r < 0.9 ? 0.55 : 1.0; }
200
+ this._stateT = 90 + Math.random() * 150;
201
+ }
202
+
203
+ // Debug: pin the weather (intensity 0-1, snow 0|1). Pass null to resume the natural cycle.
204
+ force(intensity, snow = null) {
205
+ if (intensity == null) { this._forced = false; this._snowForce = null; return; }
206
+ this._forced = true; this.target = intensity; this.intensity = intensity;
207
+ this._snowForce = snow;
208
+ }
209
+
210
+ update(dt, playerPos, sky, world) {
211
+ this._time += dt;
212
+ this.uTime.value = this._time;
213
+ this.uCenter.value.copy(playerPos);
214
+
215
+ if (!this._forced) {
216
+ if ((this._stateT -= dt) <= 0) this._roll(false);
217
+ this.intensity += (this.target - this.intensity) * Math.min(1, dt * 0.4);
218
+ }
219
+
220
+ if ((this._rollT -= dt) <= 0) {
221
+ this._rollT = 0.3;
222
+ if (this._indoorAt?.(playerPos)) this._expTarget = 0; // generic indoor-volume interface — no precipitation inside
223
+ // A ROOF IS A ROOF. This used to leave 0.18 of the rain falling indoors, which reads as rain
224
+ // coming through the ceiling — and now that every door in the county opens, you are inside far
225
+ // more often and you see it. Nothing falls under a roof.
226
+ // ...and a MOVING roof is a roof too: the shelter ray tests the building BVHs, which a train
227
+ // carriage or a coach cab is not in. shelterHook (main.js) answers for the vehicles.
228
+ else { this._roofed = (this.shelterHook?.() ?? false) || this._sheltered(playerPos, world); this._expTarget = this._roofed ? 0 : 1; }
229
+ }
230
+ this.exposure += (this._expTarget - this.exposure) * Math.min(1, dt * 2.5);
231
+
232
+ const cold = this._snowForce != null ? this._snowForce
233
+ : world?.groundAt ? THREE.MathUtils.smoothstep(world.groundAt(playerPos.x, playerPos.z), SNOWLINE - 8, SNOWLINE + 8) : 0;
234
+ this._snow += (cold - this._snow) * Math.min(1, dt * 0.5);
235
+ this.uSnow.value = this._snow;
236
+ // cloud deck LEADS precipitation: full-heavy while rain is still building (x1.75), lingers
237
+ // as it eases off — the sky visibly thickens before the rain/snow starts and clears after
238
+ uCloudiness.value = Math.min(1, this.intensity * 1.75);
239
+
240
+ // ground accumulation: rain wets the ground (fast), snow covers it (slow). Uses the player's local
241
+ // snow factor (visible ground is near the player); eases so it soaks/dries + builds/melts gradually.
242
+ this._wet += (this.intensity * (1 - this._snow) - this._wet) * Math.min(1, dt * 0.18);
243
+ this._cover += (this.intensity * this._snow - this._cover) * Math.min(1, dt * 0.07);
244
+ uWet.value = this._wet;
245
+ uRainT.value = this._time; // the ground's clock (splashes and rings)
246
+ uRain.value = this.intensity * (1 - this._snow); // what is falling NOW — snow does not splash
247
+ uCover.value = this._cover;
248
+
249
+ this.uAmount.value = this.intensity * this.exposure;
250
+
251
+ const k = this.intensity; // darken the sky even when sheltered — it's overcast outside
252
+ sky.sun.intensity *= 1 - k * 0.55;
253
+ sky.hemi.intensity *= 1 - k * 0.42;
254
+ // Fog responds to weather: clear/sunny pushes the far haze WAY back (the quality preset pulls fog in
255
+ // hard for perf — fogScale 0.45 → far ~144m, which reads as "very close"); storms pull it back in.
256
+ // Capped at 330m so we never reveal un-streamed terrain past the ~360m tile ring, whatever the preset.
257
+ sky.scene.fog.near *= 1.5 - k * 0.8; // clear ×1.5 … storm ×0.7
258
+ sky.scene.fog.far = Math.min(sky.scene.fog.far * (2.3 - k * 1.7), 330); // clear → ~330m … storm → in close
259
+ if (k > 0.01) sky.scene.fog.color.lerp(STORM_GREY, k * 0.5);
260
+ this.uFogNear.value = sky.scene.fog.near;
261
+ this.uFogFar.value = sky.scene.fog.far * 0.75; // rain fades a touch before the world far plane
262
+
263
+ this._lightning(dt, sky);
264
+ // ...but you still HEAR it. Under a roof the drops stop and the sound does not: it drums on the
265
+ // shingles above you, muffled. Silence indoors during a downpour is as wrong as rain through the
266
+ // ceiling. (Underground is a different matter — there `exposure` is zeroed without `_roofed`.)
267
+ this._audio(this.intensity * (this._roofed ? 0.35 : this.exposure));
268
+ }
269
+
270
+ // IS THERE A ROOF OVER HIM? Look up, and ask the same solid world the player collides with.
271
+ //
272
+ // This used to raycast `buildingObjects`, and that array is EMPTY by the time anyone plays:
273
+ // world.js takes a copy of it to bake the building BVH, and the region streamer splices it as it
274
+ // goes, so the county's 121 buildings are in the BVH and not in the list. Measured in the running
275
+ // game: buildingObjects.length === 0 while the scene holds 121 `bld:` meshes. The test could
276
+ // therefore never find a roof, and the rain fell indoors everywhere, in every building, always.
277
+ // Ask the BVH — it is the thing that actually knows where the walls and roofs are.
278
+ _sheltered(p, world) {
279
+ this._ray.set(this._origin.set(p.x, p.y + 1.2, p.z), this._up);
280
+ const bvh = world?.buildingBVH;
281
+ if (bvh?.raycastFirst) {
282
+ try { if (bvh.raycastFirst(this._ray.ray, THREE.DoubleSide)) return true; } catch (e) { /* a bad BVH is not worth a stack trace every 0.3s */ }
283
+ }
284
+ for (const b of world?.sideBVHs ?? []) { // ...and the settlements streamed in during play
285
+ try { if (b.raycastFirst?.(this._ray.ray, THREE.DoubleSide)) return true; } catch (e) { /* ditto */ }
286
+ }
287
+ const objs = this._shelterObjects?.() ?? [];
288
+ return objs.length > 0 && this._ray.intersectObjects(objs, true).length > 0;
289
+ }
290
+
291
+ _lightning(dt, sky) {
292
+ if (this.intensity > 0.72 && this.exposure > 0.4 && this._snow < 0.5) {
293
+ if ((this._nextBolt -= dt) <= 0) { this._flash = 1; this._nextBolt = 5 + Math.random() * 14; this._thunderIn = 1.2 + Math.random() * 2.5; }
294
+ }
295
+ if (this._flash > 0) {
296
+ this._flash = Math.max(0, this._flash - dt * 3.2);
297
+ sky.hemi.intensity += this._flash * this._flash * 1.8;
298
+ sky.sun.intensity += this._flash * this._flash * 0.9;
299
+ }
300
+ if (this._thunderIn > 0 && (this._thunderIn -= dt) <= 0) this._thunderClap(0.42 + Math.random() * 0.18);
301
+ }
302
+
303
+ _audio(amount) {
304
+ const dry = 1 - this._snow; // snow is silent
305
+ this._vol('rain', THREE.MathUtils.clamp((amount - 0.15) / 0.5, 0, 1) * 0.6 * dry);
306
+ // thunder is discrete claps synced to the bolts now (see _thunderClap / _lightning), not a
307
+ // continuous bed — so stop any lingering thunder loop.
308
+ this._vol('thunder', 0);
309
+ }
310
+
311
+ setMuted(on) { this._muted = on; }
312
+ }