#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 // Tint a settlement marker by its live environmental condition (1 = thriving, <1 = hardship/drought): // blend toward a dull withered brown-red and darken as conditions worsen. static Color witherColor(Color base, double cond) { double h = std::clamp((0.8 - cond) / 0.6, 0.0, 1.0); // 0 above 0.8 .. 1 at/below 0.2 if (h <= 0.0) return base; const unsigned char w[3] = { 130, 80, 70 }; // withered brown-red double dim = 1.0 - 0.35 * h; auto L = [&](unsigned char b, unsigned char wc) { return (unsigned char)std::clamp((b * (1.0 - h) + wc * h) * dim, 0.0, 255.0); }; return Color{ L(base.r, w[0]), L(base.g, w[1]), L(base.b, w[2]), base.a }; } // Label style for a geographic feature: font size + colour, returns true if it's a "minor" feature // (peaks/rivers/lakes/seas/small islands) -- those are drawn only when zoomed in, to declutter. static bool labelStyle(const GeoFeature& f, int& font, Color& col) { const Color land{215, 220, 235, 255}, water{120, 195, 230, 255}, river{110, 175, 235, 255}, mtn{220, 195, 150, 255}; switch (f.kind) { case FeatureKind::Continent: font = 20; col = land; return false; case FeatureKind::Ocean: font = 18; col = water; return false; case FeatureKind::Sea: font = 15; col = water; return true; case FeatureKind::Island: font = f.size >= 8 ? 15 : 13; col = land; return f.size < 8; case FeatureKind::MountainRange: font = 15; col = mtn; return false; case FeatureKind::Peak: font = 13; col = mtn; return true; case FeatureKind::River: font = 13; col = river; return true; case FeatureKind::Lake: font = 13; col = water; return true; } font = 13; col = land; return true; } // 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 (showNationBorders && !nationBorders.empty()) { // political / realm borders (dark, over the tint) rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(18, 18, 26, 235); for (size_t i = 0; i + 1 < nationBorders.size(); i += 2) { rlVertex3f(nationBorders[i].x, nationBorders[i].y, nationBorders[i].z); rlVertex3f(nationBorders[i + 1].x, nationBorders[i + 1].y, nationBorders[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } if (showCultureBorders && !cultureBorders.empty()) { // cultural-region borders (pale, distinct from political) rlSetLineWidth(3.0f); rlBegin(RL_LINES); rlColor4ub(245, 240, 220, 220); for (size_t i = 0; i + 1 < cultureBorders.size(); i += 2) { rlVertex3f(cultureBorders[i].x, cultureBorders[i].y, cultureBorders[i].z); rlVertex3f(cultureBorders[i + 1].x, cultureBorders[i + 1].y, cultureBorders[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } if (showNationBorders && !warFrontier.empty()) { // civ Step 5: war fronts (bright red, with the territory view) rlSetLineWidth(3.5f); rlBegin(RL_LINES); rlColor4ub(235, 40, 30, 255); for (size_t i = 0; i + 1 < warFrontier.size(); i += 2) { rlVertex3f(warFrontier[i].x, warFrontier[i].y, warFrontier[i].z); rlVertex3f(warFrontier[i + 1].x, warFrontier[i + 1].y, warFrontier[i + 1].z); } rlEnd(); rlSetLineWidth(1.0f); } if (showNationBorders) { // civ Step 6: diplomacy arcs (allies green, rivals dark red) rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(70, 220, 120, 200); for (size_t i = 0; i + 1 < allyLinks.size(); i += 2) { rlVertex3f(allyLinks[i].x, allyLinks[i].y, allyLinks[i].z); rlVertex3f(allyLinks[i + 1].x, allyLinks[i + 1].y, allyLinks[i + 1].z); } rlColor4ub(150, 40, 60, 200); for (size_t i = 0; i + 1 < rivalLinks.size(); i += 2) { rlVertex3f(rivalLinks[i].x, rivalLinks[i].y, rivalLinks[i].z); rlVertex3f(rivalLinks[i + 1].x, rivalLinks[i + 1].y, rivalLinks[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}); } } } // Live World volcano markers: growing vents glow red/orange, dormant vents go quiet/grey, // and post-explosion ash vents flare bright. Inside the tilted matrix, so it tracks the globe. if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight); for (const Volcano& vc : planet.volcanoes) { if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue; const Cell& c = planet.cells[vc.cell]; Vec3 u = c.unit; float r = visBase + (float)c.elevation * elevExagg; double bf = std::clamp(planet.volcanoBuilt(vc) / maxH, 0.0, 1.0); double er = planet.volcanoErupting(vc); float coneH = 0.022f + 0.045f * (float)bf; float coneR = 0.015f + 0.018f * (float)bf; Vector3 b { (float)(u.x * r), (float)(u.y * r), (float)(u.z * r) }; Vector3 apex{ (float)(u.x * (r + coneH)), (float)(u.y * (r + coneH)), (float)(u.z * (r + coneH)) }; bool dormant = vc.phase == 1; Color cone = dormant ? Color{105, 100, 95, 255} : Color{ (unsigned char)(115 + 95 * er), (unsigned char)(65 + 20 * bf), 45, 255 }; DrawCylinderEx(b, apex, coneR, coneR * 0.25f, 8, cone); if (er > 0.12 && !dormant) { unsigned char a = (unsigned char)std::clamp(60.0 + 195.0 * er, 0.0, 255.0); Color glow = vc.ashTimer > 0.0 ? Color{255, 210, 95, a} : Color{255, 140, 40, a}; DrawSphere(apex, 0.02f + 0.05f * (float)er, glow); float ph = coneH + (vc.ashTimer > 0.0 ? 0.20f : 0.12f) * (float)er; Vector3 top{ (float)(u.x * (r + ph)), (float)(u.y * (r + ph)), (float)(u.z * (r + ph)) }; rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(255, 180, 80, a); rlVertex3f(apex.x, apex.y, apex.z); rlVertex3f(top.x, top.y, top.z); rlEnd(); rlSetLineWidth(1.0f); } } } // Settlement markers (civilization): a dot per settlement, sized + coloured by tier; dim for ruins. if (showSettlements && !planet.settlements.empty()) { const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; const auto& cond = planet.settlementCondition(); for (size_t k = 0; k < planet.settlements.size(); ++k) { const Settlement& s = planet.settlements[k]; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue; const Cell& c = planet.cells[s.cell]; float r = visBase + (float)c.elevation * elevExagg + 0.006f; Vector3 p{ (float)(c.unit.x * r), (float)(c.unit.y * r), (float)(c.unit.z * r) }; bool alive = s.population >= abP; SettleTier t = settleTierOf(s.population, townP, cityP); float rad = t == SettleTier::City ? 0.026f : t == SettleTier::Town ? 0.018f : 0.012f; Color col = !alive ? Color{110, 110, 116, 255} : t == SettleTier::City ? Color{250, 220, 110, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255} : Color{210, 130, 85, 255}; col = witherColor(col, k < cond.size() ? cond[k] : 1.0); // hardship -> withered tint DrawSphere(p, rad, col); if (alive && t != SettleTier::Village) { // a ring marks notable settlements float rr = visBase + (float)c.elevation * elevExagg + 0.01f; Vector3 e = { (float)(c.unit.x * rr), (float)(c.unit.y * rr), (float)(c.unit.z * rr) }; DrawSphereWires(e, rad + 0.008f, 6, 6, Color{255, 245, 210, 150}); } } } 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 (showNationBorders && !nationBorders.empty()) drawSegments2D(nationBorders, Color{18, 18, 26, 235}, 2.0f, vr, mapLon); if (showCultureBorders && !cultureBorders.empty()) drawSegments2D(cultureBorders, Color{245, 240, 220, 230}, 2.5f, vr, mapLon); if (showNationBorders && !warFrontier.empty()) drawSegments2D(warFrontier, Color{235, 40, 30, 255}, 2.5f, vr, mapLon); if (showNationBorders && !allyLinks.empty()) drawSegments2D(allyLinks, Color{70, 220, 120, 220}, 1.5f, vr, mapLon); if (showNationBorders && !rivalLinks.empty()) drawSegments2D(rivalLinks, Color{150, 40, 60, 220}, 1.5f, 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 (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { for (const Volcano& vc : planet.volcanoes) { if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue; double er = planet.volcanoErupting(vc); double lon, lat; dirToLonLat(planet.cells[vc.cell].unit, lon, lat); Vector2 sp = projLonLat(lon, lat, mapLon, vr); float s = (5.0f + 3.0f * (float)er) * (float)std::min(2.0, mapZoom); Color tri = vc.phase == 1 ? Color{125, 120, 115, 255} : vc.ashTimer > 0.0 ? Color{245, 170, 55, 255} : Color{170, 75, 50, 255}; DrawPoly(sp, 3, s, -90.0f, tri); // filled up-pointing triangle (cone) if (er > 0.12 && vc.phase != 1) DrawCircleLines((int)sp.x, (int)sp.y, s + 3.0f, Color{255, 170, 70, (unsigned char)std::clamp(90.0 + 150.0 * er, 0.0, 255.0)}); } } if (showSettlements && !planet.settlements.empty()) { const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; const auto& cond = planet.settlementCondition(); float zf = (float)std::min(2.0, mapZoom); for (size_t k = 0; k < planet.settlements.size(); ++k) { const Settlement& s = planet.settlements[k]; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue; double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat); Vector2 sp = projLonLat(lon, lat, mapLon, vr); bool alive = s.population >= abP; SettleTier t = settleTierOf(s.population, townP, cityP); float rad = (t == SettleTier::City ? 4.5f : t == SettleTier::Town ? 3.2f : 2.2f) * zf; Color col = !alive ? Color{120, 120, 126, 255} : t == SettleTier::City ? Color{250, 220, 110, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255} : Color{210, 130, 85, 255}; col = witherColor(col, k < cond.size() ? cond[k] : 1.0); DrawCircleV(sp, rad, col); if (alive && t != SettleTier::Village) DrawCircleLines((int)sp.x, (int)sp.y, rad + 2.0f, Color{255, 245, 210, 180}); } } 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); } } // Place-name labels (the atlas). Minor features only when the map is zoomed in. if (showNames && planet.geographyBuilt()) { bool zoomed = mapZoom > 1.5; for (const GeoFeature& f : planet.geography()) { if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue; int font; Color col; bool minor = labelStyle(f, font, col); if (font <= 0 || (minor && !zoomed)) continue; double lon, lat; dirToLonLat(planet.cells[f.anchorCell].unit, lon, lat); Vector2 lp = projLonLat(lon, lat, mapLon, vr); if (!CheckCollisionPointRec(lp, mapRect)) continue; int w = MeasureText(f.name.c_str(), font); DrawText(f.name.c_str(), (int)lp.x - w / 2 + 1, (int)lp.y - font / 2 + 1, font, Color{0, 0, 0, 180}); DrawText(f.name.c_str(), (int)lp.x - w / 2, (int)lp.y - font / 2, font, col); } } 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 tabbed info panel in the freed space right of the (left-aligned) 2D map. void Viewer::renderLiveInfo() { liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear(); atlasRowCells.clear(); 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("Live info", x, y, 20, RAYWHITE); const char* tabs[9] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ", "Realms", "Culture" }; float tx = r.x + 10.0f, ty = r.y + 38.0f; for (int i = 0; i < 9; ++i) { float tw = (r.width - 20.0f) / 9.0f; Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f }; liveInfoTabRects.push_back(tr); bool on = liveInfoTab == i; DrawRectangleRec(tr, on ? Color{42, 48, 68, 255} : Color{18, 22, 34, 255}); DrawRectangleLinesEx(tr, 1, on ? Color{125, 145, 190, 255} : Color{65, 70, 90, 255}); int tfs = 11; // smaller font: 9 tabs are narrow int w = MeasureText(tabs[i], tfs); DrawText(tabs[i], (int)(tr.x + (tr.width - w) * 0.5f), (int)tr.y + 6, tfs, on ? RAYWHITE : Color{155, 165, 185, 255}); } y = (int)r.y + 72; // 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(); if (liveInfoTab == 0) { 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}); } else if (liveInfoTab == 1) { DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 28; 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) { 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); 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 += 22; 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 += 25; 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}); } else if (liveInfoTab == 2) { DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 28; 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 >= 10 || 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") : 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; } } else if (liveInfoTab == 3) { DrawText("World events", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d saved", (int)events.size()), (int)(r.x + r.width) - 74, y + 2, 13, Color{145, 155, 175, 255}); y += 28; if (events.empty()) { DrawText("(no events yet)", x, y, 15, Color{150, 155, 170, 255}); } else { for (int ei = (int)events.size() - 1; ei >= 0; --ei) { if (y > (int)(r.y + r.height) - 42) break; const WorldEvent& e = events[ei]; Rectangle row{ r.x + 8.0f, (float)y - 3.0f, r.width - 16.0f, 40.0f }; eventRowRects.push_back(row); eventRowIndices.push_back(ei); Color bg = e.severity >= 2 ? Color{58, 30, 34, 210} : e.severity == 1 ? Color{42, 42, 34, 205} : Color{18, 22, 34, 205}; Color fg = e.severity >= 2 ? Color{250, 130, 95, 255} : e.severity == 1 ? Color{230, 190, 95, 255} : Color{175, 205, 235, 255}; DrawRectangleRec(row, bg); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : "~"; DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg); double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours); DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255}); DrawText(e.title.c_str(), (int)row.x + 68, (int)row.y + 4, 14, fg); DrawText(e.detail.c_str(), (int)row.x + 68, (int)row.y + 21, 12, Color{165, 170, 185, 255}); y += 43; } } } else if (liveInfoTab == 4) { // Atlas: named geographic features, grouped by kind; click a row to fly there const auto& F = planet.geography(); DrawText("Atlas", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d named", (int)F.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255}); y += 26; if (F.empty()) { DrawText(planet.geographyBuilt() ? "(none)" : "press M to name the world", x, y, 14, Color{150, 155, 170, 255}); } else { // Order kinds for a readable list; within a kind, largest first. const FeatureKind order[8] = { FeatureKind::Continent, FeatureKind::Island, FeatureKind::Ocean, FeatureKind::Sea, FeatureKind::MountainRange, FeatureKind::Peak, FeatureKind::River, FeatureKind::Lake }; auto kindColor = [](FeatureKind k) -> Color { switch (k) { case FeatureKind::Ocean: case FeatureKind::Sea: case FeatureKind::Lake: return Color{120, 195, 230, 255}; case FeatureKind::River: return Color{110, 175, 235, 255}; case FeatureKind::MountainRange: case FeatureKind::Peak: return Color{220, 195, 150, 255}; default: return Color{215, 220, 235, 255}; } }; for (FeatureKind k : order) { std::vector idx; for (int i = 0; i < (int)F.size(); ++i) if (F[i].kind == k) idx.push_back(i); if (idx.empty()) continue; std::sort(idx.begin(), idx.end(), [&](int a, int b){ return F[a].size > F[b].size; }); if (y > (int)(r.y + r.height) - 22) break; DrawText(featureKindName(k), x, y, 13, Color{150, 158, 178, 255}); y += 18; for (int i : idx) { if (y > (int)(r.y + r.height) - 18) break; Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 18.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(F[i].anchorCell); DrawText(F[i].name.c_str(), (int)row.x + 8, (int)row.y + 1, 14, kindColor(k)); y += 19; } y += 4; } } } else if (liveInfoTab == 5) { // Eco: named ecoregions, richest first; click a row to fly there const auto& E = planet.ecoregions(); DrawText("Ecoregions", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d named", (int)E.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255}); y += 26; if (E.empty()) { DrawText(planet.ecoregionsBuilt() ? "(none)" : "press E to name ecology", x, y, 14, Color{150, 155, 170, 255}); } else { std::vector idx(E.size()); for (size_t i = 0; i < E.size(); ++i) idx[i] = (int)i; std::sort(idx.begin(), idx.end(), [&](int a, int b) { double pa = std::max({ E[a].floraProductivity, E[a].faunaProductivity, E[a].fungaProductivity }); double pb = std::max({ E[b].floraProductivity, E[b].faunaProductivity, E[b].fungaProductivity }); if (std::fabs(pa - pb) > 1e-9) return pa > pb; return E[a].size > E[b].size; }); for (int ei : idx) { if (y > (int)(r.y + r.height) - 34) break; const Ecoregion& e = E[ei]; Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 32.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(e.anchorCell); double prod = std::max({ e.floraProductivity, e.faunaProductivity, e.fungaProductivity }); DrawRectangleRec(row, Color{18, 22, 34, 205}); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); DrawText(e.name.c_str(), (int)row.x + 8, (int)row.y + 2, 14, ecoregionColor(ei, e.biome, prod)); DrawText(TextFormat("%s %.0f%% life %d cells", biomeName(e.biome), prod * 100.0, e.size), (int)row.x + 8, (int)row.y + 18, 11, Color{150, 158, 178, 255}); y += 35; } } } else if (liveInfoTab == 6) { // Civ: settlements by population (largest first); click a row to fly there const auto& S = planet.settlements; const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; DrawText("Settlements", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d", (int)S.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); y += 26; if (S.empty()) { DrawText(planet.settlementsPlaced() ? "(none)" : "press U for the dawn of civilization", x, y, 13, Color{150, 155, 170, 255}); } else { std::vector idx(S.size()); for (size_t i = 0; i < S.size(); ++i) idx[i] = (int)i; std::sort(idx.begin(), idx.end(), [&](int a, int b) { return S[a].population > S[b].population; }); for (int si : idx) { if (y > (int)(r.y + r.height) - 22) break; const Settlement& s = S[si]; bool alive = s.population >= abP; SettleTier t = settleTierOf(s.population, townP, cityP); Color fg = !alive ? Color{120, 120, 128, 255} : t == SettleTier::City ? Color{245, 215, 110, 255} : t == SettleTier::Town ? Color{210, 200, 150, 255} : Color{185, 195, 175, 255}; Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(s.cell); const char* pop = s.population >= 1.0e6 ? TextFormat("%.1fM", s.population / 1.0e6) : s.population >= 1.0e3 ? TextFormat("%.0fk", s.population / 1.0e3) : TextFormat("%.0f", s.population); DrawText(s.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg); const char* tag = !alive ? "ruins" : settleTierName(t); DrawText(TextFormat("%s %s", tag, pop), (int)(r.x + r.width) - 92, (int)row.y + 3, 11, Color{150, 158, 178, 255}); y += 20; } } } else if (liveInfoTab == 7) { // Realms: nations by population (largest first); click a row to fly to the capital const auto& N = planet.nationList(); const auto& W = planet.warList(); DrawText("Realms", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d", (int)N.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); y += 26; auto capName = [&](int cap) -> const char* { // realm name from a capital settlement index for (const Nation& nn : N) if (nn.capital == cap) return nn.name.c_str(); return "a fallen realm"; }; auto atWar = [&](int cap) { for (const War& w : W) if (w.attacker == cap || w.defender == cap) return true; return false; }; auto tieCounts = [&](int cap, int& allies, int& rivals) { // Step 6: standing relations of a realm allies = 0; rivals = 0; for (const DiploTie& t : planet.diploList()) { if (t.a != cap && t.b != cap) continue; if (t.kind == DiploKind::Alliance) ++allies; else if (t.kind == DiploKind::Rival) ++rivals; } }; if (!W.empty()) { // active wars summary DrawText(TextFormat("Wars: %d", (int)W.size()), x, y, 14, Color{235, 90, 80, 255}); y += 19; int shown = 0; for (const War& w : W) { if (shown >= 3 || y > (int)(r.y + r.height) - 60) break; DrawText(TextFormat("%s vs %s", capName(w.attacker), capName(w.defender)), x + 6, y, 11, Color{210, 140, 135, 255}); y += 15; ++shown; } y += 6; } if (N.empty()) { DrawText(planet.settlementsPlaced() ? "press P for the territory view" : "press U then P", x, y, 13, Color{150, 155, 170, 255}); } else { std::vector idx(N.size()); for (size_t i = 0; i < N.size(); ++i) idx[i] = (int)i; std::sort(idx.begin(), idx.end(), [&](int a, int b) { return N[a].totalPop > N[b].totalPop; }); for (int ni : idx) { if (y > (int)(r.y + r.height) - 22) break; const Nation& nat = N[ni]; int cap = (nat.capital >= 0 && nat.capital < (int)planet.settlements.size()) ? planet.settlements[nat.capital].cell : -1; Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(cap); Color fg = nat.tier == NationTier::Empire ? Color{250, 215, 130, 255} : nat.tier == NationTier::Kingdom ? Color{215, 210, 175, 255} : Color{180, 190, 175, 255}; const char* tp = nat.totalPop >= 1.0e6 ? TextFormat("%.1fM", nat.totalPop / 1.0e6) : nat.totalPop >= 1.0e3 ? TextFormat("%.0fk", nat.totalPop / 1.0e3) : TextFormat("%.0f", nat.totalPop); DrawText(nat.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg); int al = 0, rv = 0; tieCounts(nat.capital, al, rv); if (al > 0) DrawText(TextFormat("+%d", al), (int)(r.x + r.width) - 152, (int)row.y + 3, 11, Color{90, 210, 130, 255}); // allies if (rv > 0) DrawText(TextFormat("-%d", rv), (int)(r.x + r.width) - 128, (int)row.y + 3, 11, Color{225, 95, 95, 255}); // rivals if (atWar(nat.capital)) DrawCircle((int)(r.x + r.width) - 104, (int)row.y + 9, 3.5f, Color{235, 60, 45, 255}); // at-war marker DrawText(TextFormat("%dx %s", nat.members, tp), (int)(r.x + r.width) - 92, (int)row.y + 3, 11, Color{150, 158, 178, 255}); y += 20; } } } else { // Cultures: peoples by population (largest first); click a row to fly to their largest city const auto& C = planet.cultureList(); DrawText("Cultures", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d", (int)C.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); y += 26; if (C.empty()) { DrawText(planet.settlementsPlaced() ? "press X for the culture view" : "press U then X", x, y, 13, Color{150, 155, 170, 255}); } else { const auto& sc = planet.settleCulture(); std::vector idx(C.size()); for (size_t i = 0; i < C.size(); ++i) idx[i] = (int)i; std::sort(idx.begin(), idx.end(), [&](int a, int b) { return C[a].totalPop > C[b].totalPop; }); for (int ci : idx) { if (y > (int)(r.y + r.height) - 22) break; const Culture& cu = C[ci]; // Representative cell: the largest living settlement of this culture (to fly to). int repCell = -1; double repPop = -1.0; for (size_t s = 0; s < planet.settlements.size(); ++s) if (s < sc.size() && sc[s] == ci && planet.settlements[s].population > repPop) { repPop = planet.settlements[s].population; repCell = planet.settlements[s].cell; } Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(repCell); DrawText(cu.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, cultureColor(ci)); DrawText(TextFormat("%s %s", cultureEthosName(cu.ethos), faithFocusName(cu.faith)), (int)(r.x + r.width) - 118, (int)row.y + 3, 11, Color{150, 158, 178, 255}); y += 20; } } } } // 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, yearHH = yrD * dayH; const char* rl; double rv; if (liveRate >= yearHH) { rl = "yr/s"; rv = liveRate / yearHH; } else 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 wars: %d%s", nStorm, nHur, (int)planet.warList().size(), 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 E eco (*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]", showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off")); line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | E eco | I habitability | U settlements [%s] | P territory [%s] | X culture [%s]", showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off", !planet.settlementsPlaced() ? "seed" : showSettlements ? "on" : "off", showNationBorders ? "on" : "off", showCultureBorders ? "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); } } // 3D place-name labels (the atlas), same manual projection. Minor features (peaks/rivers/lakes/ // small islands) only show when zoomed in, to keep the default view readable. if (showNames && planet.geographyBuilt()) { 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), projW = projH * aspect; bool zoomed = camDist < 5.5; for (const GeoFeature& f : planet.geography()) { if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue; int font; Color col; bool minor = labelStyle(f, font, col); if (font <= 0 || (minor && !zoomed)) continue; const Cell& c = planet.cells[f.anchorCell]; double sr = visBase + (double)c.elevation * elevExagg + 0.01; Vec3 lp = rotateZ(c.unit, planet.cfg.axialTilt) * sr; if (lp.dot(camPos) <= 0.0) continue; // far hemisphere Vec3 rel = lp - camPos; double z = rel.dot(forward); if (z <= 0.0) continue; float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW); float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH); int w = MeasureText(f.name.c_str(), font); DrawText(f.name.c_str(), (int)sx - w / 2 + 1, (int)sy - font / 2 + 1, font, Color{0, 0, 0, 180}); DrawText(f.name.c_str(), (int)sx - w / 2, (int)sy - font / 2, font, col); } } // 3D settlement labels: name towns + cities (villages only when zoomed in), same manual projection. if (showSettlements && !planet.settlements.empty()) { Vec3 camPos{cam.position.x, cam.position.y, cam.position.z}; Vec3 camTgt{cam.target.x, cam.target.y, cam.target.z}; Vec3 forward = (camTgt - camPos).normalized(); Vec3 right = forward.cross(Vec3{cam.up.x, cam.up.y, cam.up.z}).normalized(); Vec3 up = right.cross(forward); double fovRad = cam.fovy * M_PI / 180.0, aspect = (double)view3DW / view3DH; double projH = std::tan(fovRad * 0.5), projW = projH * aspect; bool zoomed = camDist < 5.0; const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; for (const Settlement& s : planet.settlements) { if (s.cell < 0 || s.cell >= (int)planet.cells.size() || s.population < abP) continue; SettleTier t = settleTierOf(s.population, townP, cityP); if (t == SettleTier::Village && !zoomed) continue; // declutter int font = t == SettleTier::City ? 15 : t == SettleTier::Town ? 13 : 12; const Cell& c = planet.cells[s.cell]; double sr = visBase + (double)c.elevation * elevExagg + 0.02; Vec3 lp = rotateZ(c.unit, planet.cfg.axialTilt) * sr; if (lp.dot(camPos) <= 0.0) continue; Vec3 rel = lp - camPos; double z = rel.dot(forward); if (z <= 0.0) continue; float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW); float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH); int w = MeasureText(s.name.c_str(), font); DrawText(s.name.c_str(), (int)sx - w / 2 + 1, (int)sy + 6 + 1, font, Color{0, 0, 0, 190}); DrawText(s.name.c_str(), (int)sx - w / 2, (int)sy + 6, font, t == SettleTier::City ? Color{250, 230, 150, 255} : Color{225, 210, 175, 255}); } } // 3D realm labels (with the territory view): name kingdoms/empires at their capital. if (showNationBorders && !planet.nationList().empty()) { Vec3 camPos{cam.position.x, cam.position.y, cam.position.z}; Vec3 forward = (Vec3{cam.target.x, cam.target.y, cam.target.z} - camPos).normalized(); Vec3 right = forward.cross(Vec3{cam.up.x, cam.up.y, cam.up.z}).normalized(); Vec3 up = right.cross(forward); double fovRad = cam.fovy * M_PI / 180.0, aspect = (double)view3DW / view3DH; double projH = std::tan(fovRad * 0.5), projW = projH * aspect; for (const Nation& nat : planet.nationList()) { if (nat.tier == NationTier::CityState) continue; // declutter: only multi-settlement realms if (nat.capital < 0 || nat.capital >= (int)planet.settlements.size()) continue; int cell = planet.settlements[nat.capital].cell; if (cell < 0 || cell >= (int)planet.cells.size()) continue; int font = nat.tier == NationTier::Empire ? 16 : 14; Vec3 lp = rotateZ(planet.cells[cell].unit, planet.cfg.axialTilt) * (visBase + (double)planet.cells[cell].elevation * elevExagg + 0.035); if (lp.dot(camPos) <= 0.0) continue; Vec3 rel = lp - camPos; double z = rel.dot(forward); if (z <= 0.0) continue; float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW); float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH); int w = MeasureText(nat.name.c_str(), font); DrawText(nat.name.c_str(), (int)sx - w / 2 + 1, (int)sy - font - 7, font, Color{0, 0, 0, 205}); DrawText(nat.name.c_str(), (int)sx - w / 2, (int)sy - font - 8, font, Color{245, 235, 210, 255}); } } renderMap2D(); renderLiveInfo(); renderPanels(); renderHUD(); renderPrompt(); EndDrawing(); }