Compare commits

...

2 Commits

Author SHA1 Message Date
ca844a5d3f Live World weather: moving systems (lows, hurricanes & typhoons)
The base cloud/rain field relaxes to a static pattern under fixed forcing, so it looked
frozen. stepWeather now also runs a population of drifting WeatherSystem agents (world
objects, not cells; transient/not saved; separate sWeatherRng seeded from cfg.seed so
tectonic determinism is intact): spawn over warm tropical ocean (5-25 deg, SST gate) or a
mid-latitude ocean low (capped at weatherSystemMax); move along the steering wind (sWind at
the nearest cell) plus a poleward recurve at weatherSystemSpeed; intensify over warm sea,
decay and cull over land/cold; and stamp a Gaussian cloud/rain shield onto the grid so cloud
clusters travel and dissipate behind them. 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 by hemisphere) in 3D + 2D, HUD system/cyclone counts, and a basin-named
storm list in the Sky & tides panel -- all under K. New weather* storm knobs. test_weather.cpp
adds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism. All five
suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:40:58 +02:00
f84e06507a Live World weather: dynamic clouds & rain cycle (save v10)
A per-cell humidity/cloud/rain cycle advanced on the live clock (PlanetWeather.cpp,
raylib-free): evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing
wind (upwind differencing) -> condense into cloud (saturation vs temperature + windward
orographic lift) -> rain out thick cloud -> dissipate. Bounded exp-rate forms keep it stable at
any timestep, so it runs cleanly from hours/sec up to a month/sec. initWeather() spins the
fields up from the moisture climatology; fully deterministic (no RNG).

Render: a translucent cloud shell over the 3D globe (white -> dark slate where it rains,
alpha = cover) plus a matching drawWeather2D layer on the 2D map (shared drawMapTris
rasterizer), toggled with K (default on). stepSim runs stepWeather each live frame at the
sim-hours added to liveTime (held when paused). Cell-info shows cloud/humidity/raining.

Saved as v10 (humidity/cloud/rain, flag-gated; pre-v10 saves spin weather up live). New
weather* config knobs. Reseed/regen now also drops out of Live World. test_weather.cpp:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10
round-trip; the other four suites still pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:01:34 +02:00
17 changed files with 676 additions and 32 deletions

View File

@ -51,6 +51,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
N toggle the day/night terminator (Live World) N toggle the day/night terminator (Live World)
T toggle the tide-coloured coastline (Live World) T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue) O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
K toggle weather clouds/rain cover (Live World)
SPACE pause while forming / re-evolve once settled (or the on-screen button) SPACE pause while forming / re-evolve once settled (or the on-screen button)
[ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month) [ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month)
S single tectonic tick S single tectonic tick
@ -71,9 +72,9 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter; planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten). load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v9: +moons; v8 +Live World clock; planet.save binary snapshot (versioned, currently v10: +weather; v9 +moons; v8 +Live
v7 +biota): seed + config + full planet state; F5 writes it, F9 reloads and World clock; v7 +biota): seed + config + full planet state; F5 writes it, F9
resumes deterministically. As of v6 reloads and resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves planet.cfg), so adding/removing config fields no longer breaks saves
(unknown keys ignored, missing keys default). v6 cannot load pre-v6 (unknown keys ignored, missing keys default). v6 cannot load pre-v6
@ -202,18 +203,39 @@ moving snow line. axialTilt (above) drives the seasonal declination. Press W to
Moons (1-3, randomized in generateMoons() + saved v9): orbit on the live clock, raise the Moons (1-3, randomized in generateMoons() + saved v9): orbit on the live clock, raise the
tides with the sun, and render as small lit spheres with phases, orbit rings and eclipses. tides with the sun, and render as small lit spheres with phases, orbit rings and eclipses.
Weather (PlanetConfig, Live World): a dynamic clouds/rain cycle on the live clock (key K),
saved v10. Evaporate over warm seas -> advect along the wind -> condense -> rain -> dissipate.
weatherEvapRate 0.4 /h ocean evaporation toward marine saturation
weatherWindKmh 45 km/h wind speed advecting humidity/cloud
weatherSatBase 0.4 air saturation at 0 C (lower = cloudier)
weatherSatTempCoef 0.025 saturation rise per +1 C
weatherCondense 0.6 /h supersaturation -> cloud
weatherOrographic 0.0009 extra condensation per m of windward upslope
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) ## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
-o /tmp/t && /tmp/t src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# Biota / Live World / Ocean suites: same source list, swap test_logic.cpp -> # Biota / Live World / Ocean / Weather suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp or test_ocean.cpp # test_biota.cpp, test_live.cpp, test_ocean.cpp or test_weather.cpp
Verifies geometry, plate assignment, gradual non-saturating relief and Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step(). determinism. Run after changing Planet::step().

