Placement used greedy farthest-first with a hard minimum spacing, so settlements came out as a near-uniform lattice (unrealistic). Now placeSettlements() does habitability-weighted random sampling (weight = habitability^civClusterExp) with a soft Gaussian suppression (civMinSpacingRadians) around each pick, so towns cluster on good land (rivers/coasts/fertile valleys) at irregular spacing and leave empty stretches between. Nearest-neighbour distances now span ~0.04..0.39 rad (was ~uniform) and placement concentrates on the better cells. - New knob civClusterExp (3.0; higher = tighter clustering on the best land); civMinSpacingRadians repurposed as the soft suppression scale (0.10 -> 0.06). - Separate sCivRng + deterministic, so determinism / RNG isolation hold. - test_civ: replaced the hard-spacing assertion with clustering checks (nearest-neighbour spacing varies; placed cells beat the habitable mean). All 10 suites pass; GUI build clean. Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
31 KiB
Markdown
417 lines
31 KiB
Markdown
# Design notes (durable context)
|
||
|
||
These are the non-obvious decisions/conventions that were previously only in Claude's
|
||
auto-memory (which lives under `~/.claude/` and does **not** travel with the repo). Captured
|
||
here so the context survives a move to another machine/server. `CLAUDE.md` has the
|
||
authoritative current-state changelog; this is the "why / where things live" summary.
|
||
|
||
## Framing: World Creation → Live World
|
||
|
||
The roadmap is no longer rigid numbered "phases". **World Creation** is a set of
|
||
continuous, overlapping stages on a geological clock (My): tectonics → continental drift &
|
||
erosion → hydrology → climate → biomes → (fauna & flora, next). The long-term goal is a
|
||
separate **Live World** mode that runs the *finished* planet at a much slower real-time
|
||
clock (hours/days/weeks/months) with dynamic weather (clouds, rain, storms) and living
|
||
ecosystems/civilization. **Internal code still uses `phase*` names** (`Planet::drifting`,
|
||
the `phase3` flag, `phase3AfterMy`/`phase3DtScale` config keys) for save/config
|
||
compatibility — only display strings and docs use the new framing.
|
||
|
||
## Code module layout
|
||
|
||
Split into a raylib-free **engine** (`src/sim/`, headless-testable) and a raylib **viewer**
|
||
(`src/render/`); `src/main.cpp` is a ~10-line entry point. CMake adds both dirs to the
|
||
include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
|
||
|
||
`Planet` is **one class implemented across several .cpp files** (all share `Planet.hpp`):
|
||
- `PlanetTypes.hpp` — `Cell` / `Plate` / `SubGrid` / `Biome` enum / `PlanetConfig`.
|
||
- `Planet.cpp` — generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max.
|
||
- `PlanetTectonics.cpp` — `step()` (stress→uplift→relax; orogeny boosts gated on `drifting`).
|
||
- `PlanetDrift.cpp` — `cflDtMy`/`advect` + plate lifecycle (fission/kick/baby/fuse/enclosed).
|
||
- `PlanetErosion.cpp` — `erode` + `adjustSeaLevel`.
|
||
- `PlanetHydrology.cpp` — `routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes,
|
||
steepest-descent→rivers, mass-conserving stream-power incision).
|
||
- `PlanetClimate.cpp` — `computeClimate()` (temperature + orographic precipitation).
|
||
- `PlanetLive.cpp` — `computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
|
||
seasonal temperature; derived, not saved).
|
||
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
|
||
`computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved
|
||
(v9); tides/currents derived.
|
||
- `PlanetWeather.cpp` — `initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle;
|
||
saved v10).
|
||
- `PlanetBiomes.cpp` — `classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
|
||
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
|
||
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
|
||
- `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density +
|
||
per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led).
|
||
- `PlanetVolcano.cpp` — `placeVolcanoes`/`stepVolcanoes` (Live World stateful volcano lifecycle +
|
||
islands; saved v15).
|
||
- `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc).
|
||
- `PlanetGeography.{hpp,cpp}` — `generateGeography()` (named features: continents/oceans/ranges/
|
||
rivers/lakes; the atlas, saved v17+).
|
||
- `PlanetEcoregions.{hpp,cpp}` — `generateEcoregions()` (named ecological provinces from biome,
|
||
land/water context, productivity and broad biota; saved v19).
|
||
- `PlanetCiv.{hpp,cpp}` — `computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2:
|
||
habitability + settlements that grow/decline on the live clock; saved v20).
|
||
- `PlanetIO.cpp` — text config + binary save/load.
|
||
|
||
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
|
||
`ViewerInput.cpp` (camera/picking/keys), `ViewerRender.cpp` (globe/map/panels/HUD/prompt),
|
||
plus topical helpers `Colors` / `Map2D` / `Overlays` / `Picking` / `Panels`.
|
||
|
||
Per-tick order in `Viewer::refreshView()`: `computeHydrology()` (if hydrology on) →
|
||
`computeClimate()` → `classifyBiomes()` → `computeBiotaDensity()` → `recolor()`. The discrete
|
||
biota *population* (`generateBiota()`) is NOT in this per-tick path — it's on-demand (key `L`).
|
||
|
||
## Core principle (do not violate)
|
||
|
||
Geometry is **fixed** — cells (icosphere vertices) never move. Only per-cell *properties*
|
||
flow over the fixed grid + neighbor adjacency (Eulerian). New phenomena = new per-cell
|
||
fields flowed over the grid, never moving cells.
|
||
|
||
## Axial tilt render convention (non-obvious)
|
||
|
||
The 3D globe is rendered leaned by `cfg.axialTilt` via `rlRotatef(tilt,0,0,1)` wrapping all
|
||
3D content in `renderGlobe3D`. Because that rotation isn't in the data, anything mapping
|
||
between world and model space must compensate with `rotateZ(v, ±tilt)` (src/render/
|
||
Picking.cpp): 3D picking un-rotates the ray hit by `−tilt` before `nearestCell`; 3D plate
|
||
labels rotate by `+tilt` before projecting. The 2D map + biome/climate are tilt-independent.
|
||
|
||
## Save format (v7) — self-describing config + biota population
|
||
|
||
`planet.save` stores `PlanetConfig` as a **self-describing key=value text block** (not a raw
|
||
POD dump), parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared in
|
||
PlanetIO.cpp), written at `precision(17)` so doubles round-trip exactly. Consequence:
|
||
**adding/removing PlanetConfig fields no longer breaks saves** (unknown keys ignored, missing
|
||
keys keep defaults). v6 cannot load pre-v6 saves (one-time break; a length guard fails it
|
||
gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`). **v7** appends
|
||
the **biota population** (`sBiota`): a flag byte, then three `Organism{uint16 archetype, uint8
|
||
biome}` lists per cell. Densities are derived (not saved). Older saves without the block load
|
||
fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = ver>=7`).
|
||
|
||
## Biota (flora / fauna / funga) — density + slot/point population
|
||
|
||
Two layers (`PlanetBiota.cpp` + the three `*Gen.cpp`): (1) derived per-cell **density** scalars
|
||
(0..1) recomputed each tick like climate — flora = Liebig-min(temp, moisture), fauna ∝ flora
|
||
(carnivores gated on neighbourhood prey ≥ `bioCarnPreyMin`), funga = moisture/organic-matter-led
|
||
+ cold-tolerant. All three are 0 under polar `Ice`; funga is also 0 on water, while flora/fauna
|
||
extend into the ocean as marine productivity (see below). (2) On-demand discrete **population**
|
||
`generateBiota()`: each land **or ocean** cell draws broad archetypes from the comprehensive
|
||
append-only `biotaArchetypes()` table
|
||
into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by
|
||
biome/climate suitability and a **regional bonus** for archetypes already in same-biome
|
||
neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are
|
||
labelled by **taxonomy** — Family + Size + role (full `Class > Order > Family` in
|
||
`organismTaxonomy()`), never informal common names ("Felidae", not "cat"); generalist families
|
||
get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seeded from `cfg.seed`** (not
|
||
`Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in
|
||
`test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves).
|
||
|
||
**Marine flora & fauna (life in the ocean).** Funga stays land-only, but flora and fauna now
|
||
extend over water (the old hard `elevation<=sea` gate left the sea barren). Ocean cells (not under
|
||
polar `Ice`) get a marine primary productivity in `computeFloraDensity`:
|
||
`base + (1-base)·max(shelf, coast)`, where `shelf = clamp(1 - depth/bioMarineShelfDepth)` (light to
|
||
the photic floor) and `coast = clamp(1 - ringDistFromLand/bioMarineCoastRings)` (land-runoff
|
||
nutrients, a multi-source BFS ring-distance seeded from land — the continentality BFS mirrored).
|
||
`sMoist` is a land rainfall field and is not used at sea. `computeFaunaDensity` now skips only `Ice`,
|
||
so marine fauna = `flora·productivity` and the carnivore prey-gate clusters sharks/seals/squid on
|
||
rich shelves. `generateBiota` fills ocean cells too (skip `Ice`; no marine funga); `fillFlora`/
|
||
`fillFauna` are unchanged because their biome-mask filter draws only the **Ocean-masked** archetypes
|
||
appended to the table (Kelp/Seagrass/Phytoplankton; Forage fish/Reef fish/Shark/Baleen whale/Seal/
|
||
Squid — all `moistMin=0`, SST-zoned). The flora/fauna colour views render ocean on a distinct
|
||
marine ramp (`marineFloraColor`/`marineFaunaColor`). Knobs: `bioMarineBase`/`bioMarineShelfDepth`/
|
||
`bioMarineCoastRings`. No save bump (densities derived; archetypes append-only; config
|
||
self-describing).
|
||
|
||
## Climate + biome model (derived, not saved)
|
||
|
||
`computeClimate()` builds two derived per-cell fields:
|
||
- **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so
|
||
cold concentrates at poles) − `biomeElevLapse` × elevation. This is the **annual mean**; the
|
||
**Seasons** pass adds derived `sTempSummer`/`sTempWinter` = mean ± `A`, where the seasonal
|
||
half-amplitude `A = seasonAmpMax · sin(axialTilt)/sin(23.44°) · latShape · continentality`.
|
||
Continentality is a multi-source-BFS ring distance from ocean cells (oceans/coasts muted by
|
||
thermal inertia; interiors swing most). `classifyBiomes()` blends winter temp into the
|
||
Tundra/Taiga cold cutoffs via `biomeSeasonWeight` (0 = mean only → unchanged biomes), so
|
||
cold-winter continental interiors turn boreal/tundra. Seasonal fields are derived/not-saved.
|
||
**Ocean currents** add a bounded coastal warm/cold anomaly to this mean before the seasons pass
|
||
(`climateCurrentFactor`; see the Ocean section).
|
||
- **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean
|
||
cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains
|
||
out more on windward upslopes (orographic), loses a multiplicative fraction per cell
|
||
(continentality) → leeward/interior drying. The raw field is near-binary, so it's
|
||
**diffused** (`climateMoistureSmooth` passes) into transition zones, then normalized to
|
||
`sMoist∈[0,1]` by anchoring the **median land precip → 0.5** (robust to orographic spikes).
|
||
|
||
`classifyBiomes()` reads `sTemp` + `sMoist` (not a latitude hack) → rain-shadow/interior
|
||
deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome &
|
||
climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`.
|
||
|
||
## Live World (slow real-time clock) — day/night + live seasons (derived, not saved)
|
||
|
||
The arc after World Creation: the finished planet runs on a slow real-time clock instead of
|
||
the geological My clock. `PlanetLive.cpp` (raylib-free) builds two derived per-cell fields,
|
||
recomputed each frame like climate (never saved):
|
||
- `computeInsolation(dayOfYear01, timeOfDay01)` → `sInsolation` ∈ [0,1], the instantaneous
|
||
solar incidence `max(0, cell.unit · sunDir)`. `sunDir = lonLatToDir(λ, δ)` with declination
|
||
`δ = axialTilt·sin(2π·dayOfYear01)` (0 at equinox, ±tilt at solstice → polar day/night) and
|
||
sub-solar longitude `λ = π·(1−2·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 = (summer−winter)/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 **1–3 `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).
|
||
|
||
## Weather (Live World dynamic clouds & rain)
|
||
|
||
`PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock
|
||
(`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over
|
||
warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation`
|
||
daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed
|
||
`weatherWindKmh`), **condense** the supersaturated air into cloud (saturation
|
||
`weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above
|
||
`weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded
|
||
`1−exp(−rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec).
|
||
`initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live
|
||
frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused).
|
||
|
||
Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha
|
||
= cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the
|
||
2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain,
|
||
flag-gated; pre-v10 saves spin weather up on entering Live World).
|
||
|
||
**Moving weather systems** (same `stepWeather`): the base field above relaxes to a *static*
|
||
pattern under fixed forcing, so a population of drifting `WeatherSystem` **agents** (world objects,
|
||
not cells — like moons; transient/not saved; separate `sWeatherRng` seeded from `cfg.seed`)
|
||
provides the motion. Each step they **spawn** over warm tropical ocean (5–25°) or a mid-latitude
|
||
(30–62°) ocean low, **move** along the steering wind (`sWind` at the nearest cell) + a poleward
|
||
recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** over land/cold, and
|
||
**stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate
|
||
behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as
|
||
animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`.
|
||
The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a
|
||
load resumes active storms; a load also drops any stale pre-load `wxUndo` step-back ring and (v12)
|
||
restores the most recent `wxSaveMax`(40) step-back frames from the file, so stepping back after a load
|
||
can rewind storms past the saved moment. v13 also saves the Live World clock rate. Weather is
|
||
integrated/path-dependent, so reversing it past a save is only possible via this stored history — it
|
||
can't be re-derived from the loaded moment.
|
||
|
||
## Volcanoes & volcanic islands (Live World)
|
||
|
||
`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`) and a stateful lifecycle
|
||
agent. On **entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is
|
||
**ridge** if its plate is `baby` or a neighbour's is (the `buildBorders` baby test), else **border**
|
||
if a neighbour has a different `plateId`, else **interior**; placement probability is
|
||
`volcanoProbRidge` ≫ `volcanoProbBorder` ≫ `volcanoProbInterior`, reservoir-sampled to
|
||
`volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^
|
||
0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores `baseElev`, current `built`
|
||
height, `phase` (growing/dormant), dormancy and ash timers, ash carry, and decaying `activity`.
|
||
|
||
The crucial design point is now the opposite of the original v14 implementation: **volcanoes are
|
||
integrated forward**, not pure functions of `liveTime`. `stepVolcanoes(dt)` grows active vents by
|
||
`volcanoBuildRate * activity`, lets tall vents go dormant above `volcanoFreeHeight`, explodes dormant
|
||
vents after a long timer, shaves `volcanoExplodeDropFrac` of built height, starts sustained ash
|
||
emission, and decays activity so old volcanoes settle. It always reasserts
|
||
`cells[v.cell].elevation = baseElev + built`; submarine vents crossing sea level breach into islands
|
||
and can re-submerge when a restored snapshot has less built height. Explosions stamp a wide local ash
|
||
blast into `sCloud`/`sHumidity` and sustained puffs continue while `ashTimer` runs.
|
||
|
||
Because this is stochastic state, **step-back snapshots include volcanoes + `sVolRng`** alongside
|
||
weather. A backward step restores weather, storms, volcano lifecycle state and RNG, then
|
||
`stepVolcanoes(0)` reasserts terrain without advancing. Rendered as growing red/orange cones,
|
||
dormant grey quiet cones, and bright post-explosion plume markers (3D + 2D, key `V`). **Saved v15**:
|
||
the stateful `Volcano` set + `sVolRng`; v14's old pure-function block is consumed and discarded so
|
||
volcanoes reseed on the next Live World entry. Knobs: `volcano*`.
|
||
|
||
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
|
||
|
||
Three viewer-only controls over the Live World sim:
|
||
- **Storm follow-cam** (`Y`): the globe is at the origin and the camera orbits it, so to centre a
|
||
storm we point the camera **along the storm's world direction** — `rotateZ(storm.pos, +axialTilt)`
|
||
(model→world; `Picking.hpp`), then `camPitch=asin(d.y)`, `camYaw=atan2(d.x,d.z)`. Tracked by a
|
||
stable `WeatherSystem.id` (assigned at spawn; transient, no RNG/determinism impact). Orbit-drag is
|
||
disabled while following; wheel-zoom still works; cycles by descending strength, auto-releases if
|
||
the storm dissipates.
|
||
- **2D map zoom** (`mapZoom`/`mapPanX`/`mapPanY`): implemented as a **virtual projection rect**,
|
||
`Viewer::mapViewRect()` = `mapRect` scaled about its centre + pan. Every map projection call
|
||
(`drawMap2D`/`drawWeather2D`/`drawSegments2D`/graticule/markers/`mapScreen` + the 2D hover-pick)
|
||
takes this `vr` instead of `mapRect`, while the **scissor + frame stay `mapRect`** so it clips to
|
||
the panel — no Map2D signature changes. `drawMapTris` was changed to derive the y-coordinate from
|
||
the rect (not the fixed-to-mapRect `m.pos`) so both axes zoom. Wheel zooms toward the cursor (1–8×);
|
||
drag pans when zoomed, else keeps the `mapLon` longitude rotation.
|
||
- **Clock stepper**: the `stepSim` Live-World body is factored into `Viewer::liveAdvance(dtClock,
|
||
dtWeather)` (clamps `liveTime≥0`, recomputes insolation/season/tides/moons, `stepWeather`,
|
||
overlay). `.`/`,` step ±`liveRate` hours and **auto-pause** (frame-step). Weather is integrated
|
||
and not analytically reversible, so a forward step snapshots the full weather state
|
||
(`Planet::captureWeather`/`restoreWeather` — humidity/cloud/rain/storms/RNG) into a bounded
|
||
`wxUndo` ring; **`,` restores the newest snapshot at or before now**, reversing clouds/rain/storms
|
||
exactly as well as the deterministic sky. `liveAdvance` records a snapshot at ~one-step cadence on
|
||
*any* forward advance — continuous run or manual step — so storms born during a run also rewind
|
||
(the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The
|
||
restored snapshot includes the storm RNG, so re-stepping forward replays deterministically.
|
||
|
||
## Live World event journal
|
||
|
||
`Viewer` owns a saved, bounded event journal (`WorldEvent`, newest 200) shown in the tabbed
|
||
`liveInfoRect` panel beside the 2D map (`Sky` / `Tides` / `Weather` / `Events`). It is intentionally
|
||
viewer-level state: the sim emits no UI strings, and the log is **not** part of step-back history.
|
||
Rewinding restores weather/storms/volcanoes, but the journal remains the observer's record.
|
||
|
||
Events are detected in `Viewer::liveAdvance` by comparing before/after Live World state: weather
|
||
system formation, tropical systems crossing hurricane/typhoon strength, volcano dormancy, dormant
|
||
volcano eruptions, and submarine volcanoes breaching into islands. Clicking an event calls
|
||
`focusCell`: select/rebuild the cell detail, release storm follow-cam, rotate the 3D camera to the
|
||
cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom.
|
||
Save **v16** appends the event log; pre-v16 saves load with an empty journal.
|
||
|
||
## Geography & place-names — the atlas (civilization Step 1)
|
||
|
||
`PlanetGeography.cpp` + `NameGen.cpp` (engine, raylib-free, deterministic). The first step of the
|
||
civilization arc: name the world so everything civic can reference it. `Planet::generateGeography()`
|
||
extracts geographic features from the frozen terrain purely by **connectivity over the fixed grid**
|
||
(the same flood-fill idiom as `coalesceBabyPlates` / the enclosed-sea fill): connected land →
|
||
**Continent** (≥ `geoContinentMinCells`) or **Island**; the connected world ocean is split into
|
||
**basins** (a single connected body reads wrong as one name) by a distance-from-land watershed —
|
||
greedy farthest-first deep-water **centres** (`geoOceanSepRadians` apart, ≥ `geoOceanDeep` rings from
|
||
land) then multi-source BFS Voronoi over the ocean graph — each basin → an **Ocean** (or **Sea** if ≤
|
||
`geoSeaMaxCells`); inland filled basins (`lakeDepth`) → **Lake**; connected `> geoMountainElev` land →
|
||
**MountainRange** + its highest cell as a **Peak**; the largest `discharge` mouths traced upstream via
|
||
`flowTo` → **River**. It first calls `computeHydrology()` (routing only — no elevation change) so the
|
||
river/lake fields exist on a finished world.
|
||
|
||
Naming is a separate concern in `NameGen` (syllable banks; `bankForRegion` gives each continent a
|
||
"language" so its rivers/mountains share a sound) and uses a separate RNG (`sGeoRng = cfg.seed ^
|
||
magic`) + a per-feature hash, so it is deterministic and **never perturbs the tectonic stream**
|
||
(asserted in `test_geography.cpp`). Output: `Planet::geoFeatures` (id/kind/name/anchorCell/regionId/
|
||
size) plus four per-cell index arrays (`sCellLand`/`sCellWater`/`sCellRange`/`sCellRiver`) giving O(1)
|
||
"which features is this cell in" — the hook the later territory/border step will build on. Geography is
|
||
static (terrain is frozen), so it is generated **once** on a settled world (key `M`, in or out of Live
|
||
World) and **saved (v17+)** — names persist so a future culture step can rename places. The viewer draws
|
||
names as labels on the globe (the plate-label manual projection) + 2D map (minor features only when
|
||
zoomed, to declutter), lists them in an **Atlas** tab (5th live-info tab; click a row → `focusCell`),
|
||
and adds a "region" line to cell-info. Save v17 appends the feature records (with `std::string` names,
|
||
written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand.
|
||
Save v18 appends the active geography reshuffle salt (`Shift+M`) so repeated renames continue after
|
||
load.
|
||
Names dedupe on the **proper-noun root** (not the formatted string), so a continent, its river and its
|
||
mountains can't share a base name. New land created during Live World (a volcanic island breaching the
|
||
sea) is added to the atlas on the fly by `Planet::nameNewLand(cell)` — it joins an adjacent existing
|
||
landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries.
|
||
|
||
## Ecoregions — named ecological provinces
|
||
|
||
`PlanetEcoregions.cpp` (engine, raylib-free, deterministic) adds the next atlas-like layer after
|
||
geography. `Planet::generateEcoregions()` ensures climate/biomes, biota density, hydrology and
|
||
geography exist, then flood-fills connected cells by **biome + land/ocean/wet context + similar
|
||
productivity band**. Tiny fragments merge into an adjacent compatible region when possible. Each
|
||
`Ecoregion` stores a name, biome, anchor cell, containing geography feature id, size, average
|
||
flora/fauna/funga productivity and dominant broad flora/fauna/funga archetype. If the discrete biota
|
||
population exists (`L`), dominants come from the actual placed organisms; otherwise they are inferred
|
||
from density + archetype suitability. Ecoregions summarize existing broad ecology — they do not create
|
||
new species or a food-web simulation.
|
||
|
||
Names use `NameGen` with the containing geography bank, so ecological names inherit regional sound
|
||
without touching the tectonic RNG. Viewer key `E` lazily generates/toggles the ecoregion colour view,
|
||
cell-info shows the local ecoregion and dominants, and Live World has an **Eco** tab (6th tab; click
|
||
a row → `focusCell`). Save **v19** appends the ecoregion records and `sCellEcoregion`; pre-v19 saves
|
||
load with none and regenerate on demand.
|
||
|
||
## Civilization Step 2 — habitability & settlements
|
||
|
||
`PlanetCiv.cpp` (engine, raylib-free, deterministic, separate `sCivRng`). `computeHabitability()` is a
|
||
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
|
||
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
|
||
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
|
||
seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
|
||
with a **soft Gaussian suppression** (`civMinSpacingRadians`) softening nearby weights after each pick — so
|
||
settlements **cluster** on good land at irregular spacing instead of an even lattice (the earlier hard
|
||
farthest-first looked like a grid). Each is named from its continent's `NameGen` bank.
|
||
|
||
Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state
|
||
is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
|
||
`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours,
|
||
liveTime)` runs in `liveAdvance` after `stepVolcanoes`. It is **environment-driven and dynamic** (the
|
||
first cut grew every town uniformly to the same cap):
|
||
- growth **rate** `r = civGrowthRate·(civGrowthMin + (1−civGrowthMin)·habitability)` so fertile cells
|
||
grow far faster than marginal ones;
|
||
- carrying capacity `K = civMaxPopulation · habitability · siteQuality · conditions`, where
|
||
**siteQuality** = `0.45 + civSiteVariety·(coastBonus + log10(1+discharge/20))` makes max city size vary
|
||
by an order of magnitude (a continental river or coast → a metropolis, a dry inland cell → a town —
|
||
this is what spreads final sizes instead of all saturating equally);
|
||
- **conditions** = `harvest · drought · coldYear · flood · ash`, all **deterministic functions of
|
||
(≈20° region bucket, integer year, seed)** — constant within a year, region-correlated, recomputed
|
||
on a step-back (pure, so no extra saved/snapshot state): year-to-year harvests (swing scaled by the
|
||
seasonal-amplitude/continentality field), multi-year droughts (a slow noise interpolated across
|
||
`civDroughtPeriod`-year epochs, threshold raised by aridity `1−sMoist`), rare cold years (× the cell's
|
||
near-freezing-winter exposure), river floods (silt bonus / rare disaster), and the volcano-ash cut.
|
||
- **Storms** read live `storms()` (already snapshotted): a system within its `radius` of a town deals
|
||
direct deaths `civStormDeathRate·strength·overlap·(hurricane? civHurricaneDeathMult)` — a parked
|
||
hurricane can gut a coastal city.
|
||
- logistic step + an accelerated `civFamineRate` loss when `K<P` + the storm deaths; floored at 1 so a
|
||
site revives. So towns **grow, fluctuate, shrink in droughts, and collapse/abandon** under sustained
|
||
famine or an acute disaster.
|
||
Per-settlement derived `sCivCond` (combined multiplier) + `sCivDrought` (severity) drive the viewer:
|
||
markers are **withered-tinted** by hardship, cell-info shows "drought/conditions", and `detectLiveEvents`
|
||
logs kind=3 events with the **cause** — a storm over the shrunk town → "Hurricane <name> devastates X"
|
||
(reuses `weatherEventName`), else "Famine shrinks X to a Town" when `sCivDrought` is high, else tier
|
||
up/down/abandon. Markers (3D spheres + 2D dots, sized by tier; city/town 3D labels), a **Civ** tab (7th),
|
||
a cell-info line, and a `Habitability` colour mode (key `I`). `buildGeometry()` clears the set on reseed.
|
||
Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load.
|
||
Knobs `civ*`.
|
||
|
||
## Headless testing
|
||
|
||
Engine is raylib-free, so logic is tested without a display. Build/run:
|
||
```
|
||
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
|
||
src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
|
||
src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
|
||
src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
|
||
src/sim/PlanetVolcano.cpp \
|
||
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
|
||
src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp src/sim/PlanetGeography.cpp \
|
||
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
|
||
# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography use the same list.
|
||
```
|
||
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
|
||
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).
|