planetsim/src/render/Viewer.cpp
Jonas Reith dae6981dc9 Add realm borders + culture annotation overlay, combinable with any colour mode
Political realm borders and realm-name labels were Territory-mode-only
(showNationBorders() == mode==Territory), so there was no way to see borders
and realm names layered over e.g. the Biome view without losing the
terrain-type coloring. Added an independent showRealms bool (key Q, a "Realm
borders" toggle in Civilization & Ecology) OR-ed with showNationBorders() at
just the border-line and realm-label draw sites (3D, 2D, and exportMapImage()
which shares the 2D path) -- mode/vcolors/recolor() are never touched, so
this overlay draws over any active colour mode without tinting cells. War
fronts and diplomacy arcs stay showNationBorders()-only on purpose, so the
combined view doesn't drag in Territory-only clutter.

Realm-name labels everywhere they're drawn (Territory view, this new toggle,
and the always-on atlas export) now also show the realm's dominant culture
as a smaller "(the Velmar)"-style line via Nation.cultureId ->
planet.cultureList(). exportAtlasImage() forces showRealms on for the
duration of its export (same pattern already used for showSettlements) and
adds the culture line as its own lower-priority label candidate in the
decluttered pass, anchored below each realm's own label so it drops out
gracefully on a dense world via the existing collision check.
AtlasLabel::text changed from a raw pointer to an owned std::string since the
culture strings are built on the fly.

Not reset in regenWorld() -- like showDiplomacy, its underlying nation/
segment lists are already cleared on reseed, so nothing stale can render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
2026-08-30 21:20:35 +02:00

1115 lines
62 KiB
C++

