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>
This commit is contained in:
Jonas Reith 2026-06-28 10:04:45 +02:00
parent 8ed9ae4515
commit aa32e38dad
23 changed files with 1296 additions and 39 deletions

View File

@ -47,8 +47,12 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
J toggle rivers (Phase 2.5 hydrology) J toggle rivers (Phase 2.5 hydrology)
H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion) H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion)
L generate biota population (flora/fauna/funga; settled world; re-press regenerates) L generate biota population (flora/fauna/funga; settled world; re-press regenerates)
W enter / leave Live World (slow real-time clock; settled world)
N toggle the day/night terminator (Live World)
T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
SPACE pause while forming / re-evolve once settled (or the on-screen button) SPACE pause while forming / re-evolve once settled (or the on-screen button)
[ / ] drift speed (My per real second, Phase 2/3) [ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month)
S single tectonic tick S single tectonic tick
F fast-forward Phase-1 forming to settled (instant) F fast-forward Phase-1 forming to settled (instant)
R reseed planet (restart forming) R reseed planet (restart forming)
@ -67,8 +71,9 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter; planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten). load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v6): seed + config + full planet planet.save binary snapshot (versioned, currently v9: +moons; v8 +Live World clock;
state; F5 writes it, F9 reloads and resumes deterministically. As of v6 v7 +biota): seed + config + full planet state; F5 writes it, F9 reloads and
resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves planet.cfg), so adding/removing config fields no longer breaks saves
(unknown keys ignored, missing keys default). v6 cannot load pre-v6 (unknown keys ignored, missing keys default). v6 cannot load pre-v6
@ -161,6 +166,7 @@ shadow, dry interiors) then diffuses it. Color modes 6 (temperature) / 7 (precip
climateContinentality 0.05 fractional moisture lost per inland cell (dries interiors) climateContinentality 0.05 fractional moisture lost per inland cell (dries interiors)
climateWindPasses 50 moisture-advection iterations (steady state) climateWindPasses 50 moisture-advection iterations (steady state)
climateMoistureSmooth 12 precipitation diffusion passes (raise = smoother, more grass/forest) climateMoistureSmooth 12 precipitation diffusion passes (raise = smoother, more grass/forest)
climateCurrentFactor 4 C max coastal warming/cooling from ocean currents (0 = off; O shows arrows)
Biota (PlanetConfig): flora/fauna/funga. Density scalars drive color modes 8/9/0; Biota (PlanetConfig): flora/fauna/funga. Density scalars drive color modes 8/9/0;
the discrete slot/point population is generated on demand (L) and saved (v7). the discrete slot/point population is generated on demand (L) and saved (v7).
@ -182,17 +188,32 @@ the discrete slot/point population is generated on demand (L) and saved (v7).
bioFaunaPoints 16 fauna point budget at full density bioFaunaPoints 16 fauna point budget at full density
bioFungaPoints 14 funga point budget at full density bioFungaPoints 14 funga point budget at full density
Live World (PlanetConfig): the slow real-time clock (hours -> weeks/months) over the
finished planet -- a moving day/night terminator, a live seasonal temperature cycle and a
moving snow line. axialTilt (above) drives the seasonal declination. Press W to enter.
dayLengthHours 24 hours in one planetary day (rotation -> day/night)
yearLengthDays 365.25 days in one planetary year (orbit -> seasons)
snowTemp 0 C land below the live temperature shows snow
seaIceTemp -2 C ocean below the live temperature shows sea ice
tideAmplitude 0.6 m equilibrium-tide scale per unit tide-raising weight
tideSunFactor 0.46 sun's tide weight relative to a unit moon (Earth ~0.46)
Moons (1-3, randomized in generateMoons() + saved v9): orbit on the live clock, raise the
tides with the sun, and render as small lit spheres with phases, orbit rings and eclipses.
## Headless logic test (no display) ## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t -o /tmp/t && /tmp/t
# Biota suite: same source list, swap test_logic.cpp -> test_biota.cpp # Biota / Live World / Ocean suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp or test_ocean.cpp
Verifies geometry, plate assignment, gradual non-saturating relief and Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step(). determinism. Run after changing Planet::step().

125
CLAUDE.md
View File

@ -56,10 +56,33 @@ dynamic weather and life.
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not > lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
> travel with the repo. > travel with the repo.
**Live World (future):** with planet creation finished, run the world at a slow real-time **Live World (in progress):** with planet creation finished, run the world at a slow real-time
scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living
**ecosystems / civilization** evolving in real time. This is a separate large effort; **ecosystems / civilization** evolving in real time. This is a separate large effort;
the fixed-grid Eulerian model + the climate fields are the groundwork for it. the fixed-grid Eulerian model + the climate fields are the groundwork for it.
- **Clock + day/night groundwork** *(done — see `PlanetLive.cpp`)* — a slow real-time clock
(hours → weeks/months, `liveTime`/`liveRate`, key `W` to enter once settled, `[`/`]` ramp
the rate), a moving **day/night terminator** (sun from time-of-day rotation + seasonal
declination via `axialTilt`; key `N`), a raylib-free per-cell **insolation field**
(`computeInsolation`, the weather hook), a **live seasonal temperature** cycling the
static summer/winter fields over the year (`computeLiveSeason`), and a **moving snow / sea-ice
line**. Day/night + snow are render overlays over any colour mode (3D + 2D). Save **v8** adds
the live-clock state. Still **future:** actual weather, day/night temperature swing,
precipitation/evaporation, living/evolving ecosystems.
- **Moons + tides + distant sun** *(done — see `PlanetOcean.cpp`)***13 moons** generated per
world from a separate RNG (no tectonic perturbation) and **saved (v9)**; they orbit on the live
clock and, with the sun, raise an **equilibrium tide** (`computeTides``sTide`, two bulges via
`cosθ²⅓`, semidiurnal). Tides show as a **tide-coloured coastline** (`buildCoastline` + a
diverging `tideColor`, key `T`, 3D + 2D). The 3D **sun** is now small + far with a faint halo;
**moons** render with sun-lit **phases** (offset-dark-sphere) + faint **orbit rings**, plus
**eclipses** — solar (a moon transiting the sun darkens a shadow spot in the day/night overlay),
lunar (a moon in the planet's shadow dims red). Knobs `tideAmplitude`/`tideSunFactor`.
- **Ocean currents (+ climate feedback)** *(done — see `PlanetOcean.cpp`)*`computeOceanCurrents()`
builds a per-ocean-cell tangent velocity from wind stress (`sWind`) + Coriolis deflection
(right N / left S) + coast-following (gyres) + smoothing. `computeClimate()` calls it and feeds
**warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly
(`climateCurrentFactor`), so biomes shift naturally. Rendered as warm/cold **current arrows**
over the sea (key `O`, 3D + 2D). This completes the Live World ocean/sky pass.
## Current state ## Current state
@ -299,6 +322,54 @@ Working and verified (logic tested headless):
organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna≤ organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna≤
capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip, capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip,
pre-v7 loads empty. v7 reads v6-and-older (no biota block → empty population; press `L`). pre-v7 loads empty. v7 reads v6-and-older (no biota block → empty population; press `L`).
- **Live World — clock + day/night + live seasons + snow line:** the first **Live World** stage
(the slow real-time arc after World Creation). Engine (`src/sim/PlanetLive.cpp`, raylib-free,
derived/not-saved): `computeInsolation(dayOfYear01, timeOfDay01)``sInsolation` (0..1 cosine
solar incidence; declination `axialTilt·sin(2π·doy)`, sub-solar longitude sweeps once per day;
the foundation the future weather sim reads) and `computeLiveSeason(doy)``sLiveTemp` (the
annual-mean `sTemp` swung toward the existing `summerTemp`/`winterTemp` by the seasonal phase,
anti-phased across hemispheres). Viewer: key `W` (settled world) toggles **Live World** — drift
freezes and `liveTime` advances at `liveRate` (sim hours/real-second), `[`/`]` ramp it
hour→month; the HUD shows a `Year/Day/HH:MM` calendar (`dayLengthHours`/`yearLengthDays`).
`rebuildLiveOverlay()` builds a per-cell day/night brightness (`illum`, soft terminator, dim
night floor) + `shadedColors` (base colour → snow on cold land / sea-ice on cold ocean via
`snowTemp`/`seaIceTemp` → day/night dim); both the 3D globe and 2D map draw `displayColors()`
(the overlay over **any** colour mode), `N` toggles the terminator, a sun marker sits over the
lit hemisphere. Cell-info adds a `live temp / day-night / snow` line. Save **v8** appends the
Live World flag + `liveTime` (version-gated; older saves load with it off). New `PlanetLive.cpp`
in CMake + the headless list; `test_live.cpp`: insolation range, lit/dark hemispheres,
declination tracks `axialTilt` (polar day/night at solstice), live temp within the
summer/winter band + anti-phased, snow line advances in winter, determinism.
- **Live World — moons, tides & a distant sun:** engine `src/sim/PlanetOcean.cpp` (raylib-free):
`generateMoons()` seeds **13 moons** (`Moon` struct in PlanetTypes) from a separate RNG
(`cfg.seed ^ 0x900D5EED`, tectonic stream untouched); `sunDirection`/`moonDirection`/
`moonOrbitNormal` give model-space sky geometry (one source of truth — `computeInsolation` now
calls `sunDirection`). `computeTides(doy,tod,days)``sTide` (m), equilibrium two-bulge tide
(`Σ w·(cosθ²⅓)`, moons + sun weighted `tideSunFactor`, scaled `tideAmplitude`); derived/not
saved. Viewer: `T` colours the **coastline** (`buildCoastline` dual-contour + `tideColor`
diverging amber↔cyan, per-segment in 3D + `drawColoredSegments2D` in 2D), auto-scaled to the
tide extent; `stepSim` computes tides + `moonDirs`/`moonNormals` each live frame. 3D render:
small **distant sun** (`sunDist`≈9) + halo; **moons** at a visible orbit band with sun-lit
**phase** (offset-dark-sphere), faint **orbit rings**, and **eclipses** — solar shadow folded
into `rebuildLiveOverlay`'s `illum` near the sub-solar point, lunar dimming (reddish) when a
moon is in the planet's shadow. Cell-info adds a tide line; stats shows the moon count. **Save
v9** appends the moons block (`writeState`/`readState(...,hasMoons)`; pre-v9 saves synthesize
moons from the seed). `test_ocean.cpp`: moon count/determinism + RNG isolation, unit sweeping
sky dirs, zero-mean two-bulge tide (high under moon + antipode, low at 90°, moves with time),
save v9 round-trip.
- **Live World — ocean currents + climate feedback:** `Planet::computeOceanCurrents()`
(PlanetOcean.cpp) builds a per-ocean-cell tangent velocity `sCurrent` (derived/not saved):
wind stress (`sWind`) rotated by a **Coriolis** deflection (right N / left S about the cell
normal), the across-shore component removed at land neighbours so flow **follows coasts**
(gyres), then 3 smoothing passes (re-projected to the tangent plane; zero on land).
`computeClimate()` calls it right after the wind pass and feeds it back: a **bounded coastal
temperature anomaly** = `climateCurrentFactor · (poleward speed / max)` on ocean cells (warm
poleward, cold equatorward), smoothed onto the coasts and added to `sTemp` **before** seasons,
so summer/winter + biomes shift with it. Render: `buildCurrents` emits subsampled warm/cold
**arrows** over the sea (warm = poleward/red, cold = equatorward/blue), key `O` (3D + 2D), built
in `refreshView`. New knob `climateCurrentFactor` (4 °C). `test_ocean.cpp` adds: currents
tangent + zero on land + widespread, feedback bounded by the knob and produces both warming and
cooling, deterministic. Live-World ocean/sky pass complete.
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a - Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
@ -330,6 +401,8 @@ src/
PlanetErosion.cpp erode() + adjustSeaLevel() PlanetErosion.cpp erode() + adjustSeaLevel()
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3) PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetClimate.cpp computeClimate() (temperature + orographic precipitation) PlanetClimate.cpp computeClimate() (temperature + orographic precipitation)
PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons)
PlanetOcean.cpp moons (generate/orbit) + computeTides (Live World sky & tides)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate) PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
@ -394,12 +467,13 @@ raylib 5.5 is fetched automatically — do not vendor it.
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \ src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp` to run the Biota suite — same source list.) (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp` or `test_ocean.cpp` to run the
Biota / Live World / Ocean suites — same source list.)
Use this to verify tectonics after changing `Planet::step()` without launching Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -429,10 +503,13 @@ elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
(`8`/`9`/`0` = biota density; `6` **cycles** temperature → summer → winter → seasonality; (`8`/`9`/`0` = biota density; `6` **cycles** temperature → summer → winter → seasonality;
active mode shown top-center of the globe) · active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3, `B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) · all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled · `S` single tick · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga, `H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
on a settled world; re-press regenerates) · `R` reseed · on a settled world; re-press regenerates) · `W` enter/leave **Live World** (settled world) ·
`R` reseed ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load · `+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate. `F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
@ -441,6 +518,13 @@ Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes
and fluvial erosion evolve. and fluvial erosion evolve.
Live World (`W`, on a settled world): geological drift freezes and a slow real-time clock runs
(`liveTime` in hours, `liveRate` = sim hours/real-second, ramped hour→month with `[`/`]`). A
moving day/night terminator (`N`), a live seasonal temperature cycle and a moving snow/sea-ice
line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar.
**13 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant
sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). file (both applied before the initial load/generate).
@ -450,11 +534,13 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **7**; v2 adds the `[`/`]` drift rate, v3 a save header is versioned (currently **9**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte); newer-than-supported is block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
rejected. Older saves (no biota block) load fine with an empty population (press `L`). clock — a flag byte + `liveTime`, v9 appends the **moons** block); newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults), config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves — written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —
@ -462,8 +548,10 @@ a one-time break; a length guard makes that fail gracefully.) `writeConfigFields
`parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save. `parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save.
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
1344x648) + 2D Equal Earth map (bottom, 40% h); right column 30% wide = cell info 1344x648) + 2D Equal Earth map (bottom, 40% h, **left-aligned**, with the freed space at
(top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the its right holding the Live-World **"Sky & tides" panel** — `liveInfoRect`, `renderLiveInfo`:
per-moon phase discs + a selected coastal tile's tidal phase); right column 30% wide = cell
info (top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full 3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan). screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the
@ -511,6 +599,9 @@ triangles (plates are fixed in phase 1).
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes → `climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`, wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params. `climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
`climateCurrentFactor` (4 °C) is the max coastal warming/cooling from ocean currents (0 = off;
ocean-current arrows toggle with `O`). Current deflection angle + smoothing passes are
constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config.
- Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the - Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the
master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full
tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings` tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings`
@ -526,6 +617,18 @@ triangles (plates are fixed in phase 1).
it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours → it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours →
homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp
(append-only — indices are serialized in v7 saves). (append-only — indices are serialized in v7 saves).
- **Live World (`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp`, `planet.cfg`):**
`dayLengthHours` (24) sets the day/night period (and the `d/s` rate unit), `yearLengthDays`
(365.25) the season period; `axialTilt` drives the seasonal declination (0 = no day/night
tilt). `snowTemp` (0 °C) is the land snow line, `seaIceTemp` (-2 °C) the ocean sea-ice line —
raise either to grow the white caps. Render constants (night-floor brightness, terminator
softness, snow-blend ramp) live in `rebuildLiveOverlay()` (src/render/Viewer.cpp), not config.
- **Moons & tides (`tideAmplitude`/`tideSunFactor`, `planet.cfg`):** `tideAmplitude` (0.6 m)
scales the equilibrium tide per unit tide-raising weight (raise for a more dramatic coastline
swing); `tideSunFactor` (0.46) is the sun's tide weight vs a unit moon. Moon count (13) and
per-moon orbit/period/inclination/mass are randomized in `generateMoons()` (PlanetOcean.cpp);
the 3D sun distance/size, moon orbit-render band and eclipse angles are render constants
(ViewerRender.cpp / `rebuildLiveOverlay`), not config.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -26,6 +26,8 @@ add_executable(planetsim
src/sim/PlanetHydrology.cpp src/sim/PlanetHydrology.cpp
src/sim/PlanetBiomes.cpp src/sim/PlanetBiomes.cpp
src/sim/PlanetClimate.cpp src/sim/PlanetClimate.cpp
src/sim/PlanetLive.cpp
src/sim/PlanetOcean.cpp
src/sim/PlanetBiota.cpp src/sim/PlanetBiota.cpp
src/sim/PlanetFloraGen.cpp src/sim/PlanetFloraGen.cpp
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFaunaGen.cpp

View File

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

View File

@ -149,6 +149,19 @@ Color seasonColor(double rangeC) {
return Color{ L(0), L(1), L(2), 255 }; return Color{ L(0), L(1), L(2), 255 };
} }
// Tide: diverging around mid-tide. Low (negative) -> amber, high (positive) -> cyan.
Color tideColor(double level, double range) {
static const unsigned char loC[3] = { 235, 175, 80 }; // low tide (amber)
static const unsigned char midC[3] = { 150, 175, 185 }; // mid tide (pale)
static const unsigned char hiC[3] = { 55, 165, 235 }; // high tide (cyan)
double t = std::clamp(level / std::max(1e-6, range), -1.0, 1.0);
const unsigned char* a = t < 0.0 ? loC : midC;
const unsigned char* b = t < 0.0 ? midC : hiC;
double f = std::fabs(t);
auto L = [&](int c){ return (unsigned char)(a[c] + (b[c] - a[c]) * f); };
return Color{ L(0), L(1), L(2), 255 };
}
// Precipitation ramp over normalized [0,1]: tan (dry) -> green -> teal/blue (wet). // Precipitation ramp over normalized [0,1]: tan (dry) -> green -> teal/blue (wet).
Color precipColor(double moist01) { Color precipColor(double moist01) {
double t = std::clamp(moist01, 0.0, 1.0); double t = std::clamp(moist01, 0.0, 1.0);

View File

@ -25,6 +25,9 @@ Color tempColor(double celsius);
Color precipColor(double moist01); Color precipColor(double moist01);
// Seasonality: summer-winter temperature range in deg C (grey calm -> orange extreme). // Seasonality: summer-winter temperature range in deg C (grey calm -> orange extreme).
Color seasonColor(double rangeC); Color seasonColor(double rangeC);
// Tide level (m), diverging around 0: low tide amber -> high tide cyan. `range` sets the
// saturation scale (meters at which the colour is fully amber/cyan).
Color tideColor(double level, double range);
// Biota density ramps (0..1): flora barren->lush green, fauna pale->amber/red, // Biota density ramps (0..1): flora barren->lush green, fauna pale->amber/red,
// funga pale->violet/brown. // funga pale->violet/brown.
Color floraColor(double d01); Color floraColor(double d01);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,6 +131,20 @@ void Viewer::handleInput() {
{ mode = ColorMode::FloraDensity; recolor(); } { mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)"); setStatus("Biota generated (flora/fauna/funga)");
} }
if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock)
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = false;
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started");
} else {
paused = true; refreshView(); // back to World Creation (drift), paused
setStatus("Live World stopped");
}
}
if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); } if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); } if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled) if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled)
@ -152,7 +166,14 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH); if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH);
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH); if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH);
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); } if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
// Drift speed (Phase 2): My simulated per real second. // Speed control: Live World ramps the live clock (sim hours/s, ~hour -> month);
if (IsKeyPressed(KEY_RIGHT_BRACKET)) driftRate = std::min(driftRate * 1.5, 80.0); // otherwise it sets the drift rate (My simulated per real second).
if (IsKeyPressed(KEY_LEFT_BRACKET)) driftRate = std::max(driftRate / 1.5, 0.5); if (IsKeyPressed(KEY_RIGHT_BRACKET)) {
if (liveWorld) liveRate = std::min(liveRate * 1.5, 720.0);
else driftRate = std::min(driftRate * 1.5, 80.0);
}
if (IsKeyPressed(KEY_LEFT_BRACKET)) {
if (liveWorld) liveRate = std::max(liveRate / 1.5, 0.25);
else driftRate = std::max(driftRate / 1.5, 0.5);
}
} }

