#include "Viewer.hpp" #include "Picking.hpp" // angBetween (rebuildSub) #include "Projection.hpp" // EqualEarth (layout) #include #include #include #include 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 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); // 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 }; 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(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(); } // 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& temp = planet.temperature(); const std::vector& summer = planet.summerTemp(); const std::vector& winter = planet.winterTemp(); const std::vector& moist = planet.moisture(); // 0..1, already robustly normalized const std::vector& flora = planet.floraDensity(); const std::vector& fauna = planet.faunaDensity(); const std::vector& funga = planet.fungaDensity(); 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; 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& 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& sun = planet.insolation(); const std::vector& 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(); 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(); 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& beforeStorms, const std::vector& beforeVolcanoes) { 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))); } } } 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(&ver), sizeof ver); os.write(reinterpret_cast(&elapsedMy), sizeof elapsedMy); os.write(reinterpret_cast(&st), sizeof st); os.write(reinterpret_cast(&driftRate), sizeof driftRate); os.write(reinterpret_cast(&p3), sizeof p3); // v3: Phase-3 flag os.write(reinterpret_cast(&lw), sizeof lw); // v8: Live World flag os.write(reinterpret_cast(&liveTime), sizeof liveTime); // v8: live clock (hours) os.write(reinterpret_cast(&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& 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& 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(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); } // v16: persistent world event journal, separate from step-back history. uint32_t en = (uint32_t)std::min(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(&ver), sizeof ver); is.read(reinterpret_cast(&em), sizeof em); is.read(reinterpret_cast(&st), sizeof st); if (ver >= 2) is.read(reinterpret_cast(&dr), sizeof dr); if (ver >= 3) is.read(reinterpret_cast(&p3), sizeof p3); if (ver >= 8) { is.read(reinterpret_cast(&lw), sizeof lw); is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&lr), sizeof lr); // v13: Live World rate if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18)) { 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 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, 720.0); // 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& 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& 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; }; 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); auto sized = [&](const std::vector& 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 (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); 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 beforeStorms; std::vector beforeVolcanoes; if (dtWeather > 0.0) { beforeStorms = planet.storms(); beforeVolcanoes = planet.volcanoes; } 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); if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes); if (vu.breach) refreshView(); else if (vu.recolor) recolor(); rebuildLiveOverlay(); } // 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(); }