planetsim/src/render/Panels.cpp
Jonas Reith f84e06507a Live World weather: dynamic clouds & rain cycle (save v10)
A per-cell humidity/cloud/rain cycle advanced on the live clock (PlanetWeather.cpp,
raylib-free): evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing
wind (upwind differencing) -> condense into cloud (saturation vs temperature + windward
orographic lift) -> rain out thick cloud -> dissipate. Bounded exp-rate forms keep it stable at
any timestep, so it runs cleanly from hours/sec up to a month/sec. initWeather() spins the
fields up from the moisture climatology; fully deterministic (no RNG).

Render: a translucent cloud shell over the 3D globe (white -> dark slate where it rains,
alpha = cover) plus a matching drawWeather2D layer on the 2D map (shared drawMapTris
rasterizer), toggled with K (default on). stepSim runs stepWeather each live frame at the
sim-hours added to liveTime (held when paused). Cell-info shows cloud/humidity/raining.

Saved as v10 (humidity/cloud/rain, flag-gated; pre-v10 saves spin weather up live). New
weather* config knobs. Reseed/regen now also drops out of Live World. test_weather.cpp:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10
round-trip; the other four suites still pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:01:34 +02:00

248 lines
14 KiB
C++

#include "Panels.hpp"
#include "Colors.hpp" // elevationColor (subtile grid)
#include "PlanetBiota.hpp" // organismName / sizeName / roleName
#include "Projection.hpp" // dirToLonLat
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
#include <sstream>
// Draw `text` word-wrapped to `maxW` pixels starting at (x,y); continuation lines
// are indented. Returns the y after the last line; stops drawing past `maxY` (but
// keeps advancing y so callers can detect the overflow). Long biota lists would
// otherwise run off the right edge of the cell-info panel.
static int drawWrapped(const std::string& text, int x, int y, int font, Color col,
int maxW, int lineH, int maxY) {
std::istringstream iss(text);
std::string word, line;
int indent = 0;
auto flush = [&]() {
if (!line.empty()) { if (y + lineH <= maxY) DrawText(line.c_str(), x + indent, y, font, col);
y += lineH; line.clear(); indent = 14; }
};
while (iss >> word) {
std::string test = line.empty() ? word : line + " " + word;
if (MeasureText(test.c_str(), font) > maxW - indent && !line.empty()) { flush(); line = word; }
else line = test;
}
flush();
return y;
}
// elev/age come from the display snapshot so the readout matches what is drawn.
static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, double age) {
const Cell& c = p.cells[i];
double lon, lat; dirToLonLat(c.unit, lon, lat);
const Plate& pl = p.plates[c.plateId];
const int n = (int)p.cells.size();
auto sized = [&](const std::vector<double>& v) { return (int)v.size() == n; };
std::vector<std::string> L;
L.push_back(std::string(TextFormat("Cell #%d", i)));
L.push_back(std::string(TextFormat("lat %+6.1f lon %+6.1f", lat * 180.0 / M_PI, lon * 180.0 / M_PI)));
L.push_back(std::string(TextFormat("elev %.0f m (%s)", elev,
elev < p.cfg.seaLevel ? "ocean" : "land")));
L.push_back(std::string(TextFormat("plate %d (%s) crust %s", c.plateId,
pl.type == PlateType::Oceanic ? "Oceanic" : "Continental",
c.oceanic ? "Oceanic" : "Continental")));
L.push_back(std::string(TextFormat("biome: %s", biomeName(c.biome))));
// Climate (derived; present once computeClimate() has run).
if (sized(p.temperature()) && sized(p.moisture()))
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",
p.temperature()[i], p.moisture()[i] * 100.0)));
if (sized(p.summerTemp()) && sized(p.winterTemp()))
L.push_back(std::string(TextFormat(" summer %.0f C / winter %.0f C",
p.summerTemp()[i], p.winterTemp()[i])));
// Live World: current-season temperature + whether it's day or night + snow cover.
if (sized(p.liveTemp())) {
bool day = sized(p.insolation()) && p.insolation()[i] > 0.05;
bool snow = (elev > p.cfg.seaLevel) ? (p.liveTemp()[i] < p.cfg.snowTemp)
: (p.liveTemp()[i] < p.cfg.seaIceTemp);
L.push_back(std::string(TextFormat("live %.1f C %s%s", p.liveTemp()[i],
day ? "day" : "night", snow ? " snow" : "")));
}
if (sized(p.tide()))
L.push_back(std::string(TextFormat("tide %+.2f m (%s)", p.tide()[i],
p.tide()[i] >= 0.0 ? "high" : "low")));
if (sized(p.cloud()))
L.push_back(std::string(TextFormat("weather: cloud %.0f%% humidity %.0f%%%s",
p.cloud()[i] * 100.0,
sized(p.humidity()) ? p.humidity()[i] * 100.0 : 0.0,
(sized(p.rain()) && p.rain()[i] > 0.02) ? " raining" : "")));
L.push_back(std::string(TextFormat("geoAge %.0f My neighbors %d", age, (int)c.neighbors.size())));
// Hydrology (derived; present once routeFlow()/hydrology() has run).
if (sized(p.discharge()) && p.discharge()[i] > p.cfg.riverThreshold)
L.push_back(std::string(TextFormat("river: discharge %.0f", p.discharge()[i])));
if (sized(p.lakeDepth()) && p.lakeDepth()[i] > p.cfg.biomeLakeMinDepth && elev > p.cfg.seaLevel)
L.push_back(std::string(TextFormat("lake: depth %.0f m", p.lakeDepth()[i])));
// Biota: density scalars (present after computeBiotaDensity()) + the discrete
// population list (present once generateBiota()/L has run).
if (sized(p.floraDensity()) && sized(p.faunaDensity()) && sized(p.fungaDensity()))
L.push_back(std::string(TextFormat("flora %.0f%% fauna %.0f%% funga %.0f%%",
p.floraDensity()[i] * 100.0, p.faunaDensity()[i] * 100.0, p.fungaDensity()[i] * 100.0)));
if (p.biotaPopulated() && i < (int)p.biota().size()) {
const CellBiota& cb = p.biota()[i];
// Each organism reads as Family (Size, Role) -- proper taxonomy, never an
// informal common name; generalists carry a biome adjective ("Forest Felidae").
// Identical archetypes in a cell aggregate to "... xN" so the list stays clean.
auto listKind = [&](const char* tag, const std::vector<Organism>& v) {
if (v.empty()) return;
std::vector<std::pair<Organism, int>> uniq; // representative + count, first-seen order
for (const Organism& o : v) {
bool found = false;
for (auto& u : uniq) if (u.first.archetype == o.archetype) { ++u.second; found = true; break; }
if (!found) uniq.push_back({o, 1});
}
std::string s = tag;
int shown = (int)std::min<size_t>(uniq.size(), 6);
for (int k = 0; k < shown; ++k) {
const BiotaArchetype& a = biotaArchetypes()[uniq[k].first.archetype];
s += (k ? ", " : " ") + organismName(uniq[k].first) +
" (" + sizeName(a.size) + ", " + roleName(a.role) + ")";
if (uniq[k].second > 1) s += TextFormat(" x%d", uniq[k].second);
}
if ((int)uniq.size() > shown) s += TextFormat(", +%d more", (int)uniq.size() - shown);
L.push_back(s);
};
listKind("Flora:", cb.flora);
listKind("Fauna:", cb.fauna);
listKind("Funga:", cb.funga);
}
return L;
}
void drawDetailPanel(const Planet& p, const std::shared_ptr<SubGrid>& sg,
int macro, double macroElev, double macroAge,
Rectangle panel, Rectangle grid, int hoveredSub) {
DrawRectangleRec(panel, Color{12, 14, 22, 235});
DrawRectangleLinesEx(panel, 1, Color{120, 120, 150, 255});
int tx = (int)panel.x + 10, ty = (int)panel.y + 8;
DrawText(TextFormat("Tile #%d", macro), tx, ty, 20, RAYWHITE);
DrawText("C: close", (int)(panel.x + panel.width) - 78, ty + 4, 14, Color{170, 170, 185, 255});
ty += 28;
// Cramped above the subtile grid -> stop before overlapping it (the full list is
// always shown in the top-right hover panel, which has room).
int infoMaxW = (int)(panel.x + panel.width) - tx - 10;
for (auto& s : cellInfo(p, macro, macroElev, macroAge)) {
if (ty + 16 > (int)grid.y) break;
ty = drawWrapped(s, tx, ty, 14, Color{210, 210, 220, 255}, infoMaxW, 16, (int)grid.y);
}
if (!sg || sg->res < 2) return;
int R = sg->res;
float cw = grid.width / R, ch = grid.height / R;
DrawText(TextFormat("Subtiles %dx%d (elevation; dim = neighbor)", R, R),
(int)grid.x, (int)grid.y - 18, 14, Color{200, 200, 210, 255});
for (int j = 0; j < R; ++j)
for (int i = 0; i < R; ++i) {
const SubCell& s = sg->sub[(size_t)j * R + i];
Color col = elevationColor(s.elevation, p.cfg.seaLevel);
if (s.nearestMacro != macro) { // territory of a neighbor
col.r = (unsigned char)(col.r * 0.55); col.g = (unsigned char)(col.g * 0.55);
col.b = (unsigned char)(col.b * 0.55);
}
DrawRectangle((int)(grid.x + i * cw), (int)(grid.y + j * ch),
(int)std::ceil(cw), (int)std::ceil(ch), col);
}
DrawRectangleLinesEx(grid, 1, Color{90, 90, 110, 255});
int by = (int)(grid.y + grid.height) + 6;
if (hoveredSub >= 0) {
int i = hoveredSub % R, j = hoveredSub / R;
DrawRectangleLinesEx(Rectangle{grid.x + i * cw, grid.y + j * ch, cw, ch}, 2, WHITE);
const SubCell& s = sg->sub[hoveredSub];
double lon, lat; dirToLonLat(s.unit, lon, lat);
DrawText(TextFormat("subtile [%d,%d] elev %.0f m", i, j, s.elevation),
(int)panel.x + 10, by, 15, RAYWHITE);
DrawText(TextFormat("under macro #%d lat %+.2f lon %+.2f",
s.nearestMacro, lat * 180.0 / M_PI, lon * 180.0 / M_PI),
(int)panel.x + 10, by + 18, 14, Color{200, 200, 210, 255});
} else {
DrawText("hover a subtile for detail", (int)panel.x + 10, by, 14, Color{170, 170, 185, 255});
}
}
void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected) {
DrawRectangleRec(r, Color{12, 14, 22, 235});
DrawRectangleLinesEx(r, 1, Color{120, 120, 150, 255});
int x = (int)r.x + 18, y = (int)r.y + 14;
DrawText("Cell info", x, y, 24, RAYWHITE);
y += 46;
int shown = (hovered >= 0) ? hovered : selected;
if (shown < 0) {
DrawText("hover the 3D globe or the 2D map", x, y, 20, Color{170, 170, 185, 255});
return;
}
if (hovered < 0) { DrawText("(selected tile)", x, y, 18, Color{210, 180, 120, 255}); y += 30; }
int maxW = (int)(r.x + r.width) - x - 14; // wrap to the panel's inner width
int maxY = (int)(r.y + r.height) - 10; // clamp to the panel bottom
for (auto& s : cellInfo(p, shown, p.cells[shown].elevation, p.cells[shown].geoAge)) {
y = drawWrapped(s, x, y, 20, Color{215, 220, 230, 255}, maxW, 26, maxY);
if (y > maxY) break;
}
}
void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting,
bool live, double liveHours) {
DrawRectangleRec(r, Color{12, 14, 22, 235});
DrawRectangleLinesEx(r, 1, Color{120, 120, 150, 255});
int x = (int)r.x + 16, y = (int)r.y + 12;
DrawText("World statistics", x, y, 22, RAYWHITE); y += 38;
int N = (int)p.cells.size(), np = (int)p.plates.size();
double Rkm = p.cfg.radius / 1000.0;
double surfKm2 = 4.0 * M_PI * Rkm * Rkm, cellKm2 = surfKm2 / std::max(1, N);
std::vector<int> pc(np, 0), pl(np, 0);
double seaLvl = p.cfg.seaLevel;
int landGeo = 0, cont = 0; double mn = 1e30, mx = -1e30, sum = 0;
for (const auto& c : p.cells) {
if (c.plateId >= 0 && c.plateId < np) { pc[c.plateId]++; if (!c.oceanic) pl[c.plateId]++; }
if (c.elevation > seaLvl) ++landGeo; // geographic land (above sea level)
if (!c.oceanic) ++cont; // continental crust
mn = std::min(mn, c.elevation); mx = std::max(mx, c.elevation); sum += c.elevation;
}
// Real plates vs. baby (young spreading-ridge) plates are counted separately.
int contPlates = 0, used = 0, babyPlates = 0, babyCells = 0;
for (int q = 0; q < np; ++q) {
if (pc[q] == 0) continue;
if (p.plates[q].baby) { ++babyPlates; babyCells += pc[q]; continue; }
++used;
if (pl[q] * 2 > pc[q]) ++contPlates;
}
auto L = [&](const char* s) { DrawText(s, x, y, 17, Color{210, 215, 225, 255}); y += 23; };
L(TextFormat("Cells: %d cell area %.1fk km2 R %.0f km", N, cellKm2 / 1000.0, Rkm));
L(TextFormat("Surface area: %.0f M km2", surfKm2 / 1.0e6));
L(TextFormat("Plates: %d active %d continental / %d oceanic", used, contPlates, used - contPlates));
L(TextFormat("Young ridges: %d strips %d cells", babyPlates, babyCells));
L(TextFormat("Moons: %d", (int)p.getMoons().size()));
int water = N - landGeo;
double wlRatio = landGeo > 0 ? (double)water / landGeo : 0.0;
L(TextFormat("Land %.0f%% Ocean %.0f%% (water:land %.2f:1)",
100.0 * landGeo / N, 100.0 * water / N, wlRatio));
L(TextFormat("Sea level: %+.0f m", seaLvl));
L(TextFormat("Crust: %.0f%% continental / %.0f%% oceanic", 100.0 * cont / N, 100.0 * (N - cont) / N));
L(TextFormat("Elevation: %.0f .. %.0f m mean %.0f m", mn, mx, sum / N));
if (live) L(TextFormat("Live World: year %ld, day %.1f",
(long)(liveHours / p.cfg.dayLengthHours / p.cfg.yearLengthDays) + 1,
std::fmod(liveHours / p.cfg.dayLengthHours, p.cfg.yearLengthDays)));
else if (drifting) L(TextFormat("Sim time: %.0f My", elapsedMy));
y += 8;
DrawText("plate cells size area speed land", x, y, 15, Color{150, 155, 170, 255}); y += 21;
std::vector<int> idx(np); for (int q = 0; q < np; ++q) idx[q] = q;
std::sort(idx.begin(), idx.end(), [&](int a, int b){ return pc[a] > pc[b]; });
int rows = 0, rowMax = 13;
for (int q : idx) {
if (pc[q] == 0 || p.plates[q].baby) continue; // baby ridges summarised above
if (rows++ >= rowMax) break;
const char* ty = (pl[q] * 2 > pc[q]) ? "cont" : "ocn ";
DrawText(TextFormat("P%-2d %s %5dc %4.1f%% %6.1fM %4.1fcm/y %3.0f%%", q, ty, pc[q],
100.0 * pc[q] / N, pc[q] * cellKm2 / 1.0e6, p.plates[q].speedCmYr, 100.0 * pl[q] / pc[q]),
x, y, 16, Color{200, 205, 220, 255});
y += 21;
}
DrawText("click a tile to inspect its subtiles",
x, (int)(r.y + r.height) - 24, 14, Color{150, 150, 165, 255});
}