planetsim/src/render/Overlays.cpp
Jonas Reith 4a1f3377ba Civ Step 5: conflict, war & shifting borders (save v21)
Realms stop coexisting peacefully and go to war. Unlike the derived Steps 3-4,
war is stateful & path-dependent, so it adds saved state (allegiance + wars +
a war RNG), extends the step-back snapshot, and bumps the save to v21.

- PlanetConflict.{hpp,cpp}: stepConflict(year) runs once per sim year. Neighbouring
  realms (adjacency by settlement proximity) grow hostile from ambition (size gap)
  + ideology (culture/faith difference) + contested frontier + a yearly streak, and
  declare wars (cap warMaxConcurrent). Each war-year runs a battle (strength =
  totalPop x Warlike bonus x defender home advantage), inflicts casualties on
  frontier cities, and the winner conquers a loser frontier city (allegiance flips
  to the victor) or sacks it (ruins). Conquered foreign/distant cities revolt over
  time; a realm that loses its capital collapses -> empires rise and fall. Separate
  sWarRng keeps tectonics deterministic.
- computeTerritory() honours sSettleAllegiance (overriding the mono-cultural rule)
  with a chain-resolving capital lookup, so borders move as cities change hands.
- Save v21: allegiance + wars + war RNG (new hasConflict readState param + a
  per-frame block in the step-back history); WeatherSnapshot + capture/restoreWeather
  extended, so ,/. rewind conquests + revolts.
- Render: red war-front lines + a red at-war marker & active-wars list in the Realms
  tab over the Territory view (P), a cell-info AT WAR flag, a HUD war count, and
  kind=5 events (declare / capture / sack / revolt / peace).
- war* config knobs; test_conflict.cpp covers wars erupting, conquest moving the
  border, revolts, determinism, RNG isolation, save-v21 + snapshot round-trip.
  All 13 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:33:38 +02:00

337 lines
16 KiB
C++

