A continent used to always seed exactly one culture at the dawn
(seedCultures(), grouped by regionId), which -- combined with the existing
border-conversion/backfill dynamics -- tended to erode into one
super-dominant culture covering the entire world over a long simulated run.
seedCultures() now splits each continent into 1..cultDawnMaxPerContinent
initial peoples via deterministic farthest-point seeding (a settlement is
assigned to its nearest seed point, giving geographically coherent,
Voronoi-like cultural regions instead of a checkerboard). The target count
scales with the continent's settlement population via the new
cultDawnSettlementsPerCulture config knob, capped by cultDawnMaxPerContinent.
Per-culture ethos/faith/name hashes now fold in Culture.id so multiple
cultures sharing a continent (and therefore the same regionId/bank) get
distinct identities instead of colliding.
This surfaced a real, previously-latent ordering bug: computeTerritory()
(realm/vassalage grouping, which prefers matching by actual culture and only
falls back to same-continent when culture data doesn't exist yet) ran BEFORE
computeCultures() populated per-settlement culture, so on every fresh
recompute realms formed by continent for one full pass before the next
recompute split them correctly. Harmless before this change (one culture per
continent made the two equivalent), but would have left temporarily
multi-cultural realms now. Fixed at the root: split computeCultures() into
refreshSettlementCultures() (the settlement-level seed/backfill/tally slice,
no dependency on `nations`) and had computeTerritory() call it internally
before grouping -- every caller (production and the dozens of existing test
call sites) gets correct behaviour automatically, no call-site changes needed
beyond the two tests whose assertions encoded the old exact one-per-continent
invariant.
test_culture.cpp and test_cultevo.cpp updated to assert the new bounded
(1..cultDawnMaxPerContinent) invariant instead of exact equality, plus that a
single culture never itself spans two continents (still enforced).
Verified live: a single continent now shows six distinct peoples ("the
Thisur", "the Bomebro", "the Draordaes", etc.) instead of one dominant
culture, while realms remain correctly mono-cultural.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
1115 lines
62 KiB
C++
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(); // refreshes per-settlement culture itself before grouping into realms
|
|
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();
|
|
}
|