View File

@ -83,6 +83,18 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
**warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly **warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly
(`climateCurrentFactor`), so biomes shift naturally. Rendered as warm/cold **current arrows** (`climateCurrentFactor`), so biomes shift naturally. Rendered as warm/cold **current arrows**
over the sea (key `O`, 3D + 2D). This completes the Live World ocean/sky pass. over the sea (key `O`, 3D + 2D). This completes the Live World ocean/sky pass.
- **Weather — dynamic clouds & rain** *(done — see `PlanetWeather.cpp`)* — a per-cell
humidity/cloud/rain cycle advanced on the live clock: **evaporate** over warm sunlit seas →
**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).
- **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 ## Current state
@ -373,6 +385,33 @@ Working and verified (logic tested headless):
in `refreshView`. New knob `climateCurrentFactor` (4 °C). `test_ocean.cpp` adds: currents in `refreshView`. New knob `climateCurrentFactor` (4 °C). `test_ocean.cpp` adds: currents
tangent + zero on land + widespread, feedback bounded by the knob and produces both warming and tangent + zero on land + widespread, feedback bounded by the knob and produces both warming and
cooling, deterministic. Live-World ocean/sky pass complete. cooling, deterministic. Live-World ocean/sky pass complete.
- **Live World — dynamic weather (clouds & rain):** `Planet::stepWeather(dtHours)`
(PlanetWeather.cpp) advances a per-cell humidity/cloud/rain cycle on the live clock:
**evaporate** over warm sunlit ocean (uses `sInsolation`+`sTemp`), **advect** humidity & cloud
downwind (upwind differencing along `sWind`/`sUpwind`, `weatherWindKmh`), **condense** the
supersaturated air into cloud — saturation `weatherSatBase + weatherSatTempCoef·T`, plus
windward **orographic** lift — **rain** out cloud above `weatherRainThresh`, then **dissipate**.
`initWeather()` spins the fields up from the moisture climatology; bounded exponential rate
forms keep it stable at any timestep. Runs each live frame in `stepSim` (dt = the same sim-hours
added to `liveTime`; held when paused). Render: a translucent **cloud shell** (white → dark
storm where it rains, alpha = cover) over the 3D globe + a `drawWeather2D` layer on the 2D map,
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 (525°, SST ≥ `weatherTropicalSST`) or a mid-latitude (3062°) 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 - 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 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 flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
@ -405,7 +444,8 @@ src/
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3) PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetClimate.cpp computeClimate() (temperature + orographic precipitation) PlanetClimate.cpp computeClimate() (temperature + orographic precipitation)
PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons) PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons)
PlanetOcean.cpp moons (generate/orbit) + computeTides (Live World sky & tides) PlanetOcean.cpp moons (generate/orbit) + computeTides + computeOceanCurrents
PlanetWeather.cpp stepWeather (Live World dynamic clouds & rain cycle)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate) PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
@ -471,12 +511,12 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
-o /tmp/t && /tmp/t src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp` or `test_ocean.cpp` to run the (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp` or
Biota / Live World / Ocean suites — same source list.) `test_weather.cpp` to run the Biota / Live World / Ocean / Weather suites — same source list.)
Use this to verify tectonics after changing `Planet::step()` without launching Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -507,7 +547,8 @@ elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
active mode shown top-center of the globe) · active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3, `B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) · all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `SPACE` or on-screen button pause · `O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) · `[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled · `S` single tick · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga, `H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
@ -541,9 +582,11 @@ save header is versioned (currently **9**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
clock — a flag byte + `liveTime`, v9 appends the **moons** block); newer-than-supported is clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block —
humidity/cloud/rain, flag-gated); newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed. pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
saves spin weather up live.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults), config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves — written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —
@ -605,6 +648,18 @@ triangles (plates are fixed in phase 1).
`climateCurrentFactor` (4 °C) is the max coastal warming/cooling from ocean currents (0 = off; `climateCurrentFactor` (4 °C) is the max coastal warming/cooling from ocean currents (0 = off;
ocean-current arrows toggle with `O`). Current deflection angle + smoothing passes are ocean-current arrows toggle with `O`). Current deflection angle + smoothing passes are
constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config. constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config.
- **Weather (`weather*` in PlanetConfig / `planet.cfg`):** the Live World clouds/rain cycle —
`weatherEvapRate` (ocean evaporation speed), `weatherWindKmh` (advection speed of humidity/cloud),
`weatherSatBase`/`weatherSatTempCoef` (how much moisture the air holds vs temperature — lower
base = cloudier), `weatherCondense` (supersaturation→cloud rate), `weatherOrographic` (windward
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 - Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the
master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full 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` tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings`

View File

@ -28,6 +28,7 @@ add_executable(planetsim
src/sim/PlanetClimate.cpp src/sim/PlanetClimate.cpp
src/sim/PlanetLive.cpp src/sim/PlanetLive.cpp
src/sim/PlanetOcean.cpp src/sim/PlanetOcean.cpp
src/sim/PlanetWeather.cpp
src/sim/PlanetBiota.cpp src/sim/PlanetBiota.cpp
src/sim/PlanetFloraGen.cpp src/sim/PlanetFloraGen.cpp
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFaunaGen.cpp

View File

@ -34,7 +34,10 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
- `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live - `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
seasonal temperature; derived, not saved). seasonal temperature; derived, not saved).
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) + - `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
`computeTides()` (Live World sky & equilibrium tides). Moons are saved (v9); tides derived. `computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved
(v9); tides/currents derived.
- `PlanetWeather.cpp``initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle;
saved v10).
- `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate). - `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw + - `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga). `computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
@ -171,6 +174,34 @@ back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed
applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea
(`buildCurrents`, key `O`). Currents/feedback are derived (not saved). (`buildCurrents`, key `O`). Currents/feedback are derived (not saved).
## Weather (Live World dynamic clouds & rain)
`PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock
(`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over
warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation`
daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed
`weatherWindKmh`), **condense** the supersaturated air into cloud (saturation
`weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above
`weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded
`1exp(rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec).
`initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live
frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused).
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).
**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 (525°) or a mid-latitude
(3062°) 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 ## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run: Engine is raylib-free, so logic is tested without a display. Build/run:
@ -178,7 +209,7 @@ Engine is raylib-free, so logic is tested without a display. Build/run:
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \ src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \ src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp \ src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \ src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota.cpp uses the same source list (Biota suite). # test_biota.cpp uses the same source list (Biota suite).

