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
+249
View File
@@ -0,0 +1,249 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// Phase 7 M0 / M1 — render a room template OR a fully-generated dungeon
/// to a PNG. Two modes:
///
/// Template mode (M0):
/// dotnet run --project Theriapolis.Tools -- dungeon-render \
/// --template imperium.entry_grand_hall --out hall.png \
/// [--data-dir ./Content/Data] [--cell 16]
///
/// Pipeline mode (M1):
/// dotnet run --project Theriapolis.Tools -- dungeon-render \
/// --seed 12345 --poi 42 --type ImperiumRuin --out d.png \
/// [--data-dir ./Content/Data] [--cell 8]
///
/// In pipeline mode, rooms are colour-tinted by role (entry blue, boss
/// red, narrative gold, dead-end grey) so designers can visually verify
/// generator output across seeds.
/// </summary>
public static class DungeonRender
{
public static int Run(string[] args)
{
string templateId = "";
string outPath = "room.png";
string dataDir = "./Content/Data";
int cellPx = 16;
ulong? seed = null;
int? poiId = null;
string typeName = "ImperiumRuin";
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--template": if (i + 1 < args.Length) templateId = args[++i]; 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 "--cell": if (i + 1 < args.Length) cellPx = int.Parse(args[++i]); break;
case "--seed": if (i + 1 < args.Length) seed = ParseUlong(args[++i]); break;
case "--poi": if (i + 1 < args.Length) poiId = int.Parse(args[++i]); break;
case "--type": if (i + 1 < args.Length) typeName = args[++i]; break;
}
}
// Pipeline mode takes precedence when seed + poi are supplied.
if (seed is not null && poiId is not null)
return RenderPipeline(seed.Value, poiId.Value, typeName, outPath, dataDir, cellPx);
if (string.IsNullOrEmpty(templateId))
{
Console.Error.WriteLine("Either --template <id> OR --seed N --poi N required.");
return 1;
}
var loader = new ContentLoader(dataDir);
var rooms = loader.LoadRoomTemplates();
var def = Array.Find(rooms, r => string.Equals(r.Id, templateId, StringComparison.OrdinalIgnoreCase));
if (def is null)
{
Console.Error.WriteLine($"Room template '{templateId}' not found.");
Console.Error.WriteLine("Available:");
foreach (var r in rooms.OrderBy(r => r.Id, StringComparer.Ordinal))
Console.Error.WriteLine($" {r.Id}");
return 1;
}
int w = def.FootprintWTiles * cellPx;
int h = def.FootprintHTiles * cellPx;
using var img = new Image<Rgba32>(w, h);
for (int ty = 0; ty < def.FootprintHTiles; ty++)
for (int tx = 0; tx < def.FootprintWTiles; tx++)
{
char ch = def.Grid[ty][tx];
Rgba32 fill = ColorForChar(ch);
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
img[tx * cellPx + px, ty * cellPx + py] = fill;
}
// Thin grid lines so the ASCII grid is legible.
var gridColor = new Rgba32(60, 60, 60, 255);
for (int x = 0; x < w; x += cellPx)
for (int y = 0; y < h; y++) img[x, y] = gridColor;
for (int y = 0; y < h; y += cellPx)
for (int x = 0; x < w; x++) img[x, y] = gridColor;
img.SaveAsPng(outPath);
Console.WriteLine($"Wrote {outPath} ({w}×{h}px) for template '{def.Id}' " +
$"({def.FootprintWTiles}×{def.FootprintHTiles} tiles, type={def.Type}, built_by={def.BuiltBy}).");
return 0;
}
private static int RenderPipeline(ulong seed, int poiId, string typeName, string outPath, string dataDir, int cellPx)
{
if (!Enum.TryParse<PoiType>(typeName, ignoreCase: true, out var type) || type == PoiType.None)
{
Console.Error.WriteLine($"--type '{typeName}' invalid. Expected one of: ImperiumRuin, AbandonedMine, CultDen, NaturalCave, OvergrownSettlement.");
return 1;
}
var content = new ContentResolver(new ContentLoader(dataDir));
var d = DungeonGenerator.Generate(seed, poiId, type, content);
int w = d.W * cellPx;
int h = d.H * cellPx;
using var img = new Image<Rgba32>(w, h);
// Pass 1: paint surface + deco from the dungeon's tile array.
for (int ty = 0; ty < d.H; ty++)
for (int tx = 0; tx < d.W; tx++)
{
var tile = d.Tiles[tx, ty];
Rgba32 fill = ColorForTile(tile);
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
img[tx * cellPx + px, ty * cellPx + py] = fill;
}
// Pass 2: tint room interiors by role with a translucent overlay so
// designers can verify role placement at a glance.
foreach (var room in d.Rooms)
{
var tint = ColorForRole(room.Role);
for (int ty = room.AabbY + 1; ty < room.AabbY + room.AabbH - 1; ty++)
for (int tx = room.AabbX + 1; tx < room.AabbX + room.AabbW - 1; tx++)
{
if (tx < 0 || ty < 0 || tx >= d.W || ty >= d.H) continue;
if (!d.Tiles[tx, ty].IsWalkable) continue;
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
{
var existing = img[tx * cellPx + px, ty * cellPx + py];
img[tx * cellPx + px, ty * cellPx + py] = Blend(existing, tint, 0.25f);
}
}
}
// Pass 3: highlight the entrance with a magenta border.
var entrance = new Rgba32(255, 0, 200, 255);
var (ex, ey) = d.EntranceTile;
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
{
int tx = ex + dx, ty = ey + dy;
if (tx < 0 || ty < 0 || tx >= d.W || ty >= d.H) continue;
if (Math.Abs(dx) + Math.Abs(dy) != 1) continue;
for (int py = 0; py < cellPx; py++)
for (int px = 0; px < cellPx; px++)
{
if ((px == 0 || py == 0 || px == cellPx - 1 || py == cellPx - 1))
img[tx * cellPx + px, ty * cellPx + py] = entrance;
}
}
img.SaveAsPng(outPath);
Console.WriteLine($"Wrote {outPath} ({w}×{h}px) — dungeon poi={poiId} seed=0x{seed:X} type={type}");
Console.WriteLine($" {d.Rooms.Length} rooms, {d.Connections.Length} connections, " +
$"{d.W}×{d.H} tactical tiles, entrance @ ({ex},{ey}).");
foreach (var r in d.Rooms)
Console.WriteLine($" R{r.Id} [{r.Role}] {r.TemplateId} aabb=({r.AabbX},{r.AabbY},{r.AabbW}x{r.AabbH})");
return 0;
}
private static ulong ParseUlong(string raw) =>
raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? Convert.ToUInt64(raw[2..], 16)
: ulong.Parse(raw);
private static Rgba32 ColorForTile(TacticalTile tile)
{
// Surface decides base, deco overrides if present (overlay color).
if (tile.Deco != TacticalDeco.None)
{
return tile.Deco switch
{
TacticalDeco.Stairs => new Rgba32(120, 60, 150, 255),
TacticalDeco.DungeonDoor => new Rgba32(120, 90, 40, 255),
TacticalDeco.Container => new Rgba32(220, 180, 50, 255),
TacticalDeco.Trap => new Rgba32(200, 100, 100, 255),
TacticalDeco.Pillar => new Rgba32(110, 110, 130, 255),
TacticalDeco.Brazier => new Rgba32(220, 120, 50, 255),
TacticalDeco.ImperiumStatue=> new Rgba32(160, 140, 110, 255),
_ => ColorForSurface(tile.Surface),
};
}
return ColorForSurface(tile.Surface);
}
private static Rgba32 ColorForSurface(TacticalSurface s) => s switch
{
TacticalSurface.Wall => new Rgba32(45, 45, 60, 255),
TacticalSurface.DungeonFloor => new Rgba32(180, 165, 130, 255),
TacticalSurface.DungeonRubble => new Rgba32(140, 125, 100, 255),
TacticalSurface.DungeonTile => new Rgba32(100, 130, 200, 255),
TacticalSurface.MineFloor => new Rgba32(120, 100, 80, 255),
TacticalSurface.Cave => new Rgba32(90, 85, 75, 255),
TacticalSurface.None => new Rgba32(0, 0, 0, 255),
_ => new Rgba32(80, 80, 80, 255),
};
private static Rgba32 ColorForRole(RoomRole role) => role switch
{
RoomRole.Entry => new Rgba32(50, 130, 220, 255), // blue
RoomRole.Transit => new Rgba32(0, 0, 0, 0), // no tint
RoomRole.Narrative => new Rgba32(220, 180, 50, 255), // gold
RoomRole.Loot => new Rgba32(180, 220, 80, 255), // green
RoomRole.Boss => new Rgba32(220, 60, 60, 255), // red
RoomRole.DeadEnd => new Rgba32(120, 120, 120, 255), // grey
_ => new Rgba32(0, 0, 0, 0),
};
private static Rgba32 Blend(Rgba32 dst, Rgba32 src, float t)
{
// Linear blend with the tint's alpha + the t factor.
if (src.A == 0) return dst;
float a = (src.A / 255f) * t;
byte r = (byte)Math.Clamp(dst.R + (src.R - dst.R) * a, 0, 255);
byte g = (byte)Math.Clamp(dst.G + (src.G - dst.G) * a, 0, 255);
byte b = (byte)Math.Clamp(dst.B + (src.B - dst.B) * a, 0, 255);
return new Rgba32(r, g, b, (byte)255);
}
private static Rgba32 ColorForChar(char ch) => ch switch
{
'#' => new Rgba32(45, 45, 60, 255), // wall — dark slate
'.' => new Rgba32(180, 165, 130, 255), // floor — warm sand
',' => new Rgba32(140, 125, 100, 255), // rubble
'D' => new Rgba32(120, 90, 40, 255), // door — chestnut
'S' => new Rgba32(120, 60, 150, 255), // stairs — purple
'@' => new Rgba32(200, 60, 60, 255), // encounter slot — red
'C' => new Rgba32(220, 180, 50, 255), // container — gold
'T' => new Rgba32(200, 100, 100, 255), // trap — coral
'P' => new Rgba32(110, 110, 130, 255), // pillar — grey-blue
'B' => new Rgba32(220, 120, 50, 255), // brazier — fire orange
'M' => new Rgba32(100, 130, 200, 255), // mosaic — sky blue
' ' => new Rgba32(0, 0, 0, 0), // unused/transparent
_ => new Rgba32(255, 0, 200, 255), // unknown — magenta marker
};
}