Initial commit: Theriapolis baseline at port/godot branch point

Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,33 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.World.Polylines;
public enum PolylineType : byte { River, Road, Rail }
public enum RiverClass : byte { Stream, River, MajorRiver }
public enum RoadType : byte { Footpath, DirtRoad, PostRoad, Highway }
/// <summary>
/// A polyline in world-pixel space (0..32768 on each axis).
/// Source of truth for rivers, roads, and rail. Per-tile flags on WorldTile are derived caches.
/// </summary>
public sealed class Polyline
{
public PolylineType Type { get; init; }
public int Id { get; init; }
public List<Vec2> Points { get; } = new();
public List<Vec2>? SimplifiedPoints { get; set; }
public float Width { get; set; }
// River-specific
public RiverClass RiverClassification { get; set; }
public int FlowAccumulation { get; set; }
// Road-specific
public RoadType RoadClassification { get; set; }
// Infrastructure-specific (source/destination settlement IDs)
public int FromSettlementId { get; set; } = -1;
public int ToSettlementId { get; set; } = -1;
public bool IsEmpty => Points.Count < 2;
}
@@ -0,0 +1,442 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
namespace Theriapolis.Core.World.Polylines;
/// <summary>
/// Static helpers for building, smoothing, and rasterizing polylines in world-pixel space.
/// </summary>
public static class PolylineBuilder
{
// ── Catmull-Rom spline smoothing ─────────────────────────────────────────
/// <summary>
/// Apply Catmull-Rom spline smoothing to a list of control points.
/// Produces <see cref="C.SPLINE_SUBDIVISIONS"/> intermediate points per segment.
/// </summary>
public static List<Vec2> CatmullRomSmooth(IReadOnlyList<Vec2> points, int subdivisions = C.SPLINE_SUBDIVISIONS)
{
var result = new List<Vec2>(points.Count * subdivisions);
if (points.Count < 2) { result.AddRange(points); return result; }
for (int i = 0; i < points.Count - 1; i++)
{
var p0 = points[Math.Max(0, i - 1)];
var p1 = points[i];
var p2 = points[i + 1];
var p3 = points[Math.Min(points.Count - 1, i + 2)];
for (int s = 0; s < subdivisions; s++)
{
float t = s / (float)subdivisions;
result.Add(CatmullRomPoint(p0, p1, p2, p3, t));
}
}
result.Add(points[^1]);
return result;
}
private static Vec2 CatmullRomPoint(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
float x = 0.5f * ((2f * p1.X)
+ (-p0.X + p2.X) * t
+ (2f * p0.X - 5f * p1.X + 4f * p2.X - p3.X) * t2
+ (-p0.X + 3f * p1.X - 3f * p2.X + p3.X) * t3);
float y = 0.5f * ((2f * p1.Y)
+ (-p0.Y + p2.Y) * t
+ (2f * p0.Y - 5f * p1.Y + 4f * p2.Y - p3.Y) * t2
+ (-p0.Y + 3f * p1.Y - 3f * p2.Y + p3.Y) * t3);
return new Vec2(x, y);
}
// ── Perpendicular meander noise ──────────────────────────────────────────
/// <summary>
/// Apply a perpendicular noise offset to each point along a smoothed polyline.
/// Uses FastNoiseLite for deterministic, seeded noise.
/// </summary>
public static void ApplyMeanderNoise(
List<Vec2> points,
float amplitude,
float frequency,
ulong noiseSeed)
{
if (points.Count < 2) return;
// Build a FastNoiseLite instance seeded for this polyline
var noise = new FastNoiseLite
{
Seed = (int)(noiseSeed & 0x7FFFFFFFu),
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
Frequency = frequency,
};
for (int i = 0; i < points.Count; i++)
{
// Perpendicular direction at this point
Vec2 tangent;
if (i == 0)
tangent = (points[1] - points[0]).Normalized;
else if (i == points.Count - 1)
tangent = (points[^1] - points[^2]).Normalized;
else
tangent = (points[i + 1] - points[i - 1]).Normalized;
Vec2 perp = tangent.Perp;
// Sample noise at world-pixel position
float offset = noise.GetNoise(points[i].X * 0.01f, points[i].Y * 0.01f) * amplitude;
points[i] = points[i] + perp * offset;
}
}
// ── Ramer-Douglas-Peucker simplification ─────────────────────────────────
/// <summary>
/// Ramer-Douglas-Peucker polyline simplification for LOD rendering.
/// Returns a simplified point list with perpendicular distance tolerance in world pixels.
/// </summary>
public static List<Vec2> RDPSimplify(IReadOnlyList<Vec2> points, float tolerance = C.RDP_TOLERANCE)
{
if (points.Count <= 2) return new List<Vec2>(points);
var result = new List<Vec2>();
var stack = new Stack<(int start, int end)>();
var keep = new bool[points.Count];
keep[0] = keep[^1] = true;
stack.Push((0, points.Count - 1));
while (stack.Count > 0)
{
var (start, end) = stack.Pop();
float maxDist = 0f;
int maxIndex = start;
for (int i = start + 1; i < end; i++)
{
float d = PerpendicularDistance(points[i], points[start], points[end]);
if (d > maxDist) { maxDist = d; maxIndex = i; }
}
if (maxDist > tolerance)
{
keep[maxIndex] = true;
stack.Push((start, maxIndex));
stack.Push((maxIndex, end));
}
}
for (int i = 0; i < points.Count; i++)
if (keep[i]) result.Add(points[i]);
return result;
}
private static float PerpendicularDistance(Vec2 pt, Vec2 lineStart, Vec2 lineEnd)
{
Vec2 d = lineEnd - lineStart;
float len = d.Length;
if (len < 1e-6f) return Vec2.Dist(pt, lineStart);
return MathF.Abs(Vec2.Dot(d.Perp, pt - lineStart)) / len;
}
// ── Tile flag rasterization ──────────────────────────────────────────────
/// <summary>
/// Rasterizes a polyline (in world-pixel space) onto the world tile grid,
/// setting the appropriate FeatureFlags and direction on each touched tile and its neighbors.
/// </summary>
public static void RasterizeToTileFlags(
WorldState world,
Polyline polyline,
bool setAdjacency = true)
{
var pts = polyline.Points;
if (pts.Count < 2) return;
int px = C.WORLD_TILE_PIXELS;
for (int seg = 0; seg < pts.Count - 1; seg++)
{
int x0 = (int)(pts[seg].X / px);
int y0 = (int)(pts[seg].Y / px);
int x1 = (int)(pts[seg + 1].X / px);
int y1 = (int)(pts[seg + 1].Y / px);
// Direction of this segment
int ddx = Math.Sign(x1 - x0);
int ddy = Math.Sign(y1 - y0);
byte dir = Dir.FromDelta(ddx == 0 && ddy == 0 ? 0 : ddx, ddy);
// Bresenham line in tile space
BresenhamLine(x0, y0, x1, y1, (tx, ty) =>
{
if ((uint)tx >= C.WORLD_WIDTH_TILES || (uint)ty >= C.WORLD_HEIGHT_TILES) return;
if (world.TileAt(tx, ty).Biome == BiomeId.Ocean) return;
ref var tile = ref world.TileAt(tx, ty);
switch (polyline.Type)
{
case PolylineType.River:
tile.Features |= FeatureFlags.HasRiver;
if (tile.RiverFlowDir == Dir.None) tile.RiverFlowDir = dir;
break;
case PolylineType.Road:
// Allow HasRoad on river tiles (bridge crossings) so that
// SplitByExistingFeature treats shared crossings as existing road
// and doesn't create redundant short segments.
// Still skip rail tiles (Addendum A §2).
if ((tile.Features & FeatureFlags.HasRail) == 0 ||
(tile.Features & FeatureFlags.IsSettlement) != 0)
tile.Features |= FeatureFlags.HasRoad;
break;
case PolylineType.Rail:
// Bridges: don't mark a river tile as also having rail.
// The crossing is implied by the route; co-location would be a violation.
if ((tile.Features & FeatureFlags.HasRiver) == 0 ||
(tile.Features & FeatureFlags.IsSettlement) != 0)
{
tile.Features |= FeatureFlags.HasRail;
if (tile.RailDir == Dir.None) tile.RailDir = dir;
}
break;
}
if (!setAdjacency) return;
// Mark 8 neighbors as adjacent
for (int ny = ty - 1; ny <= ty + 1; ny++)
for (int nx = tx - 1; nx <= tx + 1; nx++)
{
if (nx == tx && ny == ty) continue;
if ((uint)nx >= C.WORLD_WIDTH_TILES || (uint)ny >= C.WORLD_HEIGHT_TILES) continue;
if (world.TileAt(nx, ny).Biome == BiomeId.Ocean) continue;
ref var neighbor = ref world.TileAt(nx, ny);
switch (polyline.Type)
{
case PolylineType.River:
neighbor.Features |= FeatureFlags.RiverAdjacent;
if (neighbor.RiverFlowDir == Dir.None) neighbor.RiverFlowDir = dir;
break;
case PolylineType.Rail:
neighbor.Features |= FeatureFlags.RailroadAdjacent;
if (neighbor.RailDir == Dir.None) neighbor.RailDir = dir;
break;
}
}
});
}
}
private static void BresenhamLine(int x0, int y0, int x1, int y1, Action<int, int> visit)
{
int dx = Math.Abs(x1 - x0);
int dy = Math.Abs(y1 - y0);
int sx = x0 < x1 ? 1 : -1;
int sy = y0 < y1 ? 1 : -1;
int err = dx - dy;
while (true)
{
visit(x0, y0);
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
}
// ── Staircase smoothing ───────────────────────────────────────────────────
/// <summary>
/// Post-processes an A* tile path to reduce staircase artefacts in the
/// final smoothed polyline. A* tiebreaking can produce sequences like
/// <c>SE, E, SE, SE, SE</c> where a cardinal move lands in the middle of
/// runs of diagonal moves — Catmull-Rom smoothing then develops a visible
/// kink.
///
/// This walks the path looking for (cardinal, diagonal) move pairs and
/// swaps them to (diagonal, cardinal) when the alternative intermediate
/// tile is free of preserve-flag features and remains passable under
/// <paramref name="costFn"/>. Leaves total movement cost unchanged (the
/// two legs just get reordered) so A*'s optimality choice is respected.
/// Bubbling is forward-only, so any remaining cardinals end up at the
/// end of diagonal runs.
/// </summary>
public static void SmoothStaircases(
WorldState world,
List<(int X, int Y)> path,
FeatureFlags preserveMask,
Func<int, int, int, int, byte, float> costFn)
{
if (path.Count < 3) return;
bool changed = true;
int guard = path.Count; // bound passes to prevent oscillation
while (changed && guard-- > 0)
{
changed = false;
for (int i = 0; i < path.Count - 2; i++)
{
var a = path[i];
var b = path[i + 1];
var c = path[i + 2];
// Preserve tiles with load-bearing features (existing road/rail/
// river bridges, settlement footprint). Those are deliberate A*
// choices and junction/endpoint anchors downstream rely on them.
var bFeatures = world.Tiles[b.X, b.Y].Features;
if ((bFeatures & preserveMask) != 0) continue;
int dx1 = b.X - a.X, dy1 = b.Y - a.Y;
int dx2 = c.X - b.X, dy2 = c.Y - b.Y;
bool isCard1 = (dx1 == 0) ^ (dy1 == 0);
bool isDiag2 = (dx2 != 0) && (dy2 != 0);
if (!isCard1 || !isDiag2) continue;
// Alternative intermediate: do the diagonal first, then cardinal.
int nbx = a.X + dx2, nby = a.Y + dy2;
if ((uint)nbx >= C.WORLD_WIDTH_TILES || (uint)nby >= C.WORLD_HEIGHT_TILES) continue;
// Don't swap into a tile whose features A* would have weighed
// differently — stays equivalent in character to the old b.
if ((world.Tiles[nbx, nby].Features & preserveMask) != 0) continue;
byte dirToNb = Dir.FromDelta(dx2, dy2);
byte dirToC = Dir.FromDelta(dx1, dy1);
if (float.IsPositiveInfinity(costFn(a.X, a.Y, nbx, nby, dirToNb))) continue;
if (float.IsPositiveInfinity(costFn(nbx, nby, c.X, c.Y, dirToC))) continue;
path[i + 1] = (nbx, nby);
changed = true;
}
}
}
// ── Turn-angle limiter ────────────────────────────────────────────────────
/// <summary>
/// Caps the deflection angle at every internal vertex of an A* tile path.
/// A vertex whose turn (angle between incoming and outgoing edges) exceeds
/// <paramref name="maxTurnDegrees"/> is elided when the shortcut from its
/// predecessor to successor is a single passable A* step under
/// <paramref name="costFn"/>. Vertices carrying any flag in
/// <paramref name="preserveMask"/> are never removed — those are load-bearing
/// (existing rail/road junctions, river-bridge crossings, settlement footprint),
/// matching the contract used by <see cref="SmoothStaircases"/>.
///
/// Used by rail generation to avoid 90°/135° bends that look unrealistic
/// for heavy rail traffic. The grid produces turns in 45° steps, so a cap
/// of 75° permits 0°/45° turns and forces 90°/135° corners to be smoothed
/// into two consecutive diagonals.
/// </summary>
public static void LimitTurnAngle(
WorldState world,
List<(int X, int Y)> path,
float maxTurnDegrees,
FeatureFlags preserveMask,
Func<int, int, int, int, byte, float> costFn)
{
if (path.Count < 3) return;
float minCosAllowed = MathF.Cos(maxTurnDegrees * MathF.PI / 180f);
bool changed = true;
int guard = path.Count * 2;
while (changed && guard-- > 0)
{
changed = false;
for (int i = 1; i < path.Count - 1; i++)
{
var a = path[i - 1];
var b = path[i];
var c = path[i + 1];
float vx1 = b.X - a.X, vy1 = b.Y - a.Y;
float vx2 = c.X - b.X, vy2 = c.Y - b.Y;
float l1 = MathF.Sqrt(vx1 * vx1 + vy1 * vy1);
float l2 = MathF.Sqrt(vx2 * vx2 + vy2 * vy2);
if (l1 < 1e-6f || l2 < 1e-6f) continue;
float cosTurn = (vx1 * vx2 + vy1 * vy2) / (l1 * l2);
if (cosTurn >= minCosAllowed) continue; // already gentle enough
// Keep load-bearing vertices — junctions, bridges, endpoints.
if ((world.Tiles[b.X, b.Y].Features & preserveMask) != 0) continue;
// Shortcut a→c must be a single-step move (Chebyshev 1) and
// remain passable. Larger gaps would span multi-tile stretches
// the pathfinder never evaluated.
int dx = c.X - a.X, dy = c.Y - a.Y;
if (dx == 0 && dy == 0) continue;
if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1) continue;
byte dir = Dir.FromDelta(Math.Sign(dx), Math.Sign(dy));
if (float.IsPositiveInfinity(costFn(a.X, a.Y, c.X, c.Y, dir))) continue;
path.RemoveAt(i);
changed = true;
break; // restart — indices shifted
}
}
}
// ── Path splitting for shared infrastructure ──────────────────────────────
/// <summary>
/// Splits a tile path into sub-paths covering only "new construction" — runs of tiles
/// that do NOT already have the given feature flag. Each sub-path includes one overlap
/// tile at each junction so the smoothed polyline visually connects to the existing feature.
/// Returns an empty list if the entire path is already covered.
///
/// </summary>
public static List<List<(int X, int Y)>> SplitByExistingFeature(
WorldState world,
List<(int X, int Y)> path,
FeatureFlags feature)
{
var segments = new List<List<(int X, int Y)>>();
List<(int X, int Y)>? current = null;
for (int i = 0; i < path.Count; i++)
{
var (x, y) = path[i];
var tileFeatures = world.Tiles[x, y].Features;
bool exists = (tileFeatures & feature) != 0;
if (!exists)
{
if (current == null)
{
current = new List<(int X, int Y)>();
// Include the last "existing" tile as a junction anchor
if (i > 0) current.Add(path[i - 1]);
}
current.Add(path[i]);
}
else if (current != null)
{
// Ending a new-construction run — include this existing tile as endpoint anchor
current.Add(path[i]);
if (current.Count >= 2) segments.Add(current);
current = null;
}
}
// Trailing new-construction segment (path ends on new tiles)
if (current != null && current.Count >= 2)
segments.Add(current);
return segments;
}
// ── Control point from tile coords ───────────────────────────────────────
/// <summary>Convert world tile coordinate to world-pixel center.</summary>
public static Vec2 TileToWorldPixel(int tileX, int tileY)
{
float px = C.WORLD_TILE_PIXELS;
return new Vec2(tileX * px + px * 0.5f, tileY * px + px * 0.5f);
}
}