Live World viewer controls: storm follow-cam, 2D map zoom, clock stepper

Three viewer features over the Live World sim:

* Storm follow-cam (Y): the 3D camera locks onto a weather system and keeps it centred as it
moves, by pointing along rotateZ(storm.pos,+axialTilt) (model->world) via camYaw/camPitch.
Tracked by a new stable WeatherSystem.id (assigned at spawn from sStormNextId; transient, no
RNG/determinism impact). Cycles by descending strength, off after the last; orbit-drag disabled
while following; auto-releases if the storm dissipates; wheel still zooms.

* 2D map zoom/pan: a virtual projection rect (mapViewRect = mapRect scaled about its centre +
mapPanX/Y) routes every map projection call while the scissor/frame stay mapRect (drawMapTris
now derives y from the rect, not the fixed m.pos, so both axes zoom). Wheel over the map zooms
toward the cursor (1-8x); drag pans when zoomed, else rotates mapLon; 2D picking inverts the
same rect.

* Live-clock stepper: the stepSim live body is factored into liveAdvance(dtClock,dtWeather).
'.' steps forward and ',' back by liveRate hours; backward rewinds the deterministic sky
(day/night, tides, seasons, moon phases) but holds weather (not reversible). S in Live World
aliases the forward step (no longer runs a stray tectonic tick).

All five headless suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-28 18:32:02 +02:00
parent ca844a5d3f
commit 47fee0b25f
11 changed files with 192 additions and 52 deletions

View File

@ -52,9 +52,12 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
K toggle weather clouds/rain cover (Live World)
Y follow-cam: cycle the 3D camera through active storms (Live World; off after last)
. / , step the live clock forward / back by one rate-unit (back rewinds sky only)
wheel over the 2D map: zoom toward cursor (1-8x); drag pans when zoomed
SPACE pause while forming / re-evolve once settled (or the on-screen button)
[ / ] drift speed (My/s) -- in Live World: live clock rate (hours/s, hour->month)
S single tectonic tick
S single tectonic tick (in Live World: step the clock forward, like '.')
F fast-forward Phase-1 forming to settled (instant)
R reseed planet (restart forming)
+ / - subdivision level (detail), 1..7

View File

@ -412,6 +412,19 @@ Working and verified (logic tested headless):
cyclones, blue lows; spins with `liveTime`·hemisphere) in 3D + 2D, HUD system/cyclone counts,
and a storm list (basin-named) in the Sky & tides panel — all under `K`. `test_weather.cpp`
adds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism.
- **Live World viewer controls — storm follow-cam, 2D map zoom, clock stepper:** (1) **`Y`** cycles
the 3D camera to **follow a storm** (by descending strength, off after the last). Tracked by a
stable `WeatherSystem.id` (assigned at spawn from `sStormNextId`; transient, not RNG); each frame
`handleInput` points the camera straight at it via `camYaw/camPitch` from `rotateZ(pos,+axialTilt)`
(model→world), orbit-drag disabled while following, auto-release if it dissipates. (2) **2D map
zoom**: `mapZoom`/`mapPanX`/`mapPanY` + `Viewer::mapViewRect()` (mapRect scaled about its centre +
pan); every map projection call routes through it while the **scissor/frame stay `mapRect`**
(`drawMapTris` now derives y from the rect, not the fixed `m.pos`). Mouse-wheel over the map zooms
toward the cursor (18×); drag pans when zoomed, else rotates `mapLon`; 2D picking inverts the same
rect. (3) **Clock stepper**: the `stepSim` live body is factored into `Viewer::liveAdvance(dtClock,
dtWeather)`; **`.`** steps forward and **`,`** back by `liveRate` hours — backward rewinds the
deterministic sky (day/night, tides, seasons, moon phases) but holds weather (`dtWeather=0`, not
reversible). `S` in Live World aliases the forward step (no longer runs a stray tectonic tick).
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
@ -550,7 +563,10 @@ all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured co
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled ·
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
forward/back by one rate-unit (`,` rewinds the sky only — weather can't reverse) ·
`Y` cycle the 3D camera to **follow a storm** (off after the last) · mouse-wheel **over the 2D map**
zooms toward the cursor (drag pans when zoomed) · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
on a settled world; re-press regenerates) · `W` enter/leave **Live World** (settled world) ·
`R` reseed ·
@ -568,6 +584,9 @@ moving day/night terminator (`N`), a live seasonal temperature cycle and a movin
line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar.
**13 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant
sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high).
`K` shows moving weather (clouds, rain, drifting storms / hurricanes). `Y` makes the 3D camera
**follow a storm** (cycles by strength, off after the last); `.`/`,` step the clock forward/back
by one rate-unit (back rewinds the sky only). Mouse-wheel over the 2D map zooms (drag pans).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).

