using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Tactical; using Theriapolis.Core.World; using Theriapolis.Core.World.Generation; namespace Theriapolis.Tools.Commands; /// /// Phase 6 M0 — settlement-render exports a stamped settlement to PNG. /// Lets us visually QA building layouts before they're playtested in-game. /// /// Usage: /// dotnet run --project Theriapolis.Tools -- settlement-render \ /// --seed 12345 --settlement millhaven --out millhaven.png /// /// --settlement: anchor name ("millhaven", "thornfield", ...) or numeric /// settlement id. If omitted, render the first Tier-1 anchor. /// --pad N: include N extra chunks around the settlement window. /// --data-dir: Content/Data root. Defaults to ./Content/Data. /// public static class SettlementRender { public static int Run(string[] args) { ulong seed = 12345UL; string settlement = ""; int pad = 1; string outPath = "settlement.png"; string dataDir = ResolveDataDir(); for (int i = 0; i < args.Length; i++) { switch (args[i].ToLowerInvariant()) { case "--seed": if (i + 1 < args.Length) { string raw = args[++i]; seed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? Convert.ToUInt64(raw[2..], 16) : ulong.Parse(raw); } break; case "--settlement": if (i + 1 < args.Length) settlement = args[++i]; break; case "--pad": if (i + 1 < args.Length) pad = int.Parse(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; } } if (!Directory.Exists(dataDir)) { Console.Error.WriteLine($"Data directory not found: {dataDir}"); return 1; } Console.WriteLine($"[settlement-render] seed=0x{seed:X} settlement='{settlement}' pad={pad}"); var ctx = new WorldGenContext(seed, dataDir) { Log = msg => Console.WriteLine(msg) }; WorldGenerator.RunAll(ctx); var content = new ContentResolver(new ContentLoader(dataDir)); var s = ResolveSettlement(ctx.World, settlement); if (s is null) { Console.Error.WriteLine(string.IsNullOrEmpty(settlement) ? "No Tier-1 anchor settlement found in this world." : $"Settlement '{settlement}' not found."); return 1; } Console.WriteLine($"[settlement-render] resolved -> id={s.Id} name='{s.Name}' tier={s.Tier} tile=({s.TileX},{s.TileY}) anchor={s.Anchor}"); // Compute chunk window covering the settlement plus padding. int cxPx = (int)s.WorldPixelX; int cyPx = (int)s.WorldPixelY; int radiusPx = s.Tier switch { 1 => 32, 2 => 26, 3 => 20, 4 => 16, _ => 12 }; int minTx = cxPx - radiusPx; int minTy = cyPx - radiusPx; int maxTx = cxPx + radiusPx; int maxTy = cyPx + radiusPx; int minCx = (int)Math.Floor(minTx / (double)C.TACTICAL_CHUNK_SIZE) - pad; int minCy = (int)Math.Floor(minTy / (double)C.TACTICAL_CHUNK_SIZE) - pad; int maxCx = (int)Math.Floor(maxTx / (double)C.TACTICAL_CHUNK_SIZE) + pad; int maxCy = (int)Math.Floor(maxTy / (double)C.TACTICAL_CHUNK_SIZE) + pad; int gridW = maxCx - minCx + 1; int gridH = maxCy - minCy + 1; int sideX = gridW * C.TACTICAL_CHUNK_SIZE; int sideY = gridH * C.TACTICAL_CHUNK_SIZE; Console.WriteLine($"[settlement-render] chunk window {minCx}..{maxCx} x {minCy}..{maxCy} ({sideX}x{sideY} px)"); using var img = new Image(sideX, sideY); for (int gy = 0; gy < gridH; gy++) for (int gx = 0; gx < gridW; gx++) { var cc = new ChunkCoord(minCx + gx, minCy + gy); var chunk = TacticalChunkGen.Generate(seed, cc, ctx.World, content.Settlements); int ox = gx * C.TACTICAL_CHUNK_SIZE; int oy = gy * C.TACTICAL_CHUNK_SIZE; BlitChunk(img, chunk, ox, oy); } // Building summary line. SettlementStamper_EnsureBuildingsResolved(ctx.World, s, content); Console.WriteLine($"[settlement-render] {s.Buildings.Count} buildings stamped"); foreach (var b in s.Buildings) Console.WriteLine($" [{b.Id:00}] {b.TemplateId,-16} ({b.MinX,4},{b.MinY,4})..({b.MaxX,4},{b.MaxY,4}) doors={b.Doors.Length} residents={b.Residents.Length}"); img.SaveAsPng(outPath); Console.WriteLine($"[settlement-render] wrote {outPath}"); return 0; } private static Settlement? ResolveSettlement(WorldState world, string raw) { if (string.IsNullOrEmpty(raw)) return world.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi) ?? world.Settlements.FirstOrDefault(s => s.Tier <= 2 && !s.IsPoi); if (int.TryParse(raw, out int id)) return world.Settlements.FirstOrDefault(s => s.Id == id); // Match anchor name (case-insensitive) first, then settlement.Name. return world.Settlements.FirstOrDefault( s => s.Anchor is { } a && string.Equals(a.ToString(), raw, StringComparison.OrdinalIgnoreCase)) ?? world.Settlements.FirstOrDefault( s => string.Equals(s.Name, raw, StringComparison.OrdinalIgnoreCase)); } /// /// Forces the settlement's list to /// resolve so the dump line at the end of can describe /// what got stamped — useful even before we render the chunks (e.g. if a /// chunk-window calculation goes wrong, the buildings list still tells /// the user what *would* have stamped). /// private static void SettlementStamper_EnsureBuildingsResolved(WorldState world, Settlement s, ContentResolver content) { if (s.BuildingsResolved) return; Theriapolis.Core.World.Settlements.SettlementStamper.EnsureBuildingsResolved(world.WorldSeed, s, content.Settlements); } private static void BlitChunk(Image img, TacticalChunk chunk, int ox, int oy) { for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++) for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++) { ref var t = ref chunk.Tiles[lx, ly]; img[ox + lx, oy + ly] = ColorFor(t); } } private static Rgba32 ColorFor(TacticalTile t) { // Decoration overrides for visual identification. if (t.Deco == TacticalDeco.Door) return new Rgba32(255, 200, 80); // bright yellow doors if (t.Deco == TacticalDeco.Counter) return new Rgba32(180, 130, 80); if (t.Deco == TacticalDeco.Bed) return new Rgba32(160, 100, 140); if (t.Deco == TacticalDeco.Hearth) return new Rgba32(220, 90, 40); if (t.Deco == TacticalDeco.Sign) return new Rgba32(220, 220, 100); if (t.Deco == TacticalDeco.Tree) return new Rgba32(20, 80, 30); if (t.Deco == TacticalDeco.Bush) return new Rgba32(70, 110, 50); if (t.Deco == TacticalDeco.Boulder) return new Rgba32(110, 100, 90); if (t.Deco == TacticalDeco.Rock) return new Rgba32(140, 130, 110); if (t.Deco == TacticalDeco.Flower) return new Rgba32(220, 180, 210); return t.Surface switch { TacticalSurface.DeepWater => new Rgba32(20, 60, 130), TacticalSurface.ShallowWater => new Rgba32(60, 120, 180), TacticalSurface.Marsh => new Rgba32(70, 100, 80), TacticalSurface.Mud => new Rgba32(100, 80, 60), TacticalSurface.Sand => new Rgba32(220, 200, 150), TacticalSurface.Snow => new Rgba32(230, 235, 240), TacticalSurface.Rock => new Rgba32(120, 115, 110), TacticalSurface.Cobble => new Rgba32(170, 150, 120), TacticalSurface.Gravel => new Rgba32(150, 140, 110), TacticalSurface.TroddenDirt => new Rgba32(140, 110, 80), TacticalSurface.Wall => new Rgba32(60, 55, 50), TacticalSurface.Floor => new Rgba32(220, 200, 165), TacticalSurface.Dirt => new Rgba32(120, 95, 60), TacticalSurface.TallGrass => new Rgba32(80, 140, 60), TacticalSurface.Grass => new Rgba32(110, 160, 70), _ => new Rgba32(255, 0, 255), }; } private static string ResolveDataDir() { string local = Path.Combine(AppContext.BaseDirectory, "Data"); if (Directory.Exists(local)) return local; string? dir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); for (int i = 0; i < 6; i++) { if (dir is null) break; string candidate = Path.Combine(dir, "Content", "Data"); if (Directory.Exists(candidate)) return candidate; dir = Path.GetDirectoryName(dir); } return local; } }