View File

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

View File

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

View File

@ -14,6 +14,7 @@ public:
PlanetConfig cfg; PlanetConfig cfg;
std::vector<Cell> cells; std::vector<Cell> cells;
std::vector<Plate> plates; std::vector<Plate> plates;
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -57,6 +58,37 @@ public:
const std::vector<double>& summerTemp() const { return sTempSummer; } // deg C, warmest month const std::vector<double>& summerTemp() const { return sTempSummer; } // deg C, warmest month
const std::vector<double>& winterTemp() const { return sTempWinter; } // deg C, coldest month const std::vector<double>& winterTemp() const { return sTempWinter; } // deg C, coldest month
// Live World stage (PlanetLive.cpp): the slow real-time clock's derived fields.
// computeInsolation() = instantaneous solar incidence cos(sun angle), 0..1 -- the
// physical foundation for live weather + the day/night terminator. computeLiveSeason()
// = the live temperature cycling between winterTemp/summerTemp over the year (drives the
// live temperature view + the moving snow line). Both derived/not-saved; call after
// computeClimate(). dayOfYear01/timeOfDay01 are fractions in [0,1).
void computeInsolation(double dayOfYear01, double timeOfDay01);
void computeLiveSeason(double dayOfYear01);
const std::vector<double>& insolation() const { return sInsolation; } // 0..1 cos incidence
const std::vector<double>& liveTemp() const { return sLiveTemp; } // deg C, current season
// Live World sky + oceans (PlanetOcean.cpp). Sub-solar / sub-lunar directions in model
// space at a clock fraction (drive insolation, tides and the 3D sun/moons -- one source of
// truth). generateMoons() seeds 1-3 moons from a separate RNG (tectonic determinism intact).
Vec3 sunDirection(double dayOfYear01, double timeOfDay01) const;
Vec3 moonDirection(int moonIdx, double timeOfDay01, double timeDays) const;
Vec3 moonOrbitNormal(int moonIdx, double timeOfDay01) const; // orbit-plane normal (for the ring)
const std::vector<Moon>& getMoons() const { return moons; }
void generateMoons();
// Tides: equilibrium tidal height (m) per cell from the moons + sun at the given clock.
// Derived/not saved; recomputed each frame like insolation.
void computeTides(double dayOfYear01, double timeOfDay01, double timeDays);
const std::vector<double>& tide() const { return sTide; }
// Ocean surface currents (PlanetOcean.cpp): a per-ocean-cell tangent velocity from wind
// stress + Coriolis deflection + coast-following (gyres). Derived/not saved; needs sWind
// (computeClimate() computes it, then calls this and feeds warm/cold currents back into
// sTemp -- see climateCurrentFactor). Zero on land cells.
void computeOceanCurrents();
const std::vector<Vec3>& current() const { return sCurrent; }
// Phase 3 (biomes): classify every cell into a Biome from elevation + the climate // Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into // fields (temperature + normalized precipitation). Derived + written back into
// cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves. // cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves.
@ -91,7 +123,9 @@ public:
// hasBiome: whether the stream carries the per-cell biome byte (save v4+). For // hasBiome: whether the stream carries the per-cell biome byte (save v4+). For
// older saves (v3) pass false -- biomes are reclassified after the cells load. // older saves (v3) pass false -- biomes are reclassified after the cells load.
// hasBiota: whether the stream carries the biota population block (save v7+). // hasBiota: whether the stream carries the biota population block (save v7+).
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true); // hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -160,6 +194,12 @@ private:
std::vector<Vec3> sWind; std::vector<Vec3> sWind;
std::vector<int> sUpwind; std::vector<int> sUpwind;
// Live World scratch (derived each frame in Live World; not saved). sInsolation is the
// instantaneous solar incidence; sLiveTemp is the temperature for the current day-of-year;
// sTide is the equilibrium tidal height (m) from the moons + sun.
std::vector<double> sInsolation, sLiveTemp, sTide;
std::vector<Vec3> sCurrent; // ocean surface current velocity (tangent; zero on land)
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded. // on-demand discrete population (saved). sHasBiota latches once generated/loaded.
std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity; std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity;