View File

@ -29,8 +29,9 @@ Vector2 mapScreen(const Map2D& m, int idx, Rectangle r, double lonOffset) {
return projLonLat(m.lon[idx], m.lat[idx], lonOffset, r); return projLonLat(m.lon[idx], m.lat[idx], lonOffset, r);
} }
void drawMap2D(const Planet& p, const std::vector<Color>& vc, // Shared triangle rasterizer for the 2D map: calls colorAt(cellIndex) -> Color per vertex.
const Map2D& m, Rectangle r, double lonOffset) { template <typename ColorFn>
static void drawMapTris(const Planet& p, const Map2D& m, Rectangle r, double lonOffset, ColorFn colorAt) {
double hw = EqualEarth::halfWidth(); double hw = EqualEarth::halfWidth();
auto px = [&](double lon, double lat) -> float { auto px = [&](double lon, double lat) -> float {
double x, y; EqualEarth::forward(lon, lat, x, y); double x, y; EqualEarth::forward(lon, lat, x, y);
@ -47,7 +48,8 @@ void drawMap2D(const Planet& p, const std::vector<Color>& vc,
double mx = std::max({lo[0], lo[1], lo[2]}); double mx = std::max({lo[0], lo[1], lo[2]});
if (mx - mn <= M_PI) { // fast path (no wrap) if (mx - mn <= M_PI) { // fast path (no wrap)
for (int t = 0; t < 3; ++t) { for (int t = 0; t < 3; ++t) {
rlColor4ub(vc[v[t]].r, vc[v[t]].g, vc[v[t]].b, 255); Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(lo[t], m.lat[v[t]]), m.pos[v[t]].y); rlVertex2f(px(lo[t], m.lat[v[t]]), m.pos[v[t]].y);
} }
} else { // antimeridian seam } else { // antimeridian seam
@ -60,10 +62,28 @@ void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const double shift[3] = { 0.0, 2 * M_PI, -2 * M_PI }; // both edges; scissor clips const double shift[3] = { 0.0, 2 * M_PI, -2 * M_PI }; // both edges; scissor clips
for (double sh : shift) for (double sh : shift)
for (int t = 0; t < 3; ++t) { for (int t = 0; t < 3; ++t) {
rlColor4ub(vc[v[t]].r, vc[v[t]].g, vc[v[t]].b, 255); Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(ul[t] + sh, m.lat[v[t]]), m.pos[v[t]].y); rlVertex2f(px(ul[t] + sh, m.lat[v[t]]), m.pos[v[t]].y);
} }
} }
} }
rlEnd(); rlEnd();
} }
void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const Map2D& m, Rectangle r, double lonOffset) {
drawMapTris(p, m, r, lonOffset, [&](int i) { return Color{ vc[i].r, vc[i].g, vc[i].b, 255 }; });
}
void drawWeather2D(const Planet& p, const std::vector<double>& cloud, const std::vector<double>& rain,
const Map2D& m, Rectangle r, double lonOffset) {
if (cloud.empty()) return;
double maxR = 1e-6; for (double v : rain) maxR = std::max(maxR, v);
drawMapTris(p, m, r, lonOffset, [&](int i) -> Color {
double c = std::clamp(cloud[i], 0.0, 1.0);
double rain01 = std::clamp(rain[i] / maxR, 0.0, 1.0);
return Color{ (unsigned char)(245 - 150 * rain01), (unsigned char)(245 - 130 * rain01),
(unsigned char)(250 - 95 * rain01), (unsigned char)(c * 205.0) };
});
}

