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>
554 lines
30 KiB
C++
554 lines
30 KiB
C++
#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 <algorithm>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
// 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<int>& tri = planet.triIndices();
|
|
const std::vector<Color>& 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<Vector3>& 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<double>& 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<double>& cl = planet.cloud();
|
|
const std::vector<double>& rn = planet.rain();
|
|
double maxR = 1e-6; for (double r : rn) maxR = std::max(maxR, r);
|
|
const std::vector<int>& 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();
|
|
}
|