217 lines
9.5 KiB
C#
217 lines
9.5 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 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.
|
||
|
|
/// </summary>
|
||
|
|
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<Rgba32>(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));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Forces the settlement's <see cref="Settlement.Buildings"/> list to
|
||
|
|
/// resolve so the dump line at the end of <see cref="Run"/> 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).
|
||
|
|
/// </summary>
|
||
|
|
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<Rgba32> 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;
|
||
|
|
}
|
||
|
|
}
|