View File

@ -21,3 +21,8 @@ Vector2 mapScreen(const Map2D& m, int idx, Rectangle r, double lonOffset);
// lonOffset pans the map east/west (radians); y is unchanged by the pan. // lonOffset pans the map east/west (radians); y is unchanged by the pan.
void drawMap2D(const Planet& p, const std::vector<Color>& vc, void drawMap2D(const Planet& p, const std::vector<Color>& vc,
const Map2D& m, Rectangle r, double lonOffset); const Map2D& m, Rectangle r, double lonOffset);
// Translucent Live World cloud/rain layer over the 2D map (white -> dark storm where it rains;
// alpha = cloud cover). Same triangle iteration as drawMap2D but blended on top.
void drawWeather2D(const Planet& p, const std::vector<double>& cloud, const std::vector<double>& rain,
const Map2D& m, Rectangle r, double lonOffset);

View File

@ -64,6 +64,11 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
if (sized(p.tide())) if (sized(p.tide()))
L.push_back(std::string(TextFormat("tide %+.2f m (%s)", p.tide()[i], L.push_back(std::string(TextFormat("tide %+.2f m (%s)", p.tide()[i],
p.tide()[i] >= 0.0 ? "high" : "low"))); p.tide()[i] >= 0.0 ? "high" : "low")));
if (sized(p.cloud()))
L.push_back(std::string(TextFormat("weather: cloud %.0f%% humidity %.0f%%%s",
p.cloud()[i] * 100.0,
sized(p.humidity()) ? p.humidity()[i] * 100.0 : 0.0,
(sized(p.rain()) && p.rain()[i] > 0.02) ? " raining" : "")));
L.push_back(std::string(TextFormat("geoAge %.0f My neighbors %d", age, (int)c.neighbors.size()))); L.push_back(std::string(TextFormat("geoAge %.0f My neighbors %d", age, (int)c.neighbors.size())));
// Hydrology (derived; present once routeFlow()/hydrology() has run). // Hydrology (derived; present once routeFlow()/hydrology() has run).
if (sized(p.discharge()) && p.discharge()[i] > p.cfg.riverThreshold) if (sized(p.discharge()) && p.discharge()[i] > p.cfg.riverThreshold)

View File

@ -227,6 +227,7 @@ void Viewer::regenWorld() { // after generate(): geometry change
buildMap2D(planet, mapRect, map2D); buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear(); selectedCell = -1; subgrids.clear();
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false; settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; // reseed/regen drops back to World Creation
planet.drifting = false; // Phase 1: original forming behavior planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy; phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear(); rivers.clear(); bigRivers.clear();
@ -278,7 +279,7 @@ void Viewer::loadGame(const char* path) {
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw); if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: biome, v7: biota, v9: moons if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather
cfg = planet.cfg; // adopt the loaded config cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift planet.drifting = settled; // resume drift boosts iff mid-drift
@ -302,7 +303,8 @@ void Viewer::loadGame(const char* path) {
void Viewer::stepSim() { void Viewer::stepSim() {
if (liveWorld) { if (liveWorld) {
// --- Live World: advance the slow clock; geology is frozen -------- // --- Live World: advance the slow clock; geology is frozen --------
if (!paused) liveTime += liveRate * GetFrameTime(); // hours double dtH = (!paused) ? liveRate * GetFrameTime() : 0.0; // simulated hours this frame
liveTime += dtH;
double days = liveTime / planet.cfg.dayLengthHours; double days = liveTime / planet.cfg.dayLengthHours;
double dayOfYear01 = days / planet.cfg.yearLengthDays; double dayOfYear01 = days / planet.cfg.yearLengthDays;
dayOfYear01 -= std::floor(dayOfYear01); dayOfYear01 -= std::floor(dayOfYear01);
@ -319,6 +321,7 @@ void Viewer::stepSim() {
moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z }); moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z });
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z }); moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
} }
planet.stepWeather(dtH); // dynamic clouds & rain on the live clock
rebuildLiveOverlay(); rebuildLiveOverlay();
return; return;
} }

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 9; // v9: +moons; v8: +Live World clock; v7: +biota population; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 10; // v10: +weather; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save"; const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides) std::string configPath = "planet.cfg"; // initial config (--config overrides)
@ -90,6 +90,7 @@ struct Viewer {
bool showTides = false; // colour the coastline by the live tide level (key T) bool showTides = false; // colour the coastline by the live tide level (key T)
std::vector<Vector3> currentSegs; std::vector<Color> currentCols; // ocean-current arrows std::vector<Vector3> currentSegs; std::vector<Color> currentCols; // ocean-current arrows
bool showCurrents = false; // ocean current arrows, warm/cold (key O) bool showCurrents = false; // ocean current arrows, warm/cold (key O)
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
// Selection + subgrid (phase 4/5 preview). // Selection + subgrid (phase 4/5 preview).
int selectedCell = -1; int selectedCell = -1;

View File

@ -145,6 +145,7 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); } if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); } if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled) if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled)

