#include "Viewer.hpp" #include "Overlays.hpp" #include "Map2D.hpp" #include "Panels.hpp" #include "Picking.hpp" // rotateZ (axial-tilt transform for labels) #include "rlgl.h" #include "Projection.hpp" // dirToLonLat (plate labels) #include #include #include // Render the 3D globe into its own RenderTexture (its viewport != the screen). void Viewer::renderGlobe3D() { BeginTextureMode(rt3d); ClearBackground(Color{8, 10, 16, 255}); BeginMode3D(cam); // Axial tilt: lean the whole globe (and everything drawn over it) by the // obliquity about the world Z axis. Picking + plate labels rotate to match // (see ViewerInput / renderFrame). The picking sphere is rotation-invariant. rlPushMatrix(); rlRotatef((float)planet.cfg.axialTilt, 0.0f, 0.0f, 1.0f); const std::vector& tri = planet.triIndices(); const std::vector& dc = displayColors(); // live overlay (day/night + snow) or plain rlBegin(RL_TRIANGLES); for (size_t k = 0; k + 2 < tri.size(); k += 3) { int idx[3] = { tri[k], tri[k + 1], tri[k + 2] }; for (int j = 0; j < 3; ++j) { const Cell& cc = planet.cells[idx[j]]; const Vec3& u = cc.unit; float r = visBase + (float)cc.elevation * elevExagg; const Color& col = dc[idx[j]]; rlColor4ub(col.r, col.g, col.b, 255); rlVertex3f((float)(u.x * r), (float)(u.y * r), (float)(u.z * r)); } } rlEnd(); // (The clicked tile's high-res subgrid is shown in the right-side detail panel, // not overlaid on the globe -- the overlay was a low-res, always-elevation-coloured // patch that clashed with the active colour mode and read as a "strange pattern".) if (showBorders && (!borders.empty() || !ridgeBorders.empty())) { rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(255, 235, 90, 255); // real plate borders: yellow for (size_t i = 0; i + 1 < borders.size(); i += 2) { rlVertex3f(borders[i].x, borders[i].y, borders[i].z); rlVertex3f(borders[i + 1].x, borders[i + 1].y, borders[i + 1].z); } rlColor4ub(220, 70, 60, 255); // young spreading ridges: red for (size_t i = 0; i + 1 < ridgeBorders.size(); i += 2) { rlVertex3f(ridgeBorders[i].x, ridgeBorders[i].y, ridgeBorders[i].z); rlVertex3f(ridgeBorders[i + 1].x, ridgeBorders[i + 1].y, ridgeBorders[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } if (showDrift && !driftArrows.empty()) { rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) { rlVertex3f(driftArrows[i].x, driftArrows[i].y, driftArrows[i].z); rlVertex3f(driftArrows[i + 1].x, driftArrows[i + 1].y, driftArrows[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } if (phase3 && showRivers) { // Phase-3 river network auto drawRiv = [&](const std::vector& segs, float w) { if (segs.empty()) return; rlSetLineWidth(w); rlBegin(RL_LINES); rlColor4ub(80, 170, 235, 255); for (size_t i = 0; i + 1 < segs.size(); i += 2) { rlVertex3f(segs[i].x, segs[i].y, segs[i].z); rlVertex3f(segs[i + 1].x, segs[i + 1].y, segs[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); }; drawRiv(rivers, 1.5f); drawRiv(bigRivers, 3.0f); } // Live World tide: colour the coastline by the local tide level (per-segment colour cached // in coastCols so the 2D map reuses it). Auto-scaled to the current tide extent. coastCols.clear(); if (liveWorld && showTides && !coast.empty()) { const std::vector& td = planet.tide(); double range = 1e-6; for (int oc : coastOcean) if (oc >= 0 && oc < (int)td.size()) range = std::max(range, std::fabs(td[oc])); coastCols.reserve(coastOcean.size()); for (int oc : coastOcean) coastCols.push_back((oc >= 0 && oc < (int)td.size()) ? tideColor(td[oc], range) : Color{150,175,185,255}); rlSetLineWidth(3.0f); rlBegin(RL_LINES); for (size_t i = 0, c = 0; i + 1 < coast.size(); i += 2, ++c) { const Color& col = coastCols[c]; rlColor4ub(col.r, col.g, col.b, 255); rlVertex3f(coast[i].x, coast[i].y, coast[i].z); rlVertex3f(coast[i + 1].x, coast[i + 1].y, coast[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } // Ocean currents: warm/cold arrows over the sea (per-segment colour). if (showCurrents && !currentSegs.empty()) { rlSetLineWidth(2.0f); rlBegin(RL_LINES); for (size_t i = 0, c = 0; i + 1 < currentSegs.size(); i += 2, ++c) { const Color& col = currentCols[c]; rlColor4ub(col.r, col.g, col.b, 255); rlVertex3f(currentSegs[i].x, currentSegs[i].y, currentSegs[i].z); rlVertex3f(currentSegs[i + 1].x, currentSegs[i + 1].y, currentSegs[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } // Live World weather: a translucent cloud shell over the globe (white -> dark storm where it // rains), alpha = cloud cover. Drawn as a second triangle layer just above the terrain. if (liveWorld && showClouds && !planet.cloud().empty()) { const std::vector& cl = planet.cloud(); const std::vector& rn = planet.rain(); double maxR = 1e-6; for (double r : rn) maxR = std::max(maxR, r); const std::vector& ctri = planet.triIndices(); const float cr = visBase + 0.03f; rlBegin(RL_TRIANGLES); for (size_t k = 0; k + 2 < ctri.size(); k += 3) { for (int j = 0; j < 3; ++j) { int idx = ctri[k + j]; double c = std::clamp(cl[idx], 0.0, 1.0); double rain01 = std::clamp(rn[idx] / maxR, 0.0, 1.0); unsigned char R = (unsigned char)(245 - 150 * rain01); // white -> slate unsigned char G = (unsigned char)(245 - 130 * rain01); unsigned char B = (unsigned char)(250 - 95 * rain01); unsigned char A = (unsigned char)(std::clamp(c, 0.0, 1.0) * 205.0); const Vec3& u = planet.cells[idx].unit; rlColor4ub(R, G, B, A); rlVertex3f((float)(u.x * cr), (float)(u.y * cr), (float)(u.z * cr)); } } rlEnd(); } // Live World storm markers: an animated cyclonic spiral per weather system (hurricanes red // with an eye; lows blue), spinning with the live clock by the system's hemisphere sense. if (liveWorld && showClouds && !planet.storms().empty()) { const float SR = visBase + 0.05f; for (const auto& ws : planet.storms()) { Vec3 p{ ws.pos.x, ws.pos.y, ws.pos.z }; Vec3 u = p.cross(Vec3{0, 1, 0}); if (u.length() < 1e-6) u = p.cross(Vec3{1, 0, 0}); u = u.normalized(); Vec3 v = p.cross(u).normalized(); bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; unsigned char cR = hur ? 240 : 150, cG = hur ? 60 : 200, cB = hur ? 60 : 235; unsigned char A = (unsigned char)(110 + 140 * std::clamp(ws.strength, 0.0, 1.0)); double rmax = 0.04 + 0.10 * ws.strength; double phase = liveTime * ws.spin * 0.4; rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(cR, cG, cB, A); const int N = 36; const double turns = 2.2; for (int arm = 0; arm < 2; ++arm) { double a0 = phase + arm * M_PI; Vec3 prev{}; for (int k = 0; k <= N; ++k) { double t = (double)k / N; double a = a0 + t * turns * 2.0 * M_PI * ws.spin; Vec3 dir = u * std::cos(a) + v * std::sin(a); Vec3 wp = (p + dir * (rmax * t)).normalized() * (double)SR; if (k > 0) { rlVertex3f((float)prev.x, (float)prev.y, (float)prev.z); rlVertex3f((float)wp.x, (float)wp.y, (float)wp.z); } prev = wp; } } rlEnd(); rlSetLineWidth(1.0f); if (hur) { Vec3 e = p * (double)SR; DrawSphere(Vector3{(float)e.x,(float)e.y,(float)e.z}, 0.02f, Color{255,240,200,255}); } } } if (showGrat) drawGraticule3D(graticule, gratR); // Markers: selected (orange), hovered cell (yellow), hovered subcell (white). if (selectedCell >= 0) { Vec3 u = planet.cells[selectedCell].unit * (double)(visBase + 0.012f); DrawSphere(Vector3{(float)u.x, (float)u.y, (float)u.z}, 0.03f, ORANGE); } if (hovered >= 0 && !hasHoverSub) { Vec3 u = planet.cells[hovered].unit * (double)(visBase + 0.012f); DrawSphere(Vector3{(float)u.x, (float)u.y, (float)u.z}, 0.022f, YELLOW); } if (hasHoverSub) { Vec3 u = hoverSub.unit * (double)(visBase + 0.02f); DrawSphere(Vector3{(float)u.x, (float)u.y, (float)u.z}, 0.012f, WHITE); } // Spin axis: a rod through the poles, extended beyond the surface (tilts with // the globe since it's inside the rotated matrix). Pole caps mark N (red)/S (blue). { float ax = visBase + 0.6f; rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(210, 220, 235, 255); rlVertex3f(0.0f, -ax, 0.0f); rlVertex3f(0.0f, ax, 0.0f); rlEnd(); rlSetLineWidth(1.0f); DrawSphere(Vector3{0.0f, ax, 0.0f}, 0.05f, Color{230, 90, 80, 255}); // north DrawSphere(Vector3{0.0f, -ax, 0.0f}, 0.05f, Color{80, 140, 230, 255}); // south } // Live World sky: a small, distant sun (bright core + faint halo) and the orbiting moons // (sun-lit phase + orbit ring; dimmed reddish during a lunar eclipse). All inside the tilted // matrix so they stay consistent with the model-space lit pattern. if (liveWorld) { // Sun: far away + small, with a couple of translucent halo shells so it still reads. const float sunDist = 9.0f; Vector3 sp{ sunDir.x * sunDist, sunDir.y * sunDist, sunDir.z * sunDist }; DrawSphere(sp, 0.60f, Color{255, 240, 180, 26}); DrawSphere(sp, 0.34f, Color{255, 238, 170, 55}); DrawSphere(sp, 0.17f, Color{255, 246, 205, 255}); const auto& mns = planet.getMoons(); for (size_t m = 0; m < mns.size() && m < moonDirs.size(); ++m) { const Vector3& dir = moonDirs[m]; float dist = visBase + 0.8f + (float)(mns[m].orbitRadius / 30.0) * 4.0f; // visible band Vector3 mp{ dir.x * dist, dir.y * dist, dir.z * dist }; float rr = (float)mns[m].dispRadius; // Faint orbit ring: the great circle perpendicular to the orbit-plane normal. if (m < moonNormals.size()) { Vec3 nrm = Vec3{moonNormals[m].x, moonNormals[m].y, moonNormals[m].z}.normalized(); Vec3 u = std::fabs(nrm.y) < 0.9 ? nrm.cross(Vec3{0,1,0}).normalized() : nrm.cross(Vec3{1,0,0}).normalized(); Vec3 v = nrm.cross(u); rlBegin(RL_LINES); rlColor4ub(120, 130, 160, 90); const int seg = 64; for (int k = 0; k < seg; ++k) { double a0 = 2.0 * M_PI * k / seg, a1 = 2.0 * M_PI * (k + 1) / seg; Vec3 p0 = (u * std::cos(a0) + v * std::sin(a0)) * dist; Vec3 p1 = (u * std::cos(a1) + v * std::sin(a1)) * dist; rlVertex3f((float)p0.x, (float)p0.y, (float)p0.z); rlVertex3f((float)p1.x, (float)p1.y, (float)p1.z); } rlEnd(); } // Lunar eclipse: moon near the anti-solar point (in the planet's shadow) -> dim red. double antiAlign = -(dir.x*sunDir.x + dir.y*sunDir.y + dir.z*sunDir.z); // dot(dir,-sun) bool eclipsed = antiAlign > std::cos(0.13); Color lit = eclipsed ? Color{90, 35, 30, 255} : Color{210, 210, 215, 255}; DrawSphere(mp, rr, lit); // Phase via the offset-dark-sphere trick: lit fraction k = (1 - cos(phase))/2, with // cos(phase)=dot(moonDir,sunDir) (new moon when aligned with the sun). Shift a dark // sphere toward the unlit (anti-sun) side to occlude it; offset 0 = new, ~2r = full. double cosPhase = dir.x*sunDir.x + dir.y*sunDir.y + dir.z*sunDir.z; double k = (1.0 - cosPhase) * 0.5; // 0 = new, 1 = full float off = (float)(k * 2.2 * rr); Vector3 dp{ mp.x - sunDir.x * off, mp.y - sunDir.y * off, mp.z - sunDir.z * off }; DrawSphere(dp, rr * 1.02f, Color{12, 12, 16, 255}); } } rlPopMatrix(); EndMode3D(); EndTextureMode(); } // 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, 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, 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)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, 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, 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, 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(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: // the current phase of every moon, and the tidal phase of a selected coastal tile. void Viewer::renderLiveInfo() { if (!liveWorld) return; Rectangle r = liveInfoRect; DrawRectangleRec(r, Color{10, 12, 20, 235}); DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255}); int x = (int)r.x + 14, y = (int)r.y + 10; DrawText("Sky & tides", x, y, 20, RAYWHITE); y += 30; // Sky geometry at the current clock (recomputed here so the panel is self-contained). const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays; double days = liveTime / dayH; double doy = days / yrD; doy -= std::floor(doy); double tod = days - std::floor(days); const double dStep = 0.03; // ~ for waxing/waning + rising/falling double days2 = days + dStep, doy2 = days2 / yrD - std::floor(days2 / yrD), tod2 = days2 - std::floor(days2); Vec3 sun = planet.sunDirection(doy, tod); Vec3 sun2 = planet.sunDirection(doy2, tod2); auto illumFrac = [](const Vec3& moon, const Vec3& s) { return (1.0 - moon.dot(s)) * 0.5; }; auto phaseName = [](double f, bool wax) -> const char* { if (f < 0.04) return "New"; if (f > 0.96) return "Full"; if (f > 0.46 && f < 0.54) return wax ? "First quarter" : "Last quarter"; if (f < 0.5) return wax ? "Waxing crescent" : "Waning crescent"; return wax ? "Waxing gibbous" : "Waning gibbous"; }; // A small 2D phase disc: dark circle with the lit fraction filled (terminator ellipse). auto drawPhase = [](float cx, float cy, float rad, double f, bool wax) { DrawCircle((int)cx, (int)cy, rad, Color{26, 28, 36, 255}); double cosphi = 1.0 - 2.0 * f; // terminator x = w * cosphi for (int dy = -(int)rad; dy <= (int)rad; ++dy) { double w = std::sqrt(std::max(0.0, (double)rad * rad - (double)dy * dy)); double xt = w * cosphi, xa, xb; if (wax) { xa = xt; xb = w; } else { xa = -w; xb = -xt; } if (xb > xa) DrawLine((int)(cx + xa), (int)(cy + dy), (int)(cx + xb), (int)(cy + dy), Color{226, 226, 232, 255}); } DrawCircleLines((int)cx, (int)cy, rad, Color{120, 124, 145, 255}); }; const auto& mns = planet.getMoons(); for (size_t m = 0; m < mns.size(); ++m) { Vec3 md = planet.moonDirection((int)m, tod, days); Vec3 md2 = planet.moonDirection((int)m, tod2, days2); double f = illumFrac(md, sun); bool wax = illumFrac(md2, sun2) >= f; float cy = (float)y + 20.0f; drawPhase((float)x + 22.0f, cy, 20.0f, f, wax); DrawText(TextFormat("Moon %d: %s", (int)m + 1, phaseName(f, wax)), x + 52, y + 6, 17, Color{210, 215, 225, 255}); DrawText(TextFormat("%.0f%% lit period %.0f d", f * 100.0, mns[m].periodDays), x + 52, y + 27, 15, Color{150, 160, 175, 255}); y += 50; } if (mns.empty()) { DrawText("(no moons)", x, y, 16, Color{150, 155, 170, 255}); y += 24; } // Tidal phase for a selected coastal tile (placeholder: high/low + rising/falling). y += 8; DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 26; if (selectedCell >= 0 && selectedCell < (int)planet.cells.size()) { const Cell& c = planet.cells[selectedCell]; const double sea = planet.cfg.seaLevel; bool selLand = c.elevation > sea, coastal = false; for (int nb : c.neighbors) if ((planet.cells[nb].elevation > sea) != selLand) { coastal = true; break; } if (coastal) { // Single-cell tide now vs a step ahead -> rising/falling (the field itself is the // equilibrium tide; a richer coastal/resonant model is future work). auto cellTide = [&](double dy, double td, double dd) { double h = 0.0; for (int mm = 0; mm < (int)mns.size(); ++mm) { double cc = c.unit.dot(planet.moonDirection(mm, td, dd)); h += mns[mm].tideWeight * (cc * cc - 1.0 / 3.0); } double cs = c.unit.dot(planet.sunDirection(dy, td)); h += planet.cfg.tideSunFactor * (cs * cs - 1.0 / 3.0); return h * planet.cfg.tideAmplitude; }; bool rising = cellTide(doy2, tod2, days2) >= cellTide(doy, tod, days); // direction // Level from the actual tide field (so the enclosed-sea cap is reflected here too). double lvl = ((int)planet.tide().size() == (int)planet.cells.size()) ? planet.tide()[selectedCell] : cellTide(doy, tod, days); DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 21; DrawText(TextFormat("%+.2f m %s, %s", lvl, lvl >= 0.0 ? "high" : "low", rising ? "rising" : "falling"), x, y, 17, tideColor(lvl, std::max(0.05, std::fabs(lvl)))); y += 24; DrawText("(equilibrium model - placeholder)", x, y, 13, Color{120, 125, 140, 255}); } else { DrawText("selected tile is inland", x, y, 15, Color{150, 155, 170, 255}); } } else { DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255}); } // Active weather systems (lows / tropical cyclones), named by basin. y += 12; DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 26; const auto& storms = planet.storms(); if (storms.empty()) DrawText("(calm — none active)", x, y, 15, Color{150, 155, 170, 255}); int shown = 0; for (const auto& ws : storms) { if (shown >= 6 || y > (int)(r.y + r.height) - 22) break; double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat); bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; const char* kind = hur ? (lon > -0.5 && lon < 2.4 ? "Typhoon" : "Hurricane") // W Pacific vs rest : ws.tropical ? "Tropical low" : "Low"; Color c = hur ? Color{240, 90, 80, 255} : Color{170, 200, 230, 255}; DrawText(TextFormat("%s %.0f%% @ %+.0f,%+.0f", kind, ws.strength * 100.0, lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c); y += 21; ++shown; } } // Right column: hover/selection info (top) + detail panel or world stats (bottom). void Viewer::renderPanels() { drawHoverPanel(planet, hoverRect, hovered, selectedCell); if (selectedCell >= 0 && !subgrids.empty()) drawDetailPanel(planet, subgrids[0], selectedCell, planet.cells[selectedCell].elevation, planet.cells[selectedCell].geoAge, panelRect, gridRect, hoveredSubIdx); else drawStats(planet, panelRect, elapsedMy, settled, liveWorld, liveTime); } // Top-left HUD text + the clickable pause button. void Viewer::renderHUD() { // Active view-mode label, centered at the top of the 3D viewport. { const char* vm = TextFormat("%s view", colorModeName(mode)); int vw = MeasureText(vm, 22); DrawText(vm, view3DW / 2 - vw / 2, 10, 22, Color{235, 225, 140, 255}); } int y = 10; auto line = [&](const std::string& s){ DrawText(s.c_str(), 12, y, 18, RAYWHITE); y += 22; }; double fastest = 0.0; for (const auto& pl : planet.plates) fastest = std::max(fastest, pl.speedCmYr); line(liveWorld ? "Planet Sim - Live World" : !settled ? "Planet Sim - World Creation: forming" : phase3 ? "Planet Sim - World Creation: hydrology" : "Planet Sim - World Creation: drift & erosion"); line(TextFormat("Cells: %d Subdiv: %d CellWidth: %.0f km", (int)planet.cells.size(), cfg.subdivisions, planet.cellWidthMeters() / 1000.0)); line(TextFormat("Elevation: %.0f .. %.0f m", minE, maxE)); if (!settled) line(TextFormat("Forming terrain tick %lld max change %.1f m/tick%s", stepCount, maxChange, paused ? " [PAUSED]" : "")); else if (liveWorld) { const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays; double days = liveTime / dayH; long year = (long)std::floor(days / yrD) + 1; long doy = (long)std::floor(days - std::floor(days / yrD) * yrD) + 1; double hod = liveTime - std::floor(days) * dayH; // hours into the current day int hh = (int)hod, mm = (int)((hod - hh) * 60.0); line(TextFormat("Live World Year %ld Day %ld %02d:%02d%s", year, doy, hh, mm, paused ? " [PAUSED]" : "")); const double weekH = 7.0 * dayH, monthH = 30.0 * dayH; const char* rl; double rv; if (liveRate >= monthH) { rl = "mo/s"; rv = liveRate / monthH; } else if (liveRate >= weekH) { rl = "wk/s"; rv = liveRate / weekH; } else if (liveRate >= dayH) { rl = "d/s"; rv = liveRate / dayH; } else { rl = "h/s"; rv = liveRate; } line(TextFormat("rate %.1f %s day/night %s ([ / ] speed, N toggle, W exit)", 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%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", phase3 ? "Hydrology - drift, rivers & erosion" : "Drift & erosion", elapsedMy, driftRate, paused ? " [PAUSED]" : "")); line(TextFormat("dt %.2f My/step fastest plate %.1f cm/yr [ / ] speed", dtMy, fastest)); if (phase3) { int riverCells = 0, lakeCells = 0; const auto& dq = planet.discharge(); const auto& lk = planet.lakeDepth(); for (size_t i = 0; i < planet.cells.size(); ++i) { if (!dq.empty() && dq[i] > planet.cfg.riverThreshold) ++riverCells; if (!lk.empty() && lk[i] > 20.0 && planet.cells[i].elevation > planet.cfg.seaLevel) ++lakeCells; } line(TextFormat("rivers: %d cells lakes: %d cells", riverCells, lakeCells)); } } y += 8; line("hover: cell info | click tile: open detail panel | C close"); line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)"); line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s]", showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off")); line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-", phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg"); if (!statusMsg.empty() && GetTime() < statusUntil) { y += 4; DrawText(statusMsg.c_str(), 12, y, 18, Color{120, 230, 140, 255}); y += 22; } // Clickable pause button (bottom-left of the 3D quadrant). DrawRectangleRec(pauseBtn, onPause ? Color{60, 70, 92, 255} : Color{28, 34, 46, 235}); DrawRectangleLinesEx(pauseBtn, 1, Color{120, 120, 150, 255}); const char* plbl = paused ? "> RESUME" : "|| PAUSE"; Color plcol = paused ? Color{120, 230, 140, 255} : RAYWHITE; int plw = MeasureText(plbl, 18); DrawText(plbl, (int)(pauseBtn.x + (pauseBtn.width - plw) / 2), (int)pauseBtn.y + 7, 18, plcol); } // Phase-3 transition prompt (modal overlay over the 3D viewport). void Viewer::renderPrompt() { if (!phase3Prompt) return; DrawRectangle(0, 0, (int)view3DW, (int)view3DH, Color{0, 0, 0, 150}); const char* q = TextFormat("Reached %.0f My of drift. Begin hydrology (rivers, lakes & erosion)?", elapsedMy); int qw = MeasureText(q, 22); DrawText(q, (int)(pbCx - qw / 2.0f), (int)(pbCy - 40.0f), 22, RAYWHITE); auto drawBtn = [&](Rectangle b, const char* lbl, Color fill) { bool hot = CheckCollisionPointRec(mp, b); DrawRectangleRec(b, hot ? Color{70, 90, 120, 255} : fill); DrawRectangleLinesEx(b, 1, Color{150, 150, 180, 255}); int w = MeasureText(lbl, 18); DrawText(lbl, (int)(b.x + (b.width - w) / 2.0f), (int)(b.y + 11.0f), 18, RAYWHITE); }; drawBtn(p3ContinueBtn, "Keep building", Color{40, 46, 60, 255}); drawBtn(p3StartBtn, "Start hydrology", Color{30, 72, 60, 255}); } // One full frame: globe texture, then composite + 3D labels + map + panels + // HUD + prompt onto the screen. void Viewer::renderFrame() { renderGlobe3D(); BeginDrawing(); ClearBackground(Color{8, 10, 16, 255}); DrawTextureRec(rt3d.texture, Rectangle{0, 0, (float)view3DW, -(float)view3DH}, Vector2{0, 0}, WHITE); // 3D plate labels (manually projected to match BeginMode3D's viewport exactly). if (showDrift && !plateLabels.empty()) { Vec3 camPos{cam.position.x, cam.position.y, cam.position.z}; Vec3 camTgt{cam.target.x, cam.target.y, cam.target.z}; Vec3 camUp {cam.up.x, cam.up.y, cam.up.z}; Vec3 forward = (camTgt - camPos).normalized(); Vec3 right = forward.cross(camUp).normalized(); Vec3 up = right.cross(forward); double fovRad = cam.fovy * M_PI / 180.0; double aspect = (double)view3DW / view3DH; double projH = std::tan(fovRad * 0.5); // half-height of the view frustum (NDC) double projW = projH * aspect; for (const auto& lbl : plateLabels) { Vec3 lp = rotateZ(Vec3{lbl.pos.x, lbl.pos.y, lbl.pos.z}, planet.cfg.axialTilt); // tilt to match globe if (lp.dot(camPos) <= 0.0) continue; // far hemisphere -> hidden by globe Vec3 rel = lp - camPos; double z = rel.dot(forward); if (z <= 0.0) continue; double xndc = rel.dot(right) / (projW * z); double yndc = rel.dot(up) / (projH * z); float sx = (float)((xndc * 0.5 + 0.5) * view3DW); float sy = (float)((0.5 - yndc * 0.5) * view3DH); DrawText(TextFormat("P%d", lbl.id), (int)sx + 6, (int)sy - 6, 16, RAYWHITE); } } renderMap2D(); renderLiveInfo(); renderPanels(); renderHUD(); renderPrompt(); EndDrawing(); }