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>
250 lines
11 KiB
C#
250 lines
11 KiB
C#
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
|
||
};
|
||
}
|