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
|
|||
|
|
};
|
|||
|
|
}
|