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)
H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion)
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)
[ / ] 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
F fast-forward Phase-1 forming to settled (instant)
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;
auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v6): seed + config + full planet
state; F5 writes it, F9 reloads and resumes deterministically. As of v6
planet.save binary snapshot (versioned, currently v9: +moons; v8 +Live World clock;
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
planet.cfg), so adding/removing config fields no longer breaks saves
(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)
isostaticPersist 0.85 how strongly high crust resists relax (0..<1)
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
seafloorSubsidence 280 m per sqrt(My): seafloor deepens with crustal age
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
phase3DtScale 0.2 hydrology timestep = cflDtMy()*this (finer = slower drift)
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)
riverDischargeExp 0.5 m (discharge 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
biomeTaigaMoist 0.40 cool + above this -> Taiga (else Tundra)
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;
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)
climateWindPasses 50 moisture-advection iterations (steady state)
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;
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
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)
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/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.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/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
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/
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.
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)
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
> 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
**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.
- **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
@ -252,6 +278,20 @@ Working and verified (logic tested headless):
runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config
knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior,
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
(`cellInfo`, src/render/Panels.cpp) now shows everything per cell — crust type, **biome**
(`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≤
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`).
- **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
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
@ -313,6 +401,8 @@ src/
PlanetErosion.cpp erode() + adjustSeaLevel()
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
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)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
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 \
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/PlanetBiota.cpp src/sim/PlanetFloraGen.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/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
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 ·
drag the 2D map to pan it east/west · `1`..`0` color by
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,
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 ·
`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 ·
`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
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
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
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
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
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
rejected. Older saves (no biota block) load fine with an empty population (press `L`).
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
clock — a flag byte + `liveTime`, v9 appends the **moons** block); newer-than-supported is
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
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 —
@ -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.
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
(top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
1344x648) + 2D Equal Earth map (bottom, 40% h, **left-aligned**, with the freed space at
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
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
@ -493,6 +599,15 @@ triangles (plates are fixed in phase 1).
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`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`/
`bioVegMoistRef` (flora temp/moisture limits), `bioFaunaProductivity` (animals per unit
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 →
homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp
(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
knob for how fast/high relief builds.
- `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
rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three
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
hard clamp `[-11000, 9000]` in `step()` is the Everest-class cap; a few peak
cells may sit there during drift (<2% fine).
per-tick `erode()` is what limits their height, so they don't rail the clamp.
- **Soft peak cap (drift-only) — no 9000 m plateau:** previously the hard
`[-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
**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
@ -548,7 +690,7 @@ triangles (plates are fixed in phase 1).
before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep =
`cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall`
(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`
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

View File

@ -26,6 +26,8 @@ add_executable(planetsim
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

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,
steepest-descent→rivers, mass-conserving stream-power incision).
- `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).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`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:
- **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
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
@ -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 &
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
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 \
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/t && /tmp/t
# 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::FaunaDensity: return "Fauna 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 "?";
}
@ -138,6 +141,27 @@ Color tempColor(double celsius) {
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).
Color precipColor(double moist01) {
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.
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 plateColor(int id);
@ -22,6 +23,11 @@ const char* colorModeName(ColorMode m);
// (tan dry -> green -> blue wet).
Color tempColor(double celsius);
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,
// funga pale->violet/brown.
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>> g;
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);
}
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,
float visBase, float elevExagg, double seaLevel, float eps) {
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,
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 ------------------------------------------------------
// Polylines of (lon,lat) radians (Vector2.x=lon, .y=lat).
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,
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) ------------------
void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs,
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()))
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",
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())));
// Hydrology (derived; present once routeFlow()/hydrology() has run).
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});
DrawRectangleLinesEx(r, 1, Color{120, 120, 150, 255});
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("Plates: %d active %d continental / %d oceanic", used, contPlates, used - contPlates));
L(TextFormat("Young ridges: %d strips %d cells", babyPlates, babyCells));
L(TextFormat("Moons: %d", (int)p.getMoons().size()));
int water = N - landGeo;
double wlRatio = landGeo > 0 ? (double)water / landGeo : 0.0;
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("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));
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;
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);
// 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 mapW = (int)(mapH * (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)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.
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() {
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>& 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>& flora = planet.floraDensity();
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::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::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::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;
@ -130,6 +140,71 @@ void Viewer::recolor() {
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() {
if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view
planet.computeClimate(); // temperature + precipitation fields
@ -141,6 +216,8 @@ void Viewer::refreshView() {
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
}
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();
}
@ -174,12 +251,15 @@ void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary);
if (!os) { setStatus("Save failed"); return; }
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(reinterpret_cast<const char*>(&ver), sizeof ver);
os.write(reinterpret_cast<const char*>(&elapsedMy), sizeof elapsedMy);
os.write(reinterpret_cast<const char*>(&st), sizeof st);
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*>(&lw), sizeof lw); // v8: Live World flag
os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours)
planet.writeState(os);
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);
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;
uint8_t lw = 0; double lh = 0.0;
is.read(magic, 4);
is.read(reinterpret_cast<char*>(&ver), sizeof ver);
is.read(reinterpret_cast<char*>(&em), sizeof em);
is.read(reinterpret_cast<char*>(&st), sizeof st);
if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr);
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 (!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
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
phase3 = (p3 != 0); phase3Prompt = false;
phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy);
driftRate = dr;
liveWorld = (lw != 0); liveTime = lh; // v8: resume the Live World clock
settleRun = settleNeed; // keep the settled latch consistent
dtMy = settled ? planet.cflDtMy() : 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
// equilibrium), or Phase-2 drift / Phase-3 drift+hydrology at a finer dt.
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) {
// --- Phase 1: forming, paced ticks toward equilibrium -------------
formAccum += GetFrameTime() * formRate;

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- 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* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides)
@ -26,6 +26,7 @@ struct Viewer {
int view3DW = 0, view3DH = 0;
RenderTexture2D rt3d{};
Rectangle mapRect{}, hoverRect{}, panelRect{}, gridRect{};
Rectangle liveInfoRect{}; // free space right of the (left-aligned) 2D map: moon/tide phase
Rectangle pauseBtn{}, p3ContinueBtn{}, p3StartBtn{};
float pbCx = 0.0f, pbCy = 0.0f;
@ -72,6 +73,24 @@ struct Viewer {
bool phase3 = false, phase3Prompt = false;
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).
int selectedCell = -1;
double selectedThresh = 0.06;
@ -102,6 +121,10 @@ struct Viewer {
void selectCell(int idx);
void recolor();
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 regen(); // generate(cfg) + regenWorld()
void stepOnce(); // one tick + settle bookkeeping
@ -118,6 +141,7 @@ struct Viewer {
void renderFrame();
void renderGlobe3D();
void renderMap2D();
void renderLiveInfo(); // Live World: moon phases + (coastal) tidal phase, beside the 2D map
void renderPanels();
void renderHUD();
void renderPrompt();

View File

@ -102,7 +102,13 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_THREE)) { mode = ColorMode::Age; recolor(); }
if (IsKeyPressed(KEY_FOUR)) { mode = ColorMode::Crust; 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_EIGHT)) { mode = ColorMode::FloraDensity; recolor(); }
if (IsKeyPressed(KEY_NINE)) { mode = ColorMode::FaunaDensity; recolor(); }
@ -125,6 +131,20 @@ void Viewer::handleInput() {
{ mode = ColorMode::FloraDensity; recolor(); }
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_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
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_F9)) loadGame(SAVE_PATH);
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
// Drift speed (Phase 2): My simulated per real second.
if (IsKeyPressed(KEY_RIGHT_BRACKET)) driftRate = std::min(driftRate * 1.5, 80.0);
if (IsKeyPressed(KEY_LEFT_BRACKET)) driftRate = std::max(driftRate / 1.5, 0.5);
// Speed control: Live World ramps the live clock (sim hours/s, ~hour -> month);
// otherwise it sets the drift rate (My simulated per real second).
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();
rlRotatef((float)planet.cfg.axialTilt, 0.0f, 0.0f, 1.0f);
const std::vector<int>& tri = planet.triIndices();
const std::vector<Color>& dc = displayColors(); // live overlay (day/night + snow) or plain
rlBegin(RL_TRIANGLES);
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
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 Vec3& u = cc.unit;
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);
rlVertex3f((float)(u.x * r), (float)(u.y * r), (float)(u.z * r));
}
}
rlEnd();
if (selectedCell >= 0) // detail patch
drawSubgrids(subgrids, visBase, elevExagg, planet.cfg.seaLevel, 0.0016f);
// (The clicked tile's high-res subgrid is shown in the right-side detail panel,
// 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())) {
rlSetLineWidth(2.0f); rlBegin(RL_LINES);
@ -71,6 +73,36 @@ void Viewer::renderGlobe3D() {
};
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);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
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{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();
EndMode3D();
EndTextureMode();
@ -105,11 +188,13 @@ void Viewer::renderGlobe3D() {
void Viewer::renderMap2D() {
DrawRectangleRec(mapRect, Color{6, 8, 14, 255});
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 (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 (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) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, 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});
}
// 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).
void Viewer::renderPanels() {
drawHoverPanel(planet, hoverRect, hovered, selectedCell);
@ -138,7 +312,7 @@ void Viewer::renderPanels() {
planet.cells[selectedCell].elevation, planet.cells[selectedCell].geoAge,
panelRect, gridRect, hoveredSubIdx);
else
drawStats(planet, panelRect, elapsedMy, settled);
drawStats(planet, panelRect, elapsedMy, settled, liveWorld, liveTime);
}
// Top-left HUD text + the clickable pause button.
@ -153,15 +327,34 @@ void Viewer::renderHUD() {
int y = 10;
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);
line(!settled ? "Planet Sim - World Creation: forming"
: phase3 ? "Planet Sim - World Creation: hydrology"
: "Planet Sim - World Creation: drift & erosion");
line(liveWorld ? "Planet Sim - Live World"
: !settled ? "Planet Sim - World Creation: forming"
: phase3 ? "Planet Sim - World Creation: hydrology"
: "Planet Sim - World Creation: drift & erosion");
line(TextFormat("Cells: %d Subdiv: %d CellWidth: %.0f km",
(int)planet.cells.size(), cfg.subdivisions, planet.cellWidthMeters() / 1000.0));
line(TextFormat("Elevation: %.0f .. %.0f m", minE, maxE));
if (!settled)
line(TextFormat("Forming terrain tick %lld max change %.1f m/tick%s",
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 {
line(TextFormat("%s %.1f My elapsed %.1f My/s%s",
phase3 ? "Hydrology - drift, rivers & erosion" : "Drift & erosion",
@ -178,11 +371,11 @@ void Viewer::renderHUD() {
}
y += 8;
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(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off"));
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]",
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] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");
if (!statusMsg.empty() && GetTime() < statusUntil) {
y += 4; DrawText(statusMsg.c_str(), 12, y, 18, Color{120, 230, 140, 255}); y += 22;
@ -254,6 +447,7 @@ void Viewer::renderFrame() {
}
renderMap2D();
renderLiveInfo();
renderPanels();
renderHUD();
renderPrompt();

View File

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

View File

@ -14,6 +14,7 @@ public:
PlanetConfig cfg;
std::vector<Cell> cells;
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
// 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);
// call before classifyBiomes(), which consumes these fields.
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>& 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
// 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
// older saves (v3) pass false -- biomes are reclassified after the cells load.
// 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.
double cellWidthMeters() const; // approx lateral cell spacing
@ -152,11 +188,18 @@ private:
std::vector<int> sFlowTo, sHydroOrder;
// Phase-3 climate scratch (derived each computeClimate(); not saved). sMoist is the
// 0..1-normalized precipitation the biome classifier reads.
std::vector<double> sTemp, sPrecip, sMoist;
// 0..1-normalized precipitation the biome classifier reads. sTempSummer/sTempWinter are
// 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<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
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.
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 LAKE_MIN_DEPTH = cfg.biomeLakeMinDepth;
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) {
const Cell& c = cells[i];
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 lakeD = haveLake ? sLakeDepth[i] : 0.0;
bool adjOcean = false, adjWater = false;
@ -43,9 +50,9 @@ void Planet::classifyBiomes() {
else if (elevAbove > MOUNTAIN_ELEV) b = Biome::Mountains;
else if (elevAbove > HILLS_ELEV) b = Biome::Hills;
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 (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 < GRASS_MOIST) b = (temp > SAVANNA_TEMP) ? Biome::Savanna : Biome::Grassland;
else b = Biome::Forest;

View File

@ -26,6 +26,8 @@ void Planet::computeClimate() {
sTemp.assign(n, 0.0);
sPrecip.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});
sUpwind.assign(n, -1);
@ -61,6 +63,38 @@ void Planet::computeClimate() {
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,
// double-buffered -> deterministic). Ocean cells are a moisture source; land
// 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
}
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;
}
// 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)
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();
}

View File

@ -98,7 +98,8 @@ void Planet::hydrology(double dtMy) {
}
load[d] += carried; // pass remaining sediment downstream
}
// Upper clamp tracks peakSoftCapEnd (matches step()/erode()).
#pragma omp parallel for schedule(static) if(n > 20000)
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) \
D(radius) D(seaLevel) D(axialTilt) D(continentBase) D(oceanBase) D(upliftGain) D(relax) \
D(collisionFactor) D(arcFactor) D(isostaticPersist) D(rootScale) \
D(peakSoftCapStart) D(peakSoftCapEnd) D(peakFailDrop) \
D(seafloorSubsidence) D(seafloorSeedAge) \
D(maxDriftSpeed) D(ridgeDepth) D(splitFraction) D(splitProbBase) D(splitProbSlope) \
D(stalemateEps) D(stalemateBoost) D(babyPromoteFrac) D(volcanicLandFrac) \
@ -26,15 +27,18 @@
D(biomeIceTemp) D(biomeTundraTemp) D(biomeTaigaTemp) D(biomeSavannaTemp) \
D(biomeMountainElev) D(biomeHillsElev) D(biomeBeachBand) D(biomeLowlandElev) \
D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \
D(biomeLakeMinDepth) \
D(biomeLakeMinDepth) D(biomeSeasonWeight) \
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(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \
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(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \
U(seed)
@ -114,6 +118,9 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.arcFactor, 0.0, 20.0, "arcFactor"));
E(rng(cfg.isostaticPersist, 0.0, 0.95, "isostaticPersist"));
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.seafloorSeedAge, 0.0, 1000.0, "seafloorSeedAge"));
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.biomeTaigaMoist, 0.0, 1.0, "biomeTaigaMoist"));
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.climateRainEfficiency, 0.0, 1.0, "climateRainEfficiency"));
E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic"));
E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight"));
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.bioVegTempOpt, -20.0, 50.0, "bioVegTempOpt"));
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.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin"));
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.plateCount, 1, 100, "plateCount"));
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.climateWindPasses, 1, 1000, "climateWindPasses"));
E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth"));
E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
@ -193,6 +212,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
if (cfg.oceanBase >= cfg.continentBase)
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 {};
std::string msg = "Bad config:";
@ -248,6 +269,7 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sPrevCount);
writeVec(os, sStaleStreak);
writeVec(os, sFreePlateIds);
writeVec(os, moons); // v9: natural satellites (Live World)
// 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.
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
// 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.
@ -288,6 +310,8 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) {
readVec(is, sPrevCount);
readVec(is, sStaleStreak);
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
// v7: discrete biota population. buildGeometry() already sized sBiota empty;
// 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 <algorithm>
#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 ---------------------
// Data-parallel: every pass writes only its own cell index (double-buffered
@ -138,7 +154,23 @@ double Planet::step() {
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;
// (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; }
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)
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.
double maxChange = 0.0;

View File

@ -35,6 +35,20 @@ enum class Biome : uint8_t {
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 {
int id = 0;
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 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) ----------------------------------------------
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
@ -148,7 +172,8 @@ struct PlanetConfig {
double phase3AfterMy = 300.0; // My of drift before the Phase-3 prompt
double phase3DtScale = 0.2; // Phase-3 timestep = cflDtMy() * this (finer)
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 riverDischargeExp = 0.5; // m: discharge 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 biomeTaigaMoist = 0.40; // cool + above this -> Taiga (else Tundra)
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 --
// 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)
int climateWindPasses = 50; // moisture-advection iterations (steady state)
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) -------
// 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 bioFaunaPoints = 16; // fauna 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;
}