View File

@ -63,6 +63,38 @@ void Planet::computeClimate() {
sUpwind[i] = best; sUpwind[i] = best;
} }
// 1b. Ocean currents + their temperature feedback. Currents need the wind just computed.
// Warm (poleward-flowing) currents carry equatorial heat toward the poles and cold
// (equatorward) currents the reverse; this raises/lowers coastal temperatures. We build a
// bounded anomaly on ocean cells (sign = poleward speed) and smooth it onto the coasts.
computeOceanCurrents();
if (cfg.climateCurrentFactor > 0.0 && (int)sCurrent.size() == n) {
double maxSp = 1e-9;
for (int i = 0; i < n; ++i) maxSp = std::max(maxSp, sCurrent[i].length());
std::vector<double> dT(n, 0.0);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation > sea) continue; // ocean cells only
const Vec3& nrm = cells[i].unit;
Vec3 northT = up - nrm * up.dot(nrm); // tangent toward +latitude
double nl = northT.length(); if (nl < 1e-9) continue;
northT = northT * (1.0 / nl);
double sign = (nrm.y >= 0.0) ? 1.0 : -1.0; // toward the nearer pole
double poleward = sCurrent[i].dot(northT) * sign / maxSp; // -1..1
dT[i] = cfg.climateCurrentFactor * std::clamp(poleward, -1.0, 1.0);
}
// Spread the anomaly onto coasts and let it decay inland (neighbour averaging).
std::vector<double> tmp(n);
for (int p = 0; p < 3; ++p) {
for (int i = 0; i < n; ++i) {
double s = dT[i]; int c = 1;
for (int nb : cells[i].neighbors) { s += dT[nb]; ++c; }
tmp[i] = s / c;
}
dT.swap(tmp);
}
for (int i = 0; i < n; ++i) sTemp[i] += dT[i];
}
// 2. Steady-state moisture advection along the wind (iterative upwind differencing, // 2. Steady-state moisture advection along the wind (iterative upwind differencing,
// double-buffered -> deterministic). Ocean cells are a moisture source; land // double-buffered -> deterministic). Ocean cells are a moisture source; land
// cells take their upwind moisture, rain part of it out (more on windward upslopes) // cells take their upwind moisture, rain part of it out (more on windward upslopes)