View File

@ -103,6 +103,62 @@ void Viewer::renderGlobe3D() {
} }
rlEnd(); rlSetLineWidth(1.0f); rlEnd(); rlSetLineWidth(1.0f);
} }
// Live World weather: a translucent cloud shell over the globe (white -> dark storm where it
// rains), alpha = cloud cover. Drawn as a second triangle layer just above the terrain.
if (liveWorld && showClouds && !planet.cloud().empty()) {
const std::vector<double>& cl = planet.cloud();
const std::vector<double>& rn = planet.rain();
double maxR = 1e-6; for (double r : rn) maxR = std::max(maxR, r);
const std::vector<int>& ctri = planet.triIndices();
const float cr = visBase + 0.03f;
rlBegin(RL_TRIANGLES);
for (size_t k = 0; k + 2 < ctri.size(); k += 3) {
for (int j = 0; j < 3; ++j) {
int idx = ctri[k + j];
double c = std::clamp(cl[idx], 0.0, 1.0);
double rain01 = std::clamp(rn[idx] / maxR, 0.0, 1.0);
unsigned char R = (unsigned char)(245 - 150 * rain01); // white -> slate
unsigned char G = (unsigned char)(245 - 130 * rain01);
unsigned char B = (unsigned char)(250 - 95 * rain01);
unsigned char A = (unsigned char)(std::clamp(c, 0.0, 1.0) * 205.0);
const Vec3& u = planet.cells[idx].unit;
rlColor4ub(R, G, B, A);
rlVertex3f((float)(u.x * cr), (float)(u.y * cr), (float)(u.z * cr));
}
}
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); if (showGrat) drawGraticule3D(graticule, gratR);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white). // Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
if (selectedCell >= 0) { if (selectedCell >= 0) {
@ -195,6 +251,19 @@ void Viewer::renderMap2D() {
if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, mapRect, mapLon); if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, mapRect, mapLon);
if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, mapRect, mapLon); if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, mapRect, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, 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) { if (phase3 && showRivers) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, mapRect, mapLon); drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, mapRect, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, mapRect, mapLon); drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, mapRect, mapLon);
@ -304,6 +373,24 @@ void Viewer::renderLiveInfo() {
} else { } else {
DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255}); 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). // Right column: hover/selection info (top) + detail panel or world stats (bottom).
@ -356,6 +443,9 @@ void Viewer::renderHUD() {
else { rl = "h/s"; rv = liveRate; } else { rl = "h/s"; rv = liveRate; }
line(TextFormat("rate %.1f %s day/night %s ([ / ] speed, N toggle, W exit)", line(TextFormat("rate %.1f %s day/night %s ([ / ] speed, N toggle, W exit)",
rv, rl, dayNightOn ? "on" : "off")); 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 { else {
line(TextFormat("%s %.1f My elapsed %.1f My/s%s", line(TextFormat("%s %.1f My elapsed %.1f My/s%s",
@ -374,8 +464,8 @@ void Viewer::renderHUD() {
y += 8; y += 8;
line("hover: cell info | click tile: open detail panel | C close"); line("hover: cell info | click tile: open detail panel | C close");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)"); line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]", line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off")); showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-", line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg"); line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");

View File

@ -51,6 +51,8 @@ void Planet::buildGeometry() {
} }
sBiota.assign(cells.size(), {}); // empty biota population until generateBiota() sBiota.assign(cells.size(), {}); // empty biota population until generateBiota()
sHasBiota = false; sHasBiota = false;
sHumidity.clear(); sCloud.clear(); sRain.clear(); // weather spins up on entering Live World
sHasWeather = false; sStorms.clear();
} }
void Planet::assignPlates() { void Planet::assignPlates() {

View File

@ -89,6 +89,18 @@ public:
void computeOceanCurrents(); void computeOceanCurrents();
const std::vector<Vec3>& current() const { return sCurrent; } const std::vector<Vec3>& current() const { return sCurrent; }
// Weather (Live World): dynamic per-cell humidity / cloud cover / rain advanced on the live
// clock. initWeather() spins the fields up from the climatology; stepWeather(dtHours) runs
// one cycle (evaporate over warm seas -> advect along the wind -> condense into cloud, with
// orographic lift -> rain out -> dissipate). Reads sInsolation/sTemp/sWind/sUpwind/sMoist
// (computeClimate + computeInsolation set those). Saved (v10).
void initWeather();
void stepWeather(double dtHours);
const std::vector<double>& humidity() const { return sHumidity; }
const std::vector<double>& cloud() const { return sCloud; }
const std::vector<double>& rain() const { return sRain; }
const std::vector<WeatherSystem>& storms() const { return sStorms; }
// Phase 3 (biomes): classify every cell into a Biome from elevation + the climate // Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into // fields (temperature + normalized precipitation). Derived + written back into
// cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves. // cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves.
@ -124,8 +136,10 @@ public:
// older saves (v3) pass false -- biomes are reclassified after the cells load. // older saves (v3) pass false -- biomes are reclassified after the cells load.
// hasBiota: whether the stream carries the biota population block (save v7+). // hasBiota: whether the stream carries the biota population block (save v7+).
// hasMoons: whether the stream carries the moons block (save v9+); older saves // hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead. // synthesize moons from the seed instead. hasWeather: the weather block (save v10+);
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true); // older saves leave weather to spin up on entering Live World.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -199,6 +213,13 @@ private:
// sTide is the equilibrium tidal height (m) from the moons + sun. // sTide is the equilibrium tidal height (m) from the moons + sun.
std::vector<double> sInsolation, sLiveTemp, sTide; std::vector<double> sInsolation, sLiveTemp, sTide;
std::vector<Vec3> sCurrent; // ocean surface current velocity (tangent; zero on land) std::vector<Vec3> sCurrent; // ocean surface current velocity (tangent; zero on land)
// Weather (Live World; saved v10). sHasWeather latches once spun up/loaded.
std::vector<double> 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<WeatherSystem> sStorms;
uint32_t sWeatherRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded. // on-demand discrete population (saved). sHasBiota latches once generated/loaded.

View File

@ -36,9 +36,14 @@
D(bioFungaTempMin) D(bioRegionBonus) \ D(bioFungaTempMin) D(bioRegionBonus) \
D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \ D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \
D(tideAmplitude) D(tideSunFactor) \ D(tideAmplitude) D(tideSunFactor) \
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(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ 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(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \
U(seed) U(seed)
@ -191,6 +196,22 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.seaIceTemp, -60.0, 20.0, "seaIceTemp")); E(rng(cfg.seaIceTemp, -60.0, 20.0, "seaIceTemp"));
E(rng(cfg.tideAmplitude, 0.0, 100.0, "tideAmplitude")); E(rng(cfg.tideAmplitude, 0.0, 100.0, "tideAmplitude"));
E(rng(cfg.tideSunFactor, 0.0, 5.0, "tideSunFactor")); E(rng(cfg.tideSunFactor, 0.0, 5.0, "tideSunFactor"));
E(rng(cfg.weatherEvapRate, 0.0, 50.0, "weatherEvapRate"));
E(rng(cfg.weatherWindKmh, 0.0, 1000.0, "weatherWindKmh"));
E(rng(cfg.weatherSatBase, 0.01, 5.0, "weatherSatBase"));
E(rng(cfg.weatherSatTempCoef, 0.0, 1.0, "weatherSatTempCoef"));
E(rng(cfg.weatherCondense, 0.0, 50.0, "weatherCondense"));
E(rng(cfg.weatherOrographic, 0.0, 1.0, "weatherOrographic"));
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.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -203,6 +224,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.climateWindPasses, 1, 1000, "climateWindPasses")); E(irng(cfg.climateWindPasses, 1, 1000, "climateWindPasses"));
E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth")); E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth"));
E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings")); E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings"));
E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
@ -279,9 +301,12 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga); writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga);
} }
} }
// v10: Live World weather (humidity/cloud/rain). Flag-gated like biota.
uint8_t hasWx = (sHasWeather && sHumidity.size() == cells.size()) ? 1 : 0; writePod(os, hasWx);
if (hasWx) { writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain); }
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons) { bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather) {
// Read the length-prefixed key=value config block (see writeState). A default // Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their // PlanetConfig is parsed over, so fields absent from an older save keep their
// current defaults. The length guard rejects pre-v6 (raw-POD-config) saves. // current defaults. The length guard rejects pre-v6 (raw-POD-config) saves.
@ -327,6 +352,18 @@ 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. 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) {
readVec(is, sHumidity); readVec(is, sCloud); readVec(is, sRain);
if (!is || (int)sHumidity.size() != (int)cells.size()) return false;
sHasWeather = true;
}
}
computeBiotaDensity(); // derived density scalars for the colour views computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -25,6 +25,20 @@ struct SubGrid {
enum class PlateType { Oceanic, Continental }; 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, // Phase-3 (climate & biomes) classification of a cell, derived from elevation,
// latitude (temperature) and hydrology/coast (moisture). Stored per cell (uint8, // latitude (temperature) and hydrology/coast (moisture). Stored per cell (uint8,
// serialized) so Phase-4 civilization can read it. Keep Ocean == 0 so a default- // serialized) so Phase-4 civilization can read it. Keep Ocean == 0 so a default-
@ -257,4 +271,30 @@ struct PlanetConfig {
double seaIceTemp = -2.0; // C: ocean below the live temperature shows sea ice double seaIceTemp = -2.0; // C: ocean below the live temperature shows sea ice
double tideAmplitude = 0.6; // m: equilibrium-tide scale per unit tide-raising weight double tideAmplitude = 0.6; // m: equilibrium-tide scale per unit tide-raising weight
double tideSunFactor = 0.46; // sun's tide weight relative to a unit moon (Earth ~0.46) double tideSunFactor = 0.46; // sun's tide weight relative to a unit moon (Earth ~0.46)
// --- Weather (Live World dynamic clouds & rain) -- see PlanetWeather.cpp -----
// A per-cell humidity/cloud/rain cycle advanced on the live clock: evaporate over warm
// sunlit seas, advect along the prevailing wind, condense into cloud (more on windward
// upslopes), rain out, and dissipate. Rates are per simulated hour.
double weatherEvapRate = 0.4; // /h: ocean evaporation toward marine saturation
double weatherWindKmh = 45.0; // km/h: prevailing wind speed for advecting humidity/cloud
double weatherSatBase = 0.4; // air saturation humidity at 0 C (warmer air holds more)
double weatherSatTempCoef = 0.025; // saturation rise per +1 C
double weatherCondense = 0.6; // /h: fraction of supersaturation that becomes cloud
double weatherOrographic = 0.0009; // extra condensation per m of windward upslope
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
}; };

