b451f83174
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>
354 lines
14 KiB
C#
354 lines
14 KiB
C#
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.PixelFormats;
|
||
using Theriapolis.Core;
|
||
using Theriapolis.Core.Data;
|
||
using Theriapolis.Core.World;
|
||
using Theriapolis.Core.World.Generation;
|
||
using Theriapolis.Core.World.Generation.Stages;
|
||
using Theriapolis.Core.World.Polylines;
|
||
|
||
namespace Theriapolis.Tools.Commands;
|
||
|
||
/// <summary>
|
||
/// worldgen-dump --seed <n> --out <file.png> [--data-dir <dir>]
|
||
///
|
||
/// Runs the full pipeline headless and exports a PNG of the biome map overlaid
|
||
/// with rivers (blue), roads (tan), rail (dark), and settlement icons.
|
||
/// </summary>
|
||
public static class WorldgenDump
|
||
{
|
||
public static int Run(string[] args)
|
||
{
|
||
ulong seed = 12345;
|
||
string outPath = "world.png";
|
||
string dataDir = ResolveDataDir();
|
||
bool showViolations = false;
|
||
|
||
for (int i = 0; i < args.Length; i++)
|
||
{
|
||
switch (args[i].ToLowerInvariant())
|
||
{
|
||
case "--seed":
|
||
if (i + 1 < args.Length)
|
||
{
|
||
string raw = args[++i];
|
||
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||
seed = Convert.ToUInt64(raw[2..], 16);
|
||
else
|
||
seed = ulong.Parse(raw);
|
||
}
|
||
break;
|
||
case "--out":
|
||
if (i + 1 < args.Length) outPath = args[++i];
|
||
break;
|
||
case "--data-dir":
|
||
if (i + 1 < args.Length) dataDir = args[++i];
|
||
break;
|
||
case "--show-violations":
|
||
showViolations = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
Console.WriteLine($"[worldgen-dump] seed=0x{seed:X} out={outPath} data-dir={dataDir}");
|
||
|
||
if (!Directory.Exists(dataDir))
|
||
{
|
||
Console.Error.WriteLine($"Data directory not found: {dataDir}");
|
||
return 1;
|
||
}
|
||
|
||
var ctx = new WorldGenContext(seed, dataDir)
|
||
{
|
||
ProgressCallback = (name, frac) =>
|
||
Console.Write($"\r {name,-28} {frac * 100f:F0}% "),
|
||
Log = msg => Console.WriteLine(msg),
|
||
};
|
||
|
||
WorldGenerator.RunAll(ctx);
|
||
Console.WriteLine();
|
||
|
||
// Collect biome colours from the loaded BiomeDef array
|
||
var colorMap = BuildColorMap(ctx.World.BiomeDefs!);
|
||
|
||
int W = C.WORLD_WIDTH_TILES;
|
||
int H = C.WORLD_HEIGHT_TILES;
|
||
using var image = new Image<Rgb24>(W, H);
|
||
|
||
// ── 1. Biome base layer ───────────────────────────────────────────────
|
||
for (int ty = 0; ty < H; ty++)
|
||
for (int tx = 0; tx < W; tx++)
|
||
{
|
||
var biome = ctx.World.Tiles[tx, ty].Biome;
|
||
if (!colorMap.TryGetValue(biome, out var px))
|
||
px = new Rgb24(255, 0, 255);
|
||
image[tx, ty] = px;
|
||
}
|
||
|
||
if (showViolations)
|
||
OverlayViolations(image, ctx, W, H);
|
||
|
||
// ── 2. Roads ─────────────────────────────────────────────────────────
|
||
foreach (var road in ctx.World.Roads)
|
||
{
|
||
var color = road.RoadClassification switch
|
||
{
|
||
RoadType.Highway => new Rgb24(210, 180, 80),
|
||
RoadType.PostRoad => new Rgb24(180, 155, 70),
|
||
_ => new Rgb24(150, 130, 90),
|
||
};
|
||
DrawPolyline(image, road, color, W, H);
|
||
}
|
||
|
||
// ── 3. Rivers ─────────────────────────────────────────────────────────
|
||
foreach (var river in ctx.World.Rivers)
|
||
{
|
||
var color = river.RiverClassification switch
|
||
{
|
||
RiverClass.MajorRiver => new Rgb24(40, 100, 200),
|
||
RiverClass.River => new Rgb24(60, 120, 200),
|
||
_ => new Rgb24(100, 150, 220),
|
||
};
|
||
DrawPolyline(image, river, color, W, H);
|
||
}
|
||
|
||
// ── 4. Rail ───────────────────────────────────────────────────────────
|
||
var railColor = new Rgb24(80, 65, 50);
|
||
foreach (var rail in ctx.World.Rails)
|
||
DrawPolyline(image, rail, railColor, W, H);
|
||
|
||
// ── 4b. Bridges ──────────────────────────────────────────────────────
|
||
var bridgeColor = new Rgb24(160, 140, 100);
|
||
foreach (var bridge in ctx.World.Bridges)
|
||
{
|
||
int bx = (int)(bridge.WorldPixelX / C.WORLD_TILE_PIXELS);
|
||
int by = (int)(bridge.WorldPixelY / C.WORLD_TILE_PIXELS);
|
||
// Draw a small cross at the bridge location
|
||
for (int d = -1; d <= 1; d++)
|
||
{
|
||
int px = bx + d, py = by;
|
||
if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor;
|
||
px = bx; py = by + d;
|
||
if ((uint)px < (uint)W && (uint)py < (uint)H) image[px, py] = bridgeColor;
|
||
}
|
||
}
|
||
|
||
// ── 5. Settlements ────────────────────────────────────────────────────
|
||
foreach (var s in ctx.World.Settlements)
|
||
{
|
||
var (color, radius) = s.Tier switch
|
||
{
|
||
1 => (new Rgb24(255, 215, 0), 4), // gold, capital
|
||
2 => (new Rgb24(230, 230, 230), 3), // white, city
|
||
3 => (new Rgb24(150, 200, 255), 2), // blue, town
|
||
4 => (new Rgb24(200, 200, 200), 1), // grey, village
|
||
_ => (new Rgb24(200, 60, 60), 1), // red, PoI
|
||
};
|
||
DrawDot(image, s.TileX, s.TileY, color, radius, W, H);
|
||
}
|
||
|
||
// ── 6. Biome coverage stats ────────────────────────────────────────────
|
||
PrintBiomeCoverage(ctx, W, H);
|
||
|
||
// ── 7. Settlement summary ─────────────────────────────────────────────
|
||
PrintSettlementSummary(ctx);
|
||
|
||
image.Save(outPath);
|
||
Console.WriteLine($"[worldgen-dump] Saved {outPath} ({W}×{H} px)");
|
||
return 0;
|
||
}
|
||
|
||
private static void DrawPolyline(Image<Rgb24> img, Polyline poly, Rgb24 color, int W, int H)
|
||
{
|
||
var pts = poly.Points;
|
||
if (pts.Count < 2) return;
|
||
for (int i = 0; i < pts.Count - 1; i++)
|
||
{
|
||
int x0 = (int)(pts[i].X / C.WORLD_TILE_PIXELS);
|
||
int y0 = (int)(pts[i].Y / C.WORLD_TILE_PIXELS);
|
||
int x1 = (int)(pts[i + 1].X / C.WORLD_TILE_PIXELS);
|
||
int y1 = (int)(pts[i + 1].Y / C.WORLD_TILE_PIXELS);
|
||
BresenhamLine(img, x0, y0, x1, y1, color, W, H);
|
||
}
|
||
}
|
||
|
||
private static void BresenhamLine(Image<Rgb24> img, int x0, int y0, int x1, int y1, Rgb24 color, int W, int H)
|
||
{
|
||
int dx = Math.Abs(x1 - x0), dy = Math.Abs(y1 - y0);
|
||
int sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
||
int err = dx - dy;
|
||
while (true)
|
||
{
|
||
if ((uint)x0 < (uint)W && (uint)y0 < (uint)H)
|
||
img[x0, y0] = color;
|
||
if (x0 == x1 && y0 == y1) break;
|
||
int e2 = 2 * err;
|
||
if (e2 > -dy) { err -= dy; x0 += sx; }
|
||
if (e2 < dx) { err += dx; y0 += sy; }
|
||
}
|
||
}
|
||
|
||
private static void DrawDot(Image<Rgb24> img, int cx, int cy, Rgb24 color, int radius, int W, int H)
|
||
{
|
||
for (int dy = -radius; dy <= radius; dy++)
|
||
for (int dx = -radius; dx <= radius; dx++)
|
||
{
|
||
if (dx * dx + dy * dy > radius * radius) continue;
|
||
int nx = cx + dx, ny = cy + dy;
|
||
if ((uint)nx < (uint)W && (uint)ny < (uint)H)
|
||
img[nx, ny] = color;
|
||
}
|
||
}
|
||
|
||
private static void OverlayViolations(Image<Rgb24> image, WorldGenContext ctx, int W, int H)
|
||
{
|
||
var viols = BorderDistortionGenStage.FindStraightViolations(ctx);
|
||
var byLen = viols.OrderByDescending(v => v.len).ToList();
|
||
Console.WriteLine($"[worldgen-dump] Total straight-run violations: {viols.Count}");
|
||
|
||
var buckets = new (int min, int max, int count)[]
|
||
{
|
||
(6, 7, 0), (8, 9, 0), (10, 11, 0), (12, 14, 0),
|
||
(15, 19, 0), (20, 29, 0), (30, 49, 0), (50, 99, 0),
|
||
};
|
||
foreach (var v in viols)
|
||
{
|
||
for (int i = 0; i < buckets.Length; i++)
|
||
{
|
||
if (v.len >= buckets[i].min && v.len <= buckets[i].max)
|
||
{
|
||
buckets[i].count++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
Console.WriteLine($"[worldgen-dump] Run length distribution:");
|
||
foreach (var b in buckets)
|
||
Console.WriteLine($" len {b.min,2}-{b.max,2}: {b.count,5}");
|
||
|
||
Console.WriteLine($"[worldgen-dump] Top 25 longest runs:");
|
||
for (int i = 0; i < Math.Min(25, byLen.Count); i++)
|
||
{
|
||
var v = byLen[i];
|
||
string orient = (v.dx, v.dy) switch
|
||
{
|
||
(1, 0) => "→",
|
||
(0, 1) => "↓",
|
||
(1, 1) => "↘",
|
||
(1,-1) => "↗",
|
||
_ => "?",
|
||
};
|
||
Console.WriteLine($" #{i+1,2} ({v.x,4},{v.y,4}) {orient} len={v.len}");
|
||
}
|
||
|
||
var red = new Rgb24(255, 0, 0);
|
||
int overlayCount = Math.Min(50, byLen.Count);
|
||
for (int i = 0; i < overlayCount; i++)
|
||
{
|
||
var v = byLen[i];
|
||
int px = v.x, py = v.y;
|
||
for (int step = 0; step < v.len; step++)
|
||
{
|
||
if ((uint)px < (uint)W && (uint)py < (uint)H)
|
||
image[px, py] = red;
|
||
px += v.dx;
|
||
py += v.dy;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void PrintBiomeCoverage(WorldGenContext ctx, int W, int H)
|
||
{
|
||
var biomeCounts = new Dictionary<BiomeId, int>();
|
||
int total = W * H;
|
||
for (int ty = 0; ty < H; ty++)
|
||
for (int tx = 0; tx < W; tx++)
|
||
{
|
||
var b = ctx.World.Tiles[tx, ty].Biome;
|
||
biomeCounts.TryGetValue(b, out int c);
|
||
biomeCounts[b] = c + 1;
|
||
}
|
||
int oceanCount = biomeCounts.GetValueOrDefault(BiomeId.Ocean);
|
||
int landTotal = total - oceanCount;
|
||
Console.WriteLine($"[worldgen-dump] Biome coverage: ocean={100.0 * oceanCount / total:F1}%, land tiles={landTotal}");
|
||
foreach (var kv in biomeCounts.OrderByDescending(kv => kv.Value))
|
||
{
|
||
if (kv.Key == BiomeId.Ocean) continue;
|
||
double pct = landTotal > 0 ? 100.0 * kv.Value / landTotal : 0;
|
||
Console.WriteLine($" {kv.Key,-22} {pct,6:F2}% ({kv.Value} tiles)");
|
||
}
|
||
}
|
||
|
||
private static void PrintSettlementSummary(WorldGenContext ctx)
|
||
{
|
||
var ss = ctx.World.Settlements;
|
||
if (ss.Count == 0) return;
|
||
Console.WriteLine($"[worldgen-dump] Settlements: {ss.Count} total");
|
||
for (int tier = 1; tier <= 4; tier++)
|
||
{
|
||
var ts = ss.Where(s => s.Tier == tier && !s.IsPoi).ToList();
|
||
Console.WriteLine($" Tier {tier}: {ts.Count}");
|
||
foreach (var s in ts)
|
||
Console.WriteLine($" [{s.TileX,4},{s.TileY,4}] {s.Name,-24} {s.Economy,-14} wealth={s.WealthLevel:F2}");
|
||
}
|
||
int poiCount = ss.Count(s => s.IsPoi);
|
||
Console.WriteLine($" PoIs: {poiCount}");
|
||
Console.WriteLine($"[worldgen-dump] Rivers: {ctx.World.Rivers.Count}, Roads: {ctx.World.Roads.Count}, Rails: {ctx.World.Rails.Count}, Bridges: {ctx.World.Bridges.Count}");
|
||
}
|
||
|
||
private static Dictionary<BiomeId, Rgb24> BuildColorMap(BiomeDef[] defs)
|
||
{
|
||
var map = new Dictionary<BiomeId, Rgb24>();
|
||
foreach (var def in defs)
|
||
{
|
||
var biomeId = ParseBiomeId(def.Id);
|
||
var (r, g, b) = def.ParsedColor();
|
||
map[biomeId] = new Rgb24(r, g, b);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
||
{
|
||
"ocean" => BiomeId.Ocean,
|
||
"tundra" => BiomeId.Tundra,
|
||
"boreal" => BiomeId.Boreal,
|
||
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
||
"temperate_grassland" => BiomeId.TemperateGrassland,
|
||
"mountain_alpine" => BiomeId.MountainAlpine,
|
||
"mountain_forested" => BiomeId.MountainForested,
|
||
"subtropical_forest" => BiomeId.SubtropicalForest,
|
||
"wetland" => BiomeId.Wetland,
|
||
"coastal" => BiomeId.Coastal,
|
||
"river_valley" => BiomeId.RiverValley,
|
||
"scrubland" => BiomeId.Scrubland,
|
||
"desert_cold" => BiomeId.DesertCold,
|
||
"forest_edge" => BiomeId.ForestEdge,
|
||
"foothills" => BiomeId.Foothills,
|
||
"marsh_edge" => BiomeId.MarshEdge,
|
||
"beach" => BiomeId.Beach,
|
||
"cliff" => BiomeId.Cliff,
|
||
"tidal_flat" => BiomeId.TidalFlat,
|
||
"mangrove" => BiomeId.Mangrove,
|
||
_ => BiomeId.TemperateGrassland,
|
||
};
|
||
|
||
private static string ResolveDataDir()
|
||
{
|
||
string local = Path.Combine(AppContext.BaseDirectory, "Data");
|
||
if (Directory.Exists(local)) return local;
|
||
|
||
string? dir = AppContext.BaseDirectory.TrimEnd(
|
||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
if (dir is null) break;
|
||
string candidate = Path.Combine(dir, "Content", "Data");
|
||
if (Directory.Exists(candidate)) return candidate;
|
||
dir = Path.GetDirectoryName(dir);
|
||
}
|
||
|
||
return local;
|
||
}
|
||
}
|