1/**2 * Atmosphere — fog, light, and weather as a GENERATIVE FAMILY (user3 * direction). A mood table defines the base combos; every endless ZONE4 * (a run of segments) draws a mood + parameter jitter + a weather form5 * from the mood's allowed set, all label-derived from the seed6 * (`atmosphere/zone/${z}`). Crossing a zone boundary crossfades every7 * scalar (sky, fog, sun color/intensity/angle, hemisphere, starfield) over8 * a few seconds and swaps the weather field with a fade — the void has9 * climate now.10 *11 * Presentation-only: reads the seed through its own rng family, never12 * perturbs generation. Weather fields are camera-local particle volumes13 * (snowfall, drifting dust, rising embers) that wrap around the viewer.14 */15import * as THREE from "three";16import type { SceneCtx } from "./scene.ts";17import {18 drawZone,19 MOODS,20 type MoodSpec,21 type WeatherKind,22 type ZoneAtmosphere,23} from "../gen/climate.ts";2425// The climate tables + zone draw moved to src/gen/climate.ts (M21):26// weather is a legal generation input now. Re-exported so client call27// sites keep their import paths.28export { drawZone, MOODS };29export type { MoodSpec, WeatherKind, ZoneAtmosphere };3031// ---------------------------------------------------------------------------32// Weather fields — camera-local particle volumes33// ---------------------------------------------------------------------------3435const WEATHER_BOX = 42;36const WEATHER_COUNT = 700;3738class WeatherField {39 readonly points: THREE.Points;40 private readonly velocities: Float32Array;41 private readonly phases: Float32Array;42 private readonly material: THREE.PointsMaterial;43 private targetOpacity: number;44 private t = 0;4546 constructor(readonly kind: Exclude<WeatherKind, "none">, color: number) {47 const positions = new Float32Array(WEATHER_COUNT * 3);48 this.velocities = new Float32Array(WEATHER_COUNT * 3);49 this.phases = new Float32Array(WEATHER_COUNT);50 for (let i = 0; i < WEATHER_COUNT; i++) {51 for (let a = 0; a < 3; a++) {52 positions[i * 3 + a] = (Math.random() - 0.5) * WEATHER_BOX;53 }54 this.phases[i] = Math.random() * Math.PI * 2;55 const v = kind === "snow"56 ? [0, -1.1 - Math.random() * 0.6, 0]57 : kind === "dust"58 ? [0.14 + Math.random() * 0.1, -0.04, 0.1 * (Math.random() - 0.3)]59 : [0.03, 0.42 + Math.random() * 0.3, 0.02];60 this.velocities.set(v, i * 3);61 }62 const geom = new THREE.BufferGeometry();63 geom.setAttribute("position", new THREE.BufferAttribute(positions, 3));64 this.targetOpacity = kind === "dust" ? 0.45 : 0.8;65 this.material = new THREE.PointsMaterial({66 color,67 size: kind === "snow" ? 0.055 : kind === "embers" ? 0.045 : 0.03,68 sizeAttenuation: true,69 transparent: true,70 opacity: 0,71 depthWrite: false,72 });73 this.points = new THREE.Points(geom, this.material);74 this.points.name = `weather:${kind}`;75 this.points.frustumCulled = false;76 }7778 /** dying → fade out; returns true when fully faded and disposable. */79 update(dt: number, camPos: THREE.Vector3, dying: boolean): boolean {80 this.t += dt;81 const target = dying ? 0 : this.targetOpacity;82 this.material.opacity += (target - this.material.opacity) *83 Math.min(1, dt * 1.5);84 this.points.position.copy(camPos);8586 const pos = this.points.geometry.getAttribute(87 "position",88 ) as THREE.BufferAttribute;89 const arr = pos.array as Float32Array;90 const half = WEATHER_BOX / 2;91 for (let i = 0; i < WEATHER_COUNT; i++) {92 const sway = this.kind === "snow"93 ? Math.sin(this.t * 1.7 + this.phases[i]) * 0.3594 : this.kind === "embers"95 ? Math.sin(this.t * 2.3 + this.phases[i]) * 0.1296 : Math.sin(this.t * 0.7 + this.phases[i]) * 0.2;97 arr[i * 3] += (this.velocities[i * 3] + sway * 0.4) * dt;98 arr[i * 3 + 1] += this.velocities[i * 3 + 1] * dt;99 arr[i * 3 + 2] += (this.velocities[i * 3 + 2] + sway * 0.25) * dt;100 // Wrap within the camera-local box.101 for (let a = 0; a < 3; a++) {102 const v = arr[i * 3 + a];103 if (v > half) arr[i * 3 + a] = v - WEATHER_BOX;104 else if (v < -half) arr[i * 3 + a] = v + WEATHER_BOX;105 }106 }107 pos.needsUpdate = true;108 return dying && this.material.opacity < 0.015;109 }110111 dispose(scene: THREE.Scene): void {112 scene.remove(this.points);113 this.points.geometry.dispose();114 this.material.dispose();115 }116}117118// ---------------------------------------------------------------------------119// The blender120// ---------------------------------------------------------------------------121122const BLEND_SECONDS = 4;123124interface Snapshot {125 sky: THREE.Color;126 fogNear: number;127 fogFar: number;128 sunColor: THREE.Color;129 sunIntensity: number;130 sunDir: THREE.Vector3;131 hemiSky: THREE.Color;132 hemiGround: THREE.Color;133 hemiIntensity: number;134 starColor: THREE.Color;135 starOpacity: number;136}137138function snapshotOf(z: ZoneAtmosphere): Snapshot {139 return {140 sky: new THREE.Color(z.mood.sky),141 fogNear: z.fogNear,142 fogFar: z.fogFar,143 sunColor: new THREE.Color(z.mood.sunColor),144 sunIntensity: z.sunIntensity,145 sunDir: new THREE.Vector3(...z.mood.sunDir).normalize(),146 hemiSky: new THREE.Color(z.mood.hemiSky),147 hemiGround: new THREE.Color(z.mood.hemiGround),148 hemiIntensity: z.mood.hemiIntensity,149 starColor: new THREE.Color(z.mood.starColor),150 starOpacity: z.mood.starOpacity,151 };152}153154function lerpSnap(a: Snapshot, b: Snapshot, t: number, out: Snapshot): void {155 out.sky.lerpColors(a.sky, b.sky, t);156 out.fogNear = a.fogNear + (b.fogNear - a.fogNear) * t;157 out.fogFar = a.fogFar + (b.fogFar - a.fogFar) * t;158 out.sunColor.lerpColors(a.sunColor, b.sunColor, t);159 out.sunIntensity = a.sunIntensity + (b.sunIntensity - a.sunIntensity) * t;160 out.sunDir.lerpVectors(a.sunDir, b.sunDir, t).normalize();161 out.hemiSky.lerpColors(a.hemiSky, b.hemiSky, t);162 out.hemiGround.lerpColors(a.hemiGround, b.hemiGround, t);163 out.hemiIntensity = a.hemiIntensity +164 (b.hemiIntensity - a.hemiIntensity) * t;165 out.starColor.lerpColors(a.starColor, b.starColor, t);166 out.starOpacity = a.starOpacity + (b.starOpacity - a.starOpacity) * t;167}168169export class Atmosphere {170 private from: Snapshot;171 private to: Snapshot;172 private cur: Snapshot;173 private blend = 1;174 private fog: THREE.Fog;175 private weather: WeatherField | null = null;176 private dyingWeather: WeatherField[] = [];177 private currentName: string;178 private currentWeather: WeatherKind;179180 constructor(181 private readonly ctx: SceneCtx,182 private readonly stars: THREE.Points,183 initial: ZoneAtmosphere,184 ) {185 this.from = snapshotOf(initial);186 this.to = snapshotOf(initial);187 this.cur = snapshotOf(initial);188 this.currentName = initial.mood.name;189 this.currentWeather = initial.weather;190 this.fog = new THREE.Fog(0x000000, initial.fogNear, initial.fogFar);191 ctx.scene.fog = this.fog;192 ctx.scene.background = new THREE.Color(initial.mood.sky);193 this.setWeather(initial);194 this.apply();195 }196197 get name(): string {198 return this.currentName;199 }200201 get weatherKind(): WeatherKind {202 return this.currentWeather;203 }204205 blendTo(zone: ZoneAtmosphere): void {206 this.from = { ...this.cur, ...cloneColors(this.cur) };207 this.to = snapshotOf(zone);208 this.blend = 0;209 this.currentName = zone.mood.name;210 this.setWeather(zone);211 }212213 private setWeather(zone: ZoneAtmosphere): void {214 this.currentWeather = zone.weather;215 if (this.weather) {216 if (this.weather.kind === zone.weather) return;217 this.dyingWeather.push(this.weather);218 this.weather = null;219 }220 if (zone.weather !== "none") {221 this.weather = new WeatherField(222 zone.weather,223 zone.mood.weatherColor ?? 0xffffff,224 );225 this.ctx.scene.add(this.weather.points);226 }227 }228229 /** Per-frame: advance blends + weather, aim the sun at the focus. */230 update(231 dt: number,232 focus: { center: THREE.Vector3; radius: number } | null,233 ): void {234 if (this.blend < 1) {235 this.blend = Math.min(1, this.blend + dt / BLEND_SECONDS);236 lerpSnap(this.from, this.to, smooth(this.blend), this.cur);237 this.apply();238 }239 if (focus) {240 this.ctx.sun.position.copy(focus.center).addScaledVector(241 this.cur.sunDir,242 focus.radius * 1.6 + 8,243 );244 this.ctx.sun.target.position.copy(focus.center);245 }246 const cam = this.ctx.camera.position;247 if (this.weather) this.weather.update(dt, cam, false);248 this.dyingWeather = this.dyingWeather.filter((w) => {249 if (!w.update(dt, cam, true)) return true;250 w.dispose(this.ctx.scene);251 return false;252 });253 }254255 private apply(): void {256 const c = this.cur;257 (this.ctx.scene.background as THREE.Color).copy(c.sky);258 this.fog.color.copy(c.sky);259 this.fog.near = c.fogNear;260 this.fog.far = c.fogFar;261 this.ctx.sun.color.copy(c.sunColor);262 this.ctx.sun.intensity = c.sunIntensity;263 this.ctx.hemi.color.copy(c.hemiSky);264 this.ctx.hemi.groundColor.copy(c.hemiGround);265 this.ctx.hemi.intensity = c.hemiIntensity;266 const starMat = this.stars.material as THREE.PointsMaterial;267 starMat.color.copy(c.starColor);268 starMat.opacity = c.starOpacity;269 }270271 dispose(): void {272 if (this.weather) this.weather.dispose(this.ctx.scene);273 for (const w of this.dyingWeather) w.dispose(this.ctx.scene);274 this.weather = null;275 this.dyingWeather = [];276 }277}278279function cloneColors(s: Snapshot): Partial<Snapshot> {280 return {281 sky: s.sky.clone(),282 sunColor: s.sunColor.clone(),283 hemiSky: s.hemiSky.clone(),284 hemiGround: s.hemiGround.clone(),285 starColor: s.starColor.clone(),286 sunDir: s.sunDir.clone(),287 };288}289290function smooth(t: number): number {291 return t * t * (3 - 2 * t);292}