sindicate 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,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
|
+
}
|