using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Theriapolis.Core; using Theriapolis.Core.Tactical; namespace Theriapolis.Game.Rendering; /// /// Holds one 32×32 sprite per and /// value, with optional per-variant alternates. /// /// On construction, looks for PNGs under <gfxRoot>/surface/<name>.png /// and <gfxRoot>/deco/<name>.png (lowercase enum names). For /// variants, drop in <name>_0.png, <name>_1.png, … — /// the chunk's per-tile Variant nibble picks one. Missing files fall /// back to a procedurally generated solid-color placeholder so the renderer /// always has something to draw, even with no art on disk. /// public sealed class TacticalAtlas : IDisposable { private const int Px = C.TACTICAL_TILE_SPRITE_PX; private readonly GraphicsDevice _gd; private readonly Dictionary _surfaces = new(); private readonly Dictionary _decos = new(); private readonly Dictionary _surfaceAvg = new(); private readonly List _owned = new(); private bool _disposed; public TacticalAtlas(GraphicsDevice gd, string? gfxRoot = null) { _gd = gd; LoadAll(gfxRoot); } /// Sprite for the given surface + per-tile variant. Always non-null. public Texture2D GetSurface(TacticalSurface s, byte variant) { if (!_surfaces.TryGetValue(s, out var arr) || arr.Length == 0) arr = _surfaces[TacticalSurface.None]; return arr[variant % arr.Length]; } /// Sprite for the given deco + variant, or null for . public Texture2D? GetDeco(TacticalDeco d, byte variant) { if (d == TacticalDeco.None) return null; if (!_decos.TryGetValue(d, out var arr) || arr.Length == 0) return null; return arr[variant % arr.Length]; } /// /// Average opaque-pixel RGB of a surface, cached on first request. Used by /// the renderer's edge-blend pass to soften the seam between adjacent /// dissimilar surfaces (Option B autotiling — see ). /// public Color GetSurfaceAverageColor(TacticalSurface s) { if (_surfaceAvg.TryGetValue(s, out var cached)) return cached; var tex = GetSurface(s, 0); var pixels = new Color[tex.Width * tex.Height]; tex.GetData(pixels); long r = 0, g = 0, b = 0, n = 0; foreach (var p in pixels) { if (p.A < 128) continue; r += p.R; g += p.G; b += p.B; n++; } Color avg = n == 0 ? Color.Transparent : new Color((byte)(r / n), (byte)(g / n), (byte)(b / n)); _surfaceAvg[s] = avg; return avg; } private void LoadAll(string? gfxRoot) { // Always create a magenta sentinel for missing surfaces. _surfaces[TacticalSurface.None] = new[] { MakeSolid(new Color(255, 0, 255)) }; foreach (TacticalSurface s in Enum.GetValues()) { if (s == TacticalSurface.None) continue; _surfaces[s] = LoadVariants(gfxRoot, "surface", s.ToString().ToLowerInvariant(), () => MakeSurfacePlaceholder(s)); } foreach (TacticalDeco d in Enum.GetValues()) { if (d == TacticalDeco.None) continue; _decos[d] = LoadVariants(gfxRoot, "deco", d.ToString().ToLowerInvariant(), () => MakeDecoPlaceholder(d)); } } private Texture2D[] LoadVariants(string? root, string subdir, string name, Func placeholder) { var found = new List(); if (root is not null) { string dir = Path.Combine(root, subdir); if (Directory.Exists(dir)) { // Variant suffix files: name_0.png, name_1.png, ... for (int i = 0; ; i++) { string p = Path.Combine(dir, $"{name}_{i}.png"); if (!File.Exists(p)) break; found.Add(LoadFile(p)); } // Fallback to a single name.png if no _N variants exist. if (found.Count == 0) { string p = Path.Combine(dir, $"{name}.png"); if (File.Exists(p)) found.Add(LoadFile(p)); } } } if (found.Count == 0) found.Add(placeholder()); return found.ToArray(); } private Texture2D LoadFile(string path) { using var stream = File.OpenRead(path); var tex = Texture2D.FromStream(_gd, stream); StripBorderPixels(tex); _owned.Add(tex); return tex; } /// /// Many AI-generated tile sources (Pixellab in particular) bake a uniform /// dark border into each tile — sometimes pure black, sometimes a dark /// purple/grey, and 1–3 pixels deep. Adjacent tiles in the world then /// show as grid-lined rectangles instead of seamless terrain. /// /// This pass detects each side's border depth (rows/cols where ≥80% of /// pixels are uniformly dark) and replaces those rows/cols with a copy of /// the first interior row/col, restoring the seamless look without /// touching tile interiors. No-ops on tiles that don't have a detectable /// border, so it's safe to run on any input. /// private static void StripBorderPixels(Texture2D tex) { const int BorderChannelMax = 80; // every channel ≤ this counts as "dark" const float BorderRowFrac = 0.80f; // fraction of dark pixels for a row to be a border int w = tex.Width, h = tex.Height; if (w < 3 || h < 3) return; var pixels = new Color[w * h]; tex.GetData(pixels); // Only opaque dark pixels count — otherwise transparent perimeter // (the norm for decoration sprites with see-through backgrounds) // would be misread as a border and trigger a damaging strip. bool IsDark(Color p) => p.A >= 128 && p.R <= BorderChannelMax && p.G <= BorderChannelMax && p.B <= BorderChannelMax; bool RowIsBorder(int y) { int dark = 0; for (int x = 0; x < w; x++) if (IsDark(pixels[y * w + x])) dark++; return dark >= w * BorderRowFrac; } bool ColIsBorder(int x) { int dark = 0; for (int y = 0; y < h; y++) if (IsDark(pixels[y * w + x])) dark++; return dark >= h * BorderRowFrac; } int top = 0; while (top < h / 2 && RowIsBorder(top)) top++; int bot = h - 1; while (bot > h / 2 && RowIsBorder(bot)) bot--; int lef = 0; while (lef < w / 2 && ColIsBorder(lef)) lef++; int rig = w - 1; while (rig > w / 2 && ColIsBorder(rig)) rig--; if (top == 0 && bot == h - 1 && lef == 0 && rig == w - 1) return; // no border // Replace top/bottom border rows with the first interior row's pixels. for (int y = 0; y < top; y++) for (int x = 0; x < w; x++) pixels[y * w + x] = pixels[top * w + x]; for (int y = bot + 1; y < h; y++) for (int x = 0; x < w; x++) pixels[y * w + x] = pixels[bot * w + x]; // Replace left/right columns from each row's first interior pixel // (after the top/bottom rows have been refreshed, so corners inherit // the cleaned-up content). for (int y = 0; y < h; y++) { var fillL = pixels[y * w + lef]; for (int x = 0; x < lef; x++) pixels[y * w + x] = fillL; var fillR = pixels[y * w + rig]; for (int x = rig + 1; x < w; x++) pixels[y * w + x] = fillR; } tex.SetData(pixels); } private Texture2D MakeSolid(Color c) { var tex = new Texture2D(_gd, Px, Px); var p = new Color[Px * Px]; Array.Fill(p, c); tex.SetData(p); _owned.Add(tex); return tex; } private Texture2D MakeSurfacePlaceholder(TacticalSurface s) { // Solid fill — no border. (Earlier versions drew a 1-px darker edge // as a debug aid, but it baked visible grid lines into adjacent // placeholder tiles in-game.) var c = SurfaceColor(s); var tex = new Texture2D(_gd, Px, Px); var pixels = new Color[Px * Px]; Array.Fill(pixels, c); tex.SetData(pixels); _owned.Add(tex); return tex; } private Texture2D MakeDecoPlaceholder(TacticalDeco d) { var (color, fillFraction) = DecoStyle(d); var tex = new Texture2D(_gd, Px, Px); var pixels = new Color[Px * Px]; float cx = (Px - 1) * 0.5f; float r = Px * 0.5f * fillFraction; for (int y = 0; y < Px; y++) for (int x = 0; x < Px; x++) { float dx = x - cx, dy = y - cx; pixels[y * Px + x] = (dx * dx + dy * dy) <= r * r ? color : Color.Transparent; } tex.SetData(pixels); _owned.Add(tex); return tex; } private static Color SurfaceColor(TacticalSurface s) => s switch { TacticalSurface.DeepWater => new Color(20, 60, 130), TacticalSurface.ShallowWater => new Color(60, 120, 180), TacticalSurface.Marsh => new Color(70, 100, 80), TacticalSurface.Mud => new Color(100, 80, 60), TacticalSurface.Sand => new Color(220, 200, 150), TacticalSurface.Snow => new Color(230, 235, 240), TacticalSurface.Rock => new Color(120, 115, 110), TacticalSurface.Cobble => new Color(170, 150, 120), TacticalSurface.Gravel => new Color(150, 140, 110), TacticalSurface.Wall => new Color(60, 55, 50), TacticalSurface.Floor => new Color(180, 160, 130), TacticalSurface.Dirt => new Color(120, 95, 60), TacticalSurface.TroddenDirt => new Color(140, 110, 70), // worn / lighter than wild dirt TacticalSurface.TallGrass => new Color(80, 140, 60), TacticalSurface.Grass => new Color(110, 160, 70), _ => new Color(255, 0, 255), }; private static (Color color, float fillFraction) DecoStyle(TacticalDeco d) => d switch { TacticalDeco.Tree => (new Color(20, 80, 30), 0.85f), TacticalDeco.Bush => (new Color(70, 110, 50), 0.55f), TacticalDeco.Boulder => (new Color(110,100, 90), 0.65f), TacticalDeco.Rock => (new Color(140,130,110), 0.35f), TacticalDeco.Flower => (new Color(220,180,210), 0.25f), TacticalDeco.Crop => (new Color(180,160, 60), 0.40f), TacticalDeco.Reed => (new Color(120,140, 60), 0.40f), TacticalDeco.Snag => (new Color(80, 60, 40), 0.45f), _ => (Color.Magenta, 0.5f), }; public void Dispose() { if (_disposed) return; _disposed = true; foreach (var t in _owned) t.Dispose(); _owned.Clear(); _surfaces.Clear(); _decos.Clear(); } }