- tram.js: 1 small bus, seeded out-and-back loop on the main-street spine, door-dwell at each busShelterStops stop, ping-pong at ends. Deterministic, exactly 2 draws, night headlights via lighting.isNight(). Flag-off identical. Shell wiring (2 lines) documented for F in LANE_B_NOTES. - dbg.js: 8 v2 tour bookmarks (16 total) incl. rain_verandah hero shot; tram_stop now frames the nearest-origin (open) shelter stop. - weather.js: cap rain gl_PointSize (clamp 2-22px) - near drops are thin streaks now, not blobs. Rain smoke still GREEN. - qa.sh --strict GREEN, golden hash frozen, flags-off byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
6.5 KiB
JavaScript
132 lines
6.5 KiB
JavaScript
// PROCITY Lane B — weather.js
|
|
// v2 weather (?weather=1), default-off. Seeded per (citySeed, day): clear / overcast / rain, with
|
|
// AU-plausible weights. Rain is ONE draw — a camera-following Points layer with a procedural streak
|
|
// sprite + a falling/wind vertex shader — plus a wet-ground response (roughness/darken on the shared
|
|
// ground materials) and a matching rainy sky dome (lighting.setSky). Exposes { state, intensity } at
|
|
// window.PROCITY.weather for other lanes (Lane D reads it for weather-reactive crowds).
|
|
//
|
|
// Fully procedural (no fetch beyond the sky JPEG the town already loads). Flag-off is byte-identical:
|
|
// the shell simply never constructs this, so the default path is untouched.
|
|
|
|
import * as THREE from 'three';
|
|
import { rng, frange, pick } from '../core/prng.js';
|
|
|
|
const RAIN_SKIES = ['summer-storm', 'monsoon', 'cold-front'];
|
|
const OVERCAST_SKIES = ['grey-drizzle', 'cold-front', 'high-cirrus'];
|
|
|
|
/**
|
|
* weatherFor(citySeed, day=0) → { state:'clear'|'overcast'|'rain', intensity:0..1 }
|
|
* Deterministic. Mostly clear, some overcast, occasional rain (AU coastal-town plausible).
|
|
* `day` lets a future day-counter roll new weather; today the town has one day, so day=0.
|
|
*/
|
|
export function weatherFor(citySeed, day = 0) {
|
|
const r = rng(citySeed >>> 0, 'weather', day | 0);
|
|
const x = r();
|
|
if (x < 0.55) return { state: 'clear', intensity: 0 };
|
|
if (x < 0.80) return { state: 'overcast', intensity: +frange(r, 0.3, 0.6).toFixed(2) };
|
|
return { state: 'rain', intensity: +frange(r, 0.45, 1.0).toFixed(2) };
|
|
}
|
|
|
|
// procedural vertical rain-streak sprite (canvas — no fetch)
|
|
function streakTexture() {
|
|
const c = document.createElement('canvas'); c.width = 8; c.height = 64;
|
|
const x = c.getContext('2d');
|
|
const g = x.createLinearGradient(0, 0, 0, 64);
|
|
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
|
x.fillStyle = g; x.fillRect(3, 0, 2, 64);
|
|
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.generateMipmaps = false; t.minFilter = THREE.LinearFilter;
|
|
return t;
|
|
}
|
|
|
|
/**
|
|
* createWeather({ scene, plan, skins, lighting, camera, force }) → { group, state, update, dispose }
|
|
* The shell constructs this only under ?weather; `force` (a state string) overrides the seed for
|
|
* testing/demos. Call update(dt) each street frame. Sets window.PROCITY.weather to the live state.
|
|
*/
|
|
export function createWeather({ scene, plan, skins, lighting, camera, force = null }) {
|
|
const state = force && /^(clear|overcast|rain)$/.test(force)
|
|
? { state: force, intensity: force === 'clear' ? 0 : (force === 'rain' ? 0.85 : 0.5) }
|
|
: weatherFor(plan.citySeed);
|
|
const group = new THREE.Group(); group.name = 'weather';
|
|
scene.add(group);
|
|
const r = rng(plan.citySeed >>> 0, 'weathersky', 0);
|
|
|
|
// ── matching sky dome ──
|
|
if (state.state === 'rain') lighting.setSky(pick(r, RAIN_SKIES));
|
|
else if (state.state === 'overcast') lighting.setSky(pick(r, OVERCAST_SKIES));
|
|
|
|
// ── wet ground (rain only): darken + drop roughness on the shared ground materials ──
|
|
const groundRestore = [];
|
|
if (state.state === 'rain') {
|
|
const gg = scene.getObjectByName('ground');
|
|
if (gg) gg.traverse((o) => {
|
|
if (!o.isMesh || !o.material || !o.material.isMeshStandardMaterial) return;
|
|
const m = o.material;
|
|
if (m.userData._wet) return; // shared material — touch once, restore once
|
|
m.userData._wet = true;
|
|
groundRestore.push({ m, color: m.color.clone(), roughness: m.roughness, metalness: m.metalness });
|
|
m.color.multiplyScalar(0.6); m.roughness = Math.min(m.roughness, 0.35); m.metalness = 0.18; m.needsUpdate = true;
|
|
});
|
|
}
|
|
|
|
// ── rain particles: ONE draw (Points), camera-following, procedural streaks ──
|
|
let rainMat = null;
|
|
if (state.state === 'rain') {
|
|
const COUNT = Math.round(700 + state.intensity * 1500);
|
|
const BOX = 48, BOXH = 28;
|
|
const pos = new Float32Array(COUNT * 3), phase = new Float32Array(COUNT);
|
|
for (let i = 0; i < COUNT; i++) {
|
|
pos[i * 3] = frange(r, -BOX / 2, BOX / 2);
|
|
pos[i * 3 + 1] = frange(r, -BOXH / 2, BOXH / 2);
|
|
pos[i * 3 + 2] = frange(r, -BOX / 2, BOX / 2);
|
|
phase[i] = r() * BOXH;
|
|
}
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
geo.setAttribute('aPhase', new THREE.BufferAttribute(phase, 1));
|
|
rainMat = new THREE.ShaderMaterial({
|
|
transparent: true, depthWrite: false,
|
|
uniforms: {
|
|
uTime: { value: 0 }, uTex: { value: streakTexture() }, uBoxH: { value: BOXH },
|
|
uSpeed: { value: 32 }, uSize: { value: 5.5 }, uWind: { value: 0.18 }, uOpacity: { value: 0.34 + state.intensity * 0.28 },
|
|
},
|
|
vertexShader: `
|
|
uniform float uTime, uBoxH, uSpeed, uSize, uWind; attribute float aPhase;
|
|
void main(){
|
|
vec3 p = position;
|
|
float fall = mod(uTime * uSpeed + aPhase, uBoxH);
|
|
p.y = position.y - fall; if (p.y < -uBoxH * 0.5) p.y += uBoxH; // fall + wrap
|
|
p.x += (uBoxH * 0.5 - p.y) * uWind; // wind slant
|
|
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
|
gl_Position = projectionMatrix * mv;
|
|
gl_PointSize = clamp(uSize * 30.0 / max(-mv.z, 1.0), 2.0, 22.0); // capped so near drops stay streaks, not blobs
|
|
}`,
|
|
fragmentShader: `
|
|
uniform sampler2D uTex; uniform float uOpacity;
|
|
void main(){ vec4 t = texture2D(uTex, gl_PointCoord); if (t.a < 0.04) discard; gl_FragColor = vec4(vec3(0.72, 0.77, 0.86), t.a * uOpacity); }`,
|
|
});
|
|
const pts = new THREE.Points(geo, rainMat);
|
|
pts.frustumCulled = false; // the box always surrounds the camera
|
|
group.add(pts);
|
|
}
|
|
|
|
function update(dt) {
|
|
group.position.copy(camera.position); // the rain box follows the player
|
|
if (rainMat) rainMat.uniforms.uTime.value += dt;
|
|
}
|
|
|
|
function dispose() {
|
|
for (const g of groundRestore) { g.m.color.copy(g.color); g.m.roughness = g.roughness; g.m.metalness = g.metalness; g.m.userData._wet = false; g.m.needsUpdate = true; }
|
|
group.traverse((o) => {
|
|
if (o.geometry) o.geometry.dispose();
|
|
if (o.material) { if (o.material.uniforms && o.material.uniforms.uTex) o.material.uniforms.uTex.value.dispose(); o.material.dispose(); }
|
|
});
|
|
scene.remove(group);
|
|
}
|
|
|
|
// publish the contract for other lanes (Lane D reads window.PROCITY.weather)
|
|
if (typeof window !== 'undefined') { window.PROCITY = window.PROCITY || {}; window.PROCITY.weather = state; }
|
|
|
|
return { group, state, update, dispose };
|
|
}
|