Civilizations Step 1: geography & place-naming (the atlas, save v17)
The foundation of the civilization arc: name the world so everything civic
(territory, borders, place-of-origin) can reference it. This is pure derived
geometry + a deterministic namer, so it lives in the raylib-free engine and is
fully testable headless. No agents/clock yet -- those come in later steps.
- NameGen.{hpp,cpp} (new): deterministic procedural name generator (xorshift
syllable banks; bankForRegion gives each continent a "language" so its rivers/
mountains share a sound). Reused by the whole civ arc later.
- PlanetGeography.{hpp,cpp} (new): Planet::generateGeography() extracts named
features by connectivity over the fixed grid -- continents/islands (connected
land), oceans/seas (connected water), lakes (inland filled basins), mountain
ranges + peaks (connected high terrain), rivers (largest discharge mouths
traced upstream via flowTo). Separate RNG (sGeoRng) keeps tectonic determinism
intact; per-cell index arrays (sCellLand/Water/Range/River) give O(1) lookup.
- Save v17: geography block (feature records with names + per-cell region arrays)
appended in writeState/readState; readState gains hasGeography; pre-v17 saves
load with none (regenerated on M). geo* config knobs + validation.
- Render: key M toggles place-name labels on globe (manual projection) + 2D map
(minor features only when zoomed); a 5th "Atlas" live-info tab lists features
by kind (click a row -> focusCell); cell-info shows a "region" line. Generated
lazily on a settled world (M) or on entering Live World (W).
- test_geography.cpp (new, in CMake foreach): extraction, per-cell membership,
river-traces-to-sink, names unique/deterministic, RNG isolation, v17 round-trip.
All 8 headless suites pass; GUI build clean. Docs updated (CLAUDE/design-notes/
BUILD), incl. the multi-step civilization roadmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0c8dbed3d7
commit
a4a06996fa
22
BUILD.md
22
BUILD.md
@ -53,6 +53,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
|
||||
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
|
||||
K toggle weather clouds/rain cover (Live World)
|
||||
V toggle volcano markers (Live World; cones + eruption glow, build into islands)
|
||||
M toggle place-name labels (names continents/oceans/ranges/rivers/lakes; Atlas tab)
|
||||
Y follow-cam: cycle the 3D camera through active storms (Live World; off after last)
|
||||
. / , step the live clock forward / back by one rate-unit (auto-pauses; back also
|
||||
rewinds weather + storms via an undo history)
|
||||
@ -77,7 +78,8 @@ CLI flags (applied before the first load/generate):
|
||||
planet.cfg human-editable key=value config of every PlanetConfig parameter;
|
||||
auto-created on first run, reload live with F2. Range-checked on
|
||||
load; an invalid file reverts to safe defaults (not overwritten).
|
||||
planet.save binary snapshot (versioned, currently v16: +event log; v15 +stateful
|
||||
planet.save binary snapshot (versioned, currently v17: +geography/atlas; v16
|
||||
+event log; v15 +stateful
|
||||
volcanoes; v13 +Live World clock rate; v12 +step-back history
|
||||
(~40 frames, so a load can rewind storms); v11 +weather systems/storms;
|
||||
v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5
|
||||
@ -261,6 +263,18 @@ weaker. Step-back snapshots include volcano state. Saved v15+.
|
||||
volcanoAshCloud 0.9 cloud cover injected by sustained ash
|
||||
volcanoAshCooling 6 C peak local cooling under an active ash plume
|
||||
|
||||
Geography / atlas (PlanetConfig, key M): extract + name geographic features from the frozen
|
||||
terrain (continents, islands, oceans, seas, lakes, mountain ranges, peaks, rivers). Generated
|
||||
once on a settled world, saved v17. The foundation of the civilization arc.
|
||||
|
||||
geoContinentMinCells 40 land component >= this many cells = Continent (else Island)
|
||||
geoSeaMaxCells 60 ocean component <= this many cells = Sea (else Ocean)
|
||||
geoMountainElev 2500 m min elevation for a mountain-range cell
|
||||
geoRangeMinCells 4 min cells for a named mountain range
|
||||
geoRiverMinDischarge 80 min mouth discharge for a named river
|
||||
geoMaxRivers 40 cap on named rivers (largest by discharge)
|
||||
geoMaxPeaks 40 cap on named peaks (highest)
|
||||
|
||||
## Headless logic test (no display)
|
||||
|
||||
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
|
||||
@ -270,10 +284,12 @@ weaker. Step-back snapshots include volcano state. Saved v15+.
|
||||
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
|
||||
|
||||
# Biota / Live World / Ocean / Weather / Volcano suites: same source list, swap test_logic.cpp ->
|
||||
# test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp or test_volcano.cpp.
|
||||
# Biota / Live World / Ocean / Weather / Volcano / Geography suites: same source list, swap
|
||||
# test_logic.cpp -> test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp,
|
||||
# test_volcano.cpp or test_geography.cpp.
|
||||
# The CMake build also includes test_events for the viewer event journal.
|
||||
|
||||
Verifies geometry, plate assignment, gradual non-saturating relief and
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@ -105,6 +105,24 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
|
||||
`,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and
|
||||
post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`.
|
||||
|
||||
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is
|
||||
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
|
||||
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
|
||||
on the Live World clock). **Step 1 of the roadmap is done:**
|
||||
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
|
||||
foundation everything civic references. `Planet::generateGeography()` extracts named features from
|
||||
the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land),
|
||||
**oceans/seas** (connected water), **lakes** (inland filled basins), **mountain ranges + peaks**
|
||||
(connected high terrain), **rivers** (largest discharge mouths traced upstream via `flowTo`) — and
|
||||
names each with a deterministic procedural namer (`NameGen`: syllable banks, a "language" per
|
||||
continent so a region's places share a sound). A separate RNG (`sGeoRng`) keeps tectonic determinism
|
||||
intact. Generated once on a settled world (key `M`, in or out of Live World), drawn as labels on the
|
||||
globe + 2D map (minor features only when zoomed), listed in an **Atlas** tab (click a row to fly
|
||||
there), and shown in cell-info as a "region" line. Per-cell feature-index arrays give O(1) "which
|
||||
features is this cell in" (the hook for territory/borders later). Saved (**v17**). Knobs `geo*`.
|
||||
*Next steps (not yet built): settlements + food/habitability, territory + borders, culture +
|
||||
beliefs, conflict + diplomacy.*
|
||||
|
||||
## Current state
|
||||
|
||||
Working and verified (logic tested headless):
|
||||
@ -509,6 +527,8 @@ src/
|
||||
PlanetFloraGen.cpp computeFloraDensity + fillFlora
|
||||
PlanetFaunaGen.cpp computeFaunaDensity + fillFauna (carnivores gated on prey)
|
||||
PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule)
|
||||
NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
|
||||
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
|
||||
PlanetIO.cpp config file (text) + binary save/load
|
||||
render/ (raylib viewer)
|
||||
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota)
|
||||
@ -571,11 +591,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.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
|
||||
```
|
||||
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
|
||||
`test_weather.cpp` or `test_volcano.cpp` to run the Biota / Live World / Ocean / Weather /
|
||||
Volcano suites — same source list. CMake also builds `test_events` for the viewer event journal.)
|
||||
`test_weather.cpp`, `test_volcano.cpp` or `test_geography.cpp` to run the Biota / Live World / Ocean /
|
||||
Weather / Volcano / Geography suites — same source list. CMake also builds `test_events` for the
|
||||
viewer event journal.)
|
||||
|
||||
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
|
||||
@ -607,7 +629,7 @@ active mode shown top-center of the globe) ·
|
||||
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
|
||||
all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
|
||||
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
|
||||
`V` volcano markers (Live World) ·
|
||||
`V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) ·
|
||||
`SPACE` or on-screen button pause ·
|
||||
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
|
||||
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
|
||||
@ -647,7 +669,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
|
||||
`Planet::writeState`/`readState`, resumes deterministically). Config is
|
||||
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
|
||||
defaults (without overwriting your `planet.cfg`) and shows a status message. The
|
||||
save header is versioned (currently **14**; v2 adds the `[`/`]` drift rate, v3 a
|
||||
save header is versioned (currently **17**; v2 adds the `[`/`]` drift rate, v3 a
|
||||
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
|
||||
key=value text block** instead of a raw POD dump, v7 appends the **biota population**
|
||||
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
|
||||
@ -656,7 +678,8 @@ humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG
|
||||
active storms, v12 appends the most recent **step-back frames** — `wxSaveMax`(40) weather snapshots
|
||||
— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14
|
||||
appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle
|
||||
agents plus volcano state in step-back frames, and v16 appends the saved **event journal**;
|
||||
agents plus volcano state in step-back frames, v16 appends the saved **event journal**, and v17
|
||||
appends the **geography/atlas** block — named features + per-cell region indices;
|
||||
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; pre-v10
|
||||
@ -664,7 +687,8 @@ saves spin weather up live; pre-v11 saves load with no active storms (they respa
|
||||
load with no step-back history (you can still step forward then back); pre-v13 saves resume with
|
||||
the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World
|
||||
entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped;
|
||||
pre-v16 saves load with an empty event journal.
|
||||
pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
|
||||
demand via `M`).
|
||||
A load drops any **stale** pre-load `wxUndo` history and reloads the
|
||||
saved one.
|
||||
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
|
||||
@ -786,6 +810,13 @@ triangles (plates are fixed in phase 1).
|
||||
`volcanoBlastRadius` (0.09 rad), `volcanoBlastCloud` (1.5), `volcanoAshMinYears`/`MaxYears`
|
||||
(0.5/3), `volcanoAshPuffCellsPerWeek` (2), `volcanoAshCloud` (0.9), and
|
||||
`volcanoAshCooling` (6 °C). Marker sizes/colours are render constants (ViewerRender.cpp).
|
||||
- **Geography / atlas (`geo*` in PlanetConfig / `planet.cfg`):** feature-extraction thresholds —
|
||||
`geoContinentMinCells` (40, land component ≥ this = Continent, else Island), `geoSeaMaxCells`
|
||||
(60, ocean component ≤ this = Sea, else Ocean), `geoMountainElev` (2500 m, min elevation for a
|
||||
mountain-range cell), `geoRangeMinCells` (4, min cells for a named range), `geoRiverMinDischarge`
|
||||
(80, min mouth discharge for a named river), and the label-clutter caps `geoMaxRivers` (40) /
|
||||
`geoMaxPeaks` (40, largest/highest kept). Name flavour (syllable banks, a "language" per continent)
|
||||
+ label fonts/colours are constants in NameGen.cpp / ViewerRender.cpp, not config.
|
||||
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
|
||||
knob for how fast/high relief builds.
|
||||
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks
|
||||
|
||||
@ -32,6 +32,8 @@ set(SIM_SOURCES
|
||||
src/sim/PlanetFloraGen.cpp
|
||||
src/sim/PlanetFaunaGen.cpp
|
||||
src/sim/PlanetFungiGen.cpp
|
||||
src/sim/NameGen.cpp
|
||||
src/sim/PlanetGeography.cpp
|
||||
src/sim/PlanetIO.cpp
|
||||
)
|
||||
|
||||
@ -70,7 +72,7 @@ if(UNIX AND NOT APPLE)
|
||||
endif()
|
||||
|
||||
enable_testing()
|
||||
foreach(test_name logic biota ocean live weather volcano)
|
||||
foreach(test_name logic biota ocean live weather volcano geography)
|
||||
add_executable(test_${test_name} test_${test_name}.cpp)
|
||||
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
|
||||
add_test(NAME ${test_name} COMMAND test_${test_name})
|
||||
|
||||
@ -43,6 +43,11 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
|
||||
`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).
|
||||
- `PlanetIO.cpp` — text config + binary save/load.
|
||||
|
||||
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
|
||||
@ -294,6 +299,31 @@ volcano eruptions, and submarine volcanoes breaching into islands. Clicking an e
|
||||
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**; connected water → **Ocean** or **Sea** (≤
|
||||
`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.
|
||||
|
||||
## Headless testing
|
||||
|
||||
Engine is raylib-free, so logic is tested without a display. Build/run:
|
||||
@ -302,9 +332,11 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet
|
||||
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/PlanetIO.cpp -o /tmp/t && /tmp/t
|
||||
# test_biota.cpp uses the same source list (Biota suite).
|
||||
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).
|
||||
|
||||
@ -46,6 +46,21 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
|
||||
pl.type == PlateType::Oceanic ? "Oceanic" : "Continental",
|
||||
c.oceanic ? "Oceanic" : "Continental")));
|
||||
L.push_back(std::string(TextFormat("biome: %s", biomeName(c.biome))));
|
||||
// Geography (the atlas): which named features this cell belongs to.
|
||||
if (p.geographyBuilt()) {
|
||||
const auto& F = p.geography();
|
||||
auto nameOf = [&](const std::vector<int>& arr) -> const char* {
|
||||
int fi = (i < (int)arr.size()) ? arr[i] : -1;
|
||||
return (fi >= 0 && fi < (int)F.size()) ? F[fi].name.c_str() : nullptr;
|
||||
};
|
||||
const char* loc = nameOf(c.elevation > p.cfg.seaLevel ? p.cellLand() : p.cellWater());
|
||||
if (loc) L.push_back(std::string("region: ") + loc);
|
||||
if (const char* rg = nameOf(p.cellRange())) L.push_back(std::string(" ") + rg);
|
||||
if (const char* rv = nameOf(p.cellRiver())) L.push_back(std::string(" on the ") + rv);
|
||||
// A lake cell sits on land but its water feature is the lake.
|
||||
const char* lk = (c.elevation > p.cfg.seaLevel) ? nameOf(p.cellWater()) : nullptr;
|
||||
if (lk) L.push_back(std::string(" ") + lk);
|
||||
}
|
||||
// Climate (derived; present once computeClimate() has run).
|
||||
if (sized(p.temperature()) && sized(p.moisture()))
|
||||
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",
|
||||
|
||||
@ -425,7 +425,7 @@ void Viewer::loadGame(const char* path) {
|
||||
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
|
||||
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
|
||||
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
|
||||
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes
|
||||
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography
|
||||
cfg = planet.cfg; // adopt the loaded config
|
||||
elapsedMy = em; settled = (st != 0);
|
||||
planet.drifting = settled; // resume drift boosts iff mid-drift
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
|
||||
struct Viewer {
|
||||
// ---- Files / save format ------------------------------------------------
|
||||
static constexpr uint32_t SAVE_VERSION = 16; // v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
|
||||
static constexpr uint32_t SAVE_VERSION = 17; // v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
|
||||
static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save
|
||||
static constexpr int EVENT_LOG_MAX = 200;
|
||||
const char* CONFIG_PATH = "planet.cfg";
|
||||
@ -99,6 +99,8 @@ struct Viewer {
|
||||
bool showCurrents = false; // ocean current arrows, warm/cold (key O)
|
||||
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
|
||||
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
|
||||
bool showNames = false; // geographic place-name labels (the atlas, key M)
|
||||
std::vector<int> atlasRowCells; // cell to focus per visible Atlas-tab row (parallel to the list)
|
||||
|
||||
// World event journal: currently Live World events, shaped to be reused by later phases.
|
||||
struct WorldEvent {
|
||||
@ -112,7 +114,7 @@ struct Viewer {
|
||||
};
|
||||
std::vector<WorldEvent> events;
|
||||
uint32_t nextEventId = 1;
|
||||
int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events
|
||||
int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events, 4 Atlas
|
||||
std::vector<Rectangle> liveInfoTabRects;
|
||||
std::vector<Rectangle> eventRowRects;
|
||||
std::vector<int> eventRowIndices; // indices into events for visible event rows
|
||||
|
||||
@ -44,6 +44,12 @@ void Viewer::handleInput() {
|
||||
focusCell(events[ei].cell, events[ei].title);
|
||||
break;
|
||||
}
|
||||
} else if (liveInfoTab == 4) { // Atlas: click a feature row to fly there
|
||||
for (size_t i = 0; i < eventRowRects.size() && i < atlasRowCells.size(); ++i) {
|
||||
if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue;
|
||||
if (atlasRowCells[i] >= 0) focusCell(atlasRowCells[i], "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -188,6 +194,7 @@ void Viewer::handleInput() {
|
||||
if (liveWorld) {
|
||||
phase3Prompt = false; paused = false; wxUndo.clear();
|
||||
if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement
|
||||
if (!planet.geographyBuilt()) planet.generateGeography(); // name the world's geography (the atlas)
|
||||
refreshView(); // fresh base colours; overlay builds in stepSim
|
||||
setStatus("Live World started");
|
||||
} else {
|
||||
@ -217,6 +224,11 @@ void Viewer::handleInput() {
|
||||
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
|
||||
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
|
||||
if (IsKeyPressed(KEY_V)) showVolcanoes = !showVolcanoes; // toggle volcano markers (Live World)
|
||||
if (IsKeyPressed(KEY_M) && settled) { // toggle place-name labels (the atlas)
|
||||
if (!planet.geographyBuilt()) planet.generateGeography(); // lazily name the world on first use
|
||||
showNames = !showNames;
|
||||
setStatus(showNames ? "Place names on" : "Place names off");
|
||||
}
|
||||
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
|
||||
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
|
||||
if (IsKeyPressed(KEY_S)) { // one step
|
||||
|
||||
@ -9,6 +9,24 @@
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
// Label style for a geographic feature: font size + colour, returns true if it's a "minor" feature
|
||||
// (peaks/rivers/lakes/seas/small islands) -- those are drawn only when zoomed in, to declutter.
|
||||
static bool labelStyle(const GeoFeature& f, int& font, Color& col) {
|
||||
const Color land{215, 220, 235, 255}, water{120, 195, 230, 255},
|
||||
river{110, 175, 235, 255}, mtn{220, 195, 150, 255};
|
||||
switch (f.kind) {
|
||||
case FeatureKind::Continent: font = 20; col = land; return false;
|
||||
case FeatureKind::Ocean: font = 18; col = water; return false;
|
||||
case FeatureKind::Sea: font = 15; col = water; return true;
|
||||
case FeatureKind::Island: font = f.size >= 8 ? 15 : 13; col = land; return f.size < 8;
|
||||
case FeatureKind::MountainRange: font = 15; col = mtn; return false;
|
||||
case FeatureKind::Peak: font = 13; col = mtn; return true;
|
||||
case FeatureKind::River: font = 13; col = river; return true;
|
||||
case FeatureKind::Lake: font = 13; col = water; return true;
|
||||
}
|
||||
font = 13; col = land; return true;
|
||||
}
|
||||
|
||||
// Render the 3D globe into its own RenderTexture (its viewport != the screen).
|
||||
void Viewer::renderGlobe3D() {
|
||||
BeginTextureMode(rt3d);
|
||||
@ -325,6 +343,21 @@ void Viewer::renderMap2D() {
|
||||
DrawText(txt, (int)lp.x + 4, (int)lp.y - 8, 12, RAYWHITE);
|
||||
}
|
||||
}
|
||||
// Place-name labels (the atlas). Minor features only when the map is zoomed in.
|
||||
if (showNames && planet.geographyBuilt()) {
|
||||
bool zoomed = mapZoom > 1.5;
|
||||
for (const GeoFeature& f : planet.geography()) {
|
||||
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue;
|
||||
int font; Color col; bool minor = labelStyle(f, font, col);
|
||||
if (font <= 0 || (minor && !zoomed)) continue;
|
||||
double lon, lat; dirToLonLat(planet.cells[f.anchorCell].unit, lon, lat);
|
||||
Vector2 lp = projLonLat(lon, lat, mapLon, vr);
|
||||
if (!CheckCollisionPointRec(lp, mapRect)) continue;
|
||||
int w = MeasureText(f.name.c_str(), font);
|
||||
DrawText(f.name.c_str(), (int)lp.x - w / 2 + 1, (int)lp.y - font / 2 + 1, font, Color{0, 0, 0, 180});
|
||||
DrawText(f.name.c_str(), (int)lp.x - w / 2, (int)lp.y - font / 2, font, col);
|
||||
}
|
||||
}
|
||||
if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, vr, mapLon), 5, ORANGE);
|
||||
if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, vr, mapLon), 4, YELLOW);
|
||||
EndScissorMode();
|
||||
@ -336,17 +369,17 @@ void Viewer::renderMap2D() {
|
||||
|
||||
// Live World tabbed info panel in the freed space right of the (left-aligned) 2D map.
|
||||
void Viewer::renderLiveInfo() {
|
||||
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear();
|
||||
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear(); atlasRowCells.clear();
|
||||
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("Live info", x, y, 20, RAYWHITE);
|
||||
const char* tabs[4] = { "Sky", "Tides", "Weather", "Events" };
|
||||
const char* tabs[5] = { "Sky", "Tides", "Weather", "Events", "Atlas" };
|
||||
float tx = r.x + 10.0f, ty = r.y + 38.0f;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
float tw = (r.width - 20.0f) / 4.0f;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
float tw = (r.width - 20.0f) / 5.0f;
|
||||
Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f };
|
||||
liveInfoTabRects.push_back(tr);
|
||||
bool on = liveInfoTab == i;
|
||||
@ -445,7 +478,7 @@ void Viewer::renderLiveInfo() {
|
||||
lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c);
|
||||
y += 21; ++shown;
|
||||
}
|
||||
} else {
|
||||
} else if (liveInfoTab == 3) {
|
||||
DrawText("World events", x, y, 18, Color{200, 205, 220, 255});
|
||||
DrawText(TextFormat("%d saved", (int)events.size()), (int)(r.x + r.width) - 74, y + 2, 13, Color{145, 155, 175, 255});
|
||||
y += 28;
|
||||
@ -474,6 +507,43 @@ void Viewer::renderLiveInfo() {
|
||||
y += 43;
|
||||
}
|
||||
}
|
||||
} else { // Atlas: named geographic features, grouped by kind; click a row to fly there
|
||||
const auto& F = planet.geography();
|
||||
DrawText("Atlas", x, y, 18, Color{200, 205, 220, 255});
|
||||
DrawText(TextFormat("%d named", (int)F.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255});
|
||||
y += 26;
|
||||
if (F.empty()) {
|
||||
DrawText(planet.geographyBuilt() ? "(none)" : "press M to name the world", x, y, 14, Color{150, 155, 170, 255});
|
||||
} else {
|
||||
// Order kinds for a readable list; within a kind, largest first.
|
||||
const FeatureKind order[8] = { FeatureKind::Continent, FeatureKind::Island, FeatureKind::Ocean,
|
||||
FeatureKind::Sea, FeatureKind::MountainRange, FeatureKind::Peak, FeatureKind::River, FeatureKind::Lake };
|
||||
auto kindColor = [](FeatureKind k) -> Color {
|
||||
switch (k) {
|
||||
case FeatureKind::Ocean: case FeatureKind::Sea: case FeatureKind::Lake: return Color{120, 195, 230, 255};
|
||||
case FeatureKind::River: return Color{110, 175, 235, 255};
|
||||
case FeatureKind::MountainRange: case FeatureKind::Peak: return Color{220, 195, 150, 255};
|
||||
default: return Color{215, 220, 235, 255};
|
||||
}
|
||||
};
|
||||
for (FeatureKind k : order) {
|
||||
std::vector<int> idx;
|
||||
for (int i = 0; i < (int)F.size(); ++i) if (F[i].kind == k) idx.push_back(i);
|
||||
if (idx.empty()) continue;
|
||||
std::sort(idx.begin(), idx.end(), [&](int a, int b){ return F[a].size > F[b].size; });
|
||||
if (y > (int)(r.y + r.height) - 22) break;
|
||||
DrawText(featureKindName(k), x, y, 13, Color{150, 158, 178, 255});
|
||||
y += 18;
|
||||
for (int i : idx) {
|
||||
if (y > (int)(r.y + r.height) - 18) break;
|
||||
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 18.0f };
|
||||
eventRowRects.push_back(row); atlasRowCells.push_back(F[i].anchorCell);
|
||||
DrawText(F[i].name.c_str(), (int)row.x + 8, (int)row.y + 1, 14, kindColor(k));
|
||||
y += 19;
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -550,8 +620,8 @@ void Viewer::renderHUD() {
|
||||
y += 8;
|
||||
line("hover: cell info | click tile: open detail panel | C close");
|
||||
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
|
||||
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s]",
|
||||
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off"));
|
||||
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s] | M names [%s]",
|
||||
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off"));
|
||||
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
|
||||
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
|
||||
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");
|
||||
@ -624,6 +694,37 @@ void Viewer::renderFrame() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3D place-name labels (the atlas), same manual projection. Minor features (peaks/rivers/lakes/
|
||||
// small islands) only show when zoomed in, to keep the default view readable.
|
||||
if (showNames && planet.geographyBuilt()) {
|
||||
Vec3 camPos{cam.position.x, cam.position.y, cam.position.z};
|
||||
Vec3 camTgt{cam.target.x, cam.target.y, cam.target.z};
|
||||
Vec3 camUp {cam.up.x, cam.up.y, cam.up.z};
|
||||
Vec3 forward = (camTgt - camPos).normalized();
|
||||
Vec3 right = forward.cross(camUp).normalized();
|
||||
Vec3 up = right.cross(forward);
|
||||
double fovRad = cam.fovy * M_PI / 180.0;
|
||||
double aspect = (double)view3DW / view3DH;
|
||||
double projH = std::tan(fovRad * 0.5), projW = projH * aspect;
|
||||
bool zoomed = camDist < 5.5;
|
||||
for (const GeoFeature& f : planet.geography()) {
|
||||
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue;
|
||||
int font; Color col; bool minor = labelStyle(f, font, col);
|
||||
if (font <= 0 || (minor && !zoomed)) continue;
|
||||
const Cell& c = planet.cells[f.anchorCell];
|
||||
double sr = visBase + (double)c.elevation * elevExagg + 0.01;
|
||||
Vec3 lp = rotateZ(c.unit, planet.cfg.axialTilt) * sr;
|
||||
if (lp.dot(camPos) <= 0.0) continue; // far hemisphere
|
||||
Vec3 rel = lp - camPos; double z = rel.dot(forward);
|
||||
if (z <= 0.0) continue;
|
||||
float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW);
|
||||
float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH);
|
||||
int w = MeasureText(f.name.c_str(), font);
|
||||
DrawText(f.name.c_str(), (int)sx - w / 2 + 1, (int)sy - font / 2 + 1, font, Color{0, 0, 0, 180});
|
||||
DrawText(f.name.c_str(), (int)sx - w / 2, (int)sy - font / 2, font, col);
|
||||
}
|
||||
}
|
||||
|
||||
renderMap2D();
|
||||
renderLiveInfo();
|
||||
renderPanels();
|
||||
|
||||
81
src/sim/NameGen.cpp
Normal file
81
src/sim/NameGen.cpp
Normal file
@ -0,0 +1,81 @@
|
||||
#include "NameGen.hpp"
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <cctype>
|
||||
|
||||
// Procedural namer. xorshift32 (same family as Planet::rnd / volNext) seeded per call, so names are
|
||||
// deterministic and independent of any global RNG state. Each "bank" is a set of syllable parts;
|
||||
// a syllable = onset + nucleus + (optional) coda, and a name is 2-3 syllables, capitalised.
|
||||
|
||||
namespace {
|
||||
|
||||
struct Bank { std::vector<const char*> onset, nucleus, coda; };
|
||||
|
||||
// Four loosely-flavoured phoneme banks ("languages"). Empty strings give vowel-initial syllables /
|
||||
// open syllables. Kept deliberately small and pronounceable.
|
||||
const std::array<Bank, 4>& banks() {
|
||||
static const std::array<Bank, 4> B = {{
|
||||
// 0 -- soft / liquid
|
||||
{ {"", "b", "d", "l", "m", "n", "r", "s", "th", "v", "br", "dr"},
|
||||
{"a", "e", "i", "o", "u", "ae", "ia", "eo"},
|
||||
{"", "", "n", "r", "l", "s", "th"} },
|
||||
// 1 -- harsh / guttural
|
||||
{ {"k", "kr", "g", "gr", "t", "tr", "dr", "z", "kh", "vr", "k", "g"},
|
||||
{"a", "o", "u", "au", "ou", "a", "o"},
|
||||
{"", "k", "g", "r", "rk", "th", "kh"} },
|
||||
// 2 -- flowing / coastal
|
||||
{ {"", "l", "m", "n", "s", "sh", "w", "y", "ll", "th", "n", "m"},
|
||||
{"a", "e", "i", "ai", "ei", "ia", "io", "ee"},
|
||||
{"", "", "n", "l", "s", ""} },
|
||||
// 3 -- earthen / sturdy
|
||||
{ {"", "b", "d", "g", "m", "t", "v", "br", "gr", "th", "d", "b"},
|
||||
{"a", "o", "e", "oo", "uo", "a", "o"},
|
||||
{"", "m", "n", "g", "r", "rd"} },
|
||||
}};
|
||||
return B;
|
||||
}
|
||||
|
||||
inline uint32_t nx(uint32_t& s) { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return s; }
|
||||
template <class T> const T& pick(const std::vector<T>& v, uint32_t& s) { return v[nx(s) % v.size()]; }
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace namegen {
|
||||
|
||||
int bankCount() { return (int)banks().size(); }
|
||||
|
||||
int bankForRegion(uint32_t worldSeed, int regionId) {
|
||||
uint32_t h = worldSeed ^ (uint32_t)(regionId * 2654435761u + 0x9E3779B9u);
|
||||
h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15;
|
||||
return (int)(h % (uint32_t)bankCount());
|
||||
}
|
||||
|
||||
std::string makeName(uint32_t seed, int bank, int minSyl, int maxSyl) {
|
||||
if (bank < 0 || bank >= bankCount()) bank = 0;
|
||||
if (minSyl < 1) minSyl = 1;
|
||||
if (maxSyl < minSyl) maxSyl = minSyl;
|
||||
const Bank& b = banks()[(size_t)bank];
|
||||
uint32_t s = seed ? seed : 0xA5A5A5A5u;
|
||||
nx(s); nx(s); // mix the seed before first use
|
||||
int syl = minSyl + (int)(nx(s) % (uint32_t)(maxSyl - minSyl + 1));
|
||||
std::string out;
|
||||
for (int i = 0; i < syl; ++i) {
|
||||
out += pick(b.onset, s);
|
||||
out += pick(b.nucleus, s);
|
||||
// Coda more likely on the final syllable; never two empty-onset vowels colliding awkwardly.
|
||||
bool last = (i == syl - 1);
|
||||
if (last || (nx(s) & 3u) == 0u) out += pick(b.coda, s);
|
||||
}
|
||||
// Collapse runs of 3+ identical letters to 2 (kills "oooo" / "lll" -> "oo" / "ll").
|
||||
std::string clean;
|
||||
for (char ch : out) {
|
||||
size_t m = clean.size();
|
||||
if (m >= 2 && clean[m - 1] == ch && clean[m - 2] == ch) continue;
|
||||
clean += ch;
|
||||
}
|
||||
if (clean.empty()) clean = "Or";
|
||||
clean[0] = (char)std::toupper((unsigned char)clean[0]);
|
||||
return clean;
|
||||
}
|
||||
|
||||
} // namespace namegen
|
||||
18
src/sim/NameGen.hpp
Normal file
18
src/sim/NameGen.hpp
Normal file
@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
// Deterministic procedural name generator (raylib-free). A small syllable engine reused across the
|
||||
// civilization arc: geographic features now, settlements / cultures / people later. Output is a
|
||||
// capitalised proper noun; everything is a pure function of the seed (no global RNG), so the same
|
||||
// seed always yields the same name. `bank` picks a phoneme set ("language") so a region's places can
|
||||
// share a sound -- bankForRegion() maps a region id to a stable bank.
|
||||
namespace namegen {
|
||||
|
||||
int bankCount(); // number of phoneme banks ("languages")
|
||||
int bankForRegion(uint32_t worldSeed, int regionId);
|
||||
|
||||
// Build a name of minSyl..maxSyl syllables from `bank`, seeded by `seed`.
|
||||
std::string makeName(uint32_t seed, int bank, int minSyl = 2, int maxSyl = 3);
|
||||
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
#include "IcoSphere.hpp"
|
||||
#include "PlanetTypes.hpp" // Cell, Plate, SubGrid/SubCell, PlanetConfig
|
||||
#include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota
|
||||
#include "PlanetGeography.hpp" // FeatureKind, GeoFeature
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
@ -16,6 +17,7 @@ public:
|
||||
std::vector<Plate> plates;
|
||||
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
|
||||
std::vector<Volcano> volcanoes; // Live World: stateful lifecycle volcanoes (saved v15)
|
||||
std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17)
|
||||
|
||||
// Phase flag: false during Phase-1 forming (modest, original tectonics that
|
||||
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
|
||||
@ -141,6 +143,18 @@ public:
|
||||
const std::vector<double>& discharge() const { return sDischarge; }
|
||||
const std::vector<int>& flowTo() const { return sFlowTo; }
|
||||
|
||||
// Geography stage (the atlas, PlanetGeography.cpp). generateGeography() extracts named
|
||||
// geographic features from the frozen terrain by connectivity (continents/islands, oceans/seas,
|
||||
// lakes, mountain ranges/peaks, rivers) and names them with a separate RNG (tectonic determinism
|
||||
// intact). Saved (v17). The per-cell index arrays give O(1) "which features is this cell in".
|
||||
void generateGeography();
|
||||
bool geographyBuilt() const { return !geoFeatures.empty(); }
|
||||
const std::vector<GeoFeature>& geography() const { return geoFeatures; }
|
||||
const std::vector<int>& cellLand() const { return sCellLand; } // continent/island feature index (-1)
|
||||
const std::vector<int>& cellWater() const { return sCellWater; } // ocean/sea/lake feature index (-1)
|
||||
const std::vector<int>& cellRange() const { return sCellRange; } // mountain-range feature index (-1)
|
||||
const std::vector<int>& cellRiver() const { return sCellRiver; } // river feature index (-1)
|
||||
|
||||
// Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook).
|
||||
std::shared_ptr<SubGrid> makeSubGrid(int cellIndex, int res) const;
|
||||
|
||||
@ -156,9 +170,11 @@ public:
|
||||
// older saves leave weather to spin up on entering Live World.
|
||||
// hasVolcanoes: whether the stream carries a volcano block (save v14+). hasStatefulVolcanoes
|
||||
// means v15+ lifecycle volcanoes; v14's old pure-function block is consumed and discarded.
|
||||
// hasGeography: whether the stream carries the geography/atlas block (save v17+); older saves load
|
||||
// with none (regenerated on demand).
|
||||
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
|
||||
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
|
||||
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true);
|
||||
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true);
|
||||
|
||||
// Helpers for rendering / info.
|
||||
double cellWidthMeters() const; // approx lateral cell spacing
|
||||
@ -244,6 +260,10 @@ private:
|
||||
// Volcanoes (Live World; saved v15). Separate RNG (seeded from cfg.seed in placeVolcanoes)
|
||||
// keeps tectonic determinism intact while lifecycle rolls happen during Live World.
|
||||
uint32_t sVolRng = 1;
|
||||
// Geography (the atlas; saved v17). Per-cell feature index arrays (-1 = none) + a separate RNG
|
||||
// so naming never perturbs the tectonic stream.
|
||||
std::vector<int> sCellLand, sCellWater, sCellRange, sCellRiver;
|
||||
uint32_t sGeoRng = 1;
|
||||
|
||||
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
|
||||
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.
|
||||
|
||||
196
src/sim/PlanetGeography.cpp
Normal file
196
src/sim/PlanetGeography.cpp
Normal file
@ -0,0 +1,196 @@
|
||||
#include "Planet.hpp"
|
||||
#include "NameGen.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
// --- Geography stage: the atlas ----------------------------------------------
|
||||
// Extract named geographic features from the (frozen) terrain by connectivity over the fixed grid,
|
||||
// the foundation of the civilization arc. All flood-fill / downstream-walk patterns mirror existing
|
||||
// engine code (coalesceBabyPlates, the enclosed-sea fill, the hydrology flowTo walk). Naming draws a
|
||||
// SEPARATE RNG (sGeoRng) + the deterministic NameGen, so it never perturbs the tectonic stream.
|
||||
|
||||
const char* featureKindName(FeatureKind k) {
|
||||
switch (k) {
|
||||
case FeatureKind::Continent: return "Continent";
|
||||
case FeatureKind::Island: return "Island";
|
||||
case FeatureKind::Ocean: return "Ocean";
|
||||
case FeatureKind::Sea: return "Sea";
|
||||
case FeatureKind::Lake: return "Lake";
|
||||
case FeatureKind::MountainRange: return "Mountains";
|
||||
case FeatureKind::Peak: return "Peak";
|
||||
case FeatureKind::River: return "River";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
void Planet::generateGeography() {
|
||||
const int n = (int)cells.size();
|
||||
const double sea = cfg.seaLevel;
|
||||
sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u;
|
||||
|
||||
// Rivers/lakes derive from the hydrology fields -- make sure they exist (routing only, no erosion,
|
||||
// so this changes no elevation and is safe to call on a finished world).
|
||||
if ((int)sDischarge.size() != n || (int)sLakeDepth.size() != n || (int)sFlowTo.size() != n)
|
||||
computeHydrology();
|
||||
|
||||
geoFeatures.clear();
|
||||
sCellLand.assign(n, -1); sCellWater.assign(n, -1);
|
||||
sCellRange.assign(n, -1); sCellRiver.assign(n, -1);
|
||||
|
||||
std::set<std::string> usedNames;
|
||||
auto formatName = [](FeatureKind k, const std::string& p) -> std::string {
|
||||
switch (k) {
|
||||
case FeatureKind::Ocean: return p + " Ocean";
|
||||
case FeatureKind::Sea: return p + " Sea";
|
||||
case FeatureKind::Lake: return "Lake " + p;
|
||||
case FeatureKind::MountainRange: return p + " Mountains";
|
||||
case FeatureKind::Peak: return "Mount " + p;
|
||||
case FeatureKind::River: return p + " River";
|
||||
default: return p; // Continent / Island: bare proper noun
|
||||
}
|
||||
};
|
||||
// Per-feature seed: a stable hash of (world seed, kind, anchor cell) -- order-independent.
|
||||
auto featSeed = [&](FeatureKind k, int anchor) -> uint32_t {
|
||||
uint32_t h = (cfg.seed ^ 0x6E0C12A7u) + (uint32_t)((int)k) * 0x85EBCA6Bu;
|
||||
h ^= (uint32_t)(anchor * 2654435761u + 0x165667B1u);
|
||||
h ^= h >> 15; h *= 0x2545F491u; h ^= h >> 13;
|
||||
return h ? h : 1u;
|
||||
};
|
||||
auto makeFeatureName = [&](FeatureKind k, uint32_t seed, int bank) {
|
||||
std::string nm = formatName(k, namegen::makeName(seed, bank));
|
||||
for (int guard = 0; usedNames.count(nm) && guard < 64; ++guard)
|
||||
nm = formatName(k, namegen::makeName(seed += 0x9E3779B9u, bank));
|
||||
usedNames.insert(nm);
|
||||
return nm;
|
||||
};
|
||||
auto bankOf = [&](int regionId, int fallbackAnchor) {
|
||||
return regionId >= 0 ? namegen::bankForRegion(cfg.seed, regionId)
|
||||
: namegen::bankForRegion(cfg.seed, 1000 + fallbackAnchor);
|
||||
};
|
||||
// The label/focus cell: the member nearest the component's (normalized) centroid direction.
|
||||
auto centroidCell = [&](const std::vector<int>& comp) {
|
||||
Vec3 c{0, 0, 0};
|
||||
for (int i : comp) c = c + cells[i].unit;
|
||||
if (c.length() < 1e-9) return comp.front();
|
||||
c = c.normalized();
|
||||
int best = comp.front(); double bd = -2.0;
|
||||
for (int i : comp) { double d = cells[i].unit.dot(c); if (d > bd) { bd = d; best = i; } }
|
||||
return best;
|
||||
};
|
||||
// Connected components of cells satisfying pred(), via DFS over the fixed neighbour graph.
|
||||
auto components = [&](auto pred) {
|
||||
std::vector<std::vector<int>> out;
|
||||
std::vector<char> seen(n, 0); std::vector<int> stack;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (seen[i] || !pred(i)) continue;
|
||||
std::vector<int> comp; stack.clear(); stack.push_back(i); seen[i] = 1;
|
||||
while (!stack.empty()) {
|
||||
int u = stack.back(); stack.pop_back(); comp.push_back(u);
|
||||
for (int v : cells[u].neighbors)
|
||||
if (!seen[v] && pred(v)) { seen[v] = 1; stack.push_back(v); }
|
||||
}
|
||||
out.push_back(std::move(comp));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
auto bySizeDesc = [](const std::vector<int>& a, const std::vector<int>& b) { return a.size() > b.size(); };
|
||||
auto addFeature = [&](FeatureKind k, int anchor, int regionId, int size, int bank) -> int {
|
||||
int fi = (int)geoFeatures.size();
|
||||
GeoFeature f; f.id = (uint32_t)fi + 1; f.kind = k; f.anchorCell = anchor;
|
||||
f.regionId = regionId; f.size = size;
|
||||
f.name = makeFeatureName(k, featSeed(k, anchor), bank);
|
||||
geoFeatures.push_back(std::move(f));
|
||||
return fi;
|
||||
};
|
||||
|
||||
// --- Land masses -> Continent / Island (regionId every other feature inherits) ---------------
|
||||
auto land = components([&](int i) { return cells[i].elevation > sea; });
|
||||
std::sort(land.begin(), land.end(), bySizeDesc);
|
||||
for (auto& comp : land) {
|
||||
int anchor = centroidCell(comp);
|
||||
FeatureKind k = (int)comp.size() >= cfg.geoContinentMinCells ? FeatureKind::Continent : FeatureKind::Island;
|
||||
int fi = (int)geoFeatures.size();
|
||||
int bank = namegen::bankForRegion(cfg.seed, fi);
|
||||
addFeature(k, anchor, fi, (int)comp.size(), bank); // a land feature's regionId is itself
|
||||
for (int i : comp) sCellLand[i] = fi;
|
||||
}
|
||||
|
||||
// --- Water bodies -> Ocean / Sea -------------------------------------------------------------
|
||||
auto water = components([&](int i) { return cells[i].elevation <= sea; });
|
||||
std::sort(water.begin(), water.end(), bySizeDesc);
|
||||
for (auto& comp : water) {
|
||||
int anchor = centroidCell(comp);
|
||||
FeatureKind k = (int)comp.size() <= cfg.geoSeaMaxCells ? FeatureKind::Sea : FeatureKind::Ocean;
|
||||
int fi = addFeature(k, anchor, -1, (int)comp.size(), bankOf(-1, anchor));
|
||||
for (int i : comp) sCellWater[i] = fi;
|
||||
}
|
||||
|
||||
// --- Lakes (inland filled basins above sea level) --------------------------------------------
|
||||
if ((int)sLakeDepth.size() == n) {
|
||||
auto lakes = components([&](int i) {
|
||||
return cells[i].elevation > sea && sLakeDepth[i] > cfg.biomeLakeMinDepth;
|
||||
});
|
||||
std::sort(lakes.begin(), lakes.end(), bySizeDesc);
|
||||
for (auto& comp : lakes) {
|
||||
int anchor = centroidCell(comp);
|
||||
int regId = sCellLand[anchor];
|
||||
int fi = addFeature(FeatureKind::Lake, anchor, regId, (int)comp.size(), bankOf(regId, anchor));
|
||||
for (int i : comp) sCellWater[i] = fi; // inland cells: lake overrides (was -1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mountain ranges + their highest Peak ----------------------------------------------------
|
||||
auto ranges = components([&](int i) {
|
||||
return cells[i].elevation > sea && cells[i].elevation > cfg.geoMountainElev;
|
||||
});
|
||||
std::sort(ranges.begin(), ranges.end(), bySizeDesc);
|
||||
std::vector<std::pair<int, double>> peaks; // (cell, elevation) high point of each range
|
||||
for (auto& comp : ranges) {
|
||||
if ((int)comp.size() < cfg.geoRangeMinCells) continue;
|
||||
int hi = comp.front();
|
||||
for (int i : comp) if (cells[i].elevation > cells[hi].elevation) hi = i;
|
||||
int anchor = centroidCell(comp);
|
||||
int regId = sCellLand[anchor];
|
||||
int fi = addFeature(FeatureKind::MountainRange, anchor, regId, (int)comp.size(), bankOf(regId, anchor));
|
||||
for (int i : comp) sCellRange[i] = fi;
|
||||
peaks.push_back({ hi, cells[hi].elevation });
|
||||
}
|
||||
std::sort(peaks.begin(), peaks.end(), [](auto& a, auto& b) { return a.second > b.second; });
|
||||
int peakCap = std::min((int)peaks.size(), std::max(0, cfg.geoMaxPeaks));
|
||||
for (int pi = 0; pi < peakCap; ++pi) {
|
||||
int hc = peaks[pi].first; int regId = sCellLand[hc];
|
||||
addFeature(FeatureKind::Peak, hc, regId, 1, bankOf(regId, hc));
|
||||
}
|
||||
|
||||
// --- Rivers: trace each major mouth upstream along the dominant tributary ---------------------
|
||||
if ((int)sDischarge.size() == n && (int)sFlowTo.size() == n) {
|
||||
std::vector<std::pair<int, double>> mouths; // (mouth cell, discharge)
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (cells[i].elevation <= sea || sDischarge[i] < cfg.geoRiverMinDischarge) continue;
|
||||
int d = sFlowTo[i];
|
||||
if (d < 0 || cells[d].elevation <= sea) mouths.push_back({ i, sDischarge[i] }); // to sink or ocean
|
||||
}
|
||||
std::sort(mouths.begin(), mouths.end(), [](auto& a, auto& b) { return a.second > b.second; });
|
||||
int rivCap = std::min((int)mouths.size(), std::max(0, cfg.geoMaxRivers));
|
||||
std::vector<char> onStem(n, 0);
|
||||
for (int ri = 0; ri < rivCap; ++ri) {
|
||||
int mouth = mouths[ri].first;
|
||||
std::vector<int> stem;
|
||||
for (int cur = mouth; cur >= 0 && !onStem[cur]; ) {
|
||||
onStem[cur] = 1; stem.push_back(cur);
|
||||
int up = -1; double ub = -1.0; // upstream neighbour with the most discharge
|
||||
for (int j : cells[cur].neighbors)
|
||||
if (cells[j].elevation > sea && sFlowTo[j] == cur && sDischarge[j] > ub) { ub = sDischarge[j]; up = j; }
|
||||
cur = up;
|
||||
}
|
||||
if ((int)stem.size() < 2) continue;
|
||||
int anchor = stem[stem.size() / 2];
|
||||
int regId = sCellLand[mouth];
|
||||
int fi = addFeature(FeatureKind::River, anchor, regId, (int)stem.size(), bankOf(regId, anchor));
|
||||
for (int i : stem) if (sCellRiver[i] < 0) sCellRiver[i] = fi;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/sim/PlanetGeography.hpp
Normal file
27
src/sim/PlanetGeography.hpp
Normal file
@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
// Geography stage (the atlas) -- the foundation of the civilization arc. Geographic features are
|
||||
// extracted from the (frozen) terrain by connectivity (flood-fill over the fixed grid) and given a
|
||||
// procedurally generated name; everything later (settlements, territory, cultures) references them.
|
||||
// Raylib-free + deterministic: see Planet::generateGeography() in PlanetGeography.cpp.
|
||||
|
||||
enum class FeatureKind : uint8_t {
|
||||
Continent, Island, Ocean, Sea, Lake, MountainRange, Peak, River
|
||||
};
|
||||
|
||||
// One named feature. `anchorCell` is the cell to label / focus on; `regionId` is the index (into
|
||||
// Planet::geoFeatures) of the containing continent/island (-1 = none, e.g. open ocean); `size` is the
|
||||
// member-cell count for areas or the stem length for rivers (used for label priority / font size).
|
||||
struct GeoFeature {
|
||||
uint32_t id = 0;
|
||||
FeatureKind kind = FeatureKind::Continent;
|
||||
int anchorCell = -1;
|
||||
int regionId = -1;
|
||||
int size = 0;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
const char* featureKindName(FeatureKind k); // "Continent", "Mountains", "River", ... (PlanetGeography.cpp)
|
||||
@ -48,10 +48,12 @@
|
||||
D(volcanoExplodeDropFrac) D(volcanoActivityDecay) D(volcanoDeadActivity) \
|
||||
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
|
||||
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
|
||||
D(geoMountainElev) D(geoRiverMinDischarge) \
|
||||
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
|
||||
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
|
||||
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
|
||||
I(volcanoMaxCount) \
|
||||
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
|
||||
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
|
||||
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
|
||||
U(seed)
|
||||
@ -242,6 +244,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
|
||||
E(rng(cfg.volcanoAshPuffCellsPerWeek, 0.0, 1000.0, "volcanoAshPuffCellsPerWeek"));
|
||||
E(rng(cfg.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud"));
|
||||
E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling"));
|
||||
E(rng(cfg.geoMountainElev, 0.0, 12000.0, "geoMountainElev"));
|
||||
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
|
||||
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
|
||||
E(irng(cfg.plateCount, 1, 100, "plateCount"));
|
||||
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
|
||||
@ -256,6 +260,11 @@ std::string validateConfig(const PlanetConfig& cfg) {
|
||||
E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings"));
|
||||
E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax"));
|
||||
E(irng(cfg.volcanoMaxCount, 0, 100000, "volcanoMaxCount"));
|
||||
E(irng(cfg.geoContinentMinCells, 1, 1000000, "geoContinentMinCells"));
|
||||
E(irng(cfg.geoSeaMaxCells, 0, 1000000, "geoSeaMaxCells"));
|
||||
E(irng(cfg.geoRangeMinCells, 1, 1000000, "geoRangeMinCells"));
|
||||
E(irng(cfg.geoMaxRivers, 0, 100000, "geoMaxRivers"));
|
||||
E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
|
||||
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
|
||||
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
|
||||
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
|
||||
@ -367,14 +376,28 @@ void Planet::writeState(std::ostream& os) const {
|
||||
// older readers stop before this block.
|
||||
writeVec(os, volcanoes);
|
||||
writePod(os, sVolRng);
|
||||
// v17: named geographic features (the atlas) + per-cell region indices. Feature records carry a
|
||||
// std::string name, so they are written field-by-field (not POD); the index arrays are POD.
|
||||
uint64_t nf = geoFeatures.size(); writePod(os, nf);
|
||||
for (const GeoFeature& f : geoFeatures) {
|
||||
writePod(os, f.id);
|
||||
uint8_t k = (uint8_t)f.kind; writePod(os, k);
|
||||
writePod(os, f.anchorCell); writePod(os, f.regionId); writePod(os, f.size);
|
||||
uint64_t L = f.name.size(); writePod(os, L);
|
||||
if (L) os.write(f.name.data(), (std::streamsize)L);
|
||||
}
|
||||
writeVec(os, sCellLand); writeVec(os, sCellWater);
|
||||
writeVec(os, sCellRange); writeVec(os, sCellRiver);
|
||||
}
|
||||
|
||||
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
|
||||
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes) {
|
||||
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
|
||||
bool hasGeography) {
|
||||
// Save blocks are append-only by version. If a caller asks for an older prefix,
|
||||
// later blocks cannot exist in that stream even if the default arguments say otherwise.
|
||||
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
|
||||
if (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather block
|
||||
if (!hasVolcanoes || !hasStatefulVolcanoes) hasGeography = false; // geography block follows the volcano block
|
||||
|
||||
// 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
|
||||
@ -502,6 +525,40 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
|
||||
|| !std::isfinite(v.timer) || !std::isfinite(v.ashTimer)
|
||||
|| !std::isfinite(v.ashCarry) || v.phase > 1) return false;
|
||||
}
|
||||
// v17: named geographic features (the atlas). Older saves load with none (regenerate on demand).
|
||||
geoFeatures.clear(); sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u;
|
||||
sCellLand.assign(cells.size(), -1); sCellWater.assign(cells.size(), -1);
|
||||
sCellRange.assign(cells.size(), -1); sCellRiver.assign(cells.size(), -1);
|
||||
if (hasGeography) {
|
||||
uint64_t nf = 0; readPod(is, nf);
|
||||
if (!is || nf > 200000) return false;
|
||||
geoFeatures.resize((size_t)nf);
|
||||
for (GeoFeature& f : geoFeatures) {
|
||||
readPod(is, f.id);
|
||||
uint8_t k = 0; readPod(is, k);
|
||||
if (k > (uint8_t)FeatureKind::River) return false;
|
||||
f.kind = (FeatureKind)k;
|
||||
readPod(is, f.anchorCell); readPod(is, f.regionId); readPod(is, f.size);
|
||||
uint64_t L = 0; readPod(is, L);
|
||||
if (!is || L > 256) return false;
|
||||
f.name.resize((size_t)L);
|
||||
if (L) is.read(&f.name[0], (std::streamsize)L);
|
||||
if (!is || f.anchorCell < 0 || f.anchorCell >= (int)cells.size()) return false;
|
||||
}
|
||||
// Per-cell region index arrays. Accept an empty array (geography saved before generation)
|
||||
// and treat it as "all -1"; otherwise it must be exactly cell-sized + reference valid features.
|
||||
auto okArr = [&](std::vector<int>& a) {
|
||||
if (a.empty()) { a.assign(cells.size(), -1); return true; }
|
||||
if (a.size() != cells.size()) return false;
|
||||
for (int v : a) if (v < -1 || v >= (int)geoFeatures.size()) return false;
|
||||
return true;
|
||||
};
|
||||
if (!readVec(is, sCellLand, cells.size())) return false;
|
||||
if (!readVec(is, sCellWater, cells.size())) return false;
|
||||
if (!readVec(is, sCellRange, cells.size())) return false;
|
||||
if (!readVec(is, sCellRiver, cells.size())) return false;
|
||||
if (!okArr(sCellLand) || !okArr(sCellWater) || !okArr(sCellRange) || !okArr(sCellRiver)) return false;
|
||||
}
|
||||
computeBiotaDensity(); // derived density scalars for the colour views
|
||||
return (bool)is;
|
||||
}
|
||||
|
||||
@ -360,4 +360,16 @@ struct PlanetConfig {
|
||||
double volcanoAshPuffCellsPerWeek = 2.0;// average local cells puffed per week while ashTimer runs
|
||||
double volcanoAshCloud = 0.9; // cloud cover injected at the vent per erupting hour (ash plume)
|
||||
double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume
|
||||
|
||||
// --- Geography (the atlas) -- see PlanetGeography.cpp ----------------------
|
||||
// Thresholds for extracting + naming geographic features from the frozen terrain. Generated once
|
||||
// on a settled world (key M), saved (v17). Tune to control what counts as a continent vs island,
|
||||
// an ocean vs sea, a named mountain range / major river, and to cap label clutter.
|
||||
int geoContinentMinCells = 40; // land component >= this many cells = Continent (else Island)
|
||||
int geoSeaMaxCells = 60; // ocean component <= this many cells = Sea (else Ocean)
|
||||
double geoMountainElev = 2500.0;// m: min elevation for mountain-range membership
|
||||
int geoRangeMinCells = 4; // min cells for a named mountain range
|
||||
double geoRiverMinDischarge = 80.0; // min mouth discharge for a named river
|
||||
int geoMaxRivers = 40; // cap on named rivers (largest by discharge)
|
||||
int geoMaxPeaks = 40; // cap on named peaks (highest)
|
||||
};
|
||||
|
||||
163
test_geography.cpp
Normal file
163
test_geography.cpp
Normal file
@ -0,0 +1,163 @@
|
||||
// Headless test for the geography / atlas stage (named feature extraction + naming). No display.
|
||||
//
|
||||
// g++ -std=c++17 -O2 -Isrc/sim test_geography.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/tg && /tmp/tg
|
||||
//
|
||||
// Verifies: extraction (continents/oceans/ranges/rivers/lakes), per-cell membership consistency,
|
||||
// names non-empty/unique/deterministic, RNG isolation from tectonics, and a v17 save round-trip.
|
||||
|
||||
#include "Planet.hpp"
|
||||
#include "NameGen.hpp"
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <set>
|
||||
#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 void settle(Planet& p, int maxSteps = 800) {
|
||||
int run = 0;
|
||||
for (int s = 0; s < maxSteps; ++s) {
|
||||
double mc = p.step();
|
||||
if (mc < 2.0) { if (++run >= 3) break; } else run = 0;
|
||||
}
|
||||
p.computeClimate();
|
||||
p.classifyBiomes();
|
||||
}
|
||||
static void drift(Planet& p, int iters) {
|
||||
p.drifting = true;
|
||||
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); }
|
||||
}
|
||||
|
||||
static int countKind(const Planet& p, FeatureKind k) {
|
||||
int c = 0; for (const GeoFeature& f : p.geography()) if (f.kind == k) ++c; return c;
|
||||
}
|
||||
|
||||
int main() {
|
||||
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 7777;
|
||||
Planet p; p.generate(cfg); settle(p); drift(p, 120);
|
||||
const int n = (int)p.cells.size();
|
||||
const double sea = p.cfg.seaLevel;
|
||||
|
||||
std::printf("Geography: NameGen\n");
|
||||
std::string a1 = namegen::makeName(123, 0), a2 = namegen::makeName(123, 0);
|
||||
check(!a1.empty() && a1 == a2, "makeName deterministic + non-empty");
|
||||
check(namegen::makeName(124, 0) != a1, "different seeds give different names (usually)");
|
||||
check(namegen::bankForRegion(cfg.seed, 5) == namegen::bankForRegion(cfg.seed, 5), "bankForRegion deterministic");
|
||||
|
||||
std::printf("Geography: extraction\n");
|
||||
p.generateGeography();
|
||||
const auto& F = p.geography();
|
||||
check(!F.empty(), "generateGeography produces features");
|
||||
int continents = countKind(p, FeatureKind::Continent), islands = countKind(p, FeatureKind::Island);
|
||||
int oceans = countKind(p, FeatureKind::Ocean), seas = countKind(p, FeatureKind::Sea);
|
||||
int ranges = countKind(p, FeatureKind::MountainRange), peaks = countKind(p, FeatureKind::Peak);
|
||||
int rivers = countKind(p, FeatureKind::River), lakes = countKind(p, FeatureKind::Lake);
|
||||
std::printf(" continents %d islands %d | oceans %d seas %d | ranges %d peaks %d | rivers %d lakes %d\n",
|
||||
continents, islands, oceans, seas, ranges, peaks, rivers, lakes);
|
||||
check(continents + islands >= 1, "at least one land mass");
|
||||
check(oceans >= 1, "at least one ocean");
|
||||
|
||||
std::printf("Geography: per-cell membership consistency\n");
|
||||
const auto& land = p.cellLand(); const auto& water = p.cellWater();
|
||||
const auto& range = p.cellRange();
|
||||
check((int)land.size() == n && (int)water.size() == n, "index arrays sized n");
|
||||
bool landOk = true, waterOk = true, rangeOk = true, idxOk = true;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
bool isLand = p.cells[i].elevation > sea;
|
||||
if (isLand && land[i] < 0) landOk = false; // every land cell has a continent/island
|
||||
if (!isLand && land[i] >= 0) landOk = false; // ocean cells aren't a land feature
|
||||
if (!isLand && water[i] < 0) waterOk = false; // every ocean cell has a water feature
|
||||
if (range[i] >= 0 && !(p.cells[i].elevation > p.cfg.geoMountainElev)) rangeOk = false; // range cells are high
|
||||
for (int v : {land[i], water[i], range[i]})
|
||||
if (v >= (int)F.size()) idxOk = false;
|
||||
}
|
||||
check(landOk, "land cells map to a continent/island; ocean cells don't");
|
||||
check(waterOk, "ocean cells map to an ocean/sea feature");
|
||||
check(rangeOk, "mountain-range cells are all above geoMountainElev");
|
||||
check(idxOk, "per-cell feature indices are in range");
|
||||
|
||||
std::printf("Geography: features anchor on the right terrain\n");
|
||||
bool anchorsOk = true;
|
||||
for (const GeoFeature& f : F) {
|
||||
const Cell& c = p.cells[f.anchorCell];
|
||||
bool landKind = (f.kind == FeatureKind::Continent || f.kind == FeatureKind::Island ||
|
||||
f.kind == FeatureKind::MountainRange || f.kind == FeatureKind::Peak ||
|
||||
f.kind == FeatureKind::River || f.kind == FeatureKind::Lake);
|
||||
bool oceanKind = (f.kind == FeatureKind::Ocean || f.kind == FeatureKind::Sea);
|
||||
if (landKind && c.elevation <= sea) anchorsOk = false;
|
||||
if (oceanKind && c.elevation > sea) anchorsOk = false;
|
||||
}
|
||||
check(anchorsOk, "land features anchor on land, ocean features on water");
|
||||
|
||||
if (rivers > 0) {
|
||||
std::printf("Geography: a named river traces downstream to a sink/ocean\n");
|
||||
const auto& fl = p.flowTo(); int riverCell = -1;
|
||||
for (int i = 0; i < n; ++i) if (p.cellRiver()[i] >= 0) { riverCell = i; break; }
|
||||
bool reaches = false;
|
||||
for (int cur = riverCell, guard = 0; cur >= 0 && guard < n; ++guard) {
|
||||
int d = fl[cur];
|
||||
if (d < 0 || p.cells[d].elevation <= sea) { reaches = true; break; }
|
||||
cur = d;
|
||||
}
|
||||
check(riverCell >= 0 && reaches, "a river cell flows down to an ocean/sink");
|
||||
}
|
||||
|
||||
std::printf("Geography: names non-empty + unique\n");
|
||||
bool namesOk = true; std::set<std::string> seen;
|
||||
for (const GeoFeature& f : F) {
|
||||
if (f.name.empty()) namesOk = false;
|
||||
if (!seen.insert(f.name).second) namesOk = false; // no duplicates
|
||||
}
|
||||
check(namesOk, "every feature has a unique non-empty name");
|
||||
|
||||
std::printf("Geography: determinism (same seed -> identical atlas)\n");
|
||||
Planet q; q.generate(cfg); settle(q); drift(q, 120); q.generateGeography();
|
||||
bool sameAtlas = (q.geography().size() == F.size());
|
||||
if (sameAtlas)
|
||||
for (size_t i = 0; i < F.size(); ++i)
|
||||
if (q.geography()[i].kind != F[i].kind || q.geography()[i].anchorCell != F[i].anchorCell
|
||||
|| q.geography()[i].name != F[i].name) { sameAtlas = false; break; }
|
||||
check(sameAtlas, "generateGeography is deterministic");
|
||||
|
||||
std::printf("Geography: RNG isolation from tectonics\n");
|
||||
Planet x; x.generate(cfg); settle(x);
|
||||
Planet y; y.generate(cfg); settle(y);
|
||||
for (int k = 0; k < 40; ++k) {
|
||||
double dtx = x.cflDtMy(); x.advect(dtx); x.step(); x.erode(dtx);
|
||||
double dty = y.cflDtMy(); y.advect(dty); y.step(); y.erode(dty);
|
||||
if (k == 20) y.generateGeography(); // must not touch the tectonic RNG stream
|
||||
}
|
||||
bool terrainSame = true;
|
||||
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false;
|
||||
check(terrainSame, "generateGeography never perturbs tectonic evolution");
|
||||
|
||||
std::printf("Geography: save v17 round-trip\n");
|
||||
{
|
||||
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
|
||||
p.writeState(ss);
|
||||
Planet r;
|
||||
bool ok = r.readState(ss, true, true, true, true, true, true, true, true);
|
||||
check(ok, "readState accepts a v17 stream");
|
||||
bool match = (r.geography().size() == F.size());
|
||||
if (match)
|
||||
for (size_t i = 0; i < F.size(); ++i)
|
||||
if (r.geography()[i].name != F[i].name || r.geography()[i].kind != F[i].kind
|
||||
|| r.geography()[i].anchorCell != F[i].anchorCell) { match = false; break; }
|
||||
check(match, "geography round-trips through save");
|
||||
check(r.cellLand() == p.cellLand() && r.cellRiver() == p.cellRiver(), "per-cell region arrays round-trip");
|
||||
}
|
||||
|
||||
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL GEOGRAPHY CHECKS PASSED\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user