186
src/sim/PlanetWeather.cpp Normal file
View File

@ -0,0 +1,186 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
// Live World weather: a dynamic per-cell humidity / cloud / rain cycle advanced on the live
// clock (geometry fixed -- these are fields flowed over the grid, like climate, but time-varying).
// One step: evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing wind
// -> condense the supersaturated air into cloud (extra on windward upslopes) -> rain out the
// thick cloud -> dissipate. Reads the static climate scaffolding (sTemp/sWind/sUpwind/sMoist set
// by computeClimate) and the live sInsolation (computeInsolation). Deterministic; saved (v10).
void Planet::initWeather() {
const int n = (int)cells.size();
sHumidity.assign(n, 0.0);
sCloud.assign(n, 0.0);
sRain.assign(n, 0.0);
const double sea = cfg.seaLevel;
const bool haveM = ((int)sMoist.size() == n);
for (int i = 0; i < n; ++i) {
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;
}
void Planet::stepWeather(double dtHours) {
const int n = (int)cells.size();
if (!sHasWeather || (int)sHumidity.size() != n || (int)sCloud.size() != n || (int)sRain.size() != n)
initWeather();
if (dtHours <= 0.0) return; // paused: hold the current sky
if ((int)sTemp.size() != n) return; // need the climate fields
const double sea = cfg.seaLevel;
auto isOcean = [&](int i) { return cells[i].elevation <= sea; };
const double cw = std::max(1.0, cellWidthMeters());
double advFrac = std::clamp(cfg.weatherWindKmh * 1000.0 * dtHours / cw, 0.0, 1.0);
const bool haveSun = ((int)sInsolation.size() == n);
const bool haveUp = ((int)sUpwind.size() == n);
// 1. Advect humidity downwind (upwind differencing) + evaporate over warm sunlit ocean.
std::vector<double> nh(n);
for (int i = 0; i < n; ++i) {
double hUp = (haveUp && sUpwind[i] >= 0) ? sHumidity[sUpwind[i]] : sHumidity[i];
double h = sHumidity[i] * (1.0 - advFrac) + hUp * advFrac;
if (isOcean(i)) {
double tf = std::clamp((sTemp[i] + 2.0) / 30.0, 0.0, 1.0); // warm seas evaporate more
double sun = haveSun ? (0.5 + 0.5 * sInsolation[i]) : 0.7; // daytime boost
double target = 0.55 + 0.45 * tf; // marine humidity target
double rate = 1.0 - std::exp(-cfg.weatherEvapRate * sun * dtHours);
if (target > h) h += (target - h) * rate;
}
nh[i] = h;
}
sHumidity.swap(nh);
// 2. Advect cloud (it drifts with the wind too).
std::vector<double> nc(n);
for (int i = 0; i < n; ++i) {
double cUp = (haveUp && sUpwind[i] >= 0) ? sCloud[sUpwind[i]] : sCloud[i];
nc[i] = sCloud[i] * (1.0 - advFrac) + cUp * advFrac;
}
sCloud.swap(nc);
// 3. Condense (saturation + orographic lift) -> rain -> dissipate, per cell.
const double condR = 1.0 - std::exp(-cfg.weatherCondense * dtHours);
const double rainR = 1.0 - std::exp(-cfg.weatherRainRate * dtHours);
const double dissR = 1.0 - std::exp(-cfg.weatherCloudDissip * dtHours);
const double invDt = 1.0 / dtHours;
for (int i = 0; i < n; ++i) {
double sat = std::max(0.05, cfg.weatherSatBase + cfg.weatherSatTempCoef * std::max(0.0, sTemp[i]));
double cond = 0.0;
double excess = sHumidity[i] - sat;
if (excess > 0.0) cond += excess * condR; // convective/thermal
if (haveUp && sUpwind[i] >= 0) { // orographic (windward)
double up = cells[i].elevation - cells[sUpwind[i]].elevation;
if (up > 0.0) cond += sHumidity[i] * std::min(1.0, up * cfg.weatherOrographic) * condR;
}
cond = std::min(cond, sHumidity[i]);
sHumidity[i] -= cond;
sCloud[i] += cond;
double rain = 0.0;
if (sCloud[i] > cfg.weatherRainThresh) {
rain = (sCloud[i] - cfg.weatherRainThresh) * rainR;
sCloud[i] -= rain;
}
double diss = sCloud[i] * dissR;
sCloud[i] -= diss;
sHumidity[i] += diss * 0.5; // half re-evaporates
sRain[i] = rain * invDt; // intensity (per hour)
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<double> 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);
}
}
}

