Lane B round-8: weather (?weather=1) — seeded rain/overcast/clear, exposes PROCITY.weather for D
New js/world/weather.js + a one-line lighting.setSky(). Seeded per citySeed (weatherFor):
~57/24/19% clear/overcast/rain over 400 seeds, deterministic. Rain = ONE draw (camera-following
Points + procedural streak/fall/wind shader) + wet ground (roughness/darken on shared ground mats,
restored on dispose) + matching rainy sky dome via lighting.setSky. Fully procedural (0 depot fetch).
- Exposes window.PROCITY.weather = { state:'clear'|'overcast'|'rain', intensity:0..1 } for Lane D's
weather-reactive crowds (decision #1). Documented the contract + the exact 3-line shell wiring for F
in LANE_B_NOTES (I don't touch index.html). ?weather=1 seeded (default town rolls clear);
?weather=rain|overcast|clear forces a state for demos/smokes.
- Worst view 141 draws day / 126 night (<=300); composes with ?winmap + night (rain outside a lit
interior-mapped window = the money shot, screenshotted); deterministic; 0 console errors; flag-off
byte-identical (shell never constructs it). Deferred cut line: vertex-shader wind sway on
trees/awnings (touches other lanes' materials) — noted, not done. Tram stretch not attempted.
qa.sh --strict GREEN (5/5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
081755bb71
commit
900b274a9a
@ -4,6 +4,20 @@
|
||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
||||
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
|
||||
|
||||
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7 · qa GREEN
|
||||
|
||||
New `js/world/weather.js` + `lighting.setSky()`. Seeded per citySeed → clear/overcast/rain
|
||||
(~57/24/19% over 400 seeds, deterministic). **Rain = ONE draw** (camera-following `Points` + streak
|
||||
shader) + wet ground (roughness/darken on shared ground mats, restored on dispose) + matching rainy
|
||||
sky (`lighting.setSky`). Fully procedural (0 depot fetch). Worst view **141 draws** (≤300); composes
|
||||
with `?winmap`+night — **rain outside a lit interior-mapped window** shot saved. Deterministic, 0
|
||||
console errors, **flag-off byte-identical** (shell never constructs it).
|
||||
- **Exposed the `window.PROCITY.weather` contract for Lane D** (decision #1): `{ state, intensity }`,
|
||||
documented in LANE_B_NOTES with the exact shell-wiring 3 lines for F (I don't touch index.html).
|
||||
`?weather=1` seeded (default town rolls clear); `?weather=rain|overcast|clear` forces a state.
|
||||
- Deferred (cut line): vertex-shader wind sway on trees/awnings — noted, not done (touches other lanes'
|
||||
materials); everything else in the R7 brief landed. Tram stretch not attempted.
|
||||
|
||||
## Round 6 (Fable's ROUND6 → Lane B) — placed the orphaned street props · qa GREEN
|
||||
|
||||
Wired the two published-but-unconsumed street GLBs (`street_bin_01`, `bus_shelter_01`) via the
|
||||
|
||||
@ -1,5 +1,48 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7
|
||||
|
||||
New file `js/world/weather.js` + a one-line `setSky()` on `lighting.js`. Seeded, deterministic, cheap.
|
||||
|
||||
- **Weather state** — `weatherFor(citySeed, day=0)` → `{ state, intensity }`, weights ~57% clear /
|
||||
24% overcast / 19% rain (AU coastal-town plausible; verified over 400 seeds). `day=0` today (one
|
||||
day cycle); a future day-counter rolls new weather. **Seed 20261990 (default town) rolls `clear`**,
|
||||
so `?weather=1` there is v1-identical — use **`?weather=rain`** (or `overcast`) to force a state for
|
||||
demos/smokes, or a rainy seed (7, 424242).
|
||||
- **Rain** — ONE draw: a **camera-following `THREE.Points`** layer with a procedural streak sprite and
|
||||
a fall+wind vertex shader (no CPU per-particle). Plus **wet ground** (darken + drop roughness on the
|
||||
shared ground materials, restored on dispose) and a **matching rainy sky dome** (`lighting.setSky` →
|
||||
summer-storm/monsoon/cold-front). Overcast: greyer dome, no particles. Fully procedural — 0 depot/GLB
|
||||
fetch (the sky swap is a skin JPEG, same class as the town's own).
|
||||
- **Budget/composition/determinism**: worst view **141 draws (day rain) / 126 (night)** — ≤300.
|
||||
Composes with `?winmap` + night — **rain outside a lit interior-mapped window is the money shot**
|
||||
(`docs/shots/laneB/weather_rain_night_winmap.png`). Deterministic per seed. `?noassets` compatible.
|
||||
**0 console errors** in all states. **Flag-off is byte-identical** (the shell simply never constructs
|
||||
weather). `qa.sh --strict` GREEN. *(Deferred, "if cheap": vertex-shader wind sway on trees/awnings —
|
||||
needs touching furniture's tree material + skins' awning material; noted, not done this round.)*
|
||||
|
||||
### `window.PROCITY.weather` contract (Lane D consumes this — decision #1)
|
||||
Shape: **`{ state: 'clear' | 'overcast' | 'rain', intensity: 0..1 }`**, stable for the session.
|
||||
`intensity` is 0 for clear, ~0.3–0.6 overcast, ~0.45–1.0 rain. Set by `createWeather` when the flag is
|
||||
on; **F sets `{ state:'clear', intensity:0 }` when off** so D always reads a valid value (below). D:
|
||||
read it, don't import weather.js.
|
||||
|
||||
### → Fable (F, shell wiring — I don't touch `index.html`)
|
||||
```js
|
||||
import { createWeather } from './js/world/weather.js';
|
||||
const wp = params.get('weather');
|
||||
const weather = (wp && wp !== '0')
|
||||
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(wp) ? wp : null })
|
||||
: null; // seeded when ?weather=1; forced when ?weather=rain|overcast|clear
|
||||
window.PROCITY.weather = weather ? weather.state : { state: 'clear', intensity: 0 };
|
||||
// in the street branch of the main loop: if (weather) weather.update(dt);
|
||||
```
|
||||
Flags-table row: `?weather=1` — owner B — landed, default-off, self-contained module (F wires the 3
|
||||
lines above). Smoke: boot `?weather=rain`, assert rain Points present + `PROCITY.weather.state==='rain'`,
|
||||
0 errors.
|
||||
|
||||
---
|
||||
|
||||
## Round 6 (Fable's ROUND6 → Lane B) — place the orphaned street props
|
||||
|
||||
Placed the two published-but-unconsumed street GLBs (`procity_street_bin_01`, `procity_street_bus_shelter_01`)
|
||||
|
||||
BIN
docs/shots/laneB/weather_rain_day.png
Normal file
BIN
docs/shots/laneB/weather_rain_day.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 761 KiB |
BIN
docs/shots/laneB/weather_rain_night_winmap.png
Normal file
BIN
docs/shots/laneB/weather_rain_night_winmap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@ -93,6 +93,10 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
|
||||
function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); }
|
||||
|
||||
// Swap the sky-dome skin at runtime (weather.js uses this for a matching rainy/overcast sky).
|
||||
// The per-segment day/night colour tint (skyMat.color in apply) still rides on top of the new map.
|
||||
function setSky(name) { skins.skyTex(name, (t) => { skyMat.map = t; skyMat.needsUpdate = true; }); }
|
||||
|
||||
const _sunOff = new THREE.Vector3();
|
||||
function update(dt, playerPos) {
|
||||
if (!paused) {
|
||||
@ -140,7 +144,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true
|
||||
apply(seg);
|
||||
return {
|
||||
group, sun, hemi,
|
||||
update, setSegment, stepSegment, getClock, attachRenderer, dispose,
|
||||
update, setSegment, stepSegment, setSky, getClock, attachRenderer, dispose,
|
||||
setPaused: (p) => { paused = p; },
|
||||
togglePaused: () => (paused = !paused),
|
||||
isNight: () => night,
|
||||
|
||||
131
web/js/world/weather.js
Normal file
131
web/js/world/weather.js
Normal file
@ -0,0 +1,131 @@
|
||||
// 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: 9.0 }, uWind: { value: 0.18 }, uOpacity: { value: 0.32 + state.intensity * 0.3 },
|
||||
},
|
||||
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 = uSize * 34.0 / max(-mv.z, 1.0);
|
||||
}`,
|
||||
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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user