Files
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

250 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}