diff --git a/BUILD.md b/BUILD.md index a55723c..74256bd 100644 --- a/BUILD.md +++ b/BUILD.md @@ -215,6 +215,14 @@ saved v10. Evaporate over warm seas -> advect along the wind -> condense -> rain weatherRainThresh 0.5 cloud cover above this rains weatherRainRate 0.5 /h rain rate from excess cloud weatherCloudDissip 0.12 /h cloud clearing + weatherSystemMax 8 max concurrent moving weather systems + weatherSpawnRate 0.06 /h genesis probability scale + weatherSystemSpeed 28 km/h drift speed of systems + weatherTropicalSST 26 C min sea-surface temp for tropical genesis + weatherSystemRadius 0.16 rad angular radius of a system's cloud/rain shield + weatherSystemCloud 1.2 /h cloud stamped at a system core + weatherSystemRain 1.6 /h rain at a system core + weatherHurricaneStr 0.6 strength above which a tropical system is a hurricane/typhoon ## Headless logic test (no display) diff --git a/CLAUDE.md b/CLAUDE.md index 9f13d7d..7adc79b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,13 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it. **advect** humidity & cloud along the prevailing wind → **condense** into cloud (extra on windward upslopes) → **rain** out → **dissipate**. Rendered as a translucent moving cloud shell (white → dark storm where it rains) over the globe + 2D map (key `K`). Saved (v10). - **Next:** tropical cyclones (hurricanes/typhoons) as moving vortex agents on top of this field. +- **Weather — moving systems (lows, hurricanes & typhoons)** *(done — see `PlanetWeather.cpp`)* — + drifting low-pressure **agents** (`WeatherSystem`) spawn over warm tropical seas / mid-latitude + oceans, travel with the steering wind (poleward recurve), intensify over warm water, decay over + land, and **stamp** travelling cloud/rain onto the grid — so the sky visibly evolves. The intense + tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker). + Transient (not saved; respawn from the seed). This makes the weather visibly move (the base field + alone relaxes to a static pattern). ## Current state @@ -392,6 +398,20 @@ Working and verified (logic tested headless): key `K` (default on); cell-info adds cloud/humidity/raining. **Saved v10** (humidity/cloud/rain, flag-gated; older saves spin weather up live). Deterministic (no RNG). `test_weather.cpp`: fields in range, clouds form + rain falls, oceans moister than land, determinism, v10 round-trip. +- **Live World — moving weather systems (lows / hurricanes / typhoons):** the base cloud/rain + field relaxes to a *static* pattern under fixed forcing, so `stepWeather` now also runs a + population of drifting **`WeatherSystem`** agents (PlanetTypes; transient, not saved; separate + `sWeatherRng` seeded from `cfg.seed` → tectonic determinism intact). Each step: **spawn** over + warm tropical ocean (5–25°, SST ≥ `weatherTropicalSST`) or a mid-latitude (30–62°) ocean low + (capped at `weatherSystemMax`, prob ∝ `weatherSpawnRate`); **move** along the steering wind + (`sWind` at the nearest cell) + a poleward recurve at `weatherSystemSpeed`; **intensify** over + warm sea / **decay+cull** over land/cold; **stamp** a Gaussian cloud/rain shield + (`weatherSystemCloud`/`Rain`, scaled by strength × local humidity) — so cloud clusters travel + and dissipate behind the system. A tropical system past `weatherHurricaneStr` is a + hurricane/typhoon. Render: an animated cyclonic **spiral marker** per system (red + eye for + cyclones, blue lows; spins with `liveTime`·hemisphere) in 3D + 2D, HUD system/cyclone counts, + and a storm list (basin-named) in the Sky & tides panel — all under `K`. `test_weather.cpp` + adds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism. - Mouse hover (in either view) shows per-cell info. Clicking a tile opens a right-side detail panel: tile info header + the tile's subgrid drawn as a flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res @@ -635,6 +655,11 @@ triangles (plates are fixed in phase 1). rain on mountains), `weatherRainThresh`/`weatherRainRate` (when/how fast thick cloud rains), `weatherCloudDissip` (cloud clearing). Toggle the overlay with `K`. Cloud render colours (white→storm, alpha) are constants in ViewerRender/Map2D. +- **Weather systems (`weather*` storm knobs, `planet.cfg`):** `weatherSystemMax` (concurrent + cap), `weatherSpawnRate` (genesis frequency), `weatherSystemSpeed` (km/h drift), + `weatherTropicalSST` (min SST for tropical genesis), `weatherSystemRadius` (cloud-shield size), + `weatherSystemCloud`/`weatherSystemRain` (stamp strength), `weatherHurricaneStr` (strength to + count as a hurricane/typhoon). Markers show under `K`. Tune these for a stormier or calmer world. - Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings` diff --git a/docs/design-notes.md b/docs/design-notes.md index 86bcac2..564a9bf 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -190,8 +190,17 @@ frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha = cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the 2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain, -flag-gated; pre-v10 saves spin weather up on entering Live World). Future: tropical cyclones -(moving vortex agents) layered on this field. +flag-gated; pre-v10 saves spin weather up on entering Live World). + +**Moving weather systems** (same `stepWeather`): the base field above relaxes to a *static* +pattern under fixed forcing, so a population of drifting `WeatherSystem` **agents** (world objects, +not cells — like moons; transient/not saved; separate `sWeatherRng` seeded from `cfg.seed`) +provides the motion. Each step they **spawn** over warm tropical ocean (5–25°) or a mid-latitude +(30–62°) ocean low, **move** along the steering wind (`sWind` at the nearest cell) + a poleward +recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** over land/cold, and +**stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate +behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as +animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`. ## Headless testing diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index 42d1ba9..a41a6a6 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -128,6 +128,37 @@ void Viewer::renderGlobe3D() { } rlEnd(); } + // Live World storm markers: an animated cyclonic spiral per weather system (hurricanes red + // with an eye; lows blue), spinning with the live clock by the system's hemisphere sense. + if (liveWorld && showClouds && !planet.storms().empty()) { + const float SR = visBase + 0.05f; + for (const auto& ws : planet.storms()) { + Vec3 p{ ws.pos.x, ws.pos.y, ws.pos.z }; + Vec3 u = p.cross(Vec3{0, 1, 0}); if (u.length() < 1e-6) u = p.cross(Vec3{1, 0, 0}); + u = u.normalized(); Vec3 v = p.cross(u).normalized(); + bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; + unsigned char cR = hur ? 240 : 150, cG = hur ? 60 : 200, cB = hur ? 60 : 235; + unsigned char A = (unsigned char)(110 + 140 * std::clamp(ws.strength, 0.0, 1.0)); + double rmax = 0.04 + 0.10 * ws.strength; + double phase = liveTime * ws.spin * 0.4; + rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(cR, cG, cB, A); + const int N = 36; const double turns = 2.2; + for (int arm = 0; arm < 2; ++arm) { + double a0 = phase + arm * M_PI; Vec3 prev{}; + for (int k = 0; k <= N; ++k) { + double t = (double)k / N; + double a = a0 + t * turns * 2.0 * M_PI * ws.spin; + Vec3 dir = u * std::cos(a) + v * std::sin(a); + Vec3 wp = (p + dir * (rmax * t)).normalized() * (double)SR; + if (k > 0) { rlVertex3f((float)prev.x, (float)prev.y, (float)prev.z); + rlVertex3f((float)wp.x, (float)wp.y, (float)wp.z); } + prev = wp; + } + } + rlEnd(); rlSetLineWidth(1.0f); + if (hur) { Vec3 e = p * (double)SR; DrawSphere(Vector3{(float)e.x,(float)e.y,(float)e.z}, 0.02f, Color{255,240,200,255}); } + } + } if (showGrat) drawGraticule3D(graticule, gratR); // Markers: selected (orange), hovered cell (yellow), hovered subcell (white). if (selectedCell >= 0) { @@ -221,6 +252,18 @@ void Viewer::renderMap2D() { if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, mapRect, mapLon); if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, mapRect, mapLon); if (liveWorld && showClouds && !planet.cloud().empty()) drawWeather2D(planet, planet.cloud(), planet.rain(), map2D, mapRect, mapLon); + if (liveWorld && showClouds && !planet.storms().empty()) { + for (const auto& ws : planet.storms()) { + double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat); + Vector2 sp = projLonLat(lon, lat, mapLon, mapRect); + bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; + Color c = hur ? Color{240, 60, 60, 255} : Color{150, 200, 235, 255}; + float rad = 5.0f + 10.0f * (float)ws.strength; + DrawCircleLines((int)sp.x, (int)sp.y, rad, c); + if (hur) DrawCircleLines((int)sp.x, (int)sp.y, rad * 0.55f, c); + DrawCircleV(sp, 2.0f, c); + } + } if (phase3 && showRivers) { drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, mapRect, mapLon); drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, mapRect, mapLon); @@ -330,6 +373,24 @@ void Viewer::renderLiveInfo() { } else { DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255}); } + + // Active weather systems (lows / tropical cyclones), named by basin. + y += 12; + DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 26; + const auto& storms = planet.storms(); + if (storms.empty()) DrawText("(calm — none active)", x, y, 15, Color{150, 155, 170, 255}); + int shown = 0; + for (const auto& ws : storms) { + if (shown >= 6 || y > (int)(r.y + r.height) - 22) break; + double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat); + bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; + const char* kind = hur ? (lon > -0.5 && lon < 2.4 ? "Typhoon" : "Hurricane") // W Pacific vs rest + : ws.tropical ? "Tropical low" : "Low"; + Color c = hur ? Color{240, 90, 80, 255} : Color{170, 200, 230, 255}; + DrawText(TextFormat("%s %.0f%% @ %+.0f,%+.0f", kind, ws.strength * 100.0, + lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c); + y += 21; ++shown; + } } // Right column: hover/selection info (top) + detail panel or world stats (bottom). @@ -382,6 +443,9 @@ void Viewer::renderHUD() { else { rl = "h/s"; rv = liveRate; } line(TextFormat("rate %.1f %s day/night %s ([ / ] speed, N toggle, W exit)", rv, rl, dayNightOn ? "on" : "off")); + int nStorm = 0, nHur = 0; + for (const auto& ws : planet.storms()) { ++nStorm; if (ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr) ++nHur; } + line(TextFormat("weather systems: %d tropical cyclones: %d", nStorm, nHur)); } else { line(TextFormat("%s %.1f My elapsed %.1f My/s%s", diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index f3f7254..c3108df 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -52,7 +52,7 @@ void Planet::buildGeometry() { sBiota.assign(cells.size(), {}); // empty biota population until generateBiota() sHasBiota = false; sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World - sHasWeather = false; + sHasWeather = false; sStorms.clear(); } void Planet::assignPlates() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index c6f6fbd..1c0a724 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -99,6 +99,7 @@ public: const std::vector& humidity() const { return sHumidity; } const std::vector& cloud() const { return sCloud; } const std::vector& rain() const { return sRain; } + const std::vector& storms() const { return sStorms; } // Phase 3 (biomes): classify every cell into a Biome from elevation + the climate // fields (temperature + normalized precipitation). Derived + written back into @@ -215,6 +216,10 @@ private: // Weather (Live World; saved v10). sHasWeather latches once spun up/loaded. std::vector sHumidity, sCloud, sRain; bool sHasWeather = false; + // Moving weather systems (transient agents; not saved). Separate RNG keeps tectonic + // determinism intact (seeded from cfg.seed in initWeather). + std::vector sStorms; + uint32_t sWeatherRng = 1; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // on-demand discrete population (saved). sHasBiota latches once generated/loaded. diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 24df8be..4e12bff 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -39,9 +39,11 @@ D(weatherEvapRate) D(weatherWindKmh) D(weatherSatBase) D(weatherSatTempCoef) \ D(weatherCondense) D(weatherOrographic) D(weatherRainThresh) D(weatherRainRate) \ D(weatherCloudDissip) \ + D(weatherSpawnRate) D(weatherSystemSpeed) D(weatherTropicalSST) D(weatherSystemRadius) \ + D(weatherSystemCloud) D(weatherSystemRain) D(weatherHurricaneStr) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ - I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \ + I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \ U(seed) @@ -203,6 +205,13 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.weatherRainThresh, 0.0, 1.5, "weatherRainThresh")); E(rng(cfg.weatherRainRate, 0.0, 50.0, "weatherRainRate")); E(rng(cfg.weatherCloudDissip, 0.0, 50.0, "weatherCloudDissip")); + E(rng(cfg.weatherSpawnRate, 0.0, 10.0, "weatherSpawnRate")); + E(rng(cfg.weatherSystemSpeed, 0.0, 500.0, "weatherSystemSpeed")); + E(rng(cfg.weatherTropicalSST, -10.0, 40.0, "weatherTropicalSST")); + E(rng(cfg.weatherSystemRadius, 0.01, 1.5, "weatherSystemRadius")); + E(rng(cfg.weatherSystemCloud, 0.0, 20.0, "weatherSystemCloud")); + E(rng(cfg.weatherSystemRain, 0.0, 20.0, "weatherSystemRain")); + E(rng(cfg.weatherHurricaneStr, 0.0, 1.0, "weatherHurricaneStr")); E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.beltWidth, 1, 12, "beltWidth")); @@ -215,6 +224,7 @@ std::string validateConfig(const PlanetConfig& cfg) { E(irng(cfg.climateWindPasses, 1, 1000, "climateWindPasses")); E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth")); E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings")); + E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); @@ -342,8 +352,10 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM } } } - // v10: Live World weather. Older saves leave it to spin up on entering Live World. + // v10: Live World weather. Older saves leave it to spin up on entering Live World. The moving + // weather systems are transient (not saved): clear them + reseed the weather RNG from the seed. sHasWeather = false; sHumidity.clear(); sCloud.clear(); sRain.clear(); + sStorms.clear(); sWeatherRng = cfg.seed ? (cfg.seed ^ 0x5701A123u) : 0x5701A123u; if (hasWeather) { uint8_t hasWx = 0; readPod(is, hasWx); if (hasWx) { diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 9ac82fd..5dd9a97 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -25,6 +25,20 @@ struct SubGrid { enum class PlateType { Oceanic, Continental }; +// A moving weather system (Live World): a drifting low-pressure disturbance that travels with the +// steering wind and stamps clouds & rain onto the weather fields. Geometry is fixed, so this is a +// world-object agent (a point on the sphere, like a moon), not a cell. The intense tropical ones +// (strength past weatherHurricaneStrength) are hurricanes/typhoons. Transient -- not saved. +struct WeatherSystem { + Vec3 pos; // unit position on the sphere + double strength = 0.0; // intensity 0..1 (drives cloud/rain boost + marker size) + double radius = 0.15; // angular radius (radians) + double age = 0.0; // hours alive + double life = 120.0; // total lifetime (hours) + double spin = 1.0; // cyclonic sense: +1 CCW (N hemisphere) / -1 CW (S) + bool tropical = false; // warm-core tropical (can become a cyclone) vs extratropical low +}; + // Phase-3 (climate & biomes) classification of a cell, derived from elevation, // latitude (temperature) and hydrology/coast (moisture). Stored per cell (uint8, // serialized) so Phase-4 civilization can read it. Keep Ocean == 0 so a default- @@ -271,4 +285,16 @@ struct PlanetConfig { double weatherRainThresh = 0.5; // cloud cover above this precipitates double weatherRainRate = 0.5; // /h: rain rate from excess cloud double weatherCloudDissip = 0.12; // /h: cloud clearing (half returns to humidity) + + // --- Weather systems (moving lows / hurricanes / typhoons) -- PlanetWeather.cpp --- + // Drifting low-pressure disturbances travel with the steering wind and stamp cloud/rain onto + // the grid, so the sky visibly evolves; the intense tropical ones become tropical cyclones. + int weatherSystemMax = 8; // max concurrent weather systems + double weatherSpawnRate = 0.06; // /h: genesis probability scale (when below the cap) + double weatherSystemSpeed = 28.0; // km/h: steering speed at which systems drift + double weatherTropicalSST = 26.0; // C: min sea-surface temp for tropical genesis + double weatherSystemRadius= 0.16; // rad: angular radius of a system's cloud/rain shield + double weatherSystemCloud = 1.2; // /h: cloud stamped at a system's core (scaled by strength) + double weatherSystemRain = 1.6; // /h: rain intensity at a system's core + double weatherHurricaneStr= 0.6; // strength above which a tropical system is a hurricane/typhoon }; diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index 2260b9a..9ac70f2 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -21,6 +21,8 @@ void Planet::initWeather() { if (cells[i].elevation <= sea) sHumidity[i] = 0.9; // saturated marine air else sHumidity[i] = haveM ? (0.2 + 0.5 * sMoist[i]) : 0.3; // land: from climatology } + sStorms.clear(); + sWeatherRng = cfg.seed ? (cfg.seed ^ 0x5701A123u) : 0x5701A123u; // separate RNG sHasWeather = true; } @@ -93,4 +95,92 @@ void Planet::stepWeather(double dtHours) { if (sHumidity[i] < 0.0) sHumidity[i] = 0.0; sCloud[i] = std::clamp(sCloud[i], 0.0, 1.5); } + + // 4. Moving weather systems (lows / hurricanes / typhoons). Drifting agents that travel with + // the steering wind and stamp cloud/rain onto the grid, so the sky visibly evolves. + const bool haveWind = ((int)sWind.size() == n); + auto wrnd = [&]() { uint32_t x = sWeatherRng; x ^= x << 13; x ^= x >> 17; x ^= x << 5; sWeatherRng = x; return x; }; + auto wrf = [&]() { return (wrnd() & 0xFFFFFFu) / double(0x1000000); }; + const Vec3 worldUp{0, 1, 0}; + const double D2R = M_PI / 180.0; + + // 4a. Genesis: over warm tropical ocean (5..25 deg) or a mid-latitude (30..62 deg) ocean low. + if ((int)sStorms.size() < cfg.weatherSystemMax) { + double pSpawn = 1.0 - std::exp(-cfg.weatherSpawnRate * dtHours); + if (wrf() < pSpawn) { + int bestIdx = -1; double bestScore = 0.0; bool bestTrop = false; + for (int t = 0; t < 8; ++t) { + int ci = (int)(wrnd() % (uint32_t)n); + if (cells[ci].elevation > sea) continue; + double absdeg = std::fabs(std::asin(std::clamp(cells[ci].unit.y, -1.0, 1.0))) / D2R; + double score = 0.0; bool trop = false; + if (absdeg > 5.0 && absdeg < 25.0 && sTemp[ci] >= cfg.weatherTropicalSST) { score = 0.6 + 0.4 * wrf(); trop = true; } + else if (absdeg >= 30.0 && absdeg <= 62.0) { score = 0.3 + 0.3 * wrf(); } + if (score > bestScore) { bestScore = score; bestIdx = ci; bestTrop = trop; } + } + if (bestIdx >= 0) { + WeatherSystem ws; + ws.pos = cells[bestIdx].unit; + ws.strength = 0.15; + ws.radius = cfg.weatherSystemRadius * (bestTrop ? 0.8 : 1.25); + ws.life = bestTrop ? (120.0 + 180.0 * wrf()) : (60.0 + 90.0 * wrf()); + ws.spin = (cells[bestIdx].unit.y >= 0.0) ? 1.0 : -1.0; + ws.tropical = bestTrop; + sStorms.push_back(ws); + } + } + } + + // 4b. Move, intensify and cull each system. + for (size_t s = 0; s < sStorms.size(); ) { + WeatherSystem& ws = sStorms[s]; + int nc = 0; double nd = -2.0; // nearest cell to the system + for (int i = 0; i < n; ++i) { double d = cells[i].unit.dot(ws.pos); if (d > nd) { nd = d; nc = i; } } + const Vec3& nrm = ws.pos; + Vec3 steer = (haveWind && sWind[nc].length() > 1e-9) ? sWind[nc].normalized() : Vec3{0, 0, 0}; + Vec3 northT = worldUp - nrm * worldUp.dot(nrm); double nl = northT.length(); + if (nl > 1e-9) northT = northT * (1.0 / nl); + double poleSign = (nrm.y >= 0.0) ? 1.0 : -1.0; + Vec3 vel = steer + northT * (poleSign * 0.35); // steering + poleward recurve + vel = vel - nrm * vel.dot(nrm); // keep tangent + double vl = vel.length(); + if (vl > 1e-9) { + double dAng = cfg.weatherSystemSpeed * 1000.0 * dtHours / std::max(1.0, cfg.radius); + Vec3 vdir = vel * (1.0 / vl); + ws.pos = (nrm * std::cos(dAng) + vdir * std::sin(dAng)).normalized(); + } + bool overWarmSea = (cells[nc].elevation <= sea) && (sTemp[nc] >= cfg.weatherTropicalSST - 4.0); + if (ws.tropical) { + if (overWarmSea) ws.strength += (1.0 - ws.strength) * (1.0 - std::exp(-0.05 * dtHours)); + else ws.strength -= ws.strength * (1.0 - std::exp(-0.15 * dtHours)); + } else { + double frac = std::min(1.0, ws.age / std::max(1.0, ws.life)); + ws.strength = 0.2 + 0.6 * std::sin(frac * M_PI); // rise then fade + if (cells[nc].elevation > sea) ws.strength *= 0.7; // weaker over land + } + ws.strength = std::clamp(ws.strength, 0.0, 1.0); + ws.age += dtHours; + if (ws.age > ws.life || (ws.tropical && ws.strength < 0.05 && cells[nc].elevation > sea)) { + sStorms[s] = sStorms.back(); sStorms.pop_back(); // swap-remove dead system + } else ++s; + } + + // 4c. Stamp each system's cloud/rain shield onto the grid (Gaussian-ish core falloff). + if (!sStorms.empty()) { + std::vector cosR(sStorms.size()); + for (size_t s = 0; s < sStorms.size(); ++s) cosR[s] = std::cos(std::min(M_PI, sStorms[s].radius)); + for (int i = 0; i < n; ++i) { + double moist = 0.3 + 0.7 * std::clamp(sHumidity[i], 0.0, 1.0); + for (size_t s = 0; s < sStorms.size(); ++s) { + double dot = cells[i].unit.dot(sStorms[s].pos); + if (dot < cosR[s]) continue; // outside the system radius + double d = std::acos(std::clamp(dot, -1.0, 1.0)); + double fall = 1.0 - d / sStorms[s].radius; fall *= fall; + double st = sStorms[s].strength; + sCloud[i] += st * fall * cfg.weatherSystemCloud * dtHours * moist; + sRain[i] += st * fall * cfg.weatherSystemRain * moist; + } + sCloud[i] = std::clamp(sCloud[i], 0.0, 1.5); + } + } } diff --git a/test_weather.cpp b/test_weather.cpp index e6937ca..756a6bf 100644 --- a/test_weather.cpp +++ b/test_weather.cpp @@ -22,11 +22,11 @@ static void check(bool cond, const char* what) { if (!cond) ++failures; } -// Run a fixed weather sequence on a planet (returns whether rain ever fell, max cloud seen). -static void runWeather(Planet& p, bool& everRained, double& maxCloud) { +// Run a fixed weather sequence on a planet (returns whether rain ever fell, max cloud + storms). +static void runWeather(Planet& p, bool& everRained, double& maxCloud, int& maxStorms) { p.initWeather(); - everRained = false; maxCloud = 0.0; - for (int k = 0; k < 200; ++k) { + everRained = false; maxCloud = 0.0; maxStorms = 0; + for (int k = 0; k < 300; ++k) { p.computeInsolation(0.25, std::fmod(0.3 + 0.01 * k, 1.0)); // sun advances p.stepWeather(1.0); // 1-hour steps const std::vector& rn = p.rain(); @@ -35,6 +35,7 @@ static void runWeather(Planet& p, bool& everRained, double& maxCloud) { if (rn[i] > 0.0) everRained = true; maxCloud = std::max(maxCloud, cl[i]); } + maxStorms = std::max(maxStorms, (int)p.storms().size()); } } @@ -44,8 +45,8 @@ int main() { const int n = (int)p.cells.size(); std::printf("Weather: cycle\n"); - bool rained = false; double maxCloud = 0.0; - runWeather(p, rained, maxCloud); + bool rained = false; double maxCloud = 0.0; int maxStorms = 0; + runWeather(p, rained, maxCloud, maxStorms); bool inRange = true; for (int i = 0; i < n; ++i) { @@ -67,14 +68,45 @@ int main() { std::printf(" mean humidity: ocean %.3f, land %.3f\n", oh, lh); check(oh > lh, "oceans end up moister than land"); + std::printf("Weather: moving systems\n"); + std::printf(" max concurrent systems: %d\n", maxStorms); + check(maxStorms > 0, "weather systems spawn over a run"); + if (!p.storms().empty()) { // cloud shield (no mutation of p) + const auto& ws = p.storms()[0]; + double inSum = 0, allSum = 0; int inN = 0; + for (int i = 0; i < n; ++i) { + allSum += p.cloud()[i]; + double d = std::acos(std::clamp(p.cells[i].unit.dot(ws.pos), -1.0, 1.0)); + if (d < ws.radius) { inSum += p.cloud()[i]; ++inN; } + } + check(inN > 0 && inSum / inN > allSum / n, "cloud is thicker inside a weather system"); + } + + std::printf("Weather: RNG isolation\n"); + Planet z; z.generate(cfg); + std::vector elev0(n); for (int i = 0; i < n; ++i) elev0[i] = z.cells[i].elevation; + z.initWeather(); + for (int k = 0; k < 60; ++k) { z.computeInsolation(0.25, std::fmod(0.3 + 0.01 * k, 1.0)); z.stepWeather(1.0); } + bool terrainSame = true; for (int i = 0; i < n; ++i) if (z.cells[i].elevation != elev0[i]) terrainSame = false; + check(terrainSame, "weather + storm RNG never perturb the terrain"); + std::printf("Weather: determinism\n"); Planet p2; p2.generate(cfg); - bool r2; double mc2; runWeather(p2, r2, mc2); - bool same = true; + bool r2; double mc2; int ms2; runWeather(p2, r2, mc2, ms2); // p and p2 both at 300 steps + bool same = ((int)p2.storms().size() == (int)p.storms().size()); for (int i = 0; i < n; ++i) if (p2.humidity()[i] != p.humidity()[i] || p2.cloud()[i] != p.cloud()[i] || p2.rain()[i] != p.rain()[i]) same = false; - check(same, "same seed + sequence -> identical weather"); + check(same, "same seed + sequence -> identical weather + systems"); + + std::printf("Weather: systems move\n"); + if (!p.storms().empty()) { // one more step -> a system shifts position + Vec3 before = p.storms()[0].pos; + p.computeInsolation(0.25, 0.61); p.stepWeather(1.0); + double best = -2.0; for (const auto& ws : p.storms()) best = std::max(best, before.dot(ws.pos)); + double ang = std::acos(std::clamp(best, -1.0, 1.0)); + check(ang > 1e-4 && ang < 0.3, "a weather system moves between steps"); + } std::printf("Weather: save v10\n"); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);