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; /// /// 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. /// 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 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(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(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(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 }; }