124
test_weather.cpp Normal file
View File

@ -0,0 +1,124 @@
// Headless test for the Live World weather cycle (humidity / cloud / rain). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_weather.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
// src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
// src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/tw && /tmp/tw
//
// Verifies: fields stay in range; oceans (the evaporation source) end up moister than land;
// clouds form and rain falls somewhere; the cycle is deterministic; and save v10 round-trips it.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
// 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; 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<double>& rn = p.rain();
const std::vector<double>& cl = p.cloud();
for (size_t i = 0; i < rn.size(); ++i) {
if (rn[i] > 0.0) everRained = true;
maxCloud = std::max(maxCloud, cl[i]);
}
maxStorms = std::max(maxStorms, (int)p.storms().size());
}
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 1337;
Planet p; p.generate(cfg);
const int n = (int)p.cells.size();
std::printf("Weather: cycle\n");
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) {
if (p.humidity()[i] < -1e-9) inRange = false;
if (p.cloud()[i] < -1e-9 || p.cloud()[i] > 1.5 + 1e-9) inRange = false;
if (p.rain()[i] < -1e-9) inRange = false;
}
check(inRange, "humidity/cloud/rain stay in range");
check(maxCloud > 0.05, "clouds form");
check(rained, "rain falls somewhere");
// Oceans are the moisture source -> moister than land on average.
double oh = 0, lh = 0; int oc = 0, lc = 0;
for (int i = 0; i < n; ++i) {
if (p.cells[i].elevation <= cfg.seaLevel) { oh += p.humidity()[i]; ++oc; }
else { lh += p.humidity()[i]; ++lc; }
}
oh /= std::max(1, oc); lh /= std::max(1, lc);
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<double> 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; 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 + 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);
p.writeState(ss);
Planet q;
bool ok = q.readState(ss, true, true, true, true);
bool rt = ok && (int)q.cloud().size() == n;
for (int i = 0; i < n && rt; ++i)
if (q.humidity()[i] != p.humidity()[i] || q.cloud()[i] != p.cloud()[i] || q.rain()[i] != p.rain()[i])
rt = false;
check(rt, "save v10 round-trips the weather state");
std::printf(failures ? "\nSOME WEATHER CHECKS FAILED (%d)\n" : "\nALL WEATHER CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}