View File

@ -29,11 +29,13 @@
D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \ D(biomeWetlandMoist) D(biomeDesertMoist) D(biomeGrassMoist) D(biomeTaigaMoist) \
D(biomeLakeMinDepth) D(biomeSeasonWeight) \ D(biomeLakeMinDepth) D(biomeSeasonWeight) \
D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \ D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \
D(climateOroRefHeight) D(climateContinentality) \ D(climateOroRefHeight) D(climateContinentality) D(climateCurrentFactor) \
D(seasonAmpMax) D(seasonLatExp) D(seasonOceanFactor) \ D(seasonAmpMax) D(seasonLatExp) D(seasonOceanFactor) \
D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \ D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \
D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \ D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \
D(bioFungaTempMin) D(bioRegionBonus) \ D(bioFungaTempMin) D(bioRegionBonus) \
D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \
D(tideAmplitude) D(tideSunFactor) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) \
@ -169,6 +171,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic")); E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic"));
E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight")); E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight"));
E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality")); E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality"));
E(rng(cfg.climateCurrentFactor, 0.0, 30.0, "climateCurrentFactor"));
E(rng(cfg.seasonAmpMax, 0.0, 60.0, "seasonAmpMax")); E(rng(cfg.seasonAmpMax, 0.0, 60.0, "seasonAmpMax"));
E(rng(cfg.seasonLatExp, 0.1, 6.0, "seasonLatExp")); E(rng(cfg.seasonLatExp, 0.1, 6.0, "seasonLatExp"));
E(rng(cfg.seasonOceanFactor, 0.0, 1.0, "seasonOceanFactor")); E(rng(cfg.seasonOceanFactor, 0.0, 1.0, "seasonOceanFactor"));
@ -182,6 +185,12 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight")); E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight"));
E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin")); E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin"));
E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus")); E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus"));
E(rng(cfg.dayLengthHours, 0.1, 1.0e5, "dayLengthHours"));
E(rng(cfg.yearLengthDays, 1.0, 1.0e7, "yearLengthDays"));
E(rng(cfg.snowTemp, -60.0, 30.0, "snowTemp"));
E(rng(cfg.seaIceTemp, -60.0, 20.0, "seaIceTemp"));
E(rng(cfg.tideAmplitude, 0.0, 100.0, "tideAmplitude"));
E(rng(cfg.tideSunFactor, 0.0, 5.0, "tideSunFactor"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -260,6 +269,7 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sPrevCount); writeVec(os, sPrevCount);
writeVec(os, sStaleStreak); writeVec(os, sStaleStreak);
writeVec(os, sFreePlateIds); writeVec(os, sFreePlateIds);
writeVec(os, moons); // v9: natural satellites (Live World)
// v7: discrete biota population (sBiota). A flag byte gates the block so a // v7: discrete biota population (sBiota). A flag byte gates the block so a
// not-yet-populated world stays compact; otherwise three Organism lists per cell. // not-yet-populated world stays compact; otherwise three Organism lists per cell.
uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio); uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio);
@ -271,7 +281,7 @@ void Planet::writeState(std::ostream& os) const {
} }
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) { bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons) {
// Read the length-prefixed key=value config block (see writeState). A default // Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their // PlanetConfig is parsed over, so fields absent from an older save keep their
// current defaults. The length guard rejects pre-v6 (raw-POD-config) saves. // current defaults. The length guard rejects pre-v6 (raw-POD-config) saves.
@ -300,6 +310,8 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) {
readVec(is, sPrevCount); readVec(is, sPrevCount);
readVec(is, sStaleStreak); readVec(is, sStaleStreak);
readVec(is, sFreePlateIds); readVec(is, sFreePlateIds);
if (hasMoons) readVec(is, moons); // v9: natural satellites
else generateMoons(); // pre-v9 save: synthesize moons from the seed
if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state
// v7: discrete biota population. buildGeometry() already sized sBiota empty; // v7: discrete biota population. buildGeometry() already sized sBiota empty;
// older saves (hasBiota=false) just keep the empty population (press L to fill). // older saves (hasBiota=false) just keep the empty population (press L to fill).

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

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

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

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

View File

@ -35,6 +35,20 @@ enum class Biome : uint8_t {
Desert, Forest, Taiga, Tundra, Hills, Mountains Desert, Forest, Taiga, Tundra, Hills, Mountains
}; };
// A natural satellite (Live World). Geometry is fixed, so the moon is a world object
// (not a cell): it orbits on the live clock, raises tides, and renders as a small sphere.
// Generated 1-3 per world from a separate RNG (so it never perturbs tectonic determinism)
// and saved (v9). orbitRadius is in planet radii (render scale); tideWeight is the tide-
// raising mass proxy; inclination tilts the orbit plane off the equator.
struct Moon {
double orbitRadius = 16.0; // planet radii (visual orbit distance)
double periodDays = 20.0; // orbital period (planetary days)
double phase = 0.0; // orbital phase offset (radians)
double inclination = 0.0; // orbit-plane tilt off the equator (radians)
double tideWeight = 1.0; // tide-raising strength (Moon mass proxy)
double dispRadius = 0.10; // display sphere radius (visual size)
};
struct Plate { struct Plate {
int id = 0; int id = 0;
PlateType type = PlateType::Oceanic; // initial crust type seeded onto cells PlateType type = PlateType::Oceanic; // initial crust type seeded onto cells
@ -200,6 +214,8 @@ struct PlanetConfig {
double climateContinentality = 0.05; // moisture lost per land cell crossed (dries interiors) double climateContinentality = 0.05; // moisture lost per land cell crossed (dries interiors)
int climateWindPasses = 50; // moisture-advection iterations (steady state) int climateWindPasses = 50; // moisture-advection iterations (steady state)
int climateMoistureSmooth = 12; // precipitation diffusion passes (wet/dry transition zones) int climateMoistureSmooth = 12; // precipitation diffusion passes (wet/dry transition zones)
double climateCurrentFactor = 4.0; // C: max coastal warming/cooling from ocean currents
// (warm poleward currents raise, cold equatorward lower)
// --- Seasons (obliquity) -- see PlanetClimate.cpp ----------------------- // --- Seasons (obliquity) -- see PlanetClimate.cpp -----------------------
// axialTilt (above) drives a per-cell seasonal temperature range around the annual // axialTilt (above) drives a per-cell seasonal temperature range around the annual
@ -230,4 +246,15 @@ struct PlanetConfig {
int bioFloraPoints = 20; // flora point budget at full density (scaled by density) int bioFloraPoints = 20; // flora point budget at full density (scaled by density)
int bioFaunaPoints = 16; // fauna point budget at full density int bioFaunaPoints = 16; // fauna point budget at full density
int bioFungaPoints = 14; // funga point budget at full density int bioFungaPoints = 14; // funga point budget at full density
// --- Live World (slow real-time clock) -- see PlanetLive.cpp -------------
// The finished planet can run on a slow real-time clock (hours -> weeks/months) with a
// moving day/night terminator, a live seasonal temperature cycle and a moving snow line.
// dayLengthHours/yearLengthDays set the calendar; snowTemp/seaIceTemp the freezing lines.
double dayLengthHours = 24.0; // hours in one planetary day (rotation -> day/night)
double yearLengthDays = 365.25; // days in one planetary year (orbit -> seasons)
double snowTemp = 0.0; // C: land below the live temperature shows snow
double seaIceTemp = -2.0; // C: ocean below the live temperature shows sea ice
double tideAmplitude = 0.6; // m: equilibrium-tide scale per unit tide-raising weight
double tideSunFactor = 0.46; // sun's tide weight relative to a unit moon (Earth ~0.46)
}; };

137
test_live.cpp Normal file
View File

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

144
test_ocean.cpp Normal file
View File

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