View File

@ -202,6 +202,28 @@ recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** ove
behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as
animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
Three viewer-only controls over the Live World sim:
- **Storm follow-cam** (`Y`): the globe is at the origin and the camera orbits it, so to centre a
storm we point the camera **along the storm's world direction**`rotateZ(storm.pos, +axialTilt)`
(model→world; `Picking.hpp`), then `camPitch=asin(d.y)`, `camYaw=atan2(d.x,d.z)`. Tracked by a
stable `WeatherSystem.id` (assigned at spawn; transient, no RNG/determinism impact). Orbit-drag is
disabled while following; wheel-zoom still works; cycles by descending strength, auto-releases if
the storm dissipates.
- **2D map zoom** (`mapZoom`/`mapPanX`/`mapPanY`): implemented as a **virtual projection rect**,
`Viewer::mapViewRect()` = `mapRect` scaled about its centre + pan. Every map projection call
(`drawMap2D`/`drawWeather2D`/`drawSegments2D`/graticule/markers/`mapScreen` + the 2D hover-pick)
takes this `vr` instead of `mapRect`, while the **scissor + frame stay `mapRect`** so it clips to
the panel — no Map2D signature changes. `drawMapTris` was changed to derive the y-coordinate from
the rect (not the fixed-to-mapRect `m.pos`) so both axes zoom. Wheel zooms toward the cursor (18×);
drag pans when zoomed, else keeps the `mapLon` longitude rotation.
- **Clock stepper**: the `stepSim` Live-World body is factored into `Viewer::liveAdvance(dtClock,
dtWeather)` (clamps `liveTime≥0`, recomputes insolation/season/tides/moons, `stepWeather`,
overlay). `.`/`,` step ±`liveRate` hours; backward passes `dtWeather=0` because weather is an
integrated, non-reversible path (the deterministic sky — day/night, tides, seasons, moon phases —
still rewinds fine).
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:

View File

@ -32,11 +32,17 @@ Vector2 mapScreen(const Map2D& m, int idx, Rectangle r, double lonOffset) {
// Shared triangle rasterizer for the 2D map: calls colorAt(cellIndex) -> Color per vertex.
template <typename ColorFn>
static void drawMapTris(const Planet& p, const Map2D& m, Rectangle r, double lonOffset, ColorFn colorAt) {
double hw = EqualEarth::halfWidth();
double hw = EqualEarth::halfWidth(), hh = EqualEarth::halfHeight();
auto px = [&](double lon, double lat) -> float {
double x, y; EqualEarth::forward(lon, lat, x, y);
return (float)(r.x + (x / hw * 0.5 + 0.5) * r.width);
};
// y depends only on latitude in Equal Earth; derive it from r (so zoom/pan via a virtual
// rect applies to both axes -- not from the precomputed m.pos, which is fixed to mapRect).
auto py = [&](double lat) -> float {
double x, y; EqualEarth::forward(0.0, lat, x, y);
return (float)(r.y + (0.5 - y / hh * 0.5) * r.height);
};
const std::vector<int>& tri = p.triIndices();
rlDisableBackfaceCulling();
rlBegin(RL_TRIANGLES);
@ -50,7 +56,7 @@ static void drawMapTris(const Planet& p, const Map2D& m, Rectangle r, double lon
for (int t = 0; t < 3; ++t) {
Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(lo[t], m.lat[v[t]]), m.pos[v[t]].y);
rlVertex2f(px(lo[t], m.lat[v[t]]), py(m.lat[v[t]]));
}
} else { // antimeridian seam
double ul[3] = { lo[0], lo[1], lo[2] }; // unwrap around v0
@ -64,7 +70,7 @@ static void drawMapTris(const Planet& p, const Map2D& m, Rectangle r, double lon
for (int t = 0; t < 3; ++t) {
Color c = colorAt(v[t]);
rlColor4ub(c.r, c.g, c.b, c.a);
rlVertex2f(px(ul[t] + sh, m.lat[v[t]]), m.pos[v[t]].y);
rlVertex2f(px(ul[t] + sh, m.lat[v[t]]), py(m.lat[v[t]]));
}
}
}

View File

@ -227,7 +227,7 @@ void Viewer::regenWorld() { // after generate(): geometry change
buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear();
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; // reseed/regen drops back to World Creation
liveWorld = false; followId = 0; // reseed/regen drops back to World Creation
planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear();
@ -304,25 +304,7 @@ 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
liveTime += dtH;
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); // shared sky geometry
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 });
}
planet.stepWeather(dtH); // dynamic clouds & rain on the live clock
rebuildLiveOverlay();
liveAdvance(dtH, dtH);
return;
}
if (!paused && !settled) {
@ -357,6 +339,39 @@ void Viewer::stepSim() {
}
}
// 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 });
}
planet.stepWeather(dtWeather);
rebuildLiveOverlay();
}
// 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();