#include "Overlays.hpp"
#include "Map2D.hpp" // projLonLat (2D projection of overlays)
#include "Colors.hpp" // elevationColor (subgrid patch)
#include "rlgl.h"
#include "Projection.hpp" // dirToLonLat / lonLatToDir
#include <algorithm>
#include <cmath>
#include <cstdlib> // std::abs(int)
void buildBorders(const Planet& p, float radius,
std::vector<Vector3>& real, std::vector<Vector3>& ridge) {
real.clear(); ridge.clear();
auto midV = [&](int i, int j) -> Vector3 {
Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius;
return Vector3{ (float)m.x, (float)m.y, (float)m.z };
};
auto isBaby = [&](int pid){ return pid >= 0 && pid < (int)p.plates.size() && p.plates[pid].baby; };
auto emit = [&](const Vector3& a, const Vector3& b, int pA, int pB) {
std::vector<Vector3>& out = (isBaby(pA) || isBaby(pB)) ? ridge : real;
out.push_back(a); out.push_back(b);
};
const std::vector<int>& tri = p.triIndices();
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int ia = tri[k], ib = tri[k + 1], ic = tri[k + 2];
int pa = p.cells[ia].plateId, pb = p.cells[ib].plateId, pc = p.cells[ic].plateId;
if (pa == pb && pb == pc) continue;
if (pa != pb && pb != pc && pa != pc) {
Vec3 c = ((p.cells[ia].unit + p.cells[ib].unit + p.cells[ic].unit)
* (1.0 / 3.0)).normalized() * radius;
Vector3 C{ (float)c.x, (float)c.y, (float)c.z };
emit(C, midV(ia, ib), pa, pb);
emit(C, midV(ib, ic), pb, pc);
emit(C, midV(ic, ia), pc, pa);
} else {
int lone, o1, o2;
if (pa == pb) { lone = ic; o1 = ia; o2 = ib; }
else if (pb == pc) { lone = ia; o1 = ib; o2 = ic; }
else { lone = ib; o1 = ia; o2 = ic; }
emit(midV(lone, o1), midV(lone, o2), p.cells[lone].plateId, p.cells[o1].plateId);
}
}
}
// Trace boundaries where a per-cell integer label differs across a triangle's cells (the plate
// dual-contour, parameterised by the label array). Shared by nation + culture borders.
static void buildLabelBorders(const Planet& p, float radius, const std::vector<int>& cn,
std::vector<Vector3>& segs) {
segs.clear();
if ((int)cn.size() != (int)p.cells.size()) return;
auto midV = [&](int i, int j) -> Vector3 {
Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius;
return Vector3{ (float)m.x, (float)m.y, (float)m.z };
};
auto emit = [&](const Vector3& a, const Vector3& b) { segs.push_back(a); segs.push_back(b); };
const std::vector<int>& tri = p.triIndices();
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int ia = tri[k], ib = tri[k + 1], ic = tri[k + 2];
int na = cn[ia], nb = cn[ib], nc = cn[ic];
if (na == nb && nb == nc) continue;
if (na != nb && nb != nc && na != nc) {
Vec3 c = ((p.cells[ia].unit + p.cells[ib].unit + p.cells[ic].unit) * (1.0 / 3.0)).normalized() * radius;
Vector3 C{ (float)c.x, (float)c.y, (float)c.z };
emit(C, midV(ia, ib)); emit(C, midV(ib, ic)); emit(C, midV(ic, ia));
} else {
int lone, o1, o2;
if (na == nb) { lone = ic; o1 = ia; o2 = ib; }
else if (nb == nc) { lone = ia; o1 = ib; o2 = ic; }
else { lone = ib; o1 = ia; o2 = ic; }
emit(midV(lone, o1), midV(lone, o2));
}
}
}
void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& segs) {
buildLabelBorders(p, radius, p.cellNation(), segs);
}
void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& segs) {
buildLabelBorders(p, radius, p.cellCulture(), segs);
}
void buildWarFrontier(const Planet& p, float radius, std::vector<Vector3>& segs) {
segs.clear();
const std::vector<int>& cn = p.cellNation();
if (p.warList().empty() || (int)cn.size() != (int)p.cells.size()) return;
// A short tick across each cell edge whose two realms are currently at war (the war front).
for (int i = 0; i < (int)p.cells.size(); ++i) {
int a = cn[i]; if (a < 0) continue;
for (int j : p.cells[i].neighbors) {
if (j <= i) continue; // each undirected edge once
int b = (j < (int)cn.size()) ? cn[j] : -1;
if (b < 0 || b == a || !p.realmsAtWar(a, b)) continue;
Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius;
Vec3 t = (p.cells[j].unit - p.cells[i].unit).normalized() * (radius * 0.03);
segs.push_back(Vector3{ (float)(m.x - t.x), (float)(m.y - t.y), (float)(m.z - t.z) });
segs.push_back(Vector3{ (float)(m.x + t.x), (float)(m.y + t.y), (float)(m.z + t.z) });
}
}
}
void buildDriftArrows(const Planet& p, float radius,
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
out.clear(); labels.clear();
int np = (int)p.plates.size();
if (np == 0) return;
std::vector<Vec3> sum(np, Vec3{0, 0, 0});
std::vector<int> cnt(np, 0);
for (const auto& c : p.cells)
if (c.plateId >= 0 && c.plateId < np) { sum[c.plateId] = sum[c.plateId] + c.unit; ++cnt[c.plateId]; }
std::vector<Vec3> centroid(np), vel(np);
double vmax = 1e-30;
for (int i = 0; i < np; ++i) {
if (cnt[i] == 0) continue;
centroid[i] = sum[i].normalized();
Vec3 omega = p.plates[i].driftAxis * p.plates[i].driftSpeed;
vel[i] = omega.cross(centroid[i]);
vmax = std::max(vmax, vel[i].length());
}
const double shaftMin = 0.12, shaftMax = 0.45;
auto V = [](const Vec3& v) { return Vector3{ (float)v.x, (float)v.y, (float)v.z }; };
for (int i = 0; i < np; ++i) {
if (cnt[i] == 0 || vel[i].length() < 1e-12) continue;
Vec3 dir = vel[i].normalized();
double len = shaftMin + (shaftMax - shaftMin) * (vel[i].length() / vmax);
Vec3 base = centroid[i] * (double)radius;
Vec3 tip = base + dir * len;
Vec3 perp = dir.cross(centroid[i]).normalized();
double hl = len * 0.30;
Vec3 back = dir * -1.0;
Vec3 h1 = tip + (back * 0.8 + perp * 0.6) * hl;
Vec3 h2 = tip + (back * 0.8 - perp * 0.6) * hl;
out.push_back(V(base)); out.push_back(V(tip));
out.push_back(V(tip)); out.push_back(V(h1));
out.push_back(V(tip)); out.push_back(V(h2));
Vec3 lbl = centroid[i] * (radius + 0.015); // just above the planet surface at the plate centroid
labels.push_back({i, V(lbl)});
}
}
void buildRivers(const Planet& p, float radius,
std::vector<Vector3>& rivers, std::vector<Vector3>& bigRivers) {
rivers.clear(); bigRivers.clear();
const std::vector<double>& disc = p.discharge();
const std::vector<int>& flow = p.flowTo();
if (disc.empty() || flow.empty()) return;
const double thr = p.cfg.riverThreshold;
auto V = [&](int i) { Vec3 m = p.cells[i].unit * (double)radius; return Vector3{ (float)m.x, (float)m.y, (float)m.z }; };
for (int i = 0; i < (int)p.cells.size(); ++i) {
int d = flow[i];
if (d < 0 || disc[i] < thr) continue; // not a river / reached the sea
(disc[i] > thr * 6.0 ? bigRivers : rivers).push_back(V(i));
(disc[i] > thr * 6.0 ? bigRivers : rivers).push_back(V(d));
}
}
void buildCoastline(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<int>& oceanCell) {
segs.clear(); oceanCell.clear();
const double sea = p.cfg.seaLevel;
auto isLand = [&](int i) { return p.cells[i].elevation > sea; };
auto midV = [&](int i, int j) -> Vector3 {
Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius;
return Vector3{ (float)m.x, (float)m.y, (float)m.z };
};
// The ocean cell nearest a coast segment, for sampling tide. Prefer an ocean endpoint of
// the cut edge; fall back to the lone/other cell that is ocean.
auto emit = [&](const Vector3& a, const Vector3& b, int oc) {
segs.push_back(a); segs.push_back(b); oceanCell.push_back(oc);
};
const std::vector<int>& tri = p.triIndices();
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int ia = tri[k], ib = tri[k + 1], ic = tri[k + 2];
bool la = isLand(ia), lb = isLand(ib), lc = isLand(ic);
if (la == lb && lb == lc) continue; // all land or all ocean: no coast
// Exactly one vertex differs from the other two -> one cut separating it.
int lone, o1, o2;
if (la == lb) { lone = ic; o1 = ia; o2 = ib; }
else if (lb == lc) { lone = ia; o1 = ib; o2 = ic; }
else { lone = ib; o1 = ia; o2 = ic; }
int oc = isLand(lone) ? o1 : lone; // the ocean side of the cut
emit(midV(lone, o1), midV(lone, o2), oc);
}
}
void buildCurrents(const Planet& p, float radius,
std::vector<Vector3>& segs, std::vector<Color>& cols) {
segs.clear(); cols.clear();
const std::vector<Vec3>& cur = p.current();
if (cur.empty()) return;
const double sea = p.cfg.seaLevel;
double maxSp = 1e-9;
for (const Vec3& v : cur) maxSp = std::max(maxSp, v.length());
const Vec3 up{0, 1, 0};
const Color warm{235, 120, 90, 255}, cold{90, 160, 235, 255};
auto V = [](const Vec3& q) { return Vector3{ (float)q.x, (float)q.y, (float)q.z }; };
for (int i = 0; i < (int)p.cells.size(); i += 3) { // subsample for readability
if (p.cells[i].elevation > sea) continue;
const Vec3& vel = cur[i];
double sp = vel.length();
if (sp < 0.18 * maxSp) continue; // skip the slack water
const Vec3& nrm = p.cells[i].unit;
Vec3 dir = vel * (1.0 / sp);
double len = 0.02 + 0.03 * (sp / maxSp);
Vec3 base = nrm * (double)radius;
Vec3 tip = base + dir * len;
Vec3 perp = dir.cross(nrm).normalized();
Vec3 back = dir * -1.0; double hl = len * 0.35;
Vec3 h1 = tip + (back * 0.8 + perp * 0.6) * hl;
Vec3 h2 = tip + (back * 0.8 - perp * 0.6) * hl;
// Warm if flowing poleward (toward the nearer pole), cold if equatorward.
Vec3 northT = up - nrm * up.dot(nrm); double nl = northT.length();
double pw = 0.0;
if (nl > 1e-9) { northT = northT * (1.0 / nl); pw = dir.dot(northT) * (nrm.y >= 0 ? 1.0 : -1.0); }
Color c = pw >= 0.0 ? warm : cold;
segs.push_back(V(base)); segs.push_back(V(tip)); cols.push_back(c);
segs.push_back(V(tip)); segs.push_back(V(h1)); cols.push_back(c);
segs.push_back(V(tip)); segs.push_back(V(h2)); cols.push_back(c);
}
}
std::vector<std::vector<Vector2>> buildGraticule() {
std::vector<std::vector<Vector2>> g;
const double D = M_PI / 180.0;
for (int la = -60; la <= 60; la += 30) { // parallels
std::vector<Vector2> pl;
for (int lo = -180; lo <= 180; lo += 5) pl.push_back({ (float)(lo * D), (float)(la * D) });
g.push_back(pl);
}
for (int lo = -180; lo < 180; lo += 30) { // meridians
std::vector<Vector2> pl;
for (int la = -85; la <= 85; la += 5) pl.push_back({ (float)(lo * D), (float)(la * D) });
g.push_back(pl);
}
return g;
}
void drawGraticule3D(const std::vector<std::vector<Vector2>>& g, float radius) {
rlBegin(RL_LINES); rlColor4ub(110, 125, 150, 150);
for (const auto& pl : g)
for (size_t i = 0; i + 1 < pl.size(); ++i) {
Vec3 a = lonLatToDir(pl[i].x, pl[i].y) * (double)radius;
Vec3 b = lonLatToDir(pl[i + 1].x, pl[i + 1].y) * (double)radius;
rlVertex3f((float)a.x, (float)a.y, (float)a.z);
rlVertex3f((float)b.x, (float)b.y, (float)b.z);
}
rlEnd();
}
void drawGraticule2D(const std::vector<std::vector<Vector2>>& g, Rectangle r, double lonOffset) {
rlBegin(RL_LINES); rlColor4ub(110, 125, 150, 150);
for (const auto& pl : g)
for (size_t i = 0; i + 1 < pl.size(); ++i) {
Vector2 pa = projLonLat(pl[i].x, pl[i].y, lonOffset, r);
Vector2 pb = projLonLat(pl[i + 1].x, pl[i + 1].y, lonOffset, r);
if (fabsf(pa.x - pb.x) > r.width * 0.5f) continue;
rlVertex2f(pa.x, pa.y); rlVertex2f(pb.x, pb.y);
}
rlEnd();
}
void drawGraticuleLabels2D(Rectangle r, double lonOffset) {
const double D = M_PI / 180.0;
const Color col{ 170, 185, 205, 220 };
// Latitudes at the left edge (Equal Earth y depends only on lat).
const int lats[] = { 60, 30, 0, -30, -60 };
for (int la : lats) {
Vector2 p = projLonLat(0.0, la * D, lonOffset, r);
const char* t = (la == 0) ? "0" : TextFormat("%d%c", std::abs(la), la > 0 ? 'N' : 'S');
DrawText(t, (int)r.x + 3, (int)p.y - 6, 11, col);
}
// Longitudes along the bottom edge (skip any panned off the map).
const int lons[] = { -180, -120, -60, 0, 60, 120, 180 };
int by = (int)(r.y + r.height) - 14;
for (int lo : lons) {
Vector2 p = projLonLat(lo * D, 0.0, lonOffset, r);
if (p.x < r.x + 2 || p.x > r.x + r.width - 2) continue;
const char* t = (lo == 0) ? "0" : TextFormat("%d%c", std::abs(lo), lo > 0 ? 'E' : 'W');
DrawText(t, (int)p.x - 8, by, 11, col);
}
}
void drawSegments2D(const std::vector<Vector3>& segs, Color col, float width,
Rectangle r, double lonOffset) {
if (segs.empty()) return;
rlSetLineWidth(width); rlBegin(RL_LINES); rlColor4ub(col.r, col.g, col.b, 255);
for (size_t i = 0; i + 1 < segs.size(); i += 2) {
Vec3 a = Vec3{segs[i].x, segs[i].y, segs[i].z}.normalized();
Vec3 b = Vec3{segs[i + 1].x, segs[i + 1].y, segs[i + 1].z}.normalized();
double alo, ala, blo, bla; dirToLonLat(a, alo, ala); dirToLonLat(b, blo, bla);
Vector2 pa = projLonLat(alo, ala, lonOffset, r), pb = projLonLat(blo, bla, lonOffset, r);
if (fabsf(pa.x - pb.x) > r.width * 0.5f) continue;
rlVertex2f(pa.x, pa.y); rlVertex2f(pb.x, pb.y);
}
rlEnd(); rlSetLineWidth(1.0f);
}
void drawColoredSegments2D(const std::vector<Vector3>& segs, const std::vector<Color>& cols,
float width, Rectangle r, double lonOffset) {
if (segs.empty()) return;
rlSetLineWidth(width); rlBegin(RL_LINES);
for (size_t i = 0, c = 0; i + 1 < segs.size(); i += 2, ++c) {
Vec3 a = Vec3{segs[i].x, segs[i].y, segs[i].z}.normalized();
Vec3 b = Vec3{segs[i + 1].x, segs[i + 1].y, segs[i + 1].z}.normalized();
double alo, ala, blo, bla; dirToLonLat(a, alo, ala); dirToLonLat(b, blo, bla);
Vector2 pa = projLonLat(alo, ala, lonOffset, r), pb = projLonLat(blo, bla, lonOffset, r);
if (fabsf(pa.x - pb.x) > r.width * 0.5f) continue;
const Color& col = cols[c < cols.size() ? c : cols.size() - 1];
rlColor4ub(col.r, col.g, col.b, 255);
rlVertex2f(pa.x, pa.y); rlVertex2f(pb.x, pb.y);
}
rlEnd(); rlSetLineWidth(1.0f);
}
void drawSubgrids(const std::vector<std::shared_ptr<SubGrid>>& sgs,
float visBase, float elevExagg, double seaLevel, float eps) {
for (const auto& sg : sgs) {
if (!sg || sg->res < 2) continue;
int R = sg->res;
rlBegin(RL_TRIANGLES);
for (int j = 0; j < R - 1; ++j)
for (int i = 0; i < R - 1; ++i) {
const SubCell* q[4] = {
&sg->sub[(size_t)j * R + i], &sg->sub[(size_t)j * R + i + 1],
&sg->sub[(size_t)(j + 1) * R + i + 1], &sg->sub[(size_t)(j + 1) * R + i]
};
const int order[6] = { 0, 1, 2, 0, 2, 3 };
for (int o : order) {
const SubCell* s = q[o];
Color col = elevationColor(s->elevation, seaLevel);
float rr = visBase + (float)s->elevation * elevExagg + eps;
rlColor4ub(col.r, col.g, col.b, 255);
rlVertex3f((float)(s->unit.x * rr), (float)(s->unit.y * rr), (float)(s->unit.z * rr));
}
}
rlEnd();
}
}