diff --git a/BUILD.md b/BUILD.md index 58f21c6..e90c99f 100644 --- a/BUILD.md +++ b/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 diff --git a/CLAUDE.md b/CLAUDE.md index 9e991e8..d115e01 100644 --- a/CLAUDE.md +++ b/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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 298becc..8a89c9c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/docs/design-notes.md b/docs/design-notes.md index 5c6c4f7..39d5417 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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). diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index e054858..d9b5eb1 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -46,6 +46,21 @@ static std::vector 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& 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%%", diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 5d828e8..4a878b5 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -425,7 +425,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&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 diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 5109136..e17c3fc 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -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 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 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 liveInfoTabRects; std::vector eventRowRects; std::vector eventRowIndices; // indices into events for visible event rows diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 90dd488..59ffa9d 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -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 diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index bd95a7c..010834d 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -9,6 +9,24 @@ #include #include +// 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 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(); diff --git a/src/sim/NameGen.cpp b/src/sim/NameGen.cpp new file mode 100644 index 0000000..a693e3a --- /dev/null +++ b/src/sim/NameGen.cpp @@ -0,0 +1,81 @@ +#include "NameGen.hpp" +#include +#include +#include + +// 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 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& banks() { + static const std::array 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 const T& pick(const std::vector& 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 diff --git a/src/sim/NameGen.hpp b/src/sim/NameGen.hpp new file mode 100644 index 0000000..e23f4e5 --- /dev/null +++ b/src/sim/NameGen.hpp @@ -0,0 +1,18 @@ +#pragma once +#include +#include + +// 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); + +} diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index f21d076..f6cc121 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -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 #include #include @@ -16,6 +17,7 @@ public: std::vector plates; std::vector moons; // Live World: 1-3 natural satellites (generated + saved) std::vector volcanoes; // Live World: stateful lifecycle volcanoes (saved v15) + std::vector 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& discharge() const { return sDischarge; } const std::vector& 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& geography() const { return geoFeatures; } + const std::vector& cellLand() const { return sCellLand; } // continent/island feature index (-1) + const std::vector& cellWater() const { return sCellWater; } // ocean/sea/lake feature index (-1) + const std::vector& cellRange() const { return sCellRange; } // mountain-range feature index (-1) + const std::vector& 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 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 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. diff --git a/src/sim/PlanetGeography.cpp b/src/sim/PlanetGeography.cpp new file mode 100644 index 0000000..bdf9551 --- /dev/null +++ b/src/sim/PlanetGeography.cpp @@ -0,0 +1,196 @@ +#include "Planet.hpp" +#include "NameGen.hpp" +#include +#include +#include +#include +#include + +// --- 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 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& 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> out; + std::vector seen(n, 0); std::vector stack; + for (int i = 0; i < n; ++i) { + if (seen[i] || !pred(i)) continue; + std::vector 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& a, const std::vector& 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> 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> 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 onStem(n, 0); + for (int ri = 0; ri < rivCap; ++ri) { + int mouth = mouths[ri].first; + std::vector 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; + } + } +} diff --git a/src/sim/PlanetGeography.hpp b/src/sim/PlanetGeography.hpp new file mode 100644 index 0000000..02e1f54 --- /dev/null +++ b/src/sim/PlanetGeography.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include + +// 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) diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index f201f3a..0486d3b 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.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& 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; } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index deeb274..17ef1e4 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -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) }; diff --git a/test_geography.cpp b/test_geography.cpp new file mode 100644 index 0000000..0467e84 --- /dev/null +++ b/test_geography.cpp @@ -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 +#include +#include +#include +#include + +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 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; +}