View File

@ -103,7 +103,10 @@ struct Viewer {
// Input state.
float dragDist = 0.0f;
double mapLon = 0.0; // 2D map longitude pan (radians)
double mapZoom = 1.0; // 2D map zoom factor (1 = whole map; up to 8x)
double mapPanX = 0.0, mapPanY = 0.0; // 2D map screen-space pan (pixels, used when zoomed)
bool pressInMap = false; // a drag that started on the map pans it
uint32_t followId = 0; // Live World: id of the storm the 3D camera follows (0 = none)
// Per-frame picking state (set by handleInput, read by render).
Vector2 mp{};
@ -134,6 +137,8 @@ struct Viewer {
void saveGame(const char* path);
void loadGame(const char* path);
void stepSim(); // advance forming / drift+hydrology this frame
void liveAdvance(double dtClock, double dtWeather); // advance the Live World clock + fields
Rectangle mapViewRect() const; // 2D map projection rect after zoom/pan (scissor stays mapRect)
// ---- Input (ViewerInput.cpp) --------------------------------------------
void handleInput();

View File

@ -34,19 +34,56 @@ void Viewer::handleInput() {
if (onPause) pauseAction(); // clickable pause / re-evolve button
}
}
// Live World: is the camera following a storm? (look up by stable id; release if dissipated)
const WeatherSystem* followed = nullptr;
if (liveWorld && followId != 0) {
for (const auto& ws : planet.storms()) if (ws.id == followId) { followed = &ws; break; }
if (!followed) followId = 0;
}
bool following = (followed != nullptr);
if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && !phase3Prompt) {
Vector2 d = GetMouseDelta();
dragDist += fabsf(d.x) + fabsf(d.y);
if (in3D && !onPause) { // only orbit from the 3D quadrant
if (in3D && !onPause && !following) { // orbit (disabled while following a storm)
camYaw += d.x * 0.005f;
camPitch += d.y * 0.005f;
camPitch = std::clamp(camPitch, -1.5f, 1.5f);
}
if (pressInMap) // drag the map east/west
if (pressInMap) { // drag the map: pan when zoomed, else rotate lon
if (mapZoom > 1.0) {
double w = mapRect.width * mapZoom, h = mapRect.height * mapZoom;
mapPanX = std::clamp(mapPanX + d.x, -(w - mapRect.width) * 0.5, (w - mapRect.width) * 0.5);
mapPanY = std::clamp(mapPanY + d.y, -(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5);
} else {
mapLon = wrapPi(mapLon + d.x * (2.0 * M_PI / mapRect.width));
}
camDist -= GetMouseWheelMove() * 0.4f;
}
}
// Wheel: zoom the 2D map toward the cursor when hovering it, else zoom the camera.
float wheel = GetMouseWheelMove();
if (inMap && wheel != 0.0f) {
Rectangle vr = mapViewRect();
double u = (mp.x - vr.x) / vr.width, v = (mp.y - vr.y) / vr.height; // projection coord under cursor
double nz = std::clamp(mapZoom * (wheel > 0 ? 1.2 : 1.0 / 1.2), 1.0, 8.0);
if (nz <= 1.0001) { mapZoom = 1.0; mapPanX = mapPanY = 0.0; } // back to the whole map
else {
double w = mapRect.width * nz, h = mapRect.height * nz;
mapPanX = std::clamp(mp.x - u * w - mapRect.x - (mapRect.width - w) * 0.5, -(w - mapRect.width) * 0.5, (w - mapRect.width) * 0.5);
mapPanY = std::clamp(mp.y - v * h - mapRect.y - (mapRect.height - h) * 0.5, -(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5);
mapZoom = nz;
}
} else {
camDist -= wheel * 0.4f;
camDist = std::clamp(camDist, 2.6f, 14.0f);
}
if (following) { // point the camera straight at the storm
Vec3 wd = rotateZ(followed->pos, planet.cfg.axialTilt); // model -> world (axial tilt)
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) };
@ -75,7 +112,8 @@ void Viewer::handleInput() {
hovered = nearestCell(planet, hitModel);
}
} else if (inMap) {
double nx = (mp.x - mapRect.x) / mapRect.width, ny = (mp.y - mapRect.y) / mapRect.height;
Rectangle vr = mapViewRect(); // account for 2D zoom/pan
double nx = (mp.x - vr.x) / vr.width, ny = (mp.y - vr.y) / vr.height;
double X = (nx * 2.0 - 1.0) * EqualEarth::halfWidth();
double Y = (1.0 - 2.0 * ny) * EqualEarth::halfHeight();
double lon, lat;
@ -138,17 +176,41 @@ void Viewer::handleInput() {
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started");
} else {
paused = true; refreshView(); // back to World Creation (drift), paused
paused = true; followId = 0; refreshView(); // back to World Creation (drift), paused
setStatus("Live World stopped");
}
}
if (IsKeyPressed(KEY_Y) && liveWorld) { // cycle the 3D camera through active storms
const auto& st = planet.storms();
if (st.empty()) { followId = 0; setStatus("No weather systems to follow"); }
else {
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"); }
else {
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");
}
}
}
if (IsKeyPressed(KEY_N)) dayNightOn = !dayNightOn; // toggle the day/night terminator
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled)
if (IsKeyPressed(KEY_S)) { // one step
if (liveWorld) liveAdvance(liveRate, liveRate); // Live World: step the clock forward
else { stepOnce(); refreshView(); } // forming/drift: one tectonic tick
}
// Live World clock stepper: step by one rate-unit (liveRate hours). Backward rewinds the
// deterministic sky (day/night, tides, seasons, moon phases); weather holds (can't reverse).
if (IsKeyPressed(KEY_PERIOD) && liveWorld) { liveAdvance(liveRate, liveRate); setStatus("Step forward"); }
if (IsKeyPressed(KEY_COMMA) && liveWorld) { liveAdvance(-liveRate, 0.0); setStatus("Step back (sky only)"); }
if (IsKeyPressed(KEY_F)) { // fast-forward to settled
if (!settled) {
while (!settled) stepOnce();

View File

@ -243,45 +243,48 @@ void Viewer::renderGlobe3D() {
// 2D Equal Earth map + its overlays (borders/drift/rivers/labels/markers).
void Viewer::renderMap2D() {
DrawRectangleRec(mapRect, Color{6, 8, 14, 255});
Rectangle vr = mapViewRect(); // projection rect (zoom/pan); scissor stays mapRect
BeginScissorMode((int)mapRect.x, (int)mapRect.y, (int)mapRect.width, (int)mapRect.height);
drawMap2D(planet, displayColors(), map2D, mapRect, mapLon);
if (showGrat) { drawGraticule2D(graticule, mapRect, mapLon); drawGraticuleLabels2D(mapRect, mapLon); }
if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, mapRect, mapLon);
if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, mapRect, mapLon);
if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, mapRect, mapLon);
if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, mapRect, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, mapRect, mapLon);
if (liveWorld && showClouds && !planet.cloud().empty()) drawWeather2D(planet, planet.cloud(), planet.rain(), map2D, mapRect, mapLon);
drawMap2D(planet, displayColors(), map2D, vr, mapLon);
if (showGrat) { drawGraticule2D(graticule, vr, mapLon); drawGraticuleLabels2D(vr, mapLon); }
if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, vr, mapLon);
if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, vr, mapLon);
if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, vr, mapLon);
if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, vr, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, vr, mapLon);
if (liveWorld && showClouds && !planet.cloud().empty()) drawWeather2D(planet, planet.cloud(), planet.rain(), map2D, vr, mapLon);
if (liveWorld && showClouds && !planet.storms().empty()) {
for (const auto& ws : planet.storms()) {
double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
Vector2 sp = projLonLat(lon, lat, mapLon, mapRect);
Vector2 sp = projLonLat(lon, lat, mapLon, vr);
bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr;
Color c = hur ? Color{240, 60, 60, 255} : Color{150, 200, 235, 255};
float rad = 5.0f + 10.0f * (float)ws.strength;
float rad = (5.0f + 10.0f * (float)ws.strength) * (float)std::min(2.0, mapZoom);
DrawCircleLines((int)sp.x, (int)sp.y, rad, c);
if (hur) DrawCircleLines((int)sp.x, (int)sp.y, rad * 0.55f, c);
DrawCircleV(sp, 2.0f, c);
}
}
if (phase3 && showRivers) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, mapRect, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, mapRect, mapLon);
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, vr, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, vr, mapLon);
}
if (showDrift && !plateLabels.empty()) {
for (const auto& lbl : plateLabels) {
Vec3 u = Vec3{lbl.pos.x, lbl.pos.y, lbl.pos.z}.normalized();
double lon, lat; dirToLonLat(u, lon, lat);
Vector2 lp = projLonLat(lon, lat, mapLon, mapRect);
Vector2 lp = projLonLat(lon, lat, mapLon, vr);
const char* txt = TextFormat("P%d", lbl.id);
DrawText(txt, (int)lp.x + 4, (int)lp.y - 8, 12, RAYWHITE);
}
}
if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, mapRect, mapLon), 5, ORANGE);
if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, mapRect, mapLon), 4, YELLOW);
if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, vr, mapLon), 5, ORANGE);
if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, vr, mapLon), 4, YELLOW);
EndScissorMode();
DrawRectangleLinesEx(mapRect, 1, Color{90, 90, 110, 255});
DrawText("2D Equal Earth (hover, drag to pan)", (int)mapRect.x + 6, (int)mapRect.y + 4, 14, Color{200, 200, 210, 255});
DrawText(mapZoom > 1.0 ? TextFormat("2D Equal Earth (zoom %.1fx, drag to pan, wheel to zoom)", mapZoom)
: "2D Equal Earth (hover, drag to pan, wheel to zoom)",
(int)mapRect.x + 6, (int)mapRect.y + 4, 14, Color{200, 200, 210, 255});
}
// Live World "Sky & tides" panel in the freed space right of the (left-aligned) 2D map:
@ -445,7 +448,9 @@ void Viewer::renderHUD() {
rv, rl, dayNightOn ? "on" : "off"));
int nStorm = 0, nHur = 0;
for (const auto& ws : planet.storms()) { ++nStorm; if (ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr) ++nHur; }
line(TextFormat("weather systems: %d tropical cyclones: %d", nStorm, nHur));
line(TextFormat("weather systems: %d tropical cyclones: %d%s", nStorm, nHur,
followId ? " [following]" : ""));
line("Y follow storm · . / , step clock +/- · wheel-on-map zoom");
}
else {
line(TextFormat("%s %.1f My elapsed %.1f My/s%s",

View File

@ -220,6 +220,7 @@ private:
// determinism intact (seeded from cfg.seed in initWeather).
std::vector<WeatherSystem> sStorms;
uint32_t sWeatherRng = 1;
uint32_t sStormNextId = 1; // monotonic id for follow-cam tracking
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.

View File

@ -30,6 +30,7 @@ enum class PlateType { Oceanic, Continental };
// world-object agent (a point on the sphere, like a moon), not a cell. The intense tropical ones
// (strength past weatherHurricaneStrength) are hurricanes/typhoons. Transient -- not saved.
struct WeatherSystem {
uint32_t id = 0; // stable id (for the viewer follow-cam; assigned at spawn)
Vec3 pos; // unit position on the sphere
double strength = 0.0; // intensity 0..1 (drives cloud/rain boost + marker size)
double radius = 0.15; // angular radius (radians)

View File

@ -21,7 +21,7 @@ void Planet::initWeather() {
if (cells[i].elevation <= sea) sHumidity[i] = 0.9; // saturated marine air
else sHumidity[i] = haveM ? (0.2 + 0.5 * sMoist[i]) : 0.3; // land: from climatology
}
sStorms.clear();
sStorms.clear(); sStormNextId = 1;
sWeatherRng = cfg.seed ? (cfg.seed ^ 0x5701A123u) : 0x5701A123u; // separate RNG
sHasWeather = true;
}
@ -126,6 +126,7 @@ void Planet::stepWeather(double dtHours) {
ws.life = bestTrop ? (120.0 + 180.0 * wrf()) : (60.0 + 90.0 * wrf());
ws.spin = (cells[bestIdx].unit.y >= 0.0) ? 1.0 : -1.0;
ws.tropical = bestTrop;
ws.id = sStormNextId++;
sStorms.push_back(ws);
}
}