Compare commits

...

4 Commits

Author SHA1 Message Date
aa32e38dad Live World stage: clock, day/night, seasons, moons, tides & ocean currents
Adds the slow real-time "Live World" mode (key W on a settled world) that runs the
finished planet on an hours->weeks/months clock (liveTime/liveRate, [ / ] ramp the rate)
with geology frozen. Everything new is a derived per-cell field flowed over the fixed grid.

Day/night & seasons (PlanetLive.cpp): a raylib-free per-cell insolation field
(computeInsolation) drives a moving day/night terminator (N) from time-of-day rotation +
seasonal declination (axialTilt); a live seasonal temperature cycles the static summer/winter
fields over the year (computeLiveSeason); a moving snow/sea-ice line tracks it. Day/night +
snow are render overlays over any colour mode (3D + 2D). Save v8 stores the live clock.

Sky & tides (PlanetOcean.cpp): 1-3 random moons (separate RNG, saved v9) orbit on the
clock and, with the now small/distant sun, raise an equilibrium tide (computeTides -> sTide),
shown as a tide-coloured coastline (T, buildCoastline + tideColor). Moons render with sun-lit
phases, orbit rings and eclipses (solar shadow spot in the day/night overlay, lunar dimming).
The 2D map is left-aligned; the freed space holds a Live-World "Sky & tides" panel (per-moon
phase + a selected coastal tile's tidal phase).

Ocean currents + climate feedback (PlanetOcean.cpp): computeOceanCurrents builds a
per-ocean-cell tangent velocity from wind stress + Coriolis deflection + coast-following
(gyres); computeClimate feeds warm (poleward) / cold (equatorward) currents back into sTemp as
a bounded coastal anomaly (climateCurrentFactor) before seasons, so biomes shift. Rendered as
warm/cold current arrows (O).

Config: dayLengthHours/yearLengthDays/snowTemp/seaIceTemp/tideAmplitude/tideSunFactor/
climateCurrentFactor. Save header v7->v9 (version-gated; older saves load fine). Headless
test_live.cpp + test_ocean.cpp; test_logic/test_biota still pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:04:45 +02:00
8ed9ae4515 Seasons (obliquity): per-cell summer/winter temps + richer cold biomes
axialTilt was visual-only ("groundwork for seasons"). It now drives climate.

computeClimate() adds derived sTempSummer/sTempWinter around the annual mean:
  summer/winter = sTemp +/- A,  A = seasonAmpMax * sin(tilt)/sin(23.44) *
                                    latShape * continentality
- tiltFactor: 0 tilt -> no seasons, Earth tilt -> 1.
- latShape (pow(|lat|/90, seasonLatExp)): poles swing most.
- continentality: a multi-source BFS ring-distance from ocean cells -- oceans
  and coasts are muted by thermal inertia, interiors swing most.
Result: ~0 swing at the equatorial coast, large at high-latitude interiors.

classifyBiomes() blends WINTER temp into the Tundra/Taiga cold cutoffs via
biomeSeasonWeight (0 = annual-mean only = unchanged biomes; default 0.6), so
cold-winter continental interiors become boreal/tundra (Siberia effect). The
amplitude is geographically shaped, so cold biomes expand only where seasons
bite. Fields are derived/not-saved -> no save-format change.

Viewer: color key 6 now CYCLES Temperature -> summer -> winter -> seasonality
(new seasonColor ramp + labels); cell-info shows summer/winter. New season* +
biomeSeasonWeight config knobs (planet.cfg, validated). Docs updated.

Headless (test/season): summer >= mean >= winter; equator swing ~1.6 C vs
~20 C at high latitude; interior land >> ocean; tilt=0 -> no seasons; higher
tilt -> bigger swing; biomeSeasonWeight=0 leaves biomes unchanged; cold-biome
count rises with seasons; deterministic. test_logic + test_biota pass; full
app builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:25:45 +02:00
708b23d774 Show more rivers (riverThreshold 50->25) + remove globe subgrid overlay
Two fixes from user feedback on the hydrology view:

1. Rivers looked too sparse. Investigated coupling rainfall into discharge
   (climate precip -> river rainfall): it does NOT work as hoped because river
   *location* is set by drainage topology (where water collects), not local
   rainfall -- weighting rainfall mostly changes river *size*, and concentrating
   a fixed water budget pushes mid-size rivers below the display threshold, so
   FEWER rivers show. Reverted that approach. Instead lower the default
   riverThreshold 50 -> 25 so tributaries render too: a richer, more visible
   network (~2x river cells on test worlds) without descending into noise.
   Tunable in planet.cfg.

2. Clicking a tile overlaid a low-res, always-elevation-coloured subgrid patch
   on the 3D globe that clashed with the active colour mode and read as a
   "strange pattern". Removed the globe overlay; the clicked tile's high-res
   subgrid still shows in the right-side detail panel.

test_logic + test_biota pass; full app builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:04:49 +02:00
4aacfdabdd Soft probabilistic peak cap: spread mountain heights, drop the 9000 m plateau
Drift-time peaks used to rail into the hard elevation clamp and flatten into a
9000 m plateau (the clamp lived in step(), erode() AND hydrology(), so erosion
re-flattened them every tick). Replace the hard ceiling with a probabilistic
soft cap: above peakSoftCapStart (7000 m) the chance a tick's uplift "takes"
falls linearly to 0 at peakSoftCapEnd (12000 m); a lost grow roll forfeits the
uplift and shaves a random 0..peakFailDrop (200 m) off. Peaks now spread smoothly
across a height band (strong orogeny reaches ~10-11 km, most cluster lower) with
zero cells pinned at the ceiling.

- The roll is a pure hash of (cellIndex, erodeIter, seed): never touches
  rngState, bit-identical across OpenMP thread counts, and since erodeIter is
  saved (step/erode run 1:1 in drift) F5/F9 resumes bit-identical -- no save bump.
- Drift-only (gated on Planet::drifting) so Phase-1 forming still auto-settles.
- The hard clamp's upper bound now tracks peakSoftCapEnd in all three places
  (step/erode/hydrology); lower -11000 m unchanged.
- New planet.cfg knobs peakSoftCapStart / peakSoftCapEnd / peakFailDrop with
  validation (+ start < end cross-rule). Docs updated (CLAUDE.md, BUILD.md).

Verified headless: smooth 8.5->10.5 km taper, 0 pinned, determinism + exact
resume intact, test_logic + test_biota pass, full app builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:26:38 +02:00
27 changed files with 1533 additions and 65 deletions

View File

@ -47,8 +47,12 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
J toggle rivers (Phase 2.5 hydrology) J toggle rivers (Phase 2.5 hydrology)
H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion) H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion)
L generate biota population (flora/fauna/funga; settled world; re-press regenerates) L generate biota population (flora/fauna/funga; settled world; re-press regenerates)
W enter / leave Live World (slow real-time clock; settled world)
N toggle the day/night terminator (Live World)
T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
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 per real second, Phase 2/3) [ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month)
S single tectonic tick S single tectonic tick
F fast-forward Phase-1 forming to settled (instant) F fast-forward Phase-1 forming to settled (instant)
R reseed planet (restart forming) R reseed planet (restart forming)
@ -67,8 +71,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 v6): seed + config + full planet planet.save binary snapshot (versioned, currently v9: +moons; v8 +Live World clock;
state; F5 writes it, F9 reloads and resumes deterministically. As of v6 v7 +biota): seed + config + full planet state; F5 writes it, F9 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
@ -99,6 +104,9 @@ Phase 1 forms as before; tall mountains grow during Phase-2 drift.
arcFactor 1.4 continental subduction-arc uplift (Andes) arcFactor 1.4 continental subduction-arc uplift (Andes)
isostaticPersist 0.85 how strongly high crust resists relax (0..<1) isostaticPersist 0.85 how strongly high crust resists relax (0..<1)
rootScale 2500 m above continentBase where persistence saturates rootScale 2500 m above continentBase where persistence saturates
peakSoftCapStart 7000 m below this a tick's uplift always takes (P=1)
peakSoftCapEnd 12000 m at/above this uplift never takes (P=0); also the elev clamp
peakFailDrop 200 m max random drop when the grow roll loses (drift-only)
ridgeDepth -2500 m shallow elevation of brand-new crust at a ridge ridgeDepth -2500 m shallow elevation of brand-new crust at a ridge
seafloorSubsidence 280 m per sqrt(My): seafloor deepens with crustal age seafloorSubsidence 280 m per sqrt(My): seafloor deepens with crustal age
seafloorSeedAge 80 My initial oceanic age spread at generation seafloorSeedAge 80 My initial oceanic age spread at generation
@ -110,7 +118,7 @@ Drift keeps running at a finer timestep during hydrology. (Config keys keep the
phase3AfterMy 300 My drift before the "Start Phase 2.5?" prompt phase3AfterMy 300 My drift before the "Start Phase 2.5?" prompt
phase3DtScale 0.2 hydrology timestep = cflDtMy()*this (finer = slower drift) phase3DtScale 0.2 hydrology timestep = cflDtMy()*this (finer = slower drift)
rainfall 1.0 uniform precip per cell (drainage-area unit) rainfall 1.0 uniform precip per cell (drainage-area unit)
riverThreshold 50 discharge above which a cell is a river riverThreshold 25 discharge above which a cell is a river (lower = richer network)
riverIncision 0.02 K in stream-power incision K*Q^m*S^n*dt (raise = carve faster) riverIncision 0.02 K in stream-power incision K*Q^m*S^n*dt (raise = carve faster)
riverDischargeExp 0.5 m (discharge exponent) riverDischargeExp 0.5 m (discharge exponent)
riverSlopeExp 1.0 n (slope exponent) riverSlopeExp 1.0 n (slope exponent)
@ -137,6 +145,15 @@ Biomes (PlanetConfig, Phase 3): per-cell biome classification thresholds. Temper
biomeGrassMoist 0.50 moisture below this -> Grassland/Savanna, else Forest biomeGrassMoist 0.50 moisture below this -> Grassland/Savanna, else Forest
biomeTaigaMoist 0.40 cool + above this -> Taiga (else Tundra) biomeTaigaMoist 0.40 cool + above this -> Taiga (else Tundra)
biomeLakeMinDepth 20 m filled-basin depth above sea level counting as a Lake biomeLakeMinDepth 20 m filled-basin depth above sea level counting as a Lake
biomeSeasonWeight 0.6 winter temp weight in the Tundra/Taiga cutoffs (0 = annual mean only)
Seasons (PlanetConfig): axialTilt (above) drives per-cell summer/winter temps; color key 6
cycles temperature -> summer -> winter -> seasonality.
seasonAmpMax 18 C max seasonal half-range at full tilt/latitude/interior
seasonLatExp 1.2 latitude shape exponent (>1 pushes swing toward the poles)
seasonOceanFactor 0.15 ocean/coast seasonal-swing floor (thermal inertia)
seasonContinentRings 6 ocean-distance rings to reach full continentality (1 ~ 223 km)
Climate (PlanetConfig, Phase 3): temperature uses the biome* temp params above; Climate (PlanetConfig, Phase 3): temperature uses the biome* temp params above;
precipitation advects ocean moisture along zonal winds (windward rain, leeward rain precipitation advects ocean moisture along zonal winds (windward rain, leeward rain
@ -149,6 +166,7 @@ shadow, dry interiors) then diffuses it. Color modes 6 (temperature) / 7 (precip
climateContinentality 0.05 fractional moisture lost per inland cell (dries interiors) climateContinentality 0.05 fractional moisture lost per inland cell (dries interiors)
climateWindPasses 50 moisture-advection iterations (steady state) climateWindPasses 50 moisture-advection iterations (steady state)
climateMoistureSmooth 12 precipitation diffusion passes (raise = smoother, more grass/forest) climateMoistureSmooth 12 precipitation diffusion passes (raise = smoother, more grass/forest)
climateCurrentFactor 4 C max coastal warming/cooling from ocean currents (0 = off; O shows arrows)
Biota (PlanetConfig): flora/fauna/funga. Density scalars drive color modes 8/9/0; Biota (PlanetConfig): flora/fauna/funga. Density scalars drive color modes 8/9/0;
the discrete slot/point population is generated on demand (L) and saved (v7). the discrete slot/point population is generated on demand (L) and saved (v7).
@ -170,17 +188,32 @@ the discrete slot/point population is generated on demand (L) and saved (v7).
bioFaunaPoints 16 fauna point budget at full density bioFaunaPoints 16 fauna point budget at full density
bioFungaPoints 14 funga point budget at full density bioFungaPoints 14 funga point budget at full density
Live World (PlanetConfig): the slow real-time clock (hours -> weeks/months) over the
finished planet -- a moving day/night terminator, a live seasonal temperature cycle and a
moving snow line. axialTilt (above) drives the seasonal declination. Press W to enter.
dayLengthHours 24 hours in one planetary day (rotation -> day/night)
yearLengthDays 365.25 days in one planetary year (orbit -> seasons)
snowTemp 0 C land below the live temperature shows snow
seaIceTemp -2 C ocean below the live temperature shows sea ice
tideAmplitude 0.6 m equilibrium-tide scale per unit tide-raising weight
tideSunFactor 0.46 sun's tide weight relative to a unit moon (Earth ~0.46)
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.
## 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/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t -o /tmp/t && /tmp/t
# Biota suite: same source list, swap test_logic.cpp -> test_biota.cpp # Biota / Live World / Ocean suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp or test_ocean.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().

176
CLAUDE.md
View File

@ -47,16 +47,42 @@ dynamic weather and life.
generated **on demand** (`L`) and **saved** (save v7). Fauna is a herbivore/carnivore/ generated **on demand** (`L`) and **saved** (save v7). Fauna is a herbivore/carnivore/
omnivore food chain (predators gated on local prey); funga uses a flora-like but omnivore food chain (predators gated on local prey); funga uses a flora-like but
moisture/organic-matter-led rule. The living/evolving ecosystem is reserved for Live World. moisture/organic-matter-led rule. The living/evolving ecosystem is reserved for Live World.
Other follow-ups: feed precipitation into hydrology rainfall; seasons (obliquity). - **Seasons (obliquity)** *(done)*`axialTilt` drives per-cell summer/winter temperatures
(`computeClimate`: `sTempSummer`/`sTempWinter` = annual mean ± a tilt/latitude/continentality
amplitude); winter temp feeds the Tundra/Taiga biome cutoffs (`biomeSeasonWeight`). Static
fields (the live yearly cycle is reserved for Live World). Color key `6` cycles the temp views.
> Durable design context (module layout, save format, climate/biome model, conventions) > Durable design context (module layout, save format, climate/biome model, conventions)
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not > lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
> travel with the repo. > travel with the repo.
**Live World (future):** with planet creation finished, run the world at a slow real-time **Live World (in progress):** with planet creation finished, run the world at a slow real-time
scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living
**ecosystems / civilization** evolving in real time. This is a separate large effort; **ecosystems / civilization** evolving in real time. This is a separate large effort;
the fixed-grid Eulerian model + the climate fields are the groundwork for it. the fixed-grid Eulerian model + the climate fields are the groundwork for it.
- **Clock + day/night groundwork** *(done — see `PlanetLive.cpp`)* — a slow real-time clock
(hours → weeks/months, `liveTime`/`liveRate`, key `W` to enter once settled, `[`/`]` ramp
the rate), a moving **day/night terminator** (sun from time-of-day rotation + seasonal
declination via `axialTilt`; key `N`), a raylib-free per-cell **insolation field**
(`computeInsolation`, the weather hook), a **live seasonal temperature** cycling the
static summer/winter fields over the year (`computeLiveSeason`), and a **moving snow / sea-ice
line**. Day/night + snow are render overlays over any colour mode (3D + 2D). Save **v8** adds
the live-clock state. Still **future:** actual weather, day/night temperature swing,
precipitation/evaporation, living/evolving ecosystems.
- **Moons + tides + distant sun** *(done — see `PlanetOcean.cpp`)***13 moons** generated per
world from a separate RNG (no tectonic perturbation) and **saved (v9)**; they orbit on the live
clock and, with the sun, raise an **equilibrium tide** (`computeTides``sTide`, two bulges via
`cosθ²⅓`, semidiurnal). Tides show as a **tide-coloured coastline** (`buildCoastline` + a
diverging `tideColor`, key `T`, 3D + 2D). The 3D **sun** is now small + far with a faint halo;
**moons** render with sun-lit **phases** (offset-dark-sphere) + faint **orbit rings**, plus
**eclipses** — solar (a moon transiting the sun darkens a shadow spot in the day/night overlay),
lunar (a moon in the planet's shadow dims red). Knobs `tideAmplitude`/`tideSunFactor`.
- **Ocean currents (+ climate feedback)** *(done — see `PlanetOcean.cpp`)*`computeOceanCurrents()`
builds a per-ocean-cell tangent velocity from wind stress (`sWind`) + Coriolis deflection
(right N / left S) + coast-following (gyres) + smoothing. `computeClimate()` calls it and feeds
**warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly
(`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.
## Current state ## Current state
@ -252,6 +278,20 @@ Working and verified (logic tested headless):
runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config
knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior, knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior,
deserts present, deterministic. deserts present, deterministic.
- **Seasons (obliquity):** `axialTilt` (previously visual-only) now drives a per-cell seasonal
temperature range. `computeClimate()` adds derived `sTempSummer`/`sTempWinter` (= annual mean
`sTemp` ± a half-amplitude `A = seasonAmpMax·tiltFactor·latShape·continentality`), where
`tiltFactor = sin(axialTilt)/sin(23.44°)` (0 tilt → no seasons) and **continentality** comes
from a multi-source BFS ring-distance from ocean cells (coasts/oceans muted by thermal
inertia, interiors swing most). Big swings at high-latitude continental interiors, ~0 at the
equatorial coast. `classifyBiomes()` blends **winter** temp into the Tundra/Taiga cold cutoffs
via `biomeSeasonWeight` (0 = annual-mean-only/old behaviour, default 0.6) so cold-winter
interiors turn boreal/tundra (Siberia effect) — the amplitude is geographically shaped, so
this expands cold biomes only where seasons bite. Derived/not-saved (no save bump). Color key
`6` now **cycles** mean→summer→winter→seasonality; cell-info shows summer/winter. New `season*`
+ `biomeSeasonWeight` config knobs. Headless: equator swing ≈1.6 °C vs ≈20 °C at high latitude,
interior land ≫ ocean, tilt=0 → no seasons, higher tilt → bigger swing, `biomeSeasonWeight=0`
leaves biomes unchanged, cold-biome count rises with seasons, deterministic.
- **UI polish (full cell info + view label + framing):** the cell-info panel - **UI polish (full cell info + view label + framing):** the cell-info panel
(`cellInfo`, src/render/Panels.cpp) now shows everything per cell — crust type, **biome** (`cellInfo`, src/render/Panels.cpp) now shows everything per cell — crust type, **biome**
(`biomeName`), **temperature** + **precipitation %**, and **river/lake** when hydrology is (`biomeName`), **temperature** + **precipitation %**, and **river/lake** when hydrology is
@ -282,6 +322,54 @@ Working and verified (logic tested headless):
organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna≤ organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna≤
capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip, capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip,
pre-v7 loads empty. v7 reads v6-and-older (no biota block → empty population; press `L`). pre-v7 loads empty. v7 reads v6-and-older (no biota block → empty population; press `L`).
- **Live World — clock + day/night + live seasons + snow line:** the first **Live World** stage
(the slow real-time arc after World Creation). Engine (`src/sim/PlanetLive.cpp`, raylib-free,
derived/not-saved): `computeInsolation(dayOfYear01, timeOfDay01)``sInsolation` (0..1 cosine
solar incidence; declination `axialTilt·sin(2π·doy)`, sub-solar longitude sweeps once per day;
the foundation the future weather sim reads) and `computeLiveSeason(doy)``sLiveTemp` (the
annual-mean `sTemp` swung toward the existing `summerTemp`/`winterTemp` by the seasonal phase,
anti-phased across hemispheres). Viewer: key `W` (settled world) toggles **Live World** — drift
freezes and `liveTime` advances at `liveRate` (sim hours/real-second), `[`/`]` ramp it
hour→month; the HUD shows a `Year/Day/HH:MM` calendar (`dayLengthHours`/`yearLengthDays`).
`rebuildLiveOverlay()` builds a per-cell day/night brightness (`illum`, soft terminator, dim
night floor) + `shadedColors` (base colour → snow on cold land / sea-ice on cold ocean via
`snowTemp`/`seaIceTemp` → day/night dim); both the 3D globe and 2D map draw `displayColors()`
(the overlay over **any** colour mode), `N` toggles the terminator, a sun marker sits over the
lit hemisphere. Cell-info adds a `live temp / day-night / snow` line. Save **v8** appends the
Live World flag + `liveTime` (version-gated; older saves load with it off). New `PlanetLive.cpp`
in CMake + the headless list; `test_live.cpp`: insolation range, lit/dark hemispheres,
declination tracks `axialTilt` (polar day/night at solstice), live temp within the
summer/winter band + anti-phased, snow line advances in winter, determinism.
- **Live World — moons, tides & a distant sun:** engine `src/sim/PlanetOcean.cpp` (raylib-free):
`generateMoons()` seeds **13 moons** (`Moon` struct in PlanetTypes) from a separate RNG
(`cfg.seed ^ 0x900D5EED`, tectonic stream untouched); `sunDirection`/`moonDirection`/
`moonOrbitNormal` give model-space sky geometry (one source of truth — `computeInsolation` now
calls `sunDirection`). `computeTides(doy,tod,days)``sTide` (m), equilibrium two-bulge tide
(`Σ w·(cosθ²⅓)`, moons + sun weighted `tideSunFactor`, scaled `tideAmplitude`); derived/not
saved. Viewer: `T` colours the **coastline** (`buildCoastline` dual-contour + `tideColor`
diverging amber↔cyan, per-segment in 3D + `drawColoredSegments2D` in 2D), auto-scaled to the
tide extent; `stepSim` computes tides + `moonDirs`/`moonNormals` each live frame. 3D render:
small **distant sun** (`sunDist`≈9) + halo; **moons** at a visible orbit band with sun-lit
**phase** (offset-dark-sphere), faint **orbit rings**, and **eclipses** — solar shadow folded
into `rebuildLiveOverlay`'s `illum` near the sub-solar point, lunar dimming (reddish) when a
moon is in the planet's shadow. Cell-info adds a tide line; stats shows the moon count. **Save
v9** appends the moons block (`writeState`/`readState(...,hasMoons)`; pre-v9 saves synthesize
moons from the seed). `test_ocean.cpp`: moon count/determinism + RNG isolation, unit sweeping
sky dirs, zero-mean two-bulge tide (high under moon + antipode, low at 90°, moves with time),
save v9 round-trip.
- **Live World — ocean currents + climate feedback:** `Planet::computeOceanCurrents()`
(PlanetOcean.cpp) builds a per-ocean-cell tangent velocity `sCurrent` (derived/not saved):
wind stress (`sWind`) rotated by a **Coriolis** deflection (right N / left S about the cell
normal), the across-shore component removed at land neighbours so flow **follows coasts**
(gyres), then 3 smoothing passes (re-projected to the tangent plane; zero on land).
`computeClimate()` calls it right after the wind pass and feeds it back: a **bounded coastal
temperature anomaly** = `climateCurrentFactor · (poleward speed / max)` on ocean cells (warm
poleward, cold equatorward), smoothed onto the coasts and added to `sTemp` **before** seasons,
so summer/winter + biomes shift with it. Render: `buildCurrents` emits subsampled warm/cold
**arrows** over the sea (warm = poleward/red, cold = equatorward/blue), key `O` (3D + 2D), built
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
cooling, deterministic. Live-World ocean/sky pass complete.
- 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
@ -313,6 +401,8 @@ src/
PlanetErosion.cpp erode() + adjustSeaLevel() PlanetErosion.cpp erode() + adjustSeaLevel()
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)
PlanetOcean.cpp moons (generate/orbit) + computeTides (Live World sky & tides)
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
@ -377,12 +467,13 @@ raylib 5.5 is fetched automatically — do not vendor it.
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/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp` to run the Biota suite — same source list.) (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp` or `test_ocean.cpp` to run the
Biota / Live World / Ocean 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
@ -409,12 +500,16 @@ LMB drag orbit · wheel zoom · hover for cell info (3D or map) ·
click a tile to open its detail panel (subtiles) · `C` close panel · click a tile to open its detail panel (subtiles) · `C` close panel ·
drag the 2D map to pan it east/west · `1`..`0` color by drag the 2D map to pan it east/west · `1`..`0` color by
elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
(`8`/`9`/`0` = biota density; active mode shown top-center of the globe) · (`8`/`9`/`0` = biota density; `6` **cycles** temperature → summer → winter → seasonality;
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) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) · 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 ·
`[`/`]` 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,
on a settled world; re-press regenerates) · `R` reseed · on a settled world; re-press regenerates) · `W` enter/leave **Live World** (settled world) ·
`R` reseed ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load · `+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate. `F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
@ -423,6 +518,13 @@ Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes
and fluvial erosion evolve. and fluvial erosion evolve.
Live World (`W`, on a settled world): geological drift freezes and a slow real-time clock runs
(`liveTime` in hours, `liveRate` = sim hours/real-second, ramped hour→month with `[`/`]`). A
moving day/night terminator (`N`), a live seasonal temperature cycle and a moving snow/sea-ice
line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar.
**13 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant
sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). file (both applied before the initial load/generate).
@ -432,11 +534,13 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **7**; v2 adds the `[`/`]` drift rate, v3 a 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); newer-than-supported is block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
rejected. Older saves (no biota block) load fine with an empty population (press `L`). clock — a flag byte + `liveTime`, v9 appends the **moons** block); newer-than-supported is
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.
**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 —
@ -444,8 +548,10 @@ a one-time break; a length guard makes that fail gracefully.) `writeConfigFields
`parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save. `parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save.
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
1344x648) + 2D Equal Earth map (bottom, 40% h); right column 30% wide = cell info 1344x648) + 2D Equal Earth map (bottom, 40% h, **left-aligned**, with the freed space at
(top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the its right holding the Live-World **"Sky & tides" panel** — `liveInfoRect`, `renderLiveInfo`:
per-moon phase discs + a selected coastal tile's tidal phase); right column 30% wide = cell
info (top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full 3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan). screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the
@ -493,6 +599,15 @@ triangles (plates are fixed in phase 1).
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes → `climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`, wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params. `climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
`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
constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config.
- 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`
(6, ocean-distance to full continentality; lower = coasts go continental sooner),
`seasonOceanFactor` (0.15, ocean/coast swing floor). `biomeSeasonWeight` (0.6) sets how much
winter temp drives the Tundra/Taiga cutoffs (0 = annual-mean only, restores pre-seasons biomes).
- Biota (`bio*` in PlanetConfig / `planet.cfg`) — density: `bioVegTempMin`/`bioVegTempOpt`/ - Biota (`bio*` in PlanetConfig / `planet.cfg`) — density: `bioVegTempMin`/`bioVegTempOpt`/
`bioVegMoistRef` (flora temp/moisture limits), `bioFaunaProductivity` (animals per unit `bioVegMoistRef` (flora temp/moisture limits), `bioFaunaProductivity` (animals per unit
flora), `bioCarnPreyMin`/`bioCarnScale` (carnivore prey gate + ramp), `bioFungaMoistRef`/ flora), `bioCarnPreyMin`/`bioCarnScale` (carnivore prey gate + ramp), `bioFungaMoistRef`/
@ -502,6 +617,18 @@ triangles (plates are fixed in phase 1).
it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours → it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours →
homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp
(append-only — indices are serialized in v7 saves). (append-only — indices are serialized in v7 saves).
- **Live World (`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp`, `planet.cfg`):**
`dayLengthHours` (24) sets the day/night period (and the `d/s` rate unit), `yearLengthDays`
(365.25) the season period; `axialTilt` drives the seasonal declination (0 = no day/night
tilt). `snowTemp` (0 °C) is the land snow line, `seaIceTemp` (-2 °C) the ocean sea-ice line —
raise either to grow the white caps. Render constants (night-floor brightness, terminator
softness, snow-blend ramp) live in `rebuildLiveOverlay()` (src/render/Viewer.cpp), not config.
- **Moons & tides (`tideAmplitude`/`tideSunFactor`, `planet.cfg`):** `tideAmplitude` (0.6 m)
scales the equilibrium tide per unit tide-raising weight (raise for a more dramatic coastline
swing); `tideSunFactor` (0.46) is the sun's tide weight vs a unit moon. Moon count (13) and
per-moon orbit/period/inclination/mass are randomized in `generateMoons()` (PlanetOcean.cpp);
the 3D sun distance/size, moon orbit-render band and eclipse angles are render constants
(ViewerRender.cpp / `rebuildLiveOverlay`), not config.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks
@ -535,9 +662,24 @@ triangles (plates are fixed in phase 1).
toward 1 = ranges barely erode and continents stay high; lower = more dynamic toward 1 = ranges barely erode and continents stay high; lower = more dynamic
rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three
boosts are **drift-only** (gated on `Planet::drifting`); in Phase-2 drift the boosts are **drift-only** (gated on `Planet::drifting`); in Phase-2 drift the
per-tick `erode()` is what limits their height, so they don't rail the clamp. The per-tick `erode()` is what limits their height, so they don't rail the clamp.
hard clamp `[-11000, 9000]` in `step()` is the Everest-class cap; a few peak - **Soft peak cap (drift-only) — no 9000 m plateau:** previously the hard
cells may sit there during drift (<2% fine). `[-11000, 9000]` clamp (in `step()`, `erode()` *and* `hydrology()`) flattened many
drift-time peaks into a 9000 m plateau. Now growth is **probabilistic** above
`peakSoftCapStart` (7000 m): the chance a tick's positive uplift "takes" falls
linearly to 0 at `peakSoftCapEnd` (12000 m), so peaks spread smoothly across a
height band instead of railing at one ceiling. A **lost** grow roll forfeits that
tick's uplift *and* shaves a random `0..peakFailDrop` (200 m) off, so a peak hovers
near its own height (height now tracks orogeny strength: strong seeds reach
1011 km, most cluster lower). The hard clamp's **upper bound now tracks
`peakSoftCapEnd`** in all three places (just a safety rail; lower 11000 m
unchanged). The roll is a **pure hash of `(cellIndex, erodeIter, seed)`** — never
touches `rngState`, stays bit-identical across OpenMP thread counts, and since
`erodeIter` is saved (step/erode run 1:1 in drift) F5/F9 resumes **bit-identical**
(no save-version bump). **Drift-only** (gated on `drifting`) so Phase-1 forming
still auto-settles. Tune `peakSoftCapStart` / `peakSoftCapEnd` / `peakFailDrop` in
`planet.cfg`. Verified headless: smooth 8.5→10.5 km taper, 0 cells pinned at the
ceiling, determinism + exact resume intact.
- **Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):** `oceanBase` is now the - **Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):** `oceanBase` is now the
**deep abyssal floor** (-6000 m, not a flat ocean base) and `ridgeDepth` the **deep abyssal floor** (-6000 m, not a flat ocean base) and `ridgeDepth` the
shallow young value (-2500 m); `seafloorSubsidence` (280 m per sqrt(My)) sets how shallow young value (-2500 m); `seafloorSubsidence` (280 m per sqrt(My)) sets how
@ -548,7 +690,7 @@ triangles (plates are fixed in phase 1).
before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep = before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep =
`cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall` `cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall`
(1.0) — uniform precip per cell (drainage-area unit; orographic precip is a later (1.0) — uniform precip per cell (drainage-area unit; orographic precip is a later
climate add-on); `riverThreshold` (50) — discharge above which a cell is a river climate add-on); `riverThreshold` (25) — discharge above which a cell is a river
(also the river-render threshold); `riverIncision` K (0.02) + `riverDischargeExp` (also the river-render threshold); `riverIncision` K (0.02) + `riverDischargeExp`
m (0.5) + `riverSlopeExp` n (1.0) — stream-power incision `K*Q^m*S^n*dt` (raise K m (0.5) + `riverSlopeExp` n (1.0) — stream-power incision `K*Q^m*S^n*dt` (raise K
for faster valley carving); `riverTransport` (0.1) — transport capacity for faster valley carving); `riverTransport` (0.1) — transport capacity

View File

@ -26,6 +26,8 @@ add_executable(planetsim
src/sim/PlanetHydrology.cpp src/sim/PlanetHydrology.cpp
src/sim/PlanetBiomes.cpp src/sim/PlanetBiomes.cpp
src/sim/PlanetClimate.cpp src/sim/PlanetClimate.cpp
src/sim/PlanetLive.cpp
src/sim/PlanetOcean.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

@ -31,6 +31,10 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
- `PlanetHydrology.cpp``routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes, - `PlanetHydrology.cpp``routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes,
steepest-descent→rivers, mass-conserving stream-power incision). steepest-descent→rivers, mass-conserving stream-power incision).
- `PlanetClimate.cpp``computeClimate()` (temperature + orographic precipitation). - `PlanetClimate.cpp``computeClimate()` (temperature + orographic precipitation).
- `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
seasonal temperature; derived, not saved).
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
`computeTides()` (Live World sky & equilibrium tides). Moons are saved (v9); tides derived.
- `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).
@ -92,7 +96,15 @@ get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seede
`computeClimate()` builds two derived per-cell fields: `computeClimate()` builds two derived per-cell fields:
- **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so - **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so
cold concentrates at poles) `biomeElevLapse` × elevation. cold concentrates at poles) `biomeElevLapse` × elevation. This is the **annual mean**; the
**Seasons** pass adds derived `sTempSummer`/`sTempWinter` = mean ± `A`, where the seasonal
half-amplitude `A = seasonAmpMax · sin(axialTilt)/sin(23.44°) · latShape · continentality`.
Continentality is a multi-source-BFS ring distance from ocean cells (oceans/coasts muted by
thermal inertia; interiors swing most). `classifyBiomes()` blends winter temp into the
Tundra/Taiga cold cutoffs via `biomeSeasonWeight` (0 = mean only → unchanged biomes), so
cold-winter continental interiors turn boreal/tundra. Seasonal fields are derived/not-saved.
**Ocean currents** add a bounded coastal warm/cold anomaly to this mean before the seasons pass
(`climateCurrentFactor`; see the Ocean section).
- **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean - **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean
cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains
out more on windward upslopes (orographic), loses a multiplicative fraction per cell out more on windward upslopes (orographic), loses a multiplicative fraction per cell
@ -104,6 +116,61 @@ get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seede
deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome & deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome &
climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`. climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`.
## Live World (slow real-time clock) — day/night + live seasons (derived, not saved)
The arc after World Creation: the finished planet runs on a slow real-time clock instead of
the geological My clock. `PlanetLive.cpp` (raylib-free) builds two derived per-cell fields,
recomputed each frame like climate (never saved):
- `computeInsolation(dayOfYear01, timeOfDay01)``sInsolation` ∈ [0,1], the instantaneous
solar incidence `max(0, cell.unit · sunDir)`. `sunDir = lonLatToDir(λ, δ)` with declination
`δ = axialTilt·sin(2π·dayOfYear01)` (0 at equinox, ±tilt at solstice → polar day/night) and
sub-solar longitude `λ = π·(12·timeOfDay01)` sweeping once per day. **This is the hook the
future weather sim reads** (daytime heating). Computed in **model space** (the fixed cell
units) so it stays consistent with both the tilted 3D globe (the lit pattern rotates with the
globe; the seasonal lean is carried by `δ`, not the render tilt) and the model-space 2D map.
- `computeLiveSeason(dayOfYear01)``sLiveTemp`, the annual-mean `sTemp` swung toward the
static `summerTemp`/`winterTemp` by the seasonal phase `g = sin(2π·doy)·sign(lat)`
(`liveTemp = mean + A·g`, `A = (summerwinter)/2`), anti-phased across hemispheres.
Viewer (Eulerian, geometry fixed — all overlays are per-cell render passes): key `W` (settled
world) toggles `liveWorld`; drift freezes and `liveTime` (hours) advances at `liveRate` (sim
hours/real-second, ramped hour→month with `[`/`]`). `rebuildLiveOverlay()` builds `illum` (soft
day/night terminator over `sInsolation`, dim night floor) + `shadedColors` (base colour → snow
on cold land / sea-ice on cold ocean via `snowTemp`/`seaIceTemp` → day/night dim); both the 3D
globe and 2D map draw `displayColors()` (the overlay over **any** colour mode). `N` toggles the
terminator. Save **v8** appends a Live World flag + `liveTime` (header, version-gated). Knobs:
`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp` in `planet.cfg`.
## Moons & tides (Live World sky/oceans)
`PlanetOcean.cpp` (raylib-free): `generateMoons()` seeds **13 `Moon`s** from a **separate RNG**
(`cfg.seed ^ 0x900D5EED`) so it never touches the tectonic `rngState` — moons are world objects
(not cells) and are **saved (v9)** via `writeState`/`readState(..., hasMoons)` (pre-v9 saves
synthesize them from the seed). Sky geometry is one source of truth: `sunDirection(doy,tod)` =
celestial dir leaned by declination then spun `-2π·tod` about +Y; `moonDirection(i,tod,days)` =
inclined orbit circle `Ω=2π·days/period+phase` then the same spin (so a fixed cell sees ≈one
lunar pass/day). `computeInsolation` now calls `sunDirection`. **Tides** (`computeTides`
`sTide`, derived/not saved): equilibrium two-bulge potential `Σ_body w·(cosθ²⅓)` over the moons
(weight `tideWeight`) + sun (`tideSunFactor`), scaled `tideAmplitude` — zero-mean, high under a
body and its antipode, low at 90°, sweeping ≈twice/day.
Render (Viewer): the coastline is traced once per terrain change (`buildCoastline`, dual-contour
on the land/ocean split, recording the adjacent ocean cell per segment) and coloured by
`tideColor(sTide[oceanCell])` (`T`; auto-scaled), in 3D + 2D. The 3D sun is small/distant with a
halo; moons render at a visible orbit band with a sun-lit **phase** (offset-dark-sphere trick),
faint **orbit rings** (great circle ⟂ `moonOrbitNormal`), and **eclipses** — solar darkens a spot
in `rebuildLiveOverlay`'s `illum` near the sub-solar point when a moon transits the sun; lunar
dims a moon reddish in the planet's shadow.
**Ocean currents** (`computeOceanCurrents`, also `PlanetOcean.cpp`): a per-ocean-cell tangent
velocity `sCurrent` from wind stress (`sWind`) rotated by a **Coriolis** deflection (right N /
left S about the cell normal), with the across-shore component removed at land neighbours so the
stream follows the coast (gyres), then smoothed and re-tangented (zero on land). `computeClimate`
calls it right after the wind pass and feeds **warm (poleward) / cold (equatorward)** currents
back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed onto coasts,
applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea
(`buildCurrents`, key `O`). Currents/feedback are derived (not saved).
## 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:
@ -111,6 +178,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/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

@ -91,6 +91,9 @@ const char* colorModeName(ColorMode m) {
case ColorMode::FloraDensity: return "Flora density"; case ColorMode::FloraDensity: return "Flora density";
case ColorMode::FaunaDensity: return "Fauna density"; case ColorMode::FaunaDensity: return "Fauna density";
case ColorMode::FungaDensity: return "Funga density"; case ColorMode::FungaDensity: return "Funga density";
case ColorMode::TempSummer: return "Temperature (summer)";
case ColorMode::TempWinter: return "Temperature (winter)";
case ColorMode::Seasonality: return "Seasonality (summer-winter)";
} }
return "?"; return "?";
} }
@ -138,6 +141,27 @@ Color tempColor(double celsius) {
return Color{ L(0), L(1), L(2), 255 }; return Color{ L(0), L(1), L(2), 255 };
} }
// Seasonality ramp: summer-winter range in deg C, ~[0, 45]: calm grey -> warm orange.
Color seasonColor(double rangeC) {
static const unsigned char lo[3] = { 95, 100, 110 }, hi[3] = { 235, 130, 40 };
double t = std::clamp(rangeC / 45.0, 0.0, 1.0);
auto L = [&](int c){ return (unsigned char)(lo[c] + (hi[c] - lo[c]) * t); };
return Color{ L(0), L(1), L(2), 255 };
}
// Tide: diverging around mid-tide. Low (negative) -> amber, high (positive) -> cyan.
Color tideColor(double level, double range) {
static const unsigned char loC[3] = { 235, 175, 80 }; // low tide (amber)
static const unsigned char midC[3] = { 150, 175, 185 }; // mid tide (pale)
static const unsigned char hiC[3] = { 55, 165, 235 }; // high tide (cyan)
double t = std::clamp(level / std::max(1e-6, range), -1.0, 1.0);
const unsigned char* a = t < 0.0 ? loC : midC;
const unsigned char* b = t < 0.0 ? midC : hiC;
double f = std::fabs(t);
auto L = [&](int c){ return (unsigned char)(a[c] + (b[c] - a[c]) * f); };
return Color{ L(0), L(1), L(2), 255 };
}
// Precipitation ramp over normalized [0,1]: tan (dry) -> green -> teal/blue (wet). // Precipitation ramp over normalized [0,1]: tan (dry) -> green -> teal/blue (wet).
Color precipColor(double moist01) { Color precipColor(double moist01) {
double t = std::clamp(moist01, 0.0, 1.0); double t = std::clamp(moist01, 0.0, 1.0);

View File

@ -5,7 +5,8 @@
// Cell color mapping for the viewer. Pure functions of cell properties. // Cell color mapping for the viewer. Pure functions of cell properties.
enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip, enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip,
FloraDensity, FaunaDensity, FungaDensity }; FloraDensity, FaunaDensity, FungaDensity,
TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views
Color elevationColor(double e, double seaLevel); Color elevationColor(double e, double seaLevel);
Color plateColor(int id); Color plateColor(int id);
@ -22,6 +23,11 @@ const char* colorModeName(ColorMode m);
// (tan dry -> green -> blue wet). // (tan dry -> green -> blue wet).
Color tempColor(double celsius); Color tempColor(double celsius);
Color precipColor(double moist01); Color precipColor(double moist01);
// Seasonality: summer-winter temperature range in deg C (grey calm -> orange extreme).
Color seasonColor(double rangeC);
// Tide level (m), diverging around 0: low tide amber -> high tide cyan. `range` sets the
// saturation scale (meters at which the colour is fully amber/cyan).
Color tideColor(double level, double range);
// Biota density ramps (0..1): flora barren->lush green, fauna pale->amber/red, // Biota density ramps (0..1): flora barren->lush green, fauna pale->amber/red,
// funga pale->violet/brown. // funga pale->violet/brown.
Color floraColor(double d01); Color floraColor(double d01);

View File

@ -97,6 +97,71 @@ void buildRivers(const Planet& p, float radius,
} }
} }
void buildCoastline(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<int>& oceanCell) {
segs.clear(); oceanCell.clear();
const double sea = p.cfg.seaLevel;
auto isLand = [&](int i) { return p.cells[i].elevation > sea; };
auto midV = [&](int i, int j) -> Vector3 {
Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius;
return Vector3{ (float)m.x, (float)m.y, (float)m.z };
};
// The ocean cell nearest a coast segment, for sampling tide. Prefer an ocean endpoint of
// the cut edge; fall back to the lone/other cell that is ocean.
auto emit = [&](const Vector3& a, const Vector3& b, int oc) {
segs.push_back(a); segs.push_back(b); oceanCell.push_back(oc);
};
const std::vector<int>& tri = p.triIndices();
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int ia = tri[k], ib = tri[k + 1], ic = tri[k + 2];
bool la = isLand(ia), lb = isLand(ib), lc = isLand(ic);
if (la == lb && lb == lc) continue; // all land or all ocean: no coast
// Exactly one vertex differs from the other two -> one cut separating it.
int lone, o1, o2;
if (la == lb) { lone = ic; o1 = ia; o2 = ib; }
else if (lb == lc) { lone = ia; o1 = ib; o2 = ic; }
else { lone = ib; o1 = ia; o2 = ic; }
int oc = isLand(lone) ? o1 : lone; // the ocean side of the cut
emit(midV(lone, o1), midV(lone, o2), oc);
}
}
void buildCurrents(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<Color>& cols) {
segs.clear(); cols.clear();
const std::vector<Vec3>& cur = p.current();
if (cur.empty()) return;
const double sea = p.cfg.seaLevel;
double maxSp = 1e-9;
for (const Vec3& v : cur) maxSp = std::max(maxSp, v.length());
const Vec3 up{0, 1, 0};
const Color warm{235, 120, 90, 255}, cold{90, 160, 235, 255};
auto V = [](const Vec3& q) { return Vector3{ (float)q.x, (float)q.y, (float)q.z }; };
for (int i = 0; i < (int)p.cells.size(); i += 3) { // subsample for readability
if (p.cells[i].elevation > sea) continue;
const Vec3& vel = cur[i];
double sp = vel.length();
if (sp < 0.18 * maxSp) continue; // skip the slack water
const Vec3& nrm = p.cells[i].unit;
Vec3 dir = vel * (1.0 / sp);
double len = 0.02 + 0.03 * (sp / maxSp);
Vec3 base = nrm * (double)radius;
Vec3 tip = base + dir * len;
Vec3 perp = dir.cross(nrm).normalized();
Vec3 back = dir * -1.0; double hl = len * 0.35;
Vec3 h1 = tip + (back * 0.8 + perp * 0.6) * hl;
Vec3 h2 = tip + (back * 0.8 - perp * 0.6) * hl;
// Warm if flowing poleward (toward the nearer pole), cold if equatorward.
Vec3 northT = up - nrm * up.dot(nrm); double nl = northT.length();
double pw = 0.0;
if (nl > 1e-9) { northT = northT * (1.0 / nl); pw = dir.dot(northT) * (nrm.y >= 0 ? 1.0 : -1.0); }
Color c = pw >= 0.0 ? warm : cold;
segs.push_back(V(base)); segs.push_back(V(tip)); cols.push_back(c);
segs.push_back(V(tip)); segs.push_back(V(h1)); cols.push_back(c);
segs.push_back(V(tip)); segs.push_back(V(h2)); cols.push_back(c);
}
}
std::vector<std::vector<Vector2>> buildGraticule() { std::vector<std::vector<Vector2>> buildGraticule() {
std::vector<std::vector<Vector2>> g; std::vector<std::vector<Vector2>> g;
const double D = M_PI / 180.0; const double D = M_PI / 180.0;
@ -171,6 +236,23 @@ void drawSegments2D(const std::vector<Vector3>& segs, Color col, float width,
rlEnd(); rlSetLineWidth(1.0f); rlEnd(); rlSetLineWidth(1.0f);
} }
void drawColoredSegments2D(const std::vector<Vector3>& segs, const std::vector<Color>& cols,
float width, Rectangle r, double lonOffset) {
if (segs.empty()) return;
rlSetLineWidth(width); rlBegin(RL_LINES);
for (size_t i = 0, c = 0; i + 1 < segs.size(); i += 2, ++c) {
Vec3 a = Vec3{segs[i].x, segs[i].y, segs[i].z}.normalized();
Vec3 b = Vec3{segs[i + 1].x, segs[i + 1].y, segs[i + 1].z}.normalized();
double alo, ala, blo, bla; dirToLonLat(a, alo, ala); dirToLonLat(b, blo, bla);
Vector2 pa = projLonLat(alo, ala, lonOffset, r), pb = projLonLat(blo, bla, lonOffset, r);
if (fabsf(pa.x - pb.x) > r.width * 0.5f) continue;
const Color& col = cols[c < cols.size() ? c : cols.size() - 1];
rlColor4ub(col.r, col.g, col.b, 255);
rlVertex2f(pa.x, pa.y); rlVertex2f(pb.x, pb.y);
}
rlEnd(); rlSetLineWidth(1.0f);
}
void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs, void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs,
float visBase, float elevExagg, double seaLevel, float eps) { float visBase, float elevExagg, double seaLevel, float eps) {
for (const auto& sg : sgs) { for (const auto& sg : sgs) {

View File

@ -25,6 +25,20 @@ void buildDriftArrows(const Planet& p, float radius,
void buildRivers(const Planet& p, float radius, void buildRivers(const Planet& p, float radius,
std::vector<Vector3>& rivers, std::vector<Vector3>& bigRivers); std::vector<Vector3>& rivers, std::vector<Vector3>& bigRivers);
// ---- Coastline (land/ocean boundary, dual contour through triangles) --------
// Like buildBorders but on the elevation-vs-seaLevel split. For each emitted segment
// (pairs in `segs`) records a representative adjacent OCEAN cell in `oceanCell` (one
// entry per segment, i.e. per 2 points) so the viewer can colour it by that cell's tide.
void buildCoastline(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<int>& oceanCell);
// ---- Ocean currents (subsampled arrows, warm/cold) --------------------------
// Short arrows along Planet::current() over a subsample of ocean cells. `cols` has one
// colour per segment (warm = poleward/red, cold = equatorward/blue). Needs computeClimate
// (which computes the current field) to have run.
void buildCurrents(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<Color>& cols);
// ---- Lat/lon graticule ------------------------------------------------------ // ---- Lat/lon graticule ------------------------------------------------------
// Polylines of (lon,lat) radians (Vector2.x=lon, .y=lat). // Polylines of (lon,lat) radians (Vector2.x=lon, .y=lat).
std::vector<std::vector<Vector2>> buildGraticule(); std::vector<std::vector<Vector2>> buildGraticule();
@ -38,6 +52,11 @@ void drawGraticuleLabels2D(Rectangle r, double lonOffset);
void drawSegments2D(const std::vector<Vector3>& segs, Color col, float width, void drawSegments2D(const std::vector<Vector3>& segs, Color col, float width,
Rectangle r, double lonOffset); Rectangle r, double lonOffset);
// As drawSegments2D but with a per-segment colour (cols has one entry per segment,
// i.e. per 2 points). Used for the tide-shaded coastline.
void drawColoredSegments2D(const std::vector<Vector3>& segs, const std::vector<Color>& cols,
float width, Rectangle r, double lonOffset);
// ---- Subgrid overlay (high-res patch over a selected cell) ------------------ // ---- Subgrid overlay (high-res patch over a selected cell) ------------------
void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs, void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs,
float visBase, float elevExagg, double seaLevel, float eps); float visBase, float elevExagg, double seaLevel, float eps);

View File

@ -50,6 +50,20 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
if (sized(p.temperature()) && sized(p.moisture())) if (sized(p.temperature()) && sized(p.moisture()))
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%", L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",
p.temperature()[i], p.moisture()[i] * 100.0))); p.temperature()[i], p.moisture()[i] * 100.0)));
if (sized(p.summerTemp()) && sized(p.winterTemp()))
L.push_back(std::string(TextFormat(" summer %.0f C / winter %.0f C",
p.summerTemp()[i], p.winterTemp()[i])));
// Live World: current-season temperature + whether it's day or night + snow cover.
if (sized(p.liveTemp())) {
bool day = sized(p.insolation()) && p.insolation()[i] > 0.05;
bool snow = (elev > p.cfg.seaLevel) ? (p.liveTemp()[i] < p.cfg.snowTemp)
: (p.liveTemp()[i] < p.cfg.seaIceTemp);
L.push_back(std::string(TextFormat("live %.1f C %s%s", p.liveTemp()[i],
day ? "day" : "night", snow ? " snow" : "")));
}
if (sized(p.tide()))
L.push_back(std::string(TextFormat("tide %+.2f m (%s)", p.tide()[i],
p.tide()[i] >= 0.0 ? "high" : "low")));
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)
@ -163,7 +177,8 @@ void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected) {
} }
} }
void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting) { void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting,
bool live, double liveHours) {
DrawRectangleRec(r, Color{12, 14, 22, 235}); DrawRectangleRec(r, Color{12, 14, 22, 235});
DrawRectangleLinesEx(r, 1, Color{120, 120, 150, 255}); DrawRectangleLinesEx(r, 1, Color{120, 120, 150, 255});
int x = (int)r.x + 16, y = (int)r.y + 12; int x = (int)r.x + 16, y = (int)r.y + 12;
@ -195,6 +210,7 @@ void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting) {
L(TextFormat("Surface area: %.0f M km2", surfKm2 / 1.0e6)); L(TextFormat("Surface area: %.0f M km2", surfKm2 / 1.0e6));
L(TextFormat("Plates: %d active %d continental / %d oceanic", used, contPlates, used - contPlates)); L(TextFormat("Plates: %d active %d continental / %d oceanic", used, contPlates, used - contPlates));
L(TextFormat("Young ridges: %d strips %d cells", babyPlates, babyCells)); L(TextFormat("Young ridges: %d strips %d cells", babyPlates, babyCells));
L(TextFormat("Moons: %d", (int)p.getMoons().size()));
int water = N - landGeo; int water = N - landGeo;
double wlRatio = landGeo > 0 ? (double)water / landGeo : 0.0; double wlRatio = landGeo > 0 ? (double)water / landGeo : 0.0;
L(TextFormat("Land %.0f%% Ocean %.0f%% (water:land %.2f:1)", L(TextFormat("Land %.0f%% Ocean %.0f%% (water:land %.2f:1)",
@ -202,7 +218,10 @@ void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting) {
L(TextFormat("Sea level: %+.0f m", seaLvl)); L(TextFormat("Sea level: %+.0f m", seaLvl));
L(TextFormat("Crust: %.0f%% continental / %.0f%% oceanic", 100.0 * cont / N, 100.0 * (N - cont) / N)); L(TextFormat("Crust: %.0f%% continental / %.0f%% oceanic", 100.0 * cont / N, 100.0 * (N - cont) / N));
L(TextFormat("Elevation: %.0f .. %.0f m mean %.0f m", mn, mx, sum / N)); L(TextFormat("Elevation: %.0f .. %.0f m mean %.0f m", mn, mx, sum / N));
if (drifting) L(TextFormat("Sim time: %.0f My", elapsedMy)); if (live) L(TextFormat("Live World: year %ld, day %.1f",
(long)(liveHours / p.cfg.dayLengthHours / p.cfg.yearLengthDays) + 1,
std::fmod(liveHours / p.cfg.dayLengthHours, p.cfg.yearLengthDays)));
else if (drifting) L(TextFormat("Sim time: %.0f My", elapsedMy));
y += 8; y += 8;
DrawText("plate cells size area speed land", x, y, 15, Color{150, 155, 170, 255}); y += 21; DrawText("plate cells size area speed land", x, y, 15, Color{150, 155, 170, 255}); y += 21;

View File

@ -17,4 +17,5 @@ void drawDetailPanel(const Planet& p, const std::shared_ptr<SubGrid>& sg,
void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected); void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected);
// World statistics panel (shown in the subareas quadrant when no tile selected). // World statistics panel (shown in the subareas quadrant when no tile selected).
void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting); void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting,
bool live = false, double liveHours = 0.0);

View File

@ -35,9 +35,13 @@ bool Viewer::init(int argc, char** argv) {
int mapH = mapAreaH - 30; int mapH = mapAreaH - 30;
int mapW = (int)(mapH * (EqualEarth::halfWidth() / EqualEarth::halfHeight())); int mapW = (int)(mapH * (EqualEarth::halfWidth() / EqualEarth::halfHeight()));
if (mapW > leftW - 30) { mapW = leftW - 30; mapH = (int)(mapW / (EqualEarth::halfWidth() / EqualEarth::halfHeight())); } if (mapW > leftW - 30) { mapW = leftW - 30; mapH = (int)(mapW / (EqualEarth::halfWidth() / EqualEarth::halfHeight())); }
mapRect = Rectangle{ (float)((leftW - mapW) / 2), // Left-align the 2D map (was centered) so the freed space at right holds the live sky panel.
const float mapMargin = 16.0f;
mapRect = Rectangle{ mapMargin,
(float)(mapAreaY + (mapAreaH - mapH) / 2), (float)(mapAreaY + (mapAreaH - mapH) / 2),
(float)mapW, (float)mapH }; (float)mapW, (float)mapH };
float liveX = mapRect.x + mapRect.width + 16.0f;
liveInfoRect = Rectangle{ liveX, mapRect.y, (float)leftW - liveX - 8.0f, mapRect.height };
// Right column. // Right column.
hoverRect = Rectangle{ (float)rightX + 8, 8.0f, (float)rightW - 16, (float)rightH - 16 }; hoverRect = Rectangle{ (float)rightX + 8, 8.0f, (float)rightW - 16, (float)rightH - 16 };
@ -96,6 +100,8 @@ void Viewer::selectCell(int idx) {
void Viewer::recolor() { void Viewer::recolor() {
double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge); double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge);
const std::vector<double>& temp = planet.temperature(); const std::vector<double>& temp = planet.temperature();
const std::vector<double>& summer = planet.summerTemp();
const std::vector<double>& winter = planet.winterTemp();
const std::vector<double>& moist = planet.moisture(); // 0..1, already robustly normalized const std::vector<double>& moist = planet.moisture(); // 0..1, already robustly normalized
const std::vector<double>& flora = planet.floraDensity(); const std::vector<double>& flora = planet.floraDensity();
const std::vector<double>& fauna = planet.faunaDensity(); const std::vector<double>& fauna = planet.faunaDensity();
@ -114,6 +120,10 @@ void Viewer::recolor() {
case ColorMode::Crust: vcolors[i] = crustColor(planet.cells[i].oceanic); break; case ColorMode::Crust: vcolors[i] = crustColor(planet.cells[i].oceanic); break;
case ColorMode::Biome: vcolors[i] = biomeColor(planet.cells[i].biome); break; case ColorMode::Biome: vcolors[i] = biomeColor(planet.cells[i].biome); break;
case ColorMode::Temperature: vcolors[i] = temp.empty() ? Color{90,90,90,255} : tempColor(temp[i]); break; case ColorMode::Temperature: vcolors[i] = temp.empty() ? Color{90,90,90,255} : tempColor(temp[i]); break;
case ColorMode::TempSummer: vcolors[i] = summer.empty()? Color{90,90,90,255} : tempColor(summer[i]); break;
case ColorMode::TempWinter: vcolors[i] = winter.empty()? Color{90,90,90,255} : tempColor(winter[i]); break;
case ColorMode::Seasonality: vcolors[i] = (summer.empty()||winter.empty()) ? Color{90,90,90,255}
: seasonColor(summer[i] - winter[i]); break;
case ColorMode::Precip: vcolors[i] = moist.empty() ? Color{90,90,90,255} : precipColor(moist[i]); break; case ColorMode::Precip: vcolors[i] = moist.empty() ? Color{90,90,90,255} : precipColor(moist[i]); break;
case ColorMode::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255} : floraColor(flora[i]); break; case ColorMode::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255} : floraColor(flora[i]); break;
case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255} : faunaColor(fauna[i]); break; case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255} : faunaColor(fauna[i]); break;
@ -130,6 +140,71 @@ void Viewer::recolor() {
minE = planet.minElevation(); maxE = planet.maxElevation(); minE = planet.minElevation(); maxE = planet.maxElevation();
} }
// Live World: from the sim's insolation + live-temperature fields, build the per-cell
// day/night brightness (illum) and the shaded draw colours (base colour -> snow/ice tint ->
// day/night dim). Cheap O(n); called every frame while in Live World.
void Viewer::rebuildLiveOverlay() {
const size_t n = planet.cells.size();
const std::vector<double>& sun = planet.insolation();
const std::vector<double>& lt = planet.liveTemp();
const double sea = planet.cfg.seaLevel;
const double snowT = planet.cfg.snowTemp;
const double iceT = planet.cfg.seaIceTemp;
const float nightFloor = 0.18f; // night side dim (not black) so colours read
illum.assign(n, 1.0f);
shadedColors.resize(n);
auto smoothstep = [](double e0, double e1, double x) {
double t = (e1 > e0) ? (x - e0) / (e1 - e0) : 0.0;
t = t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
return t * t * (3.0 - 2.0 * t);
};
// Solar eclipse: a moon roughly between the sun and the planet (its model-space direction
// near the sun's) casts a shadow around the sub-solar point. Strength ramps with alignment.
const Vec3 sd{ sunDir.x, sunDir.y, sunDir.z };
const double eclipseReach = 0.13; // rad: how close a moon must be to the sun to eclipse
const double umbra = 0.10; // rad: angular radius of the shadow spot
double eclipseStrength = 0.0;
for (const auto& md : moonDirs) {
double d = std::acos(std::clamp((double)(md.x*sd.x + md.y*sd.y + md.z*sd.z), -1.0, 1.0));
if (d < eclipseReach) eclipseStrength = std::max(eclipseStrength, 1.0 - d / eclipseReach);
}
auto blend = [](unsigned char c, unsigned char to, double a) {
return (unsigned char)(c + (to - c) * a);
};
for (size_t i = 0; i < n; ++i) {
// Day/night: soft sunrise band over the clamped cosine incidence.
float f = nightFloor;
if (!sun.empty()) f = nightFloor + (1.0f - nightFloor) * (float)smoothstep(0.0, 0.12, sun[i]);
// Eclipse shadow: darken cells near the sub-solar point while a moon transits the sun.
if (eclipseStrength > 0.0 && !sun.empty()) {
double dd = std::acos(std::clamp(planet.cells[i].unit.x*sd.x + planet.cells[i].unit.y*sd.y
+ planet.cells[i].unit.z*sd.z, -1.0, 1.0));
double sh = eclipseStrength * std::exp(-(dd / umbra) * (dd / umbra));
f *= (float)std::max(0.10, 1.0 - 0.85 * sh);
}
illum[i] = f;
Color c = vcolors[i];
// Snow on cold land, sea ice on cold ocean (live seasonal temperature).
if (!lt.empty()) {
double e = planet.cells[i].elevation;
if (e > sea) {
double a = (snowT - lt[i]) / 8.0; // fully snow ~8 C below freezing
if (a > 0.0) { a = a > 0.85 ? 0.85 : a;
c = Color{ blend(c.r, 242, a), blend(c.g, 246, a), blend(c.b, 250, a), 255 }; }
} else {
double a = (iceT - lt[i]) / 6.0; // sea ice
if (a > 0.0) { a = a > 0.9 ? 0.9 : a;
c = Color{ blend(c.r, 212, a), blend(c.g, 226, a), blend(c.b, 236, a), 255 }; }
}
}
// Day/night dimming over the (possibly snow-tinted) colour.
if (dayNightOn) c = Color{ (unsigned char)(c.r * f), (unsigned char)(c.g * f), (unsigned char)(c.b * f), 255 };
shadedColors[i] = c;
}
}
void Viewer::refreshView() { void Viewer::refreshView() {
if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view
planet.computeClimate(); // temperature + precipitation fields planet.computeClimate(); // temperature + precipitation fields
@ -141,6 +216,8 @@ void Viewer::refreshView() {
buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildDriftArrows(planet, driftR, driftArrows, plateLabels);
} }
if (phase3) buildRivers(planet, riverR, rivers, bigRivers); if (phase3) buildRivers(planet, riverR, rivers, bigRivers);
buildCoastline(planet, riverR, coast, coastOcean); // land/ocean boundary (for tide lines)
buildCurrents(planet, driftR, currentSegs, currentCols); // ocean current arrows (warm/cold)
if (selectedCell >= 0) rebuildSub(); if (selectedCell >= 0) rebuildSub();
} }
@ -174,12 +251,15 @@ void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary); std::ofstream os(path, std::ios::binary);
if (!os) { setStatus("Save failed"); return; } if (!os) { setStatus("Save failed"); return; }
uint32_t ver = SAVE_VERSION; uint8_t st = settled ? 1 : 0; uint8_t p3 = phase3 ? 1 : 0; uint32_t ver = SAVE_VERSION; uint8_t st = settled ? 1 : 0; uint8_t p3 = phase3 ? 1 : 0;
uint8_t lw = liveWorld ? 1 : 0;
os.write("PLSV", 4); os.write("PLSV", 4);
os.write(reinterpret_cast<const char*>(&ver), sizeof ver); os.write(reinterpret_cast<const char*>(&ver), sizeof ver);
os.write(reinterpret_cast<const char*>(&elapsedMy), sizeof elapsedMy); os.write(reinterpret_cast<const char*>(&elapsedMy), sizeof elapsedMy);
os.write(reinterpret_cast<const char*>(&st), sizeof st); os.write(reinterpret_cast<const char*>(&st), sizeof st);
os.write(reinterpret_cast<const char*>(&driftRate), sizeof driftRate); os.write(reinterpret_cast<const char*>(&driftRate), sizeof driftRate);
os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag
os.write(reinterpret_cast<const char*>(&lw), sizeof lw); // v8: Live World flag
os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours)
planet.writeState(os); planet.writeState(os);
setStatus(os ? std::string("Saved ") + path : "Save failed"); setStatus(os ? std::string("Saved ") + path : "Save failed");
} }
@ -188,20 +268,24 @@ void Viewer::loadGame(const char* path) {
std::ifstream is(path, std::ios::binary); std::ifstream is(path, std::ios::binary);
if (!is) { setStatus(std::string("No ") + path); return; } if (!is) { setStatus(std::string("No ") + path); return; }
char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0; char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0;
uint8_t lw = 0; double lh = 0.0;
is.read(magic, 4); is.read(magic, 4);
is.read(reinterpret_cast<char*>(&ver), sizeof ver); is.read(reinterpret_cast<char*>(&ver), sizeof ver);
is.read(reinterpret_cast<char*>(&em), sizeof em); is.read(reinterpret_cast<char*>(&em), sizeof em);
is.read(reinterpret_cast<char*>(&st), sizeof st); is.read(reinterpret_cast<char*>(&st), sizeof st);
if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr); if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr);
if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3); if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3);
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
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)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: biome, v7: biota if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: biome, v7: biota, v9: moons
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
phase3 = (p3 != 0); phase3Prompt = false; phase3 = (p3 != 0); phase3Prompt = false;
phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy); phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy);
driftRate = dr; driftRate = dr;
liveWorld = (lw != 0); liveTime = lh; // v8: resume the Live World clock
settleRun = settleNeed; // keep the settled latch consistent settleRun = settleNeed; // keep the settled latch consistent
dtMy = settled ? planet.cflDtMy() : 0.0; dtMy = settled ? planet.cflDtMy() : 0.0;
driftAccum = 0.0; formAccum = 0.0; driftAccum = 0.0; formAccum = 0.0;
@ -216,6 +300,28 @@ void Viewer::loadGame(const char* path) {
// Advance the simulation this frame: Phase-1 forming (paced ticks toward // Advance the simulation this frame: Phase-1 forming (paced ticks toward
// equilibrium), or Phase-2 drift / Phase-3 drift+hydrology at a finer dt. // equilibrium), or Phase-2 drift / Phase-3 drift+hydrology at a finer dt.
void Viewer::stepSim() { void Viewer::stepSim() {
if (liveWorld) {
// --- Live World: advance the slow clock; geology is frozen --------
if (!paused) liveTime += liveRate * GetFrameTime(); // hours
double days = liveTime / planet.cfg.dayLengthHours;
double dayOfYear01 = days / planet.cfg.yearLengthDays;
dayOfYear01 -= std::floor(dayOfYear01);
double timeOfDay01 = days - std::floor(days);
planet.computeInsolation(dayOfYear01, timeOfDay01);
planet.computeLiveSeason(dayOfYear01);
planet.computeTides(dayOfYear01, timeOfDay01, days);
Vec3 s = planet.sunDirection(dayOfYear01, timeOfDay01); // shared sky geometry
sunDir = Vector3{ (float)s.x, (float)s.y, (float)s.z };
moonDirs.clear(); moonNormals.clear();
for (int m = 0; m < (int)planet.getMoons().size(); ++m) {
Vec3 md = planet.moonDirection(m, timeOfDay01, days);
Vec3 mn = planet.moonOrbitNormal(m, timeOfDay01);
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 });
}
rebuildLiveOverlay();
return;
}
if (!paused && !settled) { if (!paused && !settled) {
// --- Phase 1: forming, paced ticks toward equilibrium ------------- // --- Phase 1: forming, paced ticks toward equilibrium -------------
formAccum += GetFrameTime() * formRate; formAccum += GetFrameTime() * formRate;

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 = 7; // v7: +biota population; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 9; // v9: +moons; v8: +Live World clock; v7: +biota population; 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)
@ -26,6 +26,7 @@ struct Viewer {
int view3DW = 0, view3DH = 0; int view3DW = 0, view3DH = 0;
RenderTexture2D rt3d{}; RenderTexture2D rt3d{};
Rectangle mapRect{}, hoverRect{}, panelRect{}, gridRect{}; Rectangle mapRect{}, hoverRect{}, panelRect{}, gridRect{};
Rectangle liveInfoRect{}; // free space right of the (left-aligned) 2D map: moon/tide phase
Rectangle pauseBtn{}, p3ContinueBtn{}, p3StartBtn{}; Rectangle pauseBtn{}, p3ContinueBtn{}, p3StartBtn{};
float pbCx = 0.0f, pbCy = 0.0f; float pbCx = 0.0f, pbCy = 0.0f;
@ -72,6 +73,24 @@ struct Viewer {
bool phase3 = false, phase3Prompt = false; bool phase3 = false, phase3Prompt = false;
double phase3PromptAt = 0.0; double phase3PromptAt = 0.0;
// Live World: a slow real-time clock (hours -> weeks/months) over the finished planet.
// Geological drift freezes while it runs; a moving day/night terminator, a live seasonal
// temperature cycle and a moving snow line animate. liveRate is sim hours per real second.
bool liveWorld = false, dayNightOn = true;
double liveTime = 0.0; // hours since the live clock started
double liveRate = 1.0; // sim hours advanced per real second (ramps hour->month)
std::vector<float> illum; // per-cell day/night brightness (1 = day, floor = night)
std::vector<Color> shadedColors;// vcolors + snow/ice tint + day/night dim (live overlay)
Vector3 sunDir{0.0f, 0.0f, 1.0f}; // model-space sub-solar direction (for the 3D sun marker)
std::vector<Vector3> moonDirs; // model-space sub-lunar directions (one per moon, for render)
std::vector<Vector3> moonNormals;// model-space orbit-plane normals (one per moon, for the ring)
std::vector<Vector3> coast; // coastline segments (land/ocean boundary, rebuilt with terrain)
std::vector<int> coastOcean; // ocean cell per coast segment (to sample tide)
std::vector<Color> coastCols; // per-segment tide colour (filled each frame when showTides)
bool showTides = false; // colour the coastline by the live tide level (key T)
std::vector<Vector3> currentSegs; std::vector<Color> currentCols; // ocean-current arrows
bool showCurrents = false; // ocean current arrows, warm/cold (key O)
// Selection + subgrid (phase 4/5 preview). // Selection + subgrid (phase 4/5 preview).
int selectedCell = -1; int selectedCell = -1;
double selectedThresh = 0.06; double selectedThresh = 0.06;
@ -102,6 +121,10 @@ struct Viewer {
void selectCell(int idx); void selectCell(int idx);
void recolor(); void recolor();
void refreshView(); void refreshView();
void rebuildLiveOverlay(); // Live World: fill illum + shadedColors from sim fields
// Colors the 3D globe + 2D map actually draw: the live overlay when in Live World, else the
// plain per-cell colours.
const std::vector<Color>& displayColors() const { return liveWorld ? shadedColors : vcolors; }
void regenWorld(); // after generate(): geometry changed void regenWorld(); // after generate(): geometry changed
void regen(); // generate(cfg) + regenWorld() void regen(); // generate(cfg) + regenWorld()
void stepOnce(); // one tick + settle bookkeeping void stepOnce(); // one tick + settle bookkeeping
@ -118,6 +141,7 @@ struct Viewer {
void renderFrame(); void renderFrame();
void renderGlobe3D(); void renderGlobe3D();
void renderMap2D(); void renderMap2D();
void renderLiveInfo(); // Live World: moon phases + (coastal) tidal phase, beside the 2D map
void renderPanels(); void renderPanels();
void renderHUD(); void renderHUD();
void renderPrompt(); void renderPrompt();

View File

@ -102,7 +102,13 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_THREE)) { mode = ColorMode::Age; recolor(); } if (IsKeyPressed(KEY_THREE)) { mode = ColorMode::Age; recolor(); }
if (IsKeyPressed(KEY_FOUR)) { mode = ColorMode::Crust; recolor(); } if (IsKeyPressed(KEY_FOUR)) { mode = ColorMode::Crust; recolor(); }
if (IsKeyPressed(KEY_FIVE)) { mode = ColorMode::Biome; recolor(); } if (IsKeyPressed(KEY_FIVE)) { mode = ColorMode::Biome; recolor(); }
if (IsKeyPressed(KEY_SIX)) { mode = ColorMode::Temperature; recolor(); } if (IsKeyPressed(KEY_SIX)) { // cycle temperature sub-views: mean->summer->winter->seasonality
mode = (mode == ColorMode::Temperature) ? ColorMode::TempSummer
: (mode == ColorMode::TempSummer) ? ColorMode::TempWinter
: (mode == ColorMode::TempWinter) ? ColorMode::Seasonality
: ColorMode::Temperature;
recolor();
}
if (IsKeyPressed(KEY_SEVEN)) { mode = ColorMode::Precip; recolor(); } if (IsKeyPressed(KEY_SEVEN)) { mode = ColorMode::Precip; recolor(); }
if (IsKeyPressed(KEY_EIGHT)) { mode = ColorMode::FloraDensity; recolor(); } if (IsKeyPressed(KEY_EIGHT)) { mode = ColorMode::FloraDensity; recolor(); }
if (IsKeyPressed(KEY_NINE)) { mode = ColorMode::FaunaDensity; recolor(); } if (IsKeyPressed(KEY_NINE)) { mode = ColorMode::FaunaDensity; recolor(); }
@ -125,6 +131,20 @@ void Viewer::handleInput() {
{ mode = ColorMode::FloraDensity; recolor(); } { mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)"); setStatus("Biota generated (flora/fauna/funga)");
} }
if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock)
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = false;
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started");
} else {
paused = true; refreshView(); // back to World Creation (drift), paused
setStatus("Live World stopped");
}
}
if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
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)
@ -146,7 +166,14 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH); if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH);
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH); if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH);
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); } if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
// Drift speed (Phase 2): My simulated per real second. // Speed control: Live World ramps the live clock (sim hours/s, ~hour -> month);
if (IsKeyPressed(KEY_RIGHT_BRACKET)) driftRate = std::min(driftRate * 1.5, 80.0); // otherwise it sets the drift rate (My simulated per real second).
if (IsKeyPressed(KEY_LEFT_BRACKET)) driftRate = std::max(driftRate / 1.5, 0.5); if (IsKeyPressed(KEY_RIGHT_BRACKET)) {
if (liveWorld) liveRate = std::min(liveRate * 1.5, 720.0);
else driftRate = std::min(driftRate * 1.5, 80.0);
}
if (IsKeyPressed(KEY_LEFT_BRACKET)) {
if (liveWorld) liveRate = std::max(liveRate / 1.5, 0.25);
else driftRate = std::max(driftRate / 1.5, 0.5);
}
} }

View File

@ -20,6 +20,7 @@ void Viewer::renderGlobe3D() {
rlPushMatrix(); rlPushMatrix();
rlRotatef((float)planet.cfg.axialTilt, 0.0f, 0.0f, 1.0f); rlRotatef((float)planet.cfg.axialTilt, 0.0f, 0.0f, 1.0f);
const std::vector<int>& tri = planet.triIndices(); const std::vector<int>& tri = planet.triIndices();
const std::vector<Color>& dc = displayColors(); // live overlay (day/night + snow) or plain
rlBegin(RL_TRIANGLES); rlBegin(RL_TRIANGLES);
for (size_t k = 0; k + 2 < tri.size(); k += 3) { for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int idx[3] = { tri[k], tri[k + 1], tri[k + 2] }; int idx[3] = { tri[k], tri[k + 1], tri[k + 2] };
@ -27,15 +28,16 @@ void Viewer::renderGlobe3D() {
const Cell& cc = planet.cells[idx[j]]; const Cell& cc = planet.cells[idx[j]];
const Vec3& u = cc.unit; const Vec3& u = cc.unit;
float r = visBase + (float)cc.elevation * elevExagg; float r = visBase + (float)cc.elevation * elevExagg;
const Color& col = vcolors[idx[j]]; const Color& col = dc[idx[j]];
rlColor4ub(col.r, col.g, col.b, 255); rlColor4ub(col.r, col.g, col.b, 255);
rlVertex3f((float)(u.x * r), (float)(u.y * r), (float)(u.z * r)); rlVertex3f((float)(u.x * r), (float)(u.y * r), (float)(u.z * r));
} }
} }
rlEnd(); rlEnd();
if (selectedCell >= 0) // detail patch // (The clicked tile's high-res subgrid is shown in the right-side detail panel,
drawSubgrids(subgrids, visBase, elevExagg, planet.cfg.seaLevel, 0.0016f); // not overlaid on the globe -- the overlay was a low-res, always-elevation-coloured
// patch that clashed with the active colour mode and read as a "strange pattern".)
if (showBorders && (!borders.empty() || !ridgeBorders.empty())) { if (showBorders && (!borders.empty() || !ridgeBorders.empty())) {
rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlSetLineWidth(2.0f); rlBegin(RL_LINES);
@ -71,6 +73,36 @@ void Viewer::renderGlobe3D() {
}; };
drawRiv(rivers, 1.5f); drawRiv(bigRivers, 3.0f); drawRiv(rivers, 1.5f); drawRiv(bigRivers, 3.0f);
} }
// Live World tide: colour the coastline by the local tide level (per-segment colour cached
// in coastCols so the 2D map reuses it). Auto-scaled to the current tide extent.
coastCols.clear();
if (liveWorld && showTides && !coast.empty()) {
const std::vector<double>& td = planet.tide();
double range = 1e-6;
for (int oc : coastOcean) if (oc >= 0 && oc < (int)td.size()) range = std::max(range, std::fabs(td[oc]));
coastCols.reserve(coastOcean.size());
for (int oc : coastOcean)
coastCols.push_back((oc >= 0 && oc < (int)td.size()) ? tideColor(td[oc], range) : Color{150,175,185,255});
rlSetLineWidth(3.0f); rlBegin(RL_LINES);
for (size_t i = 0, c = 0; i + 1 < coast.size(); i += 2, ++c) {
const Color& col = coastCols[c];
rlColor4ub(col.r, col.g, col.b, 255);
rlVertex3f(coast[i].x, coast[i].y, coast[i].z);
rlVertex3f(coast[i + 1].x, coast[i + 1].y, coast[i + 1].z);
}
rlEnd(); rlSetLineWidth(1.0f);
}
// Ocean currents: warm/cold arrows over the sea (per-segment colour).
if (showCurrents && !currentSegs.empty()) {
rlSetLineWidth(2.0f); rlBegin(RL_LINES);
for (size_t i = 0, c = 0; i + 1 < currentSegs.size(); i += 2, ++c) {
const Color& col = currentCols[c];
rlColor4ub(col.r, col.g, col.b, 255);
rlVertex3f(currentSegs[i].x, currentSegs[i].y, currentSegs[i].z);
rlVertex3f(currentSegs[i + 1].x, currentSegs[i + 1].y, currentSegs[i + 1].z);
}
rlEnd(); rlSetLineWidth(1.0f);
}
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) {
@ -96,6 +128,57 @@ void Viewer::renderGlobe3D() {
DrawSphere(Vector3{0.0f, ax, 0.0f}, 0.05f, Color{230, 90, 80, 255}); // north DrawSphere(Vector3{0.0f, ax, 0.0f}, 0.05f, Color{230, 90, 80, 255}); // north
DrawSphere(Vector3{0.0f, -ax, 0.0f}, 0.05f, Color{80, 140, 230, 255}); // south DrawSphere(Vector3{0.0f, -ax, 0.0f}, 0.05f, Color{80, 140, 230, 255}); // south
} }
// Live World sky: a small, distant sun (bright core + faint halo) and the orbiting moons
// (sun-lit phase + orbit ring; dimmed reddish during a lunar eclipse). All inside the tilted
// matrix so they stay consistent with the model-space lit pattern.
if (liveWorld) {
// Sun: far away + small, with a couple of translucent halo shells so it still reads.
const float sunDist = 9.0f;
Vector3 sp{ sunDir.x * sunDist, sunDir.y * sunDist, sunDir.z * sunDist };
DrawSphere(sp, 0.60f, Color{255, 240, 180, 26});
DrawSphere(sp, 0.34f, Color{255, 238, 170, 55});
DrawSphere(sp, 0.17f, Color{255, 246, 205, 255});
const auto& mns = planet.getMoons();
for (size_t m = 0; m < mns.size() && m < moonDirs.size(); ++m) {
const Vector3& dir = moonDirs[m];
float dist = visBase + 0.8f + (float)(mns[m].orbitRadius / 30.0) * 4.0f; // visible band
Vector3 mp{ dir.x * dist, dir.y * dist, dir.z * dist };
float rr = (float)mns[m].dispRadius;
// Faint orbit ring: the great circle perpendicular to the orbit-plane normal.
if (m < moonNormals.size()) {
Vec3 nrm = Vec3{moonNormals[m].x, moonNormals[m].y, moonNormals[m].z}.normalized();
Vec3 u = std::fabs(nrm.y) < 0.9 ? nrm.cross(Vec3{0,1,0}).normalized()
: nrm.cross(Vec3{1,0,0}).normalized();
Vec3 v = nrm.cross(u);
rlBegin(RL_LINES); rlColor4ub(120, 130, 160, 90);
const int seg = 64;
for (int k = 0; k < seg; ++k) {
double a0 = 2.0 * M_PI * k / seg, a1 = 2.0 * M_PI * (k + 1) / seg;
Vec3 p0 = (u * std::cos(a0) + v * std::sin(a0)) * dist;
Vec3 p1 = (u * std::cos(a1) + v * std::sin(a1)) * dist;
rlVertex3f((float)p0.x, (float)p0.y, (float)p0.z);
rlVertex3f((float)p1.x, (float)p1.y, (float)p1.z);
}
rlEnd();
}
// Lunar eclipse: moon near the anti-solar point (in the planet's shadow) -> dim red.
double antiAlign = -(dir.x*sunDir.x + dir.y*sunDir.y + dir.z*sunDir.z); // dot(dir,-sun)
bool eclipsed = antiAlign > std::cos(0.13);
Color lit = eclipsed ? Color{90, 35, 30, 255} : Color{210, 210, 215, 255};
DrawSphere(mp, rr, lit);
// Phase via the offset-dark-sphere trick: lit fraction k = (1 - cos(phase))/2, with
// cos(phase)=dot(moonDir,sunDir) (new moon when aligned with the sun). Shift a dark
// sphere toward the unlit (anti-sun) side to occlude it; offset 0 = new, ~2r = full.
double cosPhase = dir.x*sunDir.x + dir.y*sunDir.y + dir.z*sunDir.z;
double k = (1.0 - cosPhase) * 0.5; // 0 = new, 1 = full
float off = (float)(k * 2.2 * rr);
Vector3 dp{ mp.x - sunDir.x * off, mp.y - sunDir.y * off, mp.z - sunDir.z * off };
DrawSphere(dp, rr * 1.02f, Color{12, 12, 16, 255});
}
}
rlPopMatrix(); rlPopMatrix();
EndMode3D(); EndMode3D();
EndTextureMode(); EndTextureMode();
@ -105,11 +188,13 @@ void Viewer::renderGlobe3D() {
void Viewer::renderMap2D() { void Viewer::renderMap2D() {
DrawRectangleRec(mapRect, Color{6, 8, 14, 255}); DrawRectangleRec(mapRect, Color{6, 8, 14, 255});
BeginScissorMode((int)mapRect.x, (int)mapRect.y, (int)mapRect.width, (int)mapRect.height); BeginScissorMode((int)mapRect.x, (int)mapRect.y, (int)mapRect.width, (int)mapRect.height);
drawMap2D(planet, vcolors, map2D, mapRect, mapLon); drawMap2D(planet, displayColors(), map2D, mapRect, mapLon);
if (showGrat) { drawGraticule2D(graticule, mapRect, mapLon); drawGraticuleLabels2D(mapRect, mapLon); } if (showGrat) { drawGraticule2D(graticule, mapRect, mapLon); drawGraticuleLabels2D(mapRect, mapLon); }
if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, mapRect, mapLon); if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, mapRect, mapLon);
if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, mapRect, mapLon); if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, mapRect, mapLon);
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 (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, mapRect, mapLon);
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);
@ -130,6 +215,95 @@ void Viewer::renderMap2D() {
DrawText("2D Equal Earth (hover, drag to pan)", (int)mapRect.x + 6, (int)mapRect.y + 4, 14, Color{200, 200, 210, 255}); DrawText("2D Equal Earth (hover, drag to pan)", (int)mapRect.x + 6, (int)mapRect.y + 4, 14, Color{200, 200, 210, 255});
} }
// Live World "Sky & tides" panel in the freed space right of the (left-aligned) 2D map:
// the current phase of every moon, and the tidal phase of a selected coastal tile.
void Viewer::renderLiveInfo() {
if (!liveWorld) return;
Rectangle r = liveInfoRect;
DrawRectangleRec(r, Color{10, 12, 20, 235});
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int x = (int)r.x + 14, y = (int)r.y + 10;
DrawText("Sky & tides", x, y, 20, RAYWHITE); y += 30;
// Sky geometry at the current clock (recomputed here so the panel is self-contained).
const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays;
double days = liveTime / dayH;
double doy = days / yrD; doy -= std::floor(doy);
double tod = days - std::floor(days);
const double dStep = 0.03; // ~ for waxing/waning + rising/falling
double days2 = days + dStep, doy2 = days2 / yrD - std::floor(days2 / yrD), tod2 = days2 - std::floor(days2);
Vec3 sun = planet.sunDirection(doy, tod);
Vec3 sun2 = planet.sunDirection(doy2, tod2);
auto illumFrac = [](const Vec3& moon, const Vec3& s) { return (1.0 - moon.dot(s)) * 0.5; };
auto phaseName = [](double f, bool wax) -> const char* {
if (f < 0.04) return "New";
if (f > 0.96) return "Full";
if (f > 0.46 && f < 0.54) return wax ? "First quarter" : "Last quarter";
if (f < 0.5) return wax ? "Waxing crescent" : "Waning crescent";
return wax ? "Waxing gibbous" : "Waning gibbous";
};
// A small 2D phase disc: dark circle with the lit fraction filled (terminator ellipse).
auto drawPhase = [](float cx, float cy, float rad, double f, bool wax) {
DrawCircle((int)cx, (int)cy, rad, Color{26, 28, 36, 255});
double cosphi = 1.0 - 2.0 * f; // terminator x = w * cosphi
for (int dy = -(int)rad; dy <= (int)rad; ++dy) {
double w = std::sqrt(std::max(0.0, (double)rad * rad - (double)dy * dy));
double xt = w * cosphi, xa, xb;
if (wax) { xa = xt; xb = w; } else { xa = -w; xb = -xt; }
if (xb > xa) DrawLine((int)(cx + xa), (int)(cy + dy), (int)(cx + xb), (int)(cy + dy), Color{226, 226, 232, 255});
}
DrawCircleLines((int)cx, (int)cy, rad, Color{120, 124, 145, 255});
};
const auto& mns = planet.getMoons();
for (size_t m = 0; m < mns.size(); ++m) {
Vec3 md = planet.moonDirection((int)m, tod, days);
Vec3 md2 = planet.moonDirection((int)m, tod2, days2);
double f = illumFrac(md, sun);
bool wax = illumFrac(md2, sun2) >= f;
float cy = (float)y + 20.0f;
drawPhase((float)x + 22.0f, cy, 20.0f, f, wax);
DrawText(TextFormat("Moon %d: %s", (int)m + 1, phaseName(f, wax)), x + 52, y + 6, 17, Color{210, 215, 225, 255});
DrawText(TextFormat("%.0f%% lit period %.0f d", f * 100.0, mns[m].periodDays), x + 52, y + 27, 15, Color{150, 160, 175, 255});
y += 50;
}
if (mns.empty()) { DrawText("(no moons)", x, y, 16, Color{150, 155, 170, 255}); y += 24; }
// Tidal phase for a selected coastal tile (placeholder: high/low + rising/falling).
y += 8;
DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 26;
if (selectedCell >= 0 && selectedCell < (int)planet.cells.size()) {
const Cell& c = planet.cells[selectedCell];
const double sea = planet.cfg.seaLevel;
bool selLand = c.elevation > sea, coastal = false;
for (int nb : c.neighbors) if ((planet.cells[nb].elevation > sea) != selLand) { coastal = true; break; }
if (coastal) {
// Single-cell tide now vs a step ahead -> rising/falling (the field itself is the
// equilibrium tide; a richer coastal/resonant model is future work).
auto cellTide = [&](double dy, double td, double dd) {
double h = 0.0;
for (int mm = 0; mm < (int)mns.size(); ++mm) {
double cc = c.unit.dot(planet.moonDirection(mm, td, dd));
h += mns[mm].tideWeight * (cc * cc - 1.0 / 3.0);
}
double cs = c.unit.dot(planet.sunDirection(dy, td));
h += planet.cfg.tideSunFactor * (cs * cs - 1.0 / 3.0);
return h * planet.cfg.tideAmplitude;
};
double t0 = cellTide(doy, tod, days), t1 = cellTide(doy2, tod2, days2);
bool rising = t1 >= t0;
DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 21;
DrawText(TextFormat("%+.2f m %s, %s", t0, t0 >= 0.0 ? "high" : "low", rising ? "rising" : "falling"),
x, y, 17, tideColor(t0, std::max(0.05, std::fabs(t0)))); y += 24;
DrawText("(equilibrium model - placeholder)", x, y, 13, Color{120, 125, 140, 255});
} else {
DrawText("selected tile is inland", x, y, 15, Color{150, 155, 170, 255});
}
} else {
DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255});
}
}
// Right column: hover/selection info (top) + detail panel or world stats (bottom). // Right column: hover/selection info (top) + detail panel or world stats (bottom).
void Viewer::renderPanels() { void Viewer::renderPanels() {
drawHoverPanel(planet, hoverRect, hovered, selectedCell); drawHoverPanel(planet, hoverRect, hovered, selectedCell);
@ -138,7 +312,7 @@ void Viewer::renderPanels() {
planet.cells[selectedCell].elevation, planet.cells[selectedCell].geoAge, planet.cells[selectedCell].elevation, planet.cells[selectedCell].geoAge,
panelRect, gridRect, hoveredSubIdx); panelRect, gridRect, hoveredSubIdx);
else else
drawStats(planet, panelRect, elapsedMy, settled); drawStats(planet, panelRect, elapsedMy, settled, liveWorld, liveTime);
} }
// Top-left HUD text + the clickable pause button. // Top-left HUD text + the clickable pause button.
@ -153,15 +327,34 @@ void Viewer::renderHUD() {
int y = 10; int y = 10;
auto line = [&](const std::string& s){ DrawText(s.c_str(), 12, y, 18, RAYWHITE); y += 22; }; auto line = [&](const std::string& s){ DrawText(s.c_str(), 12, y, 18, RAYWHITE); y += 22; };
double fastest = 0.0; for (const auto& pl : planet.plates) fastest = std::max(fastest, pl.speedCmYr); double fastest = 0.0; for (const auto& pl : planet.plates) fastest = std::max(fastest, pl.speedCmYr);
line(!settled ? "Planet Sim - World Creation: forming" line(liveWorld ? "Planet Sim - Live World"
: phase3 ? "Planet Sim - World Creation: hydrology" : !settled ? "Planet Sim - World Creation: forming"
: "Planet Sim - World Creation: drift & erosion"); : phase3 ? "Planet Sim - World Creation: hydrology"
: "Planet Sim - World Creation: drift & erosion");
line(TextFormat("Cells: %d Subdiv: %d CellWidth: %.0f km", line(TextFormat("Cells: %d Subdiv: %d CellWidth: %.0f km",
(int)planet.cells.size(), cfg.subdivisions, planet.cellWidthMeters() / 1000.0)); (int)planet.cells.size(), cfg.subdivisions, planet.cellWidthMeters() / 1000.0));
line(TextFormat("Elevation: %.0f .. %.0f m", minE, maxE)); line(TextFormat("Elevation: %.0f .. %.0f m", minE, maxE));
if (!settled) if (!settled)
line(TextFormat("Forming terrain tick %lld max change %.1f m/tick%s", line(TextFormat("Forming terrain tick %lld max change %.1f m/tick%s",
stepCount, maxChange, paused ? " [PAUSED]" : "")); stepCount, maxChange, paused ? " [PAUSED]" : ""));
else if (liveWorld) {
const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays;
double days = liveTime / dayH;
long year = (long)std::floor(days / yrD) + 1;
long doy = (long)std::floor(days - std::floor(days / yrD) * yrD) + 1;
double hod = liveTime - std::floor(days) * dayH; // hours into the current day
int hh = (int)hod, mm = (int)((hod - hh) * 60.0);
line(TextFormat("Live World Year %ld Day %ld %02d:%02d%s",
year, doy, hh, mm, paused ? " [PAUSED]" : ""));
const double weekH = 7.0 * dayH, monthH = 30.0 * dayH;
const char* rl; double rv;
if (liveRate >= monthH) { rl = "mo/s"; rv = liveRate / monthH; }
else if (liveRate >= weekH) { rl = "wk/s"; rv = liveRate / weekH; }
else if (liveRate >= dayH) { rl = "d/s"; rv = liveRate / dayH; }
else { rl = "h/s"; rv = liveRate; }
line(TextFormat("rate %.1f %s day/night %s ([ / ] speed, N toggle, W exit)",
rv, rl, dayNightOn ? "on" : "off"));
}
else { else {
line(TextFormat("%s %.1f My elapsed %.1f My/s%s", line(TextFormat("%s %.1f My elapsed %.1f My/s%s",
phase3 ? "Hydrology - drift, rivers & erosion" : "Drift & erosion", phase3 ? "Hydrology - drift, rivers & erosion" : "Drift & erosion",
@ -178,11 +371,11 @@ 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"); 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]", line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off")); showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%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")); 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");
if (!statusMsg.empty() && GetTime() < statusUntil) { if (!statusMsg.empty() && GetTime() < statusUntil) {
y += 4; DrawText(statusMsg.c_str(), 12, y, 18, Color{120, 230, 140, 255}); y += 22; y += 4; DrawText(statusMsg.c_str(), 12, y, 18, Color{120, 230, 140, 255}); y += 22;
@ -254,6 +447,7 @@ void Viewer::renderFrame() {
} }
renderMap2D(); renderMap2D();
renderLiveInfo();
renderPanels(); renderPanels();
renderHUD(); renderHUD();
renderPrompt(); renderPrompt();

View File

@ -33,6 +33,7 @@ void Planet::generate(const PlanetConfig& c) {
assignPlates(); assignPlates();
seedInitialRelief(); seedInitialRelief();
generateMoons(); // Live World satellites (separate RNG; does not perturb tectonics)
computeClimate(); // temperature + precipitation fields (biomes read these) computeClimate(); // temperature + precipitation fields (biomes read these)
classifyBiomes(); // give the fresh world an initial biome per cell classifyBiomes(); // give the fresh world an initial biome per cell
computeBiotaDensity(); // derived flora/fauna/funga density (population is on-demand) computeBiotaDensity(); // derived flora/fauna/funga density (population is on-demand)

View File

@ -14,6 +14,7 @@ public:
PlanetConfig cfg; PlanetConfig cfg;
std::vector<Cell> cells; std::vector<Cell> cells;
std::vector<Plate> plates; std::vector<Plate> plates;
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -51,9 +52,42 @@ public:
// rain, leeward rain shadow, dry continental interiors). Derived (not saved); // rain, leeward rain shadow, dry continental interiors). Derived (not saved);
// call before classifyBiomes(), which consumes these fields. // call before classifyBiomes(), which consumes these fields.
void computeClimate(); void computeClimate();
const std::vector<double>& temperature() const { return sTemp; } // deg C const std::vector<double>& temperature() const { return sTemp; } // deg C, annual mean
const std::vector<double>& precipitation() const { return sPrecip; } // relative units const std::vector<double>& precipitation() const { return sPrecip; } // relative units
const std::vector<double>& moisture() const { return sMoist; } // 0..1 (median land -> 0.5) const std::vector<double>& moisture() const { return sMoist; } // 0..1 (median land -> 0.5)
const std::vector<double>& summerTemp() const { return sTempSummer; } // deg C, warmest month
const std::vector<double>& winterTemp() const { return sTempWinter; } // deg C, coldest month
// Live World stage (PlanetLive.cpp): the slow real-time clock's derived fields.
// computeInsolation() = instantaneous solar incidence cos(sun angle), 0..1 -- the
// physical foundation for live weather + the day/night terminator. computeLiveSeason()
// = the live temperature cycling between winterTemp/summerTemp over the year (drives the
// live temperature view + the moving snow line). Both derived/not-saved; call after
// computeClimate(). dayOfYear01/timeOfDay01 are fractions in [0,1).
void computeInsolation(double dayOfYear01, double timeOfDay01);
void computeLiveSeason(double dayOfYear01);
const std::vector<double>& insolation() const { return sInsolation; } // 0..1 cos incidence
const std::vector<double>& liveTemp() const { return sLiveTemp; } // deg C, current season
// Live World sky + oceans (PlanetOcean.cpp). Sub-solar / sub-lunar directions in model
// space at a clock fraction (drive insolation, tides and the 3D sun/moons -- one source of
// truth). generateMoons() seeds 1-3 moons from a separate RNG (tectonic determinism intact).
Vec3 sunDirection(double dayOfYear01, double timeOfDay01) const;
Vec3 moonDirection(int moonIdx, double timeOfDay01, double timeDays) const;
Vec3 moonOrbitNormal(int moonIdx, double timeOfDay01) const; // orbit-plane normal (for the ring)
const std::vector<Moon>& getMoons() const { return moons; }
void generateMoons();
// Tides: equilibrium tidal height (m) per cell from the moons + sun at the given clock.
// Derived/not saved; recomputed each frame like insolation.
void computeTides(double dayOfYear01, double timeOfDay01, double timeDays);
const std::vector<double>& tide() const { return sTide; }
// Ocean surface currents (PlanetOcean.cpp): a per-ocean-cell tangent velocity from wind
// stress + Coriolis deflection + coast-following (gyres). Derived/not saved; needs sWind
// (computeClimate() computes it, then calls this and feeds warm/cold currents back into
// sTemp -- see climateCurrentFactor). Zero on land cells.
void computeOceanCurrents();
const std::vector<Vec3>& current() const { return sCurrent; }
// 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
@ -89,7 +123,9 @@ public:
// hasBiome: whether the stream carries the per-cell biome byte (save v4+). For // hasBiome: whether the stream carries the per-cell biome byte (save v4+). For
// 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+).
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true); // hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -152,11 +188,18 @@ private:
std::vector<int> sFlowTo, sHydroOrder; std::vector<int> sFlowTo, sHydroOrder;
// Phase-3 climate scratch (derived each computeClimate(); not saved). sMoist is the // Phase-3 climate scratch (derived each computeClimate(); not saved). sMoist is the
// 0..1-normalized precipitation the biome classifier reads. // 0..1-normalized precipitation the biome classifier reads. sTempSummer/sTempWinter are
std::vector<double> sTemp, sPrecip, sMoist; // the obliquity-driven seasonal extremes around the annual mean sTemp (see Seasons).
std::vector<double> sTemp, sPrecip, sMoist, sTempSummer, sTempWinter;
std::vector<Vec3> sWind; std::vector<Vec3> sWind;
std::vector<int> sUpwind; std::vector<int> sUpwind;
// Live World scratch (derived each frame in Live World; not saved). sInsolation is the
// instantaneous solar incidence; sLiveTemp is the temperature for the current day-of-year;
// sTide is the equilibrium tidal height (m) from the moons + sun.
std::vector<double> sInsolation, sLiveTemp, sTide;
std::vector<Vec3> sCurrent; // ocean surface current velocity (tangent; zero on land)
// 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.
std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity; std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity;

View File

@ -22,11 +22,18 @@ void Planet::classifyBiomes() {
const double GRASS_MOIST = cfg.biomeGrassMoist, TAIGA_MOIST = cfg.biomeTaigaMoist; const double GRASS_MOIST = cfg.biomeGrassMoist, TAIGA_MOIST = cfg.biomeTaigaMoist;
const double LAKE_MIN_DEPTH = cfg.biomeLakeMinDepth; const double LAKE_MIN_DEPTH = cfg.biomeLakeMinDepth;
const bool haveLake = !sLakeDepth.empty(); const bool haveLake = !sLakeDepth.empty();
// Seasons: blend winter temperature into the cold (Tundra/Taiga) cutoffs so cold-winter
// continental interiors turn boreal/tundra. The seasonal amplitude is itself geographically
// shaped (large only at high-latitude interiors), so this expands cold biomes where seasons
// bite, not uniformly. biomeSeasonWeight = 0 -> coldT == annual mean -> biomes unchanged.
const double SEASON_W = cfg.biomeSeasonWeight;
const bool haveSeason = ((int)sTempWinter.size() == n);
for (int i = 0; i < n; ++i) { for (int i = 0; i < n; ++i) {
const Cell& c = cells[i]; const Cell& c = cells[i];
double elevAbove = std::max(0.0, c.elevation - sea); double elevAbove = std::max(0.0, c.elevation - sea);
double temp = sTemp[i]; // climate temperature (deg C) double temp = sTemp[i]; // climate temperature (deg C, annual mean)
double coldT = haveSeason ? temp + SEASON_W * (sTempWinter[i] - temp) : temp; // winter-blended
double moist = sMoist[i]; // climate precipitation, normalized 0..1 double moist = sMoist[i]; // climate precipitation, normalized 0..1
double lakeD = haveLake ? sLakeDepth[i] : 0.0; double lakeD = haveLake ? sLakeDepth[i] : 0.0;
bool adjOcean = false, adjWater = false; bool adjOcean = false, adjWater = false;
@ -43,9 +50,9 @@ void Planet::classifyBiomes() {
else if (elevAbove > MOUNTAIN_ELEV) b = Biome::Mountains; else if (elevAbove > MOUNTAIN_ELEV) b = Biome::Mountains;
else if (elevAbove > HILLS_ELEV) b = Biome::Hills; else if (elevAbove > HILLS_ELEV) b = Biome::Hills;
else { // lowland / plains else { // lowland / plains
if (temp < TUNDRA_TEMP) b = Biome::Tundra; if (coldT < TUNDRA_TEMP) b = Biome::Tundra;
else if (elevAbove < LOWLAND_ELEV && moist > WETLAND_MOIST && adjWater) b = Biome::Wetland; // swamps hug water else if (elevAbove < LOWLAND_ELEV && moist > WETLAND_MOIST && adjWater) b = Biome::Wetland; // swamps hug water
else if (temp < TAIGA_TEMP) b = (moist > TAIGA_MOIST) ? Biome::Taiga : Biome::Tundra; else if (coldT < TAIGA_TEMP) b = (moist > TAIGA_MOIST) ? Biome::Taiga : Biome::Tundra;
else if (moist < DESERT_MOIST) b = Biome::Desert; else if (moist < DESERT_MOIST) b = Biome::Desert;
else if (moist < GRASS_MOIST) b = (temp > SAVANNA_TEMP) ? Biome::Savanna : Biome::Grassland; else if (moist < GRASS_MOIST) b = (temp > SAVANNA_TEMP) ? Biome::Savanna : Biome::Grassland;
else b = Biome::Forest; else b = Biome::Forest;

View File

@ -26,6 +26,8 @@ void Planet::computeClimate() {
sTemp.assign(n, 0.0); sTemp.assign(n, 0.0);
sPrecip.assign(n, 0.0); sPrecip.assign(n, 0.0);
sMoist.assign(n, 0.0); sMoist.assign(n, 0.0);
sTempSummer.assign(n, 0.0);
sTempWinter.assign(n, 0.0);
sWind.assign(n, Vec3{0, 0, 0}); sWind.assign(n, Vec3{0, 0, 0});
sUpwind.assign(n, -1); sUpwind.assign(n, -1);
@ -61,6 +63,38 @@ void Planet::computeClimate() {
sUpwind[i] = best; sUpwind[i] = best;
} }
// 1b. Ocean currents + their temperature feedback. Currents need the wind just computed.
// Warm (poleward-flowing) currents carry equatorial heat toward the poles and cold
// (equatorward) currents the reverse; this raises/lowers coastal temperatures. We build a
// bounded anomaly on ocean cells (sign = poleward speed) and smooth it onto the coasts.
computeOceanCurrents();
if (cfg.climateCurrentFactor > 0.0 && (int)sCurrent.size() == n) {
double maxSp = 1e-9;
for (int i = 0; i < n; ++i) maxSp = std::max(maxSp, sCurrent[i].length());
std::vector<double> dT(n, 0.0);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation > sea) continue; // ocean cells only
const Vec3& nrm = cells[i].unit;
Vec3 northT = up - nrm * up.dot(nrm); // tangent toward +latitude
double nl = northT.length(); if (nl < 1e-9) continue;
northT = northT * (1.0 / nl);
double sign = (nrm.y >= 0.0) ? 1.0 : -1.0; // toward the nearer pole
double poleward = sCurrent[i].dot(northT) * sign / maxSp; // -1..1
dT[i] = cfg.climateCurrentFactor * std::clamp(poleward, -1.0, 1.0);
}
// Spread the anomaly onto coasts and let it decay inland (neighbour averaging).
std::vector<double> tmp(n);
for (int p = 0; p < 3; ++p) {
for (int i = 0; i < n; ++i) {
double s = dT[i]; int c = 1;
for (int nb : cells[i].neighbors) { s += dT[nb]; ++c; }
tmp[i] = s / c;
}
dT.swap(tmp);
}
for (int i = 0; i < n; ++i) sTemp[i] += dT[i];
}
// 2. Steady-state moisture advection along the wind (iterative upwind differencing, // 2. Steady-state moisture advection along the wind (iterative upwind differencing,
// double-buffered -> deterministic). Ocean cells are a moisture source; land // double-buffered -> deterministic). Ocean cells are a moisture source; land
// cells take their upwind moisture, rain part of it out (more on windward upslopes) // cells take their upwind moisture, rain part of it out (more on windward upslopes)
@ -131,4 +165,42 @@ void Planet::computeClimate() {
ref = std::max(1e-6, landP[mid] / 0.5); // median -> 0.5 ref = std::max(1e-6, landP[mid] / 0.5); // median -> 0.5
} }
for (int i = 0; i < n; ++i) sMoist[i] = std::clamp(sPrecip[i] / ref, 0.0, 1.0); for (int i = 0; i < n; ++i) sMoist[i] = std::clamp(sPrecip[i] / ref, 0.0, 1.0);
// --- Seasons (obliquity) ------------------------------------------------------
// Per-cell summer (warmest-month) and winter (coldest-month) temperatures around
// the annual mean sTemp. The seasonal half-amplitude grows with axial tilt, with
// latitude (poles swing most), and with continentality -- distance from the ocean,
// which moderates coastal climates via its thermal inertia. These are STATIC fields
// (the extremes), not an animated year, so hemisphere phase is irrelevant.
{
// Continentality: multi-source BFS ring distance from ocean cells over the fixed
// neighbour graph. Ring distance is order-independent -> deterministic, O(n).
const int rings = std::max(1, cfg.seasonContinentRings);
std::vector<int> dist(n, -1), frontier, next;
for (int i = 0; i < n; ++i)
if (cells[i].elevation <= sea) { dist[i] = 0; frontier.push_back(i); }
for (int r = 1; r <= rings && !frontier.empty(); ++r) {
next.clear();
for (int i : frontier)
for (int nb : cells[i].neighbors)
if (dist[nb] < 0) { dist[nb] = r; next.push_back(nb); }
frontier.swap(next);
}
const double tiltFactor = std::sin(cfg.axialTilt * M_PI / 180.0) /
std::sin(23.44 * M_PI / 180.0); // 0 tilt -> 0, Earth -> 1
const double ampMax = cfg.seasonAmpMax, latExp = cfg.seasonLatExp;
const double oceanF = cfg.seasonOceanFactor;
for (int i = 0; i < n; ++i) {
double cont;
if (cells[i].elevation <= sea) cont = oceanF; // ocean: muted swing
else if (dist[i] < 0) cont = 1.0; // deep interior (unreached)
else cont = (double)dist[i] / rings;// coast 0 -> interior 1
cont = std::clamp(std::max(oceanF, cont), 0.0, 1.0);
double lat = std::asin(std::clamp(cells[i].unit.y, -1.0, 1.0));
double latShape = std::pow(std::fabs(lat) / (M_PI / 2.0), latExp);
double A = ampMax * std::max(0.0, tiltFactor) * latShape * cont;
sTempSummer[i] = sTemp[i] + A;
sTempWinter[i] = sTemp[i] - A;
}
}
} }

View File

@ -30,9 +30,11 @@ void Planet::erode(double dtMy) {
} }
sErode[i] = delta; sErode[i] = delta;
} }
// Upper clamp tracks peakSoftCapEnd (matches step()); the soft peak cap, not a
// fixed 9000 m wall here, governs how high mountains stand.
#pragma omp parallel for schedule(static) if(n > 20000) #pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(cells[i].elevation + sErode[i], -11000.0, 9000.0); cells[i].elevation = std::clamp(cells[i].elevation + sErode[i], -11000.0, cfg.peakSoftCapEnd);
if (++erodeIter % cfg.seaLevelEvery == 0) adjustSeaLevel(); if (++erodeIter % cfg.seaLevelEvery == 0) adjustSeaLevel();
} }

View File

@ -98,7 +98,8 @@ void Planet::hydrology(double dtMy) {
} }
load[d] += carried; // pass remaining sediment downstream load[d] += carried; // pass remaining sediment downstream
} }
// Upper clamp tracks peakSoftCapEnd (matches step()/erode()).
#pragma omp parallel for schedule(static) if(n > 20000) #pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(cells[i].elevation, -11000.0, 9000.0); cells[i].elevation = std::clamp(cells[i].elevation, -11000.0, cfg.peakSoftCapEnd);
} }

View File

@ -15,6 +15,7 @@
#define CONFIG_FIELDS(D, I, U) \ #define CONFIG_FIELDS(D, I, U) \
D(radius) D(seaLevel) D(axialTilt) D(continentBase) D(oceanBase) D(upliftGain) D(relax) \ D(radius) D(seaLevel) D(axialTilt) D(continentBase) D(oceanBase) D(upliftGain) D(relax) \
D(collisionFactor) D(arcFactor) D(isostaticPersist) D(rootScale) \ D(collisionFactor) D(arcFactor) D(isostaticPersist) D(rootScale) \
D(peakSoftCapStart) D(peakSoftCapEnd) D(peakFailDrop) \
D(seafloorSubsidence) D(seafloorSeedAge) \ D(seafloorSubsidence) D(seafloorSeedAge) \
D(maxDriftSpeed) D(ridgeDepth) D(splitFraction) D(splitProbBase) D(splitProbSlope) \ D(maxDriftSpeed) D(ridgeDepth) D(splitFraction) D(splitProbBase) D(splitProbSlope) \
D(stalemateEps) D(stalemateBoost) D(babyPromoteFrac) D(volcanicLandFrac) \ D(stalemateEps) D(stalemateBoost) D(babyPromoteFrac) D(volcanicLandFrac) \
@ -26,15 +27,18 @@
D(biomeIceTemp) D(biomeTundraTemp) D(biomeTaigaTemp) D(biomeSavannaTemp) \ D(biomeIceTemp) D(biomeTundraTemp) D(biomeTaigaTemp) D(biomeSavannaTemp) \
D(biomeMountainElev) D(biomeHillsElev) D(biomeBeachBand) D(biomeLowlandElev) \ D(biomeMountainElev) D(biomeHillsElev) D(biomeBeachBand) D(biomeLowlandElev) \
D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \ D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \
D(biomeLakeMinDepth) \ D(biomeLakeMinDepth) D(biomeSeasonWeight) \
D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \ D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \
D(climateOroRefHeight) D(climateContinentality) \ D(climateOroRefHeight) D(climateContinentality) D(climateCurrentFactor) \
D(seasonAmpMax) D(seasonLatExp) D(seasonOceanFactor) \
D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \ D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \
D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \ D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \
D(bioFungaTempMin) D(bioRegionBonus) \ D(bioFungaTempMin) D(bioRegionBonus) \
D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \
D(tideAmplitude) D(tideSunFactor) \
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(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \
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)
@ -114,6 +118,9 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.arcFactor, 0.0, 20.0, "arcFactor")); E(rng(cfg.arcFactor, 0.0, 20.0, "arcFactor"));
E(rng(cfg.isostaticPersist, 0.0, 0.95, "isostaticPersist")); E(rng(cfg.isostaticPersist, 0.0, 0.95, "isostaticPersist"));
E(rng(cfg.rootScale, 100.0, 20000.0, "rootScale")); E(rng(cfg.rootScale, 100.0, 20000.0, "rootScale"));
E(rng(cfg.peakSoftCapStart, 0.0, 20000.0, "peakSoftCapStart"));
E(rng(cfg.peakSoftCapEnd, 0.0, 20000.0, "peakSoftCapEnd"));
E(rng(cfg.peakFailDrop, 0.0, 5000.0, "peakFailDrop"));
E(rng(cfg.seafloorSubsidence, 0.0, 2000.0, "seafloorSubsidence")); E(rng(cfg.seafloorSubsidence, 0.0, 2000.0, "seafloorSubsidence"));
E(rng(cfg.seafloorSeedAge, 0.0, 1000.0, "seafloorSeedAge")); E(rng(cfg.seafloorSeedAge, 0.0, 1000.0, "seafloorSeedAge"));
E(rng(cfg.maxDriftSpeed, 0.1, 100.0, "maxDriftSpeed")); E(rng(cfg.maxDriftSpeed, 0.1, 100.0, "maxDriftSpeed"));
@ -158,11 +165,16 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.biomeGrassMoist, 0.0, 1.0, "biomeGrassMoist")); E(rng(cfg.biomeGrassMoist, 0.0, 1.0, "biomeGrassMoist"));
E(rng(cfg.biomeTaigaMoist, 0.0, 1.0, "biomeTaigaMoist")); E(rng(cfg.biomeTaigaMoist, 0.0, 1.0, "biomeTaigaMoist"));
E(rng(cfg.biomeLakeMinDepth, 0.0, 5000.0, "biomeLakeMinDepth")); E(rng(cfg.biomeLakeMinDepth, 0.0, 5000.0, "biomeLakeMinDepth"));
E(rng(cfg.biomeSeasonWeight, 0.0, 1.0, "biomeSeasonWeight"));
E(rng(cfg.climateOceanMoisture, 0.0, 1.0e3, "climateOceanMoisture")); E(rng(cfg.climateOceanMoisture, 0.0, 1.0e3, "climateOceanMoisture"));
E(rng(cfg.climateRainEfficiency, 0.0, 1.0, "climateRainEfficiency")); E(rng(cfg.climateRainEfficiency, 0.0, 1.0, "climateRainEfficiency"));
E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic")); E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic"));
E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight")); E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight"));
E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality")); E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality"));
E(rng(cfg.climateCurrentFactor, 0.0, 30.0, "climateCurrentFactor"));
E(rng(cfg.seasonAmpMax, 0.0, 60.0, "seasonAmpMax"));
E(rng(cfg.seasonLatExp, 0.1, 6.0, "seasonLatExp"));
E(rng(cfg.seasonOceanFactor, 0.0, 1.0, "seasonOceanFactor"));
E(rng(cfg.bioVegTempMin, -40.0, 30.0, "bioVegTempMin")); E(rng(cfg.bioVegTempMin, -40.0, 30.0, "bioVegTempMin"));
E(rng(cfg.bioVegTempOpt, -20.0, 50.0, "bioVegTempOpt")); E(rng(cfg.bioVegTempOpt, -20.0, 50.0, "bioVegTempOpt"));
E(rng(cfg.bioVegMoistRef, 0.01, 1.0, "bioVegMoistRef")); E(rng(cfg.bioVegMoistRef, 0.01, 1.0, "bioVegMoistRef"));
@ -173,6 +185,12 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight")); E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight"));
E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin")); E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin"));
E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus")); E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus"));
E(rng(cfg.dayLengthHours, 0.1, 1.0e5, "dayLengthHours"));
E(rng(cfg.yearLengthDays, 1.0, 1.0e7, "yearLengthDays"));
E(rng(cfg.snowTemp, -60.0, 30.0, "snowTemp"));
E(rng(cfg.seaIceTemp, -60.0, 20.0, "seaIceTemp"));
E(rng(cfg.tideAmplitude, 0.0, 100.0, "tideAmplitude"));
E(rng(cfg.tideSunFactor, 0.0, 5.0, "tideSunFactor"));
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"));
@ -184,6 +202,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.seaLevelEvery, 1, 100000, "seaLevelEvery")); E(irng(cfg.seaLevelEvery, 1, 100000, "seaLevelEvery"));
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.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"));
@ -193,6 +212,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
if (cfg.oceanBase >= cfg.continentBase) if (cfg.oceanBase >= cfg.continentBase)
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)"); bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)
bad.push_back("peakSoftCapStart >= peakSoftCapEnd (grow probability must span a band)");
if (bad.empty()) return {}; if (bad.empty()) return {};
std::string msg = "Bad config:"; std::string msg = "Bad config:";
@ -248,6 +269,7 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sPrevCount); writeVec(os, sPrevCount);
writeVec(os, sStaleStreak); writeVec(os, sStaleStreak);
writeVec(os, sFreePlateIds); writeVec(os, sFreePlateIds);
writeVec(os, moons); // v9: natural satellites (Live World)
// v7: discrete biota population (sBiota). A flag byte gates the block so a // v7: discrete biota population (sBiota). A flag byte gates the block so a
// not-yet-populated world stays compact; otherwise three Organism lists per cell. // not-yet-populated world stays compact; otherwise three Organism lists per cell.
uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio); uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio);
@ -259,7 +281,7 @@ void Planet::writeState(std::ostream& os) const {
} }
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) { bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons) {
// 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.
@ -288,6 +310,8 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) {
readVec(is, sPrevCount); readVec(is, sPrevCount);
readVec(is, sStaleStreak); readVec(is, sStaleStreak);
readVec(is, sFreePlateIds); readVec(is, sFreePlateIds);
if (hasMoons) readVec(is, moons); // v9: natural satellites
else generateMoons(); // pre-v9 save: synthesize moons from the seed
if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state
// v7: discrete biota population. buildGeometry() already sized sBiota empty; // v7: discrete biota population. buildGeometry() already sized sBiota empty;
// older saves (hasBiota=false) just keep the empty population (press L to fill). // older saves (hasBiota=false) just keep the empty population (press L to fill).

52
src/sim/PlanetLive.cpp Normal file
View File

@ -0,0 +1,52 @@
#include "Planet.hpp"
#include "Projection.hpp" // lonLatToDir, dirToLonLat
#include <cmath>
// Live World stage: the slow real-time clock (hours -> weeks/months). Geometry is
// fixed, so -- like climate -- these are derived per-cell fields flowed over the grid,
// recomputed each frame and never saved. They are the foundation the future weather
// simulation will read.
//
// Sun geometry is computed in MODEL space (the fixed cell unit vectors). The viewer
// renders the globe leaned by axialTilt about world Z, but since the lit pattern is
// baked per cell in model space it rotates with the globe automatically; the seasonal
// lean of day/night is carried by the solar declination below, not the render tilt.
// The 2D Equal Earth map also works in model-space lon/lat, so both views stay
// consistent without any tilt bookkeeping here.
// Instantaneous solar incidence cos(theta) in [0,1] per cell.
// declination delta = axialTilt * sin(2*pi * dayOfYear01) (0 at equinox, +/-tilt at solstice)
// sub-solar lon lambda sweeps the globe once per day (westward as the planet spins east)
// sunDir = lonLatToDir(lambda, delta); incidence = max(0, cell.unit . sunDir)
void Planet::computeInsolation(double dayOfYear01, double timeOfDay01) {
const int n = (int)cells.size();
sInsolation.assign(n, 0.0);
const Vec3 sun = sunDirection(dayOfYear01, timeOfDay01); // shared sky geometry (PlanetOcean.cpp)
for (int i = 0; i < n; ++i) {
double c = cells[i].unit.dot(sun);
sInsolation[i] = c > 0.0 ? c : 0.0;
}
}
// Live temperature for the current day-of-year: the annual mean sTemp swung toward the
// summer or winter extreme by the seasonal phase, opposite across the two hemispheres.
// g = sin(2*pi * dayOfYear01) * sign(lat) in [-1,1] (+1 = this hemisphere's summer)
// liveTemp = mean + A * g, A = (summerTemp - winterTemp)/2 (the seasonal half-amplitude)
// At g=+1 -> summer, g=-1 -> winter, equator (g~0) -> mean. Requires computeClimate() first.
void Planet::computeLiveSeason(double dayOfYear01) {
const int n = (int)cells.size();
sLiveTemp.assign(n, 0.0);
if ((int)sTemp.size() != n) return; // no climate yet
const bool haveSeason = ((int)sTempSummer.size() == n && (int)sTempWinter.size() == n);
const double season = std::sin(2.0 * M_PI * dayOfYear01);
for (int i = 0; i < n; ++i) {
double mean = sTemp[i];
if (!haveSeason) { sLiveTemp[i] = mean; continue; }
double A = 0.5 * (sTempSummer[i] - sTempWinter[i]); // >= 0
double g = season * (cells[i].unit.y >= 0.0 ? 1.0 : -1.0);
sLiveTemp[i] = mean + A * g;
}
}

152
src/sim/PlanetOcean.cpp Normal file
View File

@ -0,0 +1,152 @@
#include "Planet.hpp"
#include "Projection.hpp" // lonLatToDir
#include <cmath>
#include <cstdint>
#include <algorithm>
#include <vector>
// Live World sky & oceans. Geometry is fixed, so the moons are world objects (not cells) and
// the tide is a derived per-cell field flowed over the grid -- both animated by the live clock.
// Stage 1: moon generation, sub-solar/sub-lunar directions (one source of truth, shared by
// insolation/tides/render), and the equilibrium tide. (Stage 2 adds ocean currents here.)
namespace {
// Rotations about the model axes (Y = north/spin axis). rotateY by a advances longitude by
// a: lonLatToDir(lon,lat) rotated by a -> lonLatToDir(lon+a,lat).
inline Vec3 rotateX(const Vec3& v, double a) {
double c = std::cos(a), s = std::sin(a);
return Vec3{ v.x, c * v.y - s * v.z, s * v.y + c * v.z };
}
inline Vec3 rotateY(const Vec3& v, double a) {
double c = std::cos(a), s = std::sin(a);
return Vec3{ c * v.x - s * v.z, v.y, s * v.x + c * v.z };
}
}
// 1-3 satellites from a SEPARATE RNG (seeded from cfg.seed) so generating moons never touches
// the tectonic stream (rngState) -- mirrors the biota RNG isolation.
void Planet::generateMoons() {
moons.clear();
uint32_t s = cfg.seed ? (cfg.seed ^ 0x900D5EEDu) : 0x900D5EEDu;
auto next = [&]() { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return s; };
auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); };
int count = 1 + (int)(next() % 3u); // 1..3
for (int i = 0; i < count; ++i) {
Moon m;
m.orbitRadius = 8.0 + rf() * 22.0; // 8..30 planet radii (render scale)
m.periodDays = 6.0 + rf() * 54.0; // 6..60 days
m.phase = rf() * 2.0 * M_PI;
m.inclination = (rf() * 2.0 - 1.0) * (30.0 * M_PI / 180.0); // +/-30 deg
m.tideWeight = 0.3 + rf() * 0.9; // 0.3..1.2 (Moon mass proxy)
m.dispRadius = 0.05 + m.tideWeight * 0.06; // visual size ~ mass
moons.push_back(m);
}
}
// Sub-solar direction (model space). Celestial dir at a reference longitude, leaned by the
// seasonal declination, then spun by the planet's rotation (-2*pi*timeOfDay). Equivalent to the
// old lonLatToDir(pi*(1-2t), decl), so day/night is unchanged.
Vec3 Planet::sunDirection(double dayOfYear01, double timeOfDay01) const {
double decl = cfg.axialTilt * M_PI / 180.0 * std::sin(2.0 * M_PI * dayOfYear01);
Vec3 C = lonLatToDir(M_PI, decl);
return rotateY(C, -2.0 * M_PI * timeOfDay01);
}
// Sub-lunar direction (model space): the moon orbits in an inclined plane, then the planet spin
// sweeps it across the sky (so a fixed cell sees ~one lunar pass per day).
Vec3 Planet::moonDirection(int moonIdx, double timeOfDay01, double timeDays) const {
const Moon& m = moons[moonIdx];
double Om = 2.0 * M_PI * timeDays / std::max(1e-9, m.periodDays) + m.phase;
Vec3 eq{ std::cos(Om), 0.0, std::sin(Om) }; // equatorial orbit circle
Vec3 C = rotateX(eq, m.inclination); // incline the orbit plane
return rotateY(C, -2.0 * M_PI * timeOfDay01); // planet spin
}
// Ocean surface currents: a per-ocean-cell tangent velocity. Wind drags the surface, Coriolis
// deflects it (right in the N hemisphere, left in the S), and coasts block the across-shore
// component so the flow turns to follow the shore -- which closes into gyres. A few smoothing
// passes make the field coherent. Approximate at 223 km/cell, but it gives plausible streams +
// the warm/cold pattern the climate feedback reads. Needs sWind (computeClimate sets it).
void Planet::computeOceanCurrents() {
const int n = (int)cells.size();
sCurrent.assign(n, Vec3{0, 0, 0});
if ((int)sWind.size() != n) return;
const double sea = cfg.seaLevel;
auto isOcean = [&](int i) { return cells[i].elevation <= sea; };
// 1. Wind-driven base velocity, rotated by a Coriolis deflection about the local normal.
const double deflect = 35.0 * M_PI / 180.0;
std::vector<Vec3> v(n, Vec3{0, 0, 0});
for (int i = 0; i < n; ++i) {
if (!isOcean(i)) continue;
const Vec3& w = sWind[i];
if (w.length() < 1e-9) continue;
const Vec3& nrm = cells[i].unit;
double lat = std::asin(std::clamp(nrm.y, -1.0, 1.0));
double th = deflect * (lat >= 0.0 ? -1.0 : 1.0); // deflect right (N) / left (S)
v[i] = w * std::cos(th) + nrm.cross(w) * std::sin(th); // rotate w about the normal
}
// 2. Coast deflection: remove any component flowing INTO a land neighbour, so the stream
// bends to run along the coast (the seed of gyre circulation).
for (int i = 0; i < n; ++i) {
if (!isOcean(i) || v[i].length() < 1e-12) continue;
const Vec3& nrm = cells[i].unit;
for (int nb : cells[i].neighbors) {
if (isOcean(nb)) continue;
Vec3 d = cells[nb].unit - cells[i].unit;
d = d - nrm * d.dot(nrm); // tangent direction to the land
double dl = d.length(); if (dl < 1e-12) continue;
d = d * (1.0 / dl);
double into = v[i].dot(d);
if (into > 0.0) v[i] = v[i] - d * into;
}
}
// 3. Smooth over the ocean (neighbour average, re-projected to the tangent plane).
std::vector<Vec3> tmp(n);
for (int p = 0; p < 3; ++p) {
for (int i = 0; i < n; ++i) {
if (!isOcean(i)) { tmp[i] = Vec3{0, 0, 0}; continue; }
Vec3 s = v[i]; int c = 1;
for (int nb : cells[i].neighbors) if (isOcean(nb)) { s = s + v[nb]; ++c; }
Vec3 a = s * (1.0 / c);
const Vec3& nrm = cells[i].unit;
tmp[i] = a - nrm * a.dot(nrm); // re-tangent
}
v.swap(tmp);
}
sCurrent.swap(v);
}
// Orbit-plane normal (model space) for moon i at the current spin -- the orbit is the great
// circle perpendicular to this, used to draw the faint orbit ring.
Vec3 Planet::moonOrbitNormal(int moonIdx, double timeOfDay01) const {
Vec3 nrm = rotateX(Vec3{ 0.0, 1.0, 0.0 }, moons[moonIdx].inclination);
return rotateY(nrm, -2.0 * M_PI * timeOfDay01);
}
// Equilibrium tide (meters) per cell from every tide-raising body (moons + sun). Each body
// raises two bulges -- toward it and its antipode -- via the (cos^2 - 1/3) tidal potential, so
// the field is zero-mean and a cell rises/falls ~twice a day as the bulges sweep past.
void Planet::computeTides(double dayOfYear01, double timeOfDay01, double timeDays) {
const int n = (int)cells.size();
sTide.assign(n, 0.0);
struct Body { Vec3 dir; double w; };
std::vector<Body> bodies;
bodies.reserve(moons.size() + 1);
for (int i = 0; i < (int)moons.size(); ++i)
bodies.push_back({ moonDirection(i, timeOfDay01, timeDays), moons[i].tideWeight });
bodies.push_back({ sunDirection(dayOfYear01, timeOfDay01), cfg.tideSunFactor });
const double amp = cfg.tideAmplitude;
for (int i = 0; i < n; ++i) {
double h = 0.0;
for (const auto& b : bodies) {
double c = cells[i].unit.dot(b.dir);
h += b.w * (c * c - 1.0 / 3.0);
}
sTide[i] = amp * h;
}
}

View File

@ -1,6 +1,22 @@
#include "Planet.hpp" #include "Planet.hpp"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
// Deterministic, thread-independent value in [0,1). Pure function of (cell, tick,
// seed) -- so the parallel step() loops stay bit-identical for any thread count
// and this never touches Planet::rngState. `salt` selects an independent stream
// (one for the grow roll, one for the drop magnitude). splitmix64 finalizer.
static inline double peakHash(int i, uint64_t tick, uint32_t seed, uint32_t salt) {
uint64_t x = (uint64_t)(uint32_t)i * 0x9E3779B97F4A7C15ull
+ (tick + 1) * 0xD1B54A32D192ED03ull
+ (uint64_t)seed * 0xBF58476D1CE4E5B9ull
+ (uint64_t)salt + 0x123456789ull;
x ^= x >> 30; x *= 0xBF58476D1CE4E5B9ull;
x ^= x >> 27; x *= 0x94D049BB133111EBull;
x ^= x >> 31;
return (double)(x >> 11) * (1.0 / 9007199254740992.0); // 53-bit -> [0,1)
}
// --- Phase 1/2 tectonics: one stress->uplift->relax tick --------------------- // --- Phase 1/2 tectonics: one stress->uplift->relax tick ---------------------
// Data-parallel: every pass writes only its own cell index (double-buffered // Data-parallel: every pass writes only its own cell index (double-buffered
@ -138,7 +154,23 @@ double Planet::step() {
relaxEff = cfg.relax; relaxEff = cfg.relax;
} }
} }
cells[i].elevation += delta[i]; double d = delta[i];
// Soft probabilistic peak cap (drift-only): above peakSoftCapStart the
// chance that uplift takes falls linearly to 0 at peakSoftCapEnd, so peaks
// spread across a height band instead of railing at the old hard ceiling.
// A lost roll forfeits this tick's uplift and shaves a random 0..peakFailDrop
// metres off (so a peak hovers near its own height). erodeIter is the tick
// counter (step/erode run 1:1 in drift; it's saved -> exact resume).
if (drifting && d > 0.0 && cells[i].elevation > cfg.peakSoftCapStart) {
double span = cfg.peakSoftCapEnd - cfg.peakSoftCapStart;
double p = span > 0.0 ? (cfg.peakSoftCapEnd - cells[i].elevation) / span : 0.0;
p = std::clamp(p, 0.0, 1.0);
if (peakHash(i, (uint64_t)erodeIter, cfg.seed, 0u) >= p) { // grow roll lost
d = 0.0;
cells[i].elevation -= peakHash(i, (uint64_t)erodeIter, cfg.seed, 1u) * cfg.peakFailDrop;
}
}
cells[i].elevation += d;
cells[i].elevation += (base - cells[i].elevation) * relaxEff; cells[i].elevation += (base - cells[i].elevation) * relaxEff;
// (geoAge is crust age in My, advanced by advect() in Phase 2.) // (geoAge is crust age in My, advanced by advect() in Phase 2.)
} }
@ -151,9 +183,13 @@ double Planet::step() {
for (int nb : cells[i].neighbors) { sum += cells[nb].elevation; ++cnt; } for (int nb : cells[i].neighbors) { sum += cells[nb].elevation; ++cnt; }
smoothed[i] = cells[i].elevation * 0.96 + (sum / cnt) * 0.04; smoothed[i] = cells[i].elevation * 0.96 + (sum / cnt) * 0.04;
} }
// Upper clamp tracks peakSoftCapEnd so the soft cap governs peak heights
// (a fixed 9000 m ceiling would re-flatten everything the soft cap allows);
// it's just a safety rail now. Lower clamp (-11000 m, trenches) is unchanged.
const double elevCeil = cfg.peakSoftCapEnd;
#pragma omp parallel for schedule(static) if(n > 20000) #pragma omp parallel for schedule(static) if(n > 20000)
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
cells[i].elevation = std::clamp(smoothed[i], -11000.0, 9000.0); cells[i].elevation = std::clamp(smoothed[i], -11000.0, elevCeil);
// Largest elevation change this tick -> 0 as the world reaches equilibrium. // Largest elevation change this tick -> 0 as the world reaches equilibrium.
double maxChange = 0.0; double maxChange = 0.0;

View File

@ -35,6 +35,20 @@ enum class Biome : uint8_t {
Desert, Forest, Taiga, Tundra, Hills, Mountains Desert, Forest, Taiga, Tundra, Hills, Mountains
}; };
// A natural satellite (Live World). Geometry is fixed, so the moon is a world object
// (not a cell): it orbits on the live clock, raises tides, and renders as a small sphere.
// Generated 1-3 per world from a separate RNG (so it never perturbs tectonic determinism)
// and saved (v9). orbitRadius is in planet radii (render scale); tideWeight is the tide-
// raising mass proxy; inclination tilts the orbit plane off the equator.
struct Moon {
double orbitRadius = 16.0; // planet radii (visual orbit distance)
double periodDays = 20.0; // orbital period (planetary days)
double phase = 0.0; // orbital phase offset (radians)
double inclination = 0.0; // orbit-plane tilt off the equator (radians)
double tideWeight = 1.0; // tide-raising strength (Moon mass proxy)
double dispRadius = 0.10; // display sphere radius (visual size)
};
struct Plate { struct Plate {
int id = 0; int id = 0;
PlateType type = PlateType::Oceanic; // initial crust type seeded onto cells PlateType type = PlateType::Oceanic; // initial crust type seeded onto cells
@ -94,6 +108,16 @@ struct PlanetConfig {
double isostaticPersist = 0.85; // how much high crust resists relax (0..<1) double isostaticPersist = 0.85; // how much high crust resists relax (0..<1)
double rootScale = 2500.0;// m above continentBase where persistence saturates double rootScale = 2500.0;// m above continentBase where persistence saturates
// --- Soft peak cap (drift-only): spread mountain heights, no hard plateau --
// Above peakSoftCapStart the chance that a tick's uplift "takes" falls linearly
// to 0 at peakSoftCapEnd, so peaks settle across a height band instead of all
// railing at one ceiling. A lost grow roll forfeits that tick's uplift and
// shaves a random 0..peakFailDrop metres off. The hard elevation clamp's upper
// bound tracks peakSoftCapEnd. Active only during drift (forming still settles).
double peakSoftCapStart = 7000.0; // m: below this, uplift always takes (P=1)
double peakSoftCapEnd = 12000.0; // m: at/above this, uplift never takes (P=0)
double peakFailDrop = 200.0; // m: max random drop when the grow roll loses
// --- Plate drift (Phase 2) ---------------------------------------------- // --- Plate drift (Phase 2) ----------------------------------------------
double maxDriftSpeed = 20.0; // cm/year; fastest plate (Earth is 1-10) double maxDriftSpeed = 20.0; // cm/year; fastest plate (Earth is 1-10)
double ridgeDepth = -2500.0; // m, elevation of brand-new crust at a ridge double ridgeDepth = -2500.0; // m, elevation of brand-new crust at a ridge
@ -148,7 +172,8 @@ struct PlanetConfig {
double phase3AfterMy = 300.0; // My of drift before the Phase-3 prompt double phase3AfterMy = 300.0; // My of drift before the Phase-3 prompt
double phase3DtScale = 0.2; // Phase-3 timestep = cflDtMy() * this (finer) double phase3DtScale = 0.2; // Phase-3 timestep = cflDtMy() * this (finer)
double rainfall = 1.0; // uniform precip per cell (drainage-area unit) double rainfall = 1.0; // uniform precip per cell (drainage-area unit)
double riverThreshold = 50.0; // discharge above which a cell counts as a river double riverThreshold = 25.0; // discharge above which a cell counts as a river
// (lower = richer network incl. tributaries shown)
double riverIncision = 0.02; // K in stream-power incision K*Q^m*S^n*dt double riverIncision = 0.02; // K in stream-power incision K*Q^m*S^n*dt
double riverDischargeExp = 0.5; // m: discharge exponent in stream power double riverDischargeExp = 0.5; // m: discharge exponent in stream power
double riverSlopeExp = 1.0; // n: slope exponent in stream power double riverSlopeExp = 1.0; // n: slope exponent in stream power
@ -175,6 +200,8 @@ struct PlanetConfig {
double biomeGrassMoist = 0.50; // moisture below this -> Grassland/Savanna, else Forest double biomeGrassMoist = 0.50; // moisture below this -> Grassland/Savanna, else Forest
double biomeTaigaMoist = 0.40; // cool + above this -> Taiga (else Tundra) double biomeTaigaMoist = 0.40; // cool + above this -> Taiga (else Tundra)
double biomeLakeMinDepth= 20.0; // filled-basin depth above sea level counting as a Lake double biomeLakeMinDepth= 20.0; // filled-basin depth above sea level counting as a Lake
double biomeSeasonWeight= 0.6; // how much winter temp (vs annual mean) sets the cold
// Tundra/Taiga cutoffs (0 = mean only/old behaviour, 1 = winter)
// --- Phase 3: climate (orographic precipitation) -- see PlanetClimate.cpp -- // --- Phase 3: climate (orographic precipitation) -- see PlanetClimate.cpp --
// Temperature reuses the biome* temperature fields above. Precipitation advects // Temperature reuses the biome* temperature fields above. Precipitation advects
@ -187,6 +214,18 @@ struct PlanetConfig {
double climateContinentality = 0.05; // moisture lost per land cell crossed (dries interiors) double climateContinentality = 0.05; // moisture lost per land cell crossed (dries interiors)
int climateWindPasses = 50; // moisture-advection iterations (steady state) int climateWindPasses = 50; // moisture-advection iterations (steady state)
int climateMoistureSmooth = 12; // precipitation diffusion passes (wet/dry transition zones) int climateMoistureSmooth = 12; // precipitation diffusion passes (wet/dry transition zones)
double climateCurrentFactor = 4.0; // C: max coastal warming/cooling from ocean currents
// (warm poleward currents raise, cold equatorward lower)
// --- Seasons (obliquity) -- see PlanetClimate.cpp -----------------------
// axialTilt (above) drives a per-cell seasonal temperature range around the annual
// mean sTemp: summer/winter = mean +/- A, with A = seasonAmpMax * tiltFactor *
// latShape * continentality. Big swings at high-latitude continental interiors,
// small near coasts/equator. Static fields (warmest/coldest month), not animated.
double seasonAmpMax = 18.0; // max seasonal half-amplitude (C) at full tilt/lat/interior
double seasonLatExp = 1.2; // latitude shape exponent (>1 concentrates swing toward poles)
double seasonOceanFactor = 0.15; // continentality floor: ocean/coast seasonal swing fraction
int seasonContinentRings = 6; // ocean-distance rings to reach full continentality (1 = ~223 km)
// --- Biota: flora / fauna / funga (see PlanetBiota.cpp + *Gen.cpp) ------- // --- Biota: flora / fauna / funga (see PlanetBiota.cpp + *Gen.cpp) -------
// Density scalars (derived each tick) drive the colour views; the discrete // Density scalars (derived each tick) drive the colour views; the discrete
@ -207,4 +246,15 @@ struct PlanetConfig {
int bioFloraPoints = 20; // flora point budget at full density (scaled by density) int bioFloraPoints = 20; // flora point budget at full density (scaled by density)
int bioFaunaPoints = 16; // fauna point budget at full density int bioFaunaPoints = 16; // fauna point budget at full density
int bioFungaPoints = 14; // funga point budget at full density int bioFungaPoints = 14; // funga point budget at full density
// --- Live World (slow real-time clock) -- see PlanetLive.cpp -------------
// The finished planet can run on a slow real-time clock (hours -> weeks/months) with a
// moving day/night terminator, a live seasonal temperature cycle and a moving snow line.
// dayLengthHours/yearLengthDays set the calendar; snowTemp/seaIceTemp the freezing lines.
double dayLengthHours = 24.0; // hours in one planetary day (rotation -> day/night)
double yearLengthDays = 365.25; // days in one planetary year (orbit -> seasons)
double snowTemp = 0.0; // C: land below the live temperature shows snow
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 tideSunFactor = 0.46; // sun's tide weight relative to a unit moon (Earth ~0.46)
}; };

137
test_live.cpp Normal file
View File

@ -0,0 +1,137 @@
// Headless test for the Live World stage (insolation + live seasonal temperature).
// No display / raylib needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_live.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/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
// src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/tl && /tmp/tl
//
// Verifies: insolation range; the sub-solar hemisphere is lit and the night side dark;
// the solar declination tracks axialTilt (poles lit/dark at solstice, neutral at equinox);
// live temperature stays within the summer/winter band and is anti-phased across the
// hemispheres; the snow line advances in the winter hemisphere; and determinism.
#include "Planet.hpp"
#include "Projection.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
// Index of the cell whose unit direction is closest to dir.
static int nearestCell(const Planet& p, const Vec3& dir) {
int best = 0; double bd = -2.0;
for (int i = 0; i < (int)p.cells.size(); ++i) {
double d = p.cells[i].unit.dot(dir);
if (d > bd) { bd = d; best = i; }
}
return best;
}
int main() {
Planet planet;
PlanetConfig cfg;
cfg.subdivisions = 5;
cfg.seed = 1337;
planet.generate(cfg);
planet.computeClimate(); // live season needs the climate fields
const int n = (int)planet.cells.size();
// North/south pole cells (extreme latitude) for declination checks.
int north = 0, south = 0;
for (int i = 0; i < n; ++i) {
if (planet.cells[i].unit.y > planet.cells[north].unit.y) north = i;
if (planet.cells[i].unit.y < planet.cells[south].unit.y) south = i;
}
const double tilt = cfg.axialTilt * M_PI / 180.0;
std::printf("Live World: insolation\n");
// --- Range + lit/dark hemispheres (arbitrary time, summer solstice) -------
planet.computeInsolation(0.25, 0.30);
const std::vector<double>& sun = planet.insolation();
bool inRange = true; int lit = 0; double mx = 0.0;
for (int i = 0; i < n; ++i) {
if (sun[i] < 0.0 || sun[i] > 1.0) inRange = false;
if (sun[i] > 0.0) ++lit;
mx = std::max(mx, sun[i]);
}
check(inRange, "insolation in [0,1]");
check(lit > (int)(0.40 * n) && lit < (int)(0.60 * n), "about half the planet is in daylight");
// Sub-solar cell is fully lit; its antipode is dark.
double decl = tilt * std::sin(2.0 * M_PI * 0.25);
double lon = M_PI * (1.0 - 2.0 * 0.30);
Vec3 sd = lonLatToDir(lon, decl);
int subsolar = nearestCell(planet, sd);
int antipode = nearestCell(planet, sd * -1.0);
check(sun[subsolar] > 0.99, "sub-solar cell is fully lit");
check(sun[antipode] == 0.0, "antipodal (midnight) cell is dark");
std::printf("Live World: declination tracks axial tilt\n");
// Equinox (decl=0): both poles near the terminator (~0). Solstice: summer pole lit,
// winter pole in polar night.
planet.computeInsolation(0.0, 0.0); // equinox
double npEq = planet.insolation()[north], spEq = planet.insolation()[south];
planet.computeInsolation(0.25, 0.0); // northern summer solstice
double npSol = planet.insolation()[north], spSol = planet.insolation()[south];
check(npEq < 0.1 && spEq < 0.1, "equinox: both poles near the terminator");
check(npSol > 0.3, "summer solstice: summer pole sees the midnight sun");
check(spSol == 0.0, "summer solstice: winter pole is in polar night");
check(std::fabs(npSol - std::sin(tilt)) < 0.05, "polar insolation ~ sin(axialTilt)");
std::printf("Live World: seasonal temperature\n");
// At northern summer (doy=0.25) live temp = summer in the north, winter in the south;
// everywhere it stays within the [winter, summer] band.
planet.computeLiveSeason(0.25);
const std::vector<double>& lt = planet.liveTemp();
const std::vector<double>& summ = planet.summerTemp();
const std::vector<double>& wint = planet.winterTemp();
bool inBand = true; double nLiveSummer = 0.0; int nN = 0;
for (int i = 0; i < n; ++i) {
if (lt[i] < wint[i] - 1e-6 || lt[i] > summ[i] + 1e-6) inBand = false;
if (planet.cells[i].unit.y > 0.3) { nLiveSummer += lt[i]; ++nN; }
}
nLiveSummer /= std::max(1, nN);
check(inBand, "live temp within [winter, summer] for every cell");
check(std::fabs(lt[north] - summ[north]) < 0.5, "northern summer solstice -> north at its summer temp");
check(std::fabs(lt[south] - wint[south]) < 0.5, "northern summer solstice -> south at its winter temp");
// Half a year later the northern hemisphere is colder (anti-phase).
planet.computeLiveSeason(0.75);
double nLiveWinter = 0.0; nN = 0;
for (int i = 0; i < n; ++i)
if (planet.cells[i].unit.y > 0.3) { nLiveWinter += planet.liveTemp()[i]; ++nN; }
nLiveWinter /= std::max(1, nN);
check(nLiveWinter < nLiveSummer - 1.0, "northern hemisphere colder in its winter than its summer");
std::printf("Live World: snow line advances in winter\n");
auto snowCountNorth = [&](double doy) {
planet.computeLiveSeason(doy); const std::vector<double>& t = planet.liveTemp();
int c = 0;
for (int i = 0; i < n; ++i)
if (planet.cells[i].unit.y > 0.0 && planet.cells[i].elevation > cfg.seaLevel
&& t[i] < cfg.snowTemp) ++c;
return c;
};
int snowSummer = snowCountNorth(0.25), snowWinter = snowCountNorth(0.75);
std::printf(" north land snow cells: summer %d, winter %d\n", snowSummer, snowWinter);
check(snowWinter > snowSummer, "more northern land under snow in winter than summer");
std::printf("Live World: determinism\n");
Planet p2; p2.generate(cfg); p2.computeClimate();
p2.computeInsolation(0.37, 0.61); p2.computeLiveSeason(0.37);
planet.computeInsolation(0.37, 0.61); planet.computeLiveSeason(0.37);
bool same = true;
for (int i = 0; i < n; ++i)
if (p2.insolation()[i] != planet.insolation()[i] || p2.liveTemp()[i] != planet.liveTemp()[i])
same = false;
check(same, "same seed + time -> identical insolation & live temp");
std::printf(failures ? "\nSOME LIVE CHECKS FAILED (%d)\n" : "\nALL LIVE CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}

144
test_ocean.cpp Normal file
View File

@ -0,0 +1,144 @@
// Headless test for the Live World ocean/sky stage (moons + tides). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_ocean.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/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
// -o /tmp/to && /tmp/to
//
// Verifies: 1-3 moons generated deterministically; moon/sun directions are unit vectors that
// sweep with the clock; the equilibrium tide is a zero-mean two-bulge field (high under a body
// and its antipode, low at 90 deg); and save v9 round-trips the moons.
#include "Planet.hpp"
#include "Projection.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;
}
static int nearestCell(const Planet& p, const Vec3& dir) {
int best = 0; double bd = -2.0;
for (int i = 0; i < (int)p.cells.size(); ++i) {
double d = p.cells[i].unit.dot(dir);
if (d > bd) { bd = d; best = i; }
}
return best;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 1337;
Planet planet; planet.generate(cfg);
const int n = (int)planet.cells.size();
std::printf("Ocean: moons\n");
int nm = (int)planet.getMoons().size();
std::printf(" moon count: %d\n", nm);
check(nm >= 1 && nm <= 3, "1-3 moons generated");
// Determinism: same seed -> identical moons (and tectonic state unchanged by moon RNG).
Planet p2; p2.generate(cfg);
bool sameMoons = ((int)p2.getMoons().size() == nm);
for (int i = 0; i < nm && sameMoons; ++i) {
const Moon& a = planet.getMoons()[i]; const Moon& b = p2.getMoons()[i];
if (a.orbitRadius != b.orbitRadius || a.periodDays != b.periodDays || a.phase != b.phase
|| a.inclination != b.inclination || a.tideWeight != b.tideWeight) sameMoons = false;
}
check(sameMoons, "moon generation is deterministic for a seed");
bool sameCells = true;
for (int i = 0; i < n; ++i) if (planet.cells[i].elevation != p2.cells[i].elevation) sameCells = false;
check(sameCells, "moon RNG does not perturb tectonic determinism");
std::printf("Ocean: sky directions\n");
Vec3 s0 = planet.sunDirection(0.25, 0.10), s1 = planet.sunDirection(0.25, 0.60);
check(std::fabs(s0.length() - 1.0) < 1e-9, "sun direction is a unit vector");
check((s0 - s1).length() > 0.1, "sun sweeps as the day advances (time of day)");
if (nm > 0) {
Vec3 m0 = planet.moonDirection(0, 0.10, 3.0), m1 = planet.moonDirection(0, 0.60, 3.0);
check(std::fabs(m0.length() - 1.0) < 1e-9, "moon direction is a unit vector");
check((m0 - m1).length() > 0.1, "moon sweeps across the sky with the clock");
}
std::printf("Ocean: tides (single equatorial moon, sun off)\n");
// Isolate one equatorial moon so the two-bulge shape is unambiguous.
Planet t; t.generate(cfg);
t.cfg.tideSunFactor = 0.0;
t.moons.clear();
Moon m; m.orbitRadius = 20; m.periodDays = 27; m.phase = 0; m.inclination = 0; m.tideWeight = 1; m.dispRadius = 0.1;
t.moons.push_back(m);
t.computeTides(0.0, 0.25, 5.0);
const std::vector<double> tide = t.tide(); // copy (t.tide() is overwritten on recompute)
double sum = 0.0, mx = -1e30, mn = 1e30;
for (int i = 0; i < n; ++i) { sum += tide[i]; mx = std::max(mx, tide[i]); mn = std::min(mn, tide[i]); }
double mean = sum / n;
check(std::fabs(mean) < 0.02, "tide field is ~zero-mean");
check(mx > 0.0 && mn < 0.0, "tide has highs and lows (bulges + troughs)");
Vec3 md = t.moonDirection(0, 0.25, 5.0);
int sub = nearestCell(t, md);
int anti = nearestCell(t, md * -1.0);
int pole = nearestCell(t, Vec3{0, 1, 0}); // 90 deg from an equatorial moon
check(tide[sub] > 0.0 && tide[anti] > 0.0, "high tide under the moon AND its antipode");
check(tide[pole] < 0.0, "low tide at 90 degrees from the moon");
// The bulge moves with the clock. (Use a quarter-ish step, not 0.5: half a day apart the
// moon is at the antipode and the tide -- being cos^2 symmetric -- is correctly identical.)
t.computeTides(0.0, 0.40, 5.0);
bool moved = false;
for (int i = 0; i < n; ++i) if (std::fabs(t.tide()[i] - tide[i]) > 1e-6) { moved = true; break; }
check(moved, "the tidal bulge moves as time of day advances");
std::printf("Ocean: currents\n");
const std::vector<Vec3>& cur = planet.current(); // populated by generate()'s computeClimate
check((int)cur.size() == n, "current field sized to the grid");
bool tangent = true, landZero = true; int flowing = 0;
for (int i = 0; i < n; ++i) {
if (planet.cells[i].elevation <= cfg.seaLevel) {
if (std::fabs(cur[i].dot(planet.cells[i].unit)) > 1e-6) tangent = false;
if (cur[i].length() > 1e-9) ++flowing;
} else if (cur[i].length() > 1e-12) landZero = false;
}
check(tangent, "ocean currents are tangent to the surface");
check(landZero, "currents are zero on land");
check(flowing > 50, "many ocean cells carry a current");
// Climate feedback: identical terrain, current factor on vs off.
PlanetConfig cfgOff = cfg; cfgOff.climateCurrentFactor = 0.0;
Planet conOn; conOn.generate(cfg);
Planet conOff; conOff.generate(cfgOff);
double maxAbs = 0.0, maxWarm = -1e9, maxCold = 1e9;
for (int i = 0; i < n; ++i) {
double d = conOn.temperature()[i] - conOff.temperature()[i];
maxAbs = std::max(maxAbs, std::fabs(d));
maxWarm = std::max(maxWarm, d); maxCold = std::min(maxCold, d);
}
check(maxAbs <= cfg.climateCurrentFactor + 1e-6, "current temp feedback bounded by climateCurrentFactor");
check(maxWarm > 0.1 && maxCold < -0.1, "currents both warm and cool coasts");
Planet conOn2; conOn2.generate(cfg);
bool det = true;
for (int i = 0; i < n; ++i) {
if (conOn2.temperature()[i] != conOn.temperature()[i]) det = false;
if ((conOn2.current()[i] - conOn.current()[i]).length() != 0.0) det = false;
}
check(det, "currents + feedback deterministic for a seed");
std::printf("Ocean: save v9\n");
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
planet.writeState(ss);
Planet q;
bool ok = q.readState(ss, true, true, true);
bool moonsRT = ok && (int)q.getMoons().size() == nm;
for (int i = 0; i < nm && moonsRT; ++i)
if (q.getMoons()[i].periodDays != planet.getMoons()[i].periodDays
|| q.getMoons()[i].tideWeight != planet.getMoons()[i].tideWeight) moonsRT = false;
check(moonsRT, "save v9 round-trips the moons");
std::printf(failures ? "\nSOME OCEAN CHECKS FAILED (%d)\n" : "\nALL OCEAN CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}