#include "Viewer.hpp"
#include "Picking.hpp" // angBetween (rebuildSub)
#include "Projection.hpp" // EqualEarth (layout)
#include "Toolbar.hpp" // setupToolbarStyle
#include <algorithm>
#include <cmath>
#include <cstring>
#include <fstream>
#include <set>
#include <utility>
namespace {
const char* weatherEventName(const WeatherSystem& ws, const Planet& p) {
double lon = 0.0, lat = 0.0;
dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
if (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr)
return (lon > -0.5 && lon < 2.4) ? "Typhoon" : "Hurricane";
return ws.tropical ? "Tropical low" : "Low";
}
int eventSeverityForWeather(const WeatherSystem& ws, const Planet& p) {
return (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr) ? 2 : (ws.tropical ? 1 : 0);
}
bool validFrameWar(const War& w, int settlementCount) {
return w.attacker >= 0 && w.attacker < settlementCount
&& w.defender >= 0 && w.defender < settlementCount
&& w.attacker != w.defender
&& w.battles >= 0
&& std::isfinite(w.warscore);
}
bool sanitizeFrameDiplomacy(std::vector<DiploTie>& ties, int settlementCount) {
std::set<std::pair<int, int>> seen;
for (DiploTie& t : ties) {
if (t.a < 0 || t.a >= settlementCount || t.b < 0 || t.b >= settlementCount || t.a == t.b)
return false;
if (t.a > t.b) std::swap(t.a, t.b);
if (!seen.insert({t.a, t.b}).second) return false;
if (!std::isfinite(t.attitude)) return false;
t.attitude = std::clamp(t.attitude, -1.0, 1.0);
if ((uint8_t)t.kind > (uint8_t)DiploKind::Rival) t.kind = DiploKind::Neutral;
}
return true;
}
bool validateFrameCivState(WeatherSnapshot& w, size_t currentSettlements) {
if (w.settlementPop.size() > currentSettlements) return false;
for (double p : w.settlementPop) if (!std::isfinite(p)) return false;
const int settlementCount = (int)w.settlementPop.size();
if (!w.settlementAllegiance.empty()) {
if ((int)w.settlementAllegiance.size() != settlementCount) return false;
for (int a : w.settlementAllegiance)
if (a < -1 || a >= settlementCount) return false;
}
std::set<std::pair<int, int>> warsSeen;
for (const War& war : w.wars) {
if (!validFrameWar(war, settlementCount)) return false;
if (!warsSeen.insert(std::minmax(war.attacker, war.defender)).second) return false;
}
// v23: per-settlement culture (indices into the frame's culture-list prefix).
if (w.cultureCount > 100000) return false;
if (!w.settlementCulture.empty()) {
if ((int)w.settlementCulture.size() != settlementCount) return false;
for (int c : w.settlementCulture)
if (c < -1 || c >= (int)w.cultureCount) return false;
}
return sanitizeFrameDiplomacy(w.diplomacy, settlementCount);
}
}
bool Viewer::init(int argc, char** argv) {
uint32_t cliSeed = 0; // 0 = no --seed given
for (int a = 1; a < argc; ++a) {
if (!std::strcmp(argv[a], "--seed") && a + 1 < argc)
cliSeed = (uint32_t)std::strtoul(argv[++a], nullptr, 10);
else if (!std::strcmp(argv[a], "--config") && a + 1 < argc)
configPath = argv[++a];
}
SetConfigFlags(FLAG_MSAA_4X_HINT);
InitWindow(screenW, screenH, "Planet Sim - Phase 1: Tectonics");
SetTargetFPS(60);
SetExitKey(KEY_NULL); // raylib's default exit-on-ESC would fight edit mode's Escape-to-cancel
// Layout: left column 70% wide (3D globe 60% h on top, 2D map 40% h below);
// right column 30% wide (cell info 50% h on top, subareas 50% below).
leftW = (int)(screenW * 0.70f); // 1344
rightX = leftW; rightW = screenW - leftW; // 576
rightH = screenH / 2; // 540
// Top-left: 3D globe (render texture, its own aspect).
view3DW = leftW; view3DH = (int)(screenH * 0.60f); // 1344 x 648
rt3d = LoadRenderTexture(view3DW, view3DH);
SetTextureFilter(rt3d.texture, TEXTURE_FILTER_BILINEAR);
// Bottom-left: 2D Equal Earth map, fit (keep aspect) into the 40% strip.
const int mapAreaY = view3DH, mapAreaH = screenH - view3DH; // (0,648) 1344 x 432
int mapH = mapAreaH - 30;
int mapW = (int)(mapH * (EqualEarth::halfWidth() / EqualEarth::halfHeight()));
if (mapW > leftW - 30) { mapW = leftW - 30; mapH = (int)(mapW / (EqualEarth::halfWidth() / EqualEarth::halfHeight())); }
// Left-align the 2D map (was centered) so the freed space at right holds the live sky panel.
const float mapMargin = 16.0f;
mapRect = Rectangle{ mapMargin,
(float)(mapAreaY + (mapAreaH - mapH) / 2),
(float)mapW, (float)mapH };
float liveX = mapRect.x + mapRect.width + 16.0f;
liveInfoRect = Rectangle{ liveX, mapRect.y, (float)leftW - liveX - 8.0f, mapRect.height };
// Right column.
hoverRect = Rectangle{ (float)rightX + 8, 8.0f, (float)rightW - 16, (float)rightH - 16 };
panelRect = Rectangle{ (float)rightX + 8, (float)rightH + 8, (float)rightW - 16, (float)rightH - 16 };
const float panelHeader = 120.0f;
const float gridSide = std::min(panelRect.width - 40.0f, panelRect.height - panelHeader - 56.0f);
gridRect = Rectangle{ panelRect.x + (panelRect.width - gridSide) / 2.0f,
panelRect.y + panelHeader, gridSide, gridSide };
// Buttons (pause + Phase-3 prompt, centered in the 3D viewport).
pauseBtn = Rectangle{ 16.0f, (float)view3DH - 44.0f, 160.0f, 32.0f };
const float pbW = 220.0f, pbH = 42.0f, pbGap = 24.0f;
pbCx = view3DW * 0.5f; pbCy = view3DH * 0.5f;
p3ContinueBtn = Rectangle{ pbCx - pbW - pbGap * 0.5f, pbCy + 8.0f, pbW, pbH };
p3StartBtn = Rectangle{ pbCx + pbGap * 0.5f, pbCy + 8.0f, pbW, pbH };
// Toolbar (raygui sidebar): right edge of the 3D viewport, doesn't overlap the top-center
// view-mode label, the phase-3 modal, or the bottom-left pause button.
toolbarRect = Rectangle{ (float)view3DW - 300.0f, 0.0f, 300.0f, (float)view3DH };
toolbarToggleBtn = Rectangle{ (float)view3DW - 92.0f, 4.0f, 84.0f, 26.0f };
setupToolbarStyle();
cfg.subdivisions = 5;
if (!loadConfig(configPath, cfg)) saveConfig(configPath, cfg); // load, or create a default
if (cliSeed != 0) cfg.seed = cliSeed; // CLI --seed overrides config
std::string cfgErr = validateConfig(cfg);
if (!cfgErr.empty()) cfg = PlanetConfig{}; // revert to safe defaults
planet.generate(cfg);
cam.position = {0, 0, 6}; cam.target = {0, 0, 0}; cam.up = {0, 1, 0};
cam.fovy = 45; cam.projection = CAMERA_PERSPECTIVE;
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
graticule = buildGraticule();
buildMap2D(planet, mapRect, map2D);
phase3PromptAt = planet.cfg.phase3AfterMy;
refreshView(); // colour the freshly generated (flat) world
return true;
}
void Viewer::rebuildSub() {
subgrids.clear();
if (selectedCell < 0) return;
subgrids.push_back(planet.makeSubGrid(selectedCell, subRes));
const Cell& c = planet.cells[selectedCell];
for (int nb : c.neighbors) subgrids.push_back(planet.makeSubGrid(nb, subRes));
double ma = 0.0;
for (int nb : c.neighbors) ma += angBetween(c.unit, planet.cells[nb].unit);
ma /= std::max<size_t>(1, c.neighbors.size());
selectedThresh = ma * 1.4;
}
void Viewer::selectCell(int idx) {
if (idx < 0) return;
if (idx == selectedCell) { selectedCell = -1; subgrids.clear(); return; }
selectedCell = idx; rebuildSub();
if (editMode) { // switching tiles mid-edit cancels any open picker / typed entry (keeps the tab)
editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None;
}
}
void Viewer::exitEditMode() { editPanel = EditPanelState{}; }
// Recolor the mesh + refresh the elevation range, read straight from cells.
void Viewer::recolor() {
double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge);
const std::vector<double>& temp = planet.temperature();
const std::vector<double>& summer = planet.summerTemp();
const std::vector<double>& winter = planet.winterTemp();
const std::vector<double>& moist = planet.moisture(); // 0..1, already robustly normalized
const std::vector<double>& flora = planet.floraDensity();
const std::vector<double>& fauna = planet.faunaDensity();
const std::vector<double>& funga = planet.fungaDensity();
const std::vector<int>& ecoCell = planet.cellEcoregion();
const auto& eco = planet.ecoregions();
const std::vector<double>& hab = planet.habitability();
const std::vector<int>& cnat = planet.cellNation();
const std::vector<int>& ccult = planet.cellCulture();
const std::vector<double>& cwealth = planet.cellWealth();
double wealthMax = 1e-6; // normalise the wealth heat map to the current max
for (double w : cwealth) if (w > wealthMax) wealthMax = w;
vcolors.resize(planet.cells.size());
for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) {
case ColorMode::Plate: {
int pid = planet.cells[i].plateId;
vcolors[i] = (pid >= 0 && pid < (int)planet.plates.size() && planet.plates[pid].baby)
? Color{70, 80, 95, 255} // young spreading-ridge crust
: plateColor(pid);
break;
}
case ColorMode::Age: vcolors[i] = ageColor(planet.cells[i].geoAge, maxAge); break;
case ColorMode::Crust: vcolors[i] = crustColor(planet.cells[i].oceanic); break;
case ColorMode::Biome: vcolors[i] = biomeColor(planet.cells[i].biome); break;
case ColorMode::Temperature: vcolors[i] = temp.empty() ? Color{90,90,90,255} : tempColor(temp[i]); break;
case ColorMode::TempSummer: vcolors[i] = summer.empty()? Color{90,90,90,255} : tempColor(summer[i]); break;
case ColorMode::TempWinter: vcolors[i] = winter.empty()? Color{90,90,90,255} : tempColor(winter[i]); break;
case ColorMode::Seasonality: vcolors[i] = (summer.empty()||winter.empty()) ? Color{90,90,90,255}
: seasonColor(summer[i] - winter[i]); break;
case ColorMode::Precip: vcolors[i] = moist.empty() ? Color{90,90,90,255} : precipColor(moist[i]); break;
case ColorMode::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255}
: (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFloraColor(flora[i]) : floraColor(flora[i])); break;
case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255}
: (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFaunaColor(fauna[i]) : faunaColor(fauna[i])); break;
case ColorMode::FungaDensity: vcolors[i] = funga.empty() ? Color{90,90,90,255} : fungaColor(funga[i]); break;
case ColorMode::Ecoregion: {
int ei = (i < ecoCell.size()) ? ecoCell[i] : -1;
double prod = (ei >= 0 && ei < (int)eco.size())
? std::max({ eco[ei].floraProductivity, eco[ei].faunaProductivity, eco[ei].fungaProductivity })
: 0.0;
vcolors[i] = (ei >= 0 && ei < (int)eco.size())
? ecoregionColor(ei, eco[ei].biome, prod) : Color{55, 58, 64, 255};
break;
}
case ColorMode::Habitability:
vcolors[i] = (i < hab.size() && planet.cells[i].elevation > planet.cfg.seaLevel
&& planet.cells[i].biome != Biome::Ice)
? habitabilityColor(hab[i]) : Color{30, 42, 64, 255}; // ocean/ice: dim blue
break;
case ColorMode::Territory: {
int ni = (i < cnat.size()) ? cnat[i] : -1;
if (ni >= 0) vcolors[i] = nationColor(ni); // owned: realm tint
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // wilderness land vs sea
? Color{60, 64, 58, 255} : Color{26, 34, 52, 255};
break;
}
case ColorMode::Culture: {
int ci = (i < ccult.size()) ? ccult[i] : -1;
if (ci >= 0) vcolors[i] = cultureColor(ci); // owned: culture tint
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // uncultured land vs sea
? Color{60, 64, 58, 255} : Color{26, 34, 52, 255};
break;
}
case ColorMode::Wealth: {
double w = (i < cwealth.size()) ? cwealth[i] : 0.0;
if (w > 0.0) vcolors[i] = wealthColor(w / wealthMax); // owned land: trade prosperity
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // wilderness land vs sea
? Color{40, 42, 46, 255} : Color{22, 30, 44, 255};
break;
}
default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel);
}
}
// Phase 3: shade filled basins above sea level as inland water (lakes).
const std::vector<double>& lk = planet.lakeDepth();
if (phase3 && !lk.empty())
for (size_t i = 0; i < planet.cells.size(); ++i)
if (lk[i] > 20.0 && planet.cells[i].elevation > planet.cfg.seaLevel)
vcolors[i] = lakeColor();
minE = planet.minElevation(); maxE = planet.maxElevation();
}
// Live World: from the sim's insolation + live-temperature fields, build the per-cell
// day/night brightness (illum) and the shaded draw colours (base colour -> snow/ice tint ->
// day/night dim). Cheap O(n); called every frame while in Live World.
void Viewer::rebuildLiveOverlay() {
const size_t n = planet.cells.size();
const std::vector<double>& sun = planet.insolation();
const std::vector<double>& lt = planet.liveTemp();
const double sea = planet.cfg.seaLevel;
const double snowT = planet.cfg.snowTemp;
const double iceT = planet.cfg.seaIceTemp;
const float nightFloor = 0.18f; // night side dim (not black) so colours read
illum.assign(n, 1.0f);
shadedColors.resize(n);
auto smoothstep = [](double e0, double e1, double x) {
double t = (e1 > e0) ? (x - e0) / (e1 - e0) : 0.0;
t = t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
return t * t * (3.0 - 2.0 * t);
};
// Solar eclipse: a moon roughly between the sun and the planet (its model-space direction
// near the sun's) casts a shadow around the sub-solar point. Strength ramps with alignment.
const Vec3 sd{ sunDir.x, sunDir.y, sunDir.z };
const double eclipseReach = 0.13; // rad: how close a moon must be to the sun to eclipse
const double umbra = 0.10; // rad: angular radius of the shadow spot
double eclipseStrength = 0.0;
for (const auto& md : moonDirs) {
double d = std::acos(std::clamp((double)(md.x*sd.x + md.y*sd.y + md.z*sd.z), -1.0, 1.0));
if (d < eclipseReach) eclipseStrength = std::max(eclipseStrength, 1.0 - d / eclipseReach);
}
auto blend = [](unsigned char c, unsigned char to, double a) {
return (unsigned char)(c + (to - c) * a);
};
for (size_t i = 0; i < n; ++i) {
// Day/night: soft sunrise band over the clamped cosine incidence.
float f = nightFloor;
if (!sun.empty()) f = nightFloor + (1.0f - nightFloor) * (float)smoothstep(0.0, 0.12, sun[i]);
// Eclipse shadow: darken cells near the sub-solar point while a moon transits the sun.
if (eclipseStrength > 0.0 && !sun.empty()) {
double dd = std::acos(std::clamp(planet.cells[i].unit.x*sd.x + planet.cells[i].unit.y*sd.y
+ planet.cells[i].unit.z*sd.z, -1.0, 1.0));
double sh = eclipseStrength * std::exp(-(dd / umbra) * (dd / umbra));
f *= (float)std::max(0.10, 1.0 - 0.85 * sh);
}
illum[i] = f;
Color c = vcolors[i];
// Snow on cold land, sea ice on cold ocean (live seasonal temperature).
if (!lt.empty()) {
double e = planet.cells[i].elevation;
if (e > sea) {
double a = (snowT - lt[i]) / 8.0; // fully snow ~8 C below freezing
if (a > 0.0) { a = a > 0.85 ? 0.85 : a;
c = Color{ blend(c.r, 242, a), blend(c.g, 246, a), blend(c.b, 250, a), 255 }; }
} else {
double a = (iceT - lt[i]) / 6.0; // sea ice
if (a > 0.0) { a = a > 0.9 ? 0.9 : a;
c = Color{ blend(c.r, 212, a), blend(c.g, 226, a), blend(c.b, 236, a), 255 }; }
}
}
// Day/night dimming over the (possibly snow-tinted) colour.
if (dayNightOn) c = Color{ (unsigned char)(c.r * f), (unsigned char)(c.g * f), (unsigned char)(c.b * f), 255 };
shadedColors[i] = c;
}
}
void Viewer::refreshView() {
if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view
planet.computeClimate(); // temperature + precipitation fields
planet.classifyBiomes(); // keep cell.biome current (reads the climate)
planet.computeBiotaDensity(); // flora/fauna/funga density (population is on-demand)
recolor();
if (settled) { // Phase 2: plates moved -> boundaries moved
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
}
if (phase3) buildRivers(planet, riverR, rivers, bigRivers);
buildCoastline(planet, riverR, coast, coastOcean); // land/ocean boundary (for tide lines)
buildCurrents(planet, driftR, currentSegs, currentCols); // ocean current arrows (warm/cold)
if (selectedCell >= 0) rebuildSub();
}
void Viewer::regenWorld() { // after generate(): geometry changed
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear();
editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None;
mapZoom = 1.0; mapPanX = 0.0; mapPanY = 0.0; // drop any 2D-map zoom/pan from the old world
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; followId = 0; wxUndo.clear(); events.clear(); nextEventId = 1; // reseed/regen drops back to World Creation
liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
// Civilization overlays are viewer-side segment lists; buildGeometry() cleared the engine data but not
// these, so drop them on a reseed or they keep drawing over the new world (their draw toggles are
// derived from `mode`, reset right below, so nothing separate needs resetting here).
nationBorders.clear(); cultureBorders.clear(); warFrontier.clear();
allyLinks.clear(); rivalLinks.clear(); tradeSea.clear(); tradeLand.clear();
lastTerritoryYear = -1;
if (mode == ColorMode::Territory || mode == ColorMode::Culture || mode == ColorMode::Wealth)
mode = ColorMode::Biome; // civ colour views have no data until settlements are placed
planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear();
elapsedMy = 0.0; dtMy = 0.0; driftAccum = 0.0;
refreshView();
}
void Viewer::regen() { planet.generate(cfg); regenWorld(); }
void Viewer::stepOnce() { // one tick + settle bookkeeping
maxChange = planet.step(); ++stepCount;
if (maxChange < settleThresh) { if (++settleRun >= settleNeed) settled = true; }
else settleRun = 0;
}
void Viewer::pauseAction() { paused = !paused; } // pause/resume forming or drift
void Viewer::setStatus(const std::string& m) { statusMsg = m; statusUntil = GetTime() + 3.0; }
void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
const std::string& title, const std::string& detail) {
if (cell < 0 || cell >= (int)planet.cells.size()) return;
WorldEvent e;
e.id = nextEventId++;
e.kind = kind;
e.severity = severity;
e.timeHours = timeHours;
e.cell = cell;
e.sourceId = sourceId;
e.title = title;
e.detail = detail;
events.push_back(std::move(e));
if ((int)events.size() > EVENT_LOG_MAX)
events.erase(events.begin(), events.begin() + ((int)events.size() - EVENT_LOG_MAX));
}
void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements,
const std::vector<double>& beforePlague) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
return nullptr;
};
for (const WeatherSystem& ws : planet.storms()) {
const WeatherSystem* old = beforeStorm(ws.id);
int cell = nearestCell(planet, Vec3{ws.pos.x, ws.pos.y, ws.pos.z});
double lon = 0.0, lat = 0.0; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
std::string loc = std::string(TextFormat("%+.0f lat, %+.0f lon", lat * 180.0 / M_PI, lon * 180.0 / M_PI));
if (!old) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, (uint8_t)eventSeverityForWeather(ws, planet), liveTime, cell, ws.id,
std::string(name) + " formed",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
} else if (ws.tropical && old->strength < planet.cfg.weatherHurricaneStr
&& ws.strength >= planet.cfg.weatherHurricaneStr) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, 2, liveTime, cell, ws.id,
std::string(name) + " intensified",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
}
}
auto beforeVolcano = [&](uint32_t id) -> const Volcano* {
for (const Volcano& v : beforeVolcanoes) if (v.id == id) return &v;
return nullptr;
};
for (const Volcano& v : planet.volcanoes) {
const Volcano* old = beforeVolcano(v.id);
if (!old || v.cell < 0 || v.cell >= (int)planet.cells.size()) continue;
const char* kind = v.kind == 0 ? "Ridge volcano" : v.kind == 1 ? "Border volcano" : "Hotspot volcano";
double oldElev = old->baseElev + old->built;
double newElev = v.baseElev + v.built;
if (old->submarine && oldElev <= planet.cfg.seaLevel && newElev > planet.cfg.seaLevel) {
// Add the new land to the atlas: join an adjacent landmass or mint a fresh Island name.
std::string land = planet.nameNewLand(v.cell);
std::string title = land.empty() ? std::string("Volcanic island formed")
: land + " formed";
appendEvent(2, 1, liveTime, v.cell, v.id, title,
std::string(TextFormat("%s breached sea level (+%.0f m built)", kind, v.built)));
}
if (old->phase != 1 && v.phase == 1) {
appendEvent(2, 1, liveTime, v.cell, v.id, "Volcano went dormant",
std::string(TextFormat("%s, +%.0f m built", kind, v.built)));
}
if (old->phase == 1 && v.phase == 0 && v.ashTimer > 0.0) {
appendEvent(2, 2, liveTime, v.cell, v.id, "Volcano erupted",
std::string(TextFormat("%s exploded, +%.0f m remains", kind, v.built)));
}
}
// Civilization (kind=3): a settlement crossing a tier boundary or being abandoned / revived. The
// set is fixed, so compare by index against the before-snapshot.
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
auto popLine = [&](const Settlement& st) -> std::string {
double p = st.population;
if (p >= 1.0e6) return std::string(TextFormat("pop %.1fM", p / 1.0e6));
if (p >= 1.0e3) return std::string(TextFormat("pop %.0fk", p / 1.0e3));
return std::string(TextFormat("pop %.0f", p));
};
for (size_t k = 0; k < planet.settlements.size() && k < beforeSettlements.size(); ++k) {
const Settlement& s = planet.settlements[k];
const Settlement& o = beforeSettlements[k];
SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP);
bool aliveB = o.population >= abP, aliveA = s.population >= abP;
double pl = (k < planet.settlementPlague().size()) ? planet.settlementPlague()[k] : 0.0;
// Plague onset: an epidemic wave started in this settlement this frame.
if (aliveA && pl > 0.02 && (k >= beforePlague.size() || beforePlague[k] <= 1e-9))
appendEvent(3, 2, liveTime, s.cell, s.id, "Plague ravages " + s.name,
std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s));
if (aliveB && !aliveA)
appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s));
else if (!aliveB && aliveA)
appendEvent(3, 1, liveTime, s.cell, s.id, s.name + " was resettled", popLine(s));
else if (aliveA && (int)ta > (int)tb)
appendEvent(3, 1, liveTime, s.cell, s.id,
s.name + " grew into a " + settleTierName(ta), popLine(s));
else if (aliveA && (int)ta < (int)tb) { // tier DOWN -- attribute the cause
const char* stormName = nullptr;
for (const auto& ws : planet.storms()) {
double ang = std::acos(std::clamp(planet.cells[s.cell].unit.dot(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}), -1.0, 1.0));
if (ang < ws.radius && ws.strength > 0.35) { stormName = weatherEventName(ws, planet); break; }
}
double dr = (k < planet.settlementDrought().size()) ? planet.settlementDrought()[k] : 0.0;
std::string title, detail = popLine(s);
if (stormName) title = std::string(stormName) + " devastates " + s.name;
else if (pl > 0.02) { title = std::string("Plague shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s); }
else if (dr > 0.25) { title = std::string("Famine shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("drought %.0f%%, ", dr * 100.0)) + popLine(s); }
else title = s.name + " declined to a " + settleTierName(ta);
appendEvent(3, 2, liveTime, s.cell, s.id, title, detail);
}
}
}
// Nation/realm events (kind=4): compare the new realms to the pre-recompute set by capital settlement id
// -> realm foundings, tier rises (to a kingdom/empire), and collapses (capital lost/absorbed).
void Viewer::detectNationEvents(const std::vector<Nation>& before) {
auto byCapital = [](const std::vector<Nation>& v, int cap) -> const Nation* {
for (const Nation& nn : v)
if (nn.capital == cap) return &nn;
return nullptr;
};
for (const Nation& nat : planet.nationList()) {
if (nat.capital < 0 || nat.capital >= (int)planet.settlements.size()) continue;
int cell = planet.settlements[nat.capital].cell;
const Nation* o = byCapital(before, nat.capital);
if (!o) {
if (nat.tier != NationTier::CityState) // skip lone city-state spam
appendEvent(4, 1, liveTime, cell, nat.id, std::string("The ") + nat.name + " is founded",
std::string(TextFormat("%d settlements, pop %.0fk", nat.members, nat.totalPop / 1.0e3)));
} else if ((int)nat.tier > (int)o->tier) {
appendEvent(4, 1, liveTime, cell, nat.id, nat.name + " rises to " +
(nat.tier == NationTier::Empire ? "an Empire" : "a Kingdom"),
std::string(TextFormat("%d settlements", nat.members)));
}
}
for (const Nation& o : before) {
if (o.tier == NationTier::CityState) continue;
if (!byCapital(planet.nationList(), o.capital) &&
o.capital >= 0 && o.capital < (int)planet.settlements.size())
appendEvent(4, 2, liveTime, planet.settlements[o.capital].cell, o.id,
std::string("The ") + o.name + " collapsed", "");
}
}
void Viewer::focusCell(int idx, const std::string& status) {
if (idx < 0 || idx >= (int)planet.cells.size()) return;
selectedCell = idx;
rebuildSub();
followId = 0;
Vec3 wd = rotateZ(planet.cells[idx].unit, planet.cfg.axialTilt);
camPitch = std::clamp((float)std::asin(std::clamp(wd.y, -1.0, 1.0)), -1.5f, 1.5f);
camYaw = (float)std::atan2(wd.x, wd.z);
cam.position = { camDist * cosf(camPitch) * sinf(camYaw),
camDist * sinf(camPitch),
camDist * cosf(camPitch) * cosf(camYaw) };
double lon = map2D.lon.empty() ? 0.0 : map2D.lon[idx];
double lat = map2D.lat.empty() ? 0.0 : map2D.lat[idx];
mapLon = wrapPi(-lon);
if (mapZoom <= 1.0001) {
mapPanX = mapPanY = 0.0;
} else {
double x = 0.0, y = 0.0;
EqualEarth::forward(0.0, lat, x, y);
double hh = EqualEarth::halfHeight();
double h = mapRect.height * mapZoom;
double targetY = mapRect.y + (0.5 - y / hh * 0.5) * h + (mapRect.height - h) * 0.5;
mapPanY = std::clamp(mapRect.y + mapRect.height * 0.5 - targetY,
-(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5);
double w = mapRect.width * mapZoom;
mapPanX = std::clamp(0.0, -(w - mapRect.width) * 0.5, (w - mapRect.width) * 0.5);
}
if (!status.empty()) setStatus(status);
}
// F5: write seed + config + full planet state. F9: read it back and resume.
void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary);
if (!os) { setStatus("Save failed"); return; }
uint32_t ver = SAVE_VERSION; uint8_t st = settled ? 1 : 0; uint8_t p3 = phase3 ? 1 : 0;
uint8_t lw = liveWorld ? 1 : 0;
os.write("PLSV", 4);
os.write(reinterpret_cast<const char*>(&ver), sizeof ver);
os.write(reinterpret_cast<const char*>(&elapsedMy), sizeof elapsedMy);
os.write(reinterpret_cast<const char*>(&st), sizeof st);
os.write(reinterpret_cast<const char*>(&driftRate), sizeof driftRate);
os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag
os.write(reinterpret_cast<const char*>(&lw), sizeof lw); // v8: Live World flag
os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours)
os.write(reinterpret_cast<const char*>(&liveRate), sizeof liveRate); // v13: live clock rate
planet.writeState(os);
// v12: persist the most recent step-back frames so a load can rewind storms past the moment.
// v15: frames also carry stateful volcano agents + their RNG.
auto wD = [&](const std::vector<double>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(double))); };
auto wV = [&](const std::vector<Volcano>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(Volcano))); };
auto wS = [&](const std::string& s){ uint64_t m = s.size(); os.write((char*)&m, 8); if (m) os.write(s.data(), (std::streamsize)m); };
uint32_t hn = (uint32_t)std::min<size_t>(wxUndo.size(), (size_t)wxSaveMax);
os.write((char*)&hn, 4);
for (size_t i = wxUndo.size() - hn; i < wxUndo.size(); ++i) {
const WxFrame& f = wxUndo[i];
os.write((char*)&f.t, 8);
wD(f.w.humidity); wD(f.w.cloud); wD(f.w.rain);
uint64_t sc = f.w.storms.size(); os.write((char*)&sc, 8);
if (sc) os.write((const char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4);
wV(f.w.volcanoes);
os.write((char*)&f.w.volRng, 4);
wD(f.w.settlementPop); // v20: per-frame settlement populations
// v21: per-frame conflict state (allegiance + wars + war RNG), so a load can rewind conquests.
{ uint64_t m = f.w.settlementAllegiance.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); }
{ uint64_t m = f.w.wars.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.wars.data(), (std::streamsize)(m * sizeof(War))); }
os.write((char*)&f.w.warRng, 4); os.write((char*)&f.w.warNextId, 4);
// v22: per-frame diplomacy, so a load can rewind alliances/rivalries past the saved moment.
{ uint64_t m = f.w.diplomacy.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
// v23: per-frame culture state (per-settlement culture + culture-list length), so a load can
// rewind conversions/assimilations/schisms past the saved moment.
{ uint64_t m = f.w.settlementCulture.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
os.write((char*)&f.w.cultureCount, 4); os.write((char*)&f.w.cultureNextId, 4);
}
// v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
os.write((char*)&nextEventId, 4);
os.write((char*)&en, 4);
for (size_t i = events.size() - en; i < events.size(); ++i) {
const WorldEvent& e = events[i];
os.write((char*)&e.id, 4);
os.write((char*)&e.kind, 1);
os.write((char*)&e.severity, 1);
os.write((char*)&e.timeHours, 8);
os.write((char*)&e.cell, 4);
os.write((char*)&e.sourceId, 4);
wS(e.title); wS(e.detail);
}
setStatus(os ? std::string("Saved ") + path : "Save failed");
}
void Viewer::loadGame(const char* path) {
std::ifstream is(path, std::ios::binary);
if (!is) { setStatus(std::string("No ") + path); return; }
char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0;
uint8_t lw = 0; double lh = 0.0; double lr = 1.0;
is.read(magic, 4);
is.read(reinterpret_cast<char*>(&ver), sizeof ver);
is.read(reinterpret_cast<char*>(&em), sizeof em);
is.read(reinterpret_cast<char*>(&st), sizeof st);
if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr);
if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3);
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (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, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23, ver >= 24)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures, v24 edit mode
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
phase3 = (p3 != 0); phase3Prompt = false;
phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy);
driftRate = dr;
liveWorld = (lw != 0); liveTime = lh; liveRate = std::clamp(lr, 0.25, liveRateMax()); // v8/v13: resume the Live World clock
settleRun = settleNeed; // keep the settled latch consistent
dtMy = settled ? planet.cflDtMy() : 0.0;
driftAccum = 0.0; formAccum = 0.0;
wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
bool skippedHistory = false;
if (ver >= 12 && ver < 15) {
skippedHistory = true; // old frames lack stateful volcanoes; do not restore them
}
if (ver >= 15) { // v15: restore saved step-back frames, including volcano state
bool historyOk = true;
const uint64_t cellCount = planet.cells.size();
auto rD = [&](std::vector<double>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || (m != 0 && m != cellCount)) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
auto rV = [&](std::vector<Volcano>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 100000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(Volcano)));
if (!is) historyOk = false;
};
auto rP = [&](std::vector<double>& v){ // settlement populations (not cell-sized)
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) {
WxFrame f; is.read((char*)&f.t, 8);
rD(f.w.humidity); rD(f.w.cloud); rD(f.w.rain);
uint64_t sc = 0; is.read((char*)&sc, 8);
if (!is || sc > (uint64_t)planet.cfg.weatherSystemMax) { historyOk = false; break; }
f.w.storms.resize((size_t)sc);
if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4);
rV(f.w.volcanoes);
is.read((char*)&f.w.volRng, 4);
if (ver >= 20) rP(f.w.settlementPop); // v20: per-frame settlement populations
if (ver >= 21) { // v21: per-frame conflict state
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.settlementAllegiance.resize((size_t)m); if (m) is.read((char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); }
uint64_t nw = 0; is.read((char*)&nw, 8);
if (!is || nw > 100000) historyOk = false;
else { f.w.wars.resize((size_t)nw); if (nw) is.read((char*)f.w.wars.data(), (std::streamsize)(nw * sizeof(War))); }
is.read((char*)&f.w.warRng, 4); is.read((char*)&f.w.warNextId, 4);
if (!is) historyOk = false;
}
if (ver >= 22) { // v22: per-frame diplomacy
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.diplomacy.resize((size_t)m); if (m) is.read((char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
if (!is) historyOk = false;
}
if (ver >= 23) { // v23: per-frame culture state
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.settlementCulture.resize((size_t)m); if (m) is.read((char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
is.read((char*)&f.w.cultureCount, 4); is.read((char*)&f.w.cultureNextId, 4);
if (!is) historyOk = false;
}
auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
historyOk = false;
for (const WeatherSystem& ws : f.w.storms)
if (!std::isfinite(ws.pos.x) || !std::isfinite(ws.pos.y) || !std::isfinite(ws.pos.z)
|| std::fabs(ws.pos.length() - 1.0) > 1e-6
|| !std::isfinite(ws.strength) || ws.strength < 0.0 || ws.strength > 1.0
|| !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) historyOk = false;
for (const Volcano& v : f.w.volcanoes)
if (v.cell < 0 || v.cell >= (int)planet.cells.size() || v.phase > 1
|| !std::isfinite(v.activity) || !std::isfinite(v.baseElev)
|| !std::isfinite(v.built) || !std::isfinite(v.timer)
|| !std::isfinite(v.ashTimer) || !std::isfinite(v.ashCarry)) historyOk = false;
if (!validateFrameCivState(f.w, planet.settlements.size())) historyOk = false;
if (historyOk) wxUndo.push_back(std::move(f));
}
if (!historyOk) { wxUndo.clear(); skippedHistory = true; }
}
events.clear(); nextEventId = 1; liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
if (ver >= 16) {
bool eventsOk = true;
auto rS = [&](std::string& s) {
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 4096) { eventsOk = false; s.clear(); return; }
s.assign((size_t)m, '\0');
if (m) is.read(&s[0], (std::streamsize)m);
if (!is) eventsOk = false;
};
uint32_t en = 0;
is.read((char*)&nextEventId, 4);
is.read((char*)&en, 4);
if (!is || en > (uint32_t)EVENT_LOG_MAX) eventsOk = false;
for (uint32_t k = 0; k < en && is; ++k) {
WorldEvent e;
is.read((char*)&e.id, 4);
is.read((char*)&e.kind, 1);
is.read((char*)&e.severity, 1);
is.read((char*)&e.timeHours, 8);
is.read((char*)&e.cell, 4);
is.read((char*)&e.sourceId, 4);
rS(e.title); rS(e.detail);
if (e.cell < 0 || e.cell >= (int)planet.cells.size()
|| !std::isfinite(e.timeHours) || e.kind == 0 || e.kind > 32 || e.severity > 3)
eventsOk = false;
if (eventsOk) events.push_back(std::move(e));
}
if (!eventsOk) { events.clear(); nextEventId = 1; skippedHistory = true; }
}
paused = true; selectedCell = -1; subgrids.clear();
// Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves
// restore their own). A non-live save places them when the user first presses W.
if (liveWorld && planet.volcanoes.empty()) planet.placeVolcanoes(liveTime);
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D);
if (planet.settlementsPlaced()) rebuildTerritory(); // territory/nations are derived -> recompute
refreshView();
setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)"
: std::string("Loaded ") + path);
}
// Advance the simulation this frame: Phase-1 forming (paced ticks toward
// equilibrium), or Phase-2 drift / Phase-3 drift+hydrology at a finer dt.
void Viewer::stepSim() {
if (liveWorld) {
// --- Live World: advance the slow clock; geology is frozen --------
double dtH = (!paused) ? liveRate * GetFrameTime() : 0.0; // simulated hours this frame
if (dtH > 0.0 && (wxUndo.empty() || liveTime - wxUndo.back().t >= liveRate - 1e-9))
wxPushSnapshot(); // throttled history during a continuous run (~1 snapshot/sec)
liveAdvance(dtH, dtH);
return;
}
if (!paused && !settled) {
// --- Phase 1: forming, paced ticks toward equilibrium -------------
formAccum += GetFrameTime() * formRate;
int budget = 0;
while (formAccum >= 1.0 && budget < 2000) {
stepOnce(); formAccum -= 1.0; ++budget;
if (settled) break;
}
if (settled) { dtMy = planet.cflDtMy(); planet.drifting = true; } // entering Phase 2
refreshView(); // live update so you watch the terrain rise
} else if (!paused && settled) {
// --- Phase 2 drift (and Phase 3 = drift + hydrology at a finer dt) --
// Drift never stops; Phase 3 just uses a smaller timestep so each step
// advances fewer My (more steps before plates visibly move) while
// rivers/lakes/fluvial erosion resolve.
double dt = planet.cflDtMy() * (phase3 ? planet.cfg.phase3DtScale : 1.0);
dtMy = dt;
driftAccum += driftRate * GetFrameTime(); // accumulate across frames
const int guardMax = phase3 ? 60 : 500; // Phase-3 ticks are heavier
int guard = 0; bool advanced = false;
while (driftAccum >= dt && guard < guardMax) {
planet.advect(dt); planet.step(); planet.erode(dt);
if (phase3) planet.hydrology(dt);
elapsedMy += dt; driftAccum -= dt; ++guard; advanced = true;
// Timed Phase-3 invitation: pause + prompt once we cross the mark.
if (!phase3 && elapsedMy >= phase3PromptAt) { phase3Prompt = true; paused = true; break; }
}
if (driftAccum > 2.0 * dt) driftAccum = 2.0 * dt; // drop backlog (don't runaway)
if (advanced) refreshView(); // live: watch the world evolve
}
}
// Advance the Live World clock by dtClock hours and recompute the derived fields. Weather is an
// integrated, non-reversible path, so it advances by dtWeather (0 = hold, used for a backward
// step which still rewinds the deterministic sky: day/night, tides, seasons, moon phases).
void Viewer::liveAdvance(double dtClock, double dtWeather) {
liveTime = std::max(0.0, liveTime + dtClock);
double days = liveTime / planet.cfg.dayLengthHours;
double dayOfYear01 = days / planet.cfg.yearLengthDays; dayOfYear01 -= std::floor(dayOfYear01);
double timeOfDay01 = days - std::floor(days);
planet.computeInsolation(dayOfYear01, timeOfDay01);
planet.computeLiveSeason(dayOfYear01);
planet.computeTides(dayOfYear01, timeOfDay01, days);
Vec3 s = planet.sunDirection(dayOfYear01, timeOfDay01);
sunDir = Vector3{ (float)s.x, (float)s.y, (float)s.z };
moonDirs.clear(); moonNormals.clear();
for (int m = 0; m < (int)planet.getMoons().size(); ++m) {
Vec3 md = planet.moonDirection(m, timeOfDay01, days);
Vec3 mn = planet.moonOrbitNormal(m, timeOfDay01);
moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z });
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
}
std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes;
std::vector<Settlement> beforeSettlements;
std::vector<double> beforePlague;
if (dtWeather > 0.0) {
beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes;
beforeSettlements = planet.settlements;
beforePlague = planet.settlementPlague();
}
planet.stepWeather(dtWeather);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle.
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements, beforePlague);
// Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines).
// Wars (civ Step 5) run on the same yearly tick: stepConflict mutates allegiance/populations first,
// then territory recomputes so borders move as cities change hands.
bool territoryChanged = false;
if (planet.settlementsPlaced()) {
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
long year = (long)std::floor(liveTime / yearHours);
if (year != lastTerritoryYear) {
std::vector<Nation> beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector<Nation>{};
if (dtWeather > 0.0) { // advancing: simulate each crossed year (not on a step back)
// At high clock rates a frame can span multiple years -- catch up one year at a time so
// wars/territory aren't skipped (bounded: an extreme jump only simulates the most recent).
const long maxCatch = 12;
long firstYear = std::max(lastTerritoryYear + 1, year - maxCatch + 1);
for (long y = firstYear; y <= year; ++y) {
ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
// Step 8 after conflict (assimilation reads this year's fresh allegiance) and before
// colonization (a colony is stamped with its founder's possibly-just-changed culture).
for (const WarEvent& e : planet.stepCulture(y)) // cultural evolution
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
for (const WarEvent& e : planet.stepColonization(y)) // kingdoms found new colonies (appends settlements)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it
}
}
rebuildTerritory(); // final recompute (also refreshes a backward year-cross)
if (dtWeather > 0.0) detectNationEvents(beforeNations);
territoryChanged = true;
}
}
if (vu.breach) refreshView();
else if (vu.recolor || cu.recolor
|| (territoryChanged && (mode == ColorMode::Territory || mode == ColorMode::Culture || mode == ColorMode::Wealth))) recolor();
rebuildLiveOverlay();
}
// Recompute realms/territory + cultures from the (derived) settlement set and rebuild the border
// segments (political + cultural). Cultures depend on territory, so compute them right after.
void Viewer::rebuildTerritory() {
planet.computeTerritory();
planet.computeCultures();
planet.computeTrade(); // civ Step 7: trade links + prosperity (derived)
buildNationBorders(planet, borderR, nationBorders);
buildCultureBorders(planet, borderR, cultureBorders);
buildWarFrontier(planet, borderR + 0.001f, warFrontier); // civ Step 5: current war fronts (drawn red)
buildDiploLinks(planet, borderR, allyLinks, rivalLinks); // civ Step 6: alliance/rivalry arcs
buildTradeRoutes(planet, borderR, tradeSea, tradeLand); // civ Step 7: trade routes
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
lastTerritoryYear = (long)std::floor(liveTime / yearHours);
}
// ---- Actions shared by keyboard shortcuts (ViewerInput.cpp) and the toolbar (Toolbar.cpp) -----
// Each extracted verbatim from its key handler's body (guard moved inside), so a keypress and the
// matching toolbar widget can never diverge in behaviour -- see Viewer.hpp for the full list.
void Viewer::toggleTerritoryView() { // P: territory/realms colour view + political borders
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.nationsBuilt() || (int)planet.cellNation().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Territory) ? ColorMode::Biome : ColorMode::Territory;
recolor();
setStatus(mode == ColorMode::Territory ? "Territory / realms on" : "Territory off");
}
void Viewer::toggleCultureView() { // X: culture/faiths colour view + cultural borders
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.culturesBuilt() || (int)planet.cellCulture().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Culture) ? ColorMode::Biome : ColorMode::Culture;
recolor();
setStatus(mode == ColorMode::Culture ? "Cultures / faiths on" : "Cultures off");
}
void Viewer::toggleTradeView() { // Z: wealth/trade colour view + trade routes
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.tradeBuilt() || (int)planet.cellWealth().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Wealth) ? ColorMode::Biome : ColorMode::Wealth;
recolor();
setStatus(mode == ColorMode::Wealth ? "Wealth / trade on" : "Wealth off");
}
void Viewer::toggleRealmOverlay() { // Q: realm borders + names (+culture) over any colour mode
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.nationsBuilt() || (int)planet.cellNation().size() != (int)planet.cells.size()) rebuildTerritory();
showRealms = !showRealms;
setStatus(showRealms ? "Realm borders on" : "Realm borders off");
}
void Viewer::placeOrToggleSettlements() { // U: seed on first press ("the dawn"), then toggle markers
if (!settled) return;
if (!planet.settlementsPlaced()) {
planet.placeSettlements();
rebuildTerritory(); // initial realms + borders
showSettlements = true;
appendEvent(3, 1, liveTime, planet.settlements.empty() ? 0 : planet.settlements[0].cell, 0,
"Civilization begins",
std::string(TextFormat("%d villages founded", (int)planet.settlements.size())));
setStatus(TextFormat("Civilization begins (%d settlements)", (int)planet.settlements.size()));
} else {
showSettlements = !showSettlements;
setStatus(showSettlements ? "Settlements on" : "Settlements off");
}
}
void Viewer::toggleHabitabilityView() { // I: habitability heat-map view
if (!settled) return;
planet.computeHabitability();
mode = (mode == ColorMode::Habitability) ? ColorMode::Biome : ColorMode::Habitability;
recolor();
setStatus(mode == ColorMode::Habitability ? "Habitability on" : "Habitability off");
}
void Viewer::toggleEcoregionView() { // E: ecoregion atlas colour view (names ecology on first use)
if (!settled) return;
if (!planet.ecoregionsBuilt()) planet.generateEcoregions();
mode = (mode == ColorMode::Ecoregion) ? ColorMode::Biome : ColorMode::Ecoregion;
recolor();
setStatus(mode == ColorMode::Ecoregion ? "Ecoregions on" : "Ecoregions off");
}
void Viewer::generateOrRegenerateBiota() { // L: generate / regenerate the biota population
if (!settled) return;
planet.generateBiota();
if (planet.ecoregionsBuilt()) planet.generateEcoregions();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)");
}
void Viewer::toggleHydrology() { // H: toggle Phase 3 (hydrology)
if (!settled) return;
phase3 = !phase3; phase3Prompt = false;
paused = true; // switching stage auto-pauses (SPACE to run)
if (phase3) { phase3PromptAt = elapsedMy; setStatus("Hydrology ON - paused (SPACE to run)"); }
else { phase3PromptAt = elapsedMy + planet.cfg.phase3AfterMy; rivers.clear(); bigRivers.clear(); setStatus("Hydrology OFF - paused"); }
refreshView();
}
void Viewer::enterOrLeaveLiveWorld() { // W: enter / leave Live World (slow real-time clock)
if (!settled) return;
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = true; wxUndo.clear(); // enter Live World paused (SPACE to run)
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 - paused (SPACE to run)");
} else {
paused = true; followId = 0; wxUndo.clear(); refreshView(); // back to World Creation, paused
setStatus("Live World stopped");
}
}
void Viewer::cycleFollowStorm() { // Y: cycle the 3D camera through active storms
if (!liveWorld) return;
const auto& st = planet.storms();
if (st.empty()) { followId = 0; setStatus("No weather systems to follow"); return; }
std::vector<int> idx(st.size()); for (size_t i = 0; i < st.size(); ++i) idx[i] = (int)i;
std::sort(idx.begin(), idx.end(), [&](int a, int b){ return st[a].strength > st[b].strength; });
int cur = -1; for (size_t k = 0; k < idx.size(); ++k) if (st[idx[k]].id == followId) { cur = (int)k; break; }
int next = (cur < 0) ? 0 : cur + 1;
if (next >= (int)idx.size()) { followId = 0; setStatus("Follow cam off"); return; }
const WeatherSystem& ws = st[idx[next]];
followId = ws.id;
bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr;
setStatus(hur ? "Following tropical cyclone" : "Following weather system");
}
void Viewer::toggleNames() { // M: toggle place-name labels (the atlas)
if (!settled) return;
if (!planet.geographyBuilt()) planet.generateGeography(); // lazily name the world on first use
showNames = !showNames;
setStatus(showNames ? "Place names on" : "Place names off");
}
void Viewer::reshuffleNames() { // Shift+M: re-extract current geography + use different names
if (!settled) return;
planet.reshuffleGeography();
showNames = true;
setStatus("Place names reshuffled");
}
void Viewer::reseed() { // R: reseed
cfg.seed = (uint32_t)(GetTime() * 100000) | 1;
regen();
}
void Viewer::stepAction() { // S: one step (forming/drift tick, or Live World clock step)
if (liveWorld) liveStepForward();
else { stepOnce(); refreshView(); }
}
void Viewer::fastForward() { // F: fast-forward Phase-1 forming to settled
if (settled) return;
while (!settled) stepOnce();
dtMy = planet.cflDtMy(); planet.drifting = true; refreshView();
}
void Viewer::toggleEditMode() { // F3: toggle edit mode (manual cell/settlement edits)
if (!settled) return;
editMode = !editMode;
if (editMode) { paused = true; setStatus("Edit mode on - click a tile to edit (F3 to exit)"); }
else { exitEditMode(); setStatus("Edit mode off"); }
}
// Push the current (pre-advance) weather state onto the bounded step-back ring.
void Viewer::wxPushSnapshot() {
if ((int)wxUndo.size() >= wxUndoMax) wxUndo.erase(wxUndo.begin());
wxUndo.push_back(WxFrame{ liveTime, planet.captureWeather() });
}
// Step the live clock forward one rate-unit. Auto-pauses (like a video frame-step); always records
// the pre-step snapshot first (rate-independent) so the backward step restores weather + storms.
void Viewer::liveStepForward() {
paused = true;
wxPushSnapshot();
liveAdvance(liveRate, liveRate);
}
// Step everything back: restore the newest snapshot at or before the current time (clock +
// weather + storms) -- so storms reverse whether they were born while stepping or during a run. If
// the history is exhausted, fall back to rewinding the deterministic sky only.
void Viewer::liveStepBack() {
paused = true;
while (!wxUndo.empty() && wxUndo.back().t > liveTime + 1e-6) wxUndo.pop_back(); // drop only true future frames
if (!wxUndo.empty()) {
WxFrame f = wxUndo.back(); wxUndo.pop_back();
liveTime = f.t;
planet.restoreWeather(f.w);
liveAdvance(0.0, 0.0); // recompute the sky/overlay at the restored time (weather held)
setStatus("Step back");
} else {
liveAdvance(-liveRate, 0.0); // no recorded past (e.g. right after a load): sky rewinds, weather holds
setStatus("Step back (sky only - no earlier weather; play/step forward first)");
}
}
// The 2D map's projection rect after zoom/pan: mapRect scaled about its centre by mapZoom and
// shifted by the screen-space pan. The scissor + frame stay the real mapRect, so it clips cleanly.
Rectangle Viewer::mapViewRect() const {
float w = (float)(mapRect.width * mapZoom), h = (float)(mapRect.height * mapZoom);
float x = mapRect.x + (mapRect.width - w) * 0.5f + (float)mapPanX;
float y = mapRect.y + (mapRect.height - h) * 0.5f + (float)mapPanY;
return Rectangle{ x, y, w, h };
}
void Viewer::run() {
while (!WindowShouldClose()) {
handleInput();
stepSim();
// A regenerate this frame may have shrunk the planet; keep indices valid.
if (hovered >= (int)planet.cells.size()) hovered = -1;
renderFrame();
}
UnloadRenderTexture(rt3d);
CloseWindow();
}