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