planetsim/src/render/ViewerInput.cpp
Jonas Reith 47fee0b25f 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>
2026-06-28 18:32:02 +02:00

243 lines
14 KiB
C++

#include "Viewer.hpp"
#include "Picking.hpp"
#include "Map2D.hpp" // wrapPi
#include "Projection.hpp" // EqualEarth, lonLatToDir
#include <algorithm>
#include <cmath>
// One frame of input: camera orbit/zoom, hover picking (panel subtile -> 3D ray
// -> 2D map), click-to-select, and key handling. Writes the per-frame picking
// members (mp/onPause/hovered/hasHoverSub/hoverSub/hoveredSubIdx) for the render
// pass, and may step / regenerate / save / load the world.
void Viewer::handleInput() {
mp = GetMousePosition();
bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant
bool inMap = CheckCollisionPointRec(mp, mapRect);
bool inPanel = (selectedCell >= 0) && CheckCollisionPointRec(mp, panelRect);
onPause = CheckCollisionPointRec(mp, pauseBtn);
// --- Camera input (LMB drag orbits; tracks drag distance for clicks) --
if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) {
if (phase3Prompt) { // modal: only the two buttons act
if (CheckCollisionPointRec(mp, p3StartBtn)) {
phase3 = true; phase3Prompt = false; paused = false;
phase3PromptAt = elapsedMy; setStatus("Hydrology started");
refreshView();
} else if (CheckCollisionPointRec(mp, p3ContinueBtn)) {
phase3Prompt = false; paused = false;
phase3PromptAt = elapsedMy + planet.cfg.phase3AfterMy;
setStatus("Continuing world-building");
}
} else {
dragDist = 0.0f;
pressInMap = inMap; // drag started on the map -> pan it
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 && !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: 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));
}
}
}
// 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) };
// --- Hover picking: tile panel subtile, else 3D ray, else 2D map ------
// The globe is rendered tilted by axialTilt about world Z; the picking sphere is
// rotation-invariant, so un-rotate the world-space hit direction by -tilt to get
// the model-space direction used to match cells/subcells (hitModel).
hovered = -1; hasHoverSub = false; hoveredSubIdx = -1;
bool have3DHit = false; Vec3 hitUnit, hitModel;
if (inPanel) {
if (!subgrids.empty() && CheckCollisionPointRec(mp, gridRect)) {
const auto& sg = subgrids[0]; int R = sg->res;
int i = std::clamp((int)((mp.x - gridRect.x) / (gridRect.width / R)), 0, R - 1);
int j = std::clamp((int)((mp.y - gridRect.y) / (gridRect.height / R)), 0, R - 1);
hoveredSubIdx = j * R + i;
hoverSub = sg->sub[hoveredSubIdx]; hasHoverSub = true; // marks it on the globe
}
} else if (in3D) {
Vec3 d = rayDirFromMouse(cam.position, cam.target, cam.fovy,
mp.x, mp.y, (float)view3DW, (float)view3DH);
Vec3 o{cam.position.x, cam.position.y, cam.position.z};
if (raySphere(o, d, visBase, hitUnit)) {
have3DHit = true;
hitModel = rotateZ(hitUnit, -planet.cfg.axialTilt); // world -> model (undo tilt)
hovered = nearestCell(planet, hitModel);
}
} else if (inMap) {
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;
if (EqualEarth::inverse(X, Y, lon, lat))
hovered = nearestCell(planet, lonLatToDir(wrapPi(lon - mapLon), lat));
}
if (have3DHit && !subgrids.empty()) { // prefer subcell under cursor
double best = -2.0; const SubCell* bs = nullptr;
for (auto& sg : subgrids) {
if (!sg) continue;
for (auto& s : sg->sub) { double dd = s.unit.dot(hitModel); if (dd > best) { best = dd; bs = &s; } }
}
if (bs && angBetween(bs->unit, hitModel) < selectedThresh) { hoverSub = *bs; hasHoverSub = true; }
}
// --- Click = select a tile (ignored over panel/button / while dragging) -
if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && dragDist < 6.0f && !inPanel && !onPause && !phase3Prompt && hovered >= 0)
selectCell(hovered);
// --- Keys -------------------------------------------------------------
if (IsKeyPressed(KEY_SPACE) && !phase3Prompt) pauseAction();
if (IsKeyPressed(KEY_ONE)) { mode = ColorMode::Elevation; recolor(); }
if (IsKeyPressed(KEY_TWO)) { mode = ColorMode::Plate; recolor(); }
if (IsKeyPressed(KEY_THREE)) { mode = ColorMode::Age; recolor(); }
if (IsKeyPressed(KEY_FOUR)) { mode = ColorMode::Crust; recolor(); }
if (IsKeyPressed(KEY_FIVE)) { mode = ColorMode::Biome; recolor(); }
if (IsKeyPressed(KEY_SIX)) { // cycle temperature sub-views: mean->summer->winter->seasonality
mode = (mode == ColorMode::Temperature) ? ColorMode::TempSummer
: (mode == ColorMode::TempSummer) ? ColorMode::TempWinter
: (mode == ColorMode::TempWinter) ? ColorMode::Seasonality
: ColorMode::Temperature;
recolor();
}
if (IsKeyPressed(KEY_SEVEN)) { mode = ColorMode::Precip; recolor(); }
if (IsKeyPressed(KEY_EIGHT)) { mode = ColorMode::FloraDensity; recolor(); }
if (IsKeyPressed(KEY_NINE)) { mode = ColorMode::FaunaDensity; recolor(); }
if (IsKeyPressed(KEY_ZERO)) { mode = ColorMode::FungaDensity; recolor(); }
if (IsKeyPressed(KEY_B)) showBorders = !showBorders;
if (IsKeyPressed(KEY_D)) showDrift = !showDrift;
if (IsKeyPressed(KEY_G)) showGrat = !showGrat;
if (IsKeyPressed(KEY_J)) showRivers = !showRivers;
if (IsKeyPressed(KEY_H) && settled) { // toggle Phase 3 (hydrology)
bool wasPrompt = phase3Prompt;
phase3 = !phase3; phase3Prompt = false;
if (wasPrompt) paused = false; // taking the choice resumes the sim
if (phase3) { phase3PromptAt = elapsedMy; setStatus("Hydrology ON"); }
else { phase3PromptAt = elapsedMy + planet.cfg.phase3AfterMy; rivers.clear(); bigRivers.clear(); setStatus("Hydrology OFF"); }
refreshView();
}
if (IsKeyPressed(KEY_L) && settled) { // generate / regenerate biota population
planet.generateBiota();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)");
}
if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock)
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = false;
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started");
} else {
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)) { // 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();
dtMy = planet.cflDtMy(); planet.drifting = true; refreshView();
}
}
if (IsKeyPressed(KEY_EQUAL) && cfg.subdivisions < 7) { cfg.subdivisions++; regen(); }
if (IsKeyPressed(KEY_MINUS) && cfg.subdivisions > 1) { cfg.subdivisions--; regen(); }
if (IsKeyPressed(KEY_F2)) {
if (loadConfig(CONFIG_PATH, cfg)) {
std::string cerr = validateConfig(cfg);
if (!cerr.empty()) { cfg = PlanetConfig{}; setStatus("Bad planet.cfg — using defaults"); }
regen();
setStatus(cerr.empty() ? "Reloaded planet.cfg" : "Bad planet.cfg — using defaults");
} else setStatus("No planet.cfg"); }
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH);
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH);
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
// Speed control: Live World ramps the live clock (sim hours/s, ~hour -> month);
// otherwise it sets the drift rate (My simulated per real second).
if (IsKeyPressed(KEY_RIGHT_BRACKET)) {
if (liveWorld) liveRate = std::min(liveRate * 1.5, 720.0);
else driftRate = std::min(driftRate * 1.5, 80.0);
}
if (IsKeyPressed(KEY_LEFT_BRACKET)) {
if (liveWorld) liveRate = std::max(liveRate / 1.5, 0.25);
else driftRate = std::max(driftRate / 1.5, 0.5);
}
}