Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

278 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core;
using Theriapolis.Core.Tactical;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Holds one 32×32 sprite per <see cref="TacticalSurface"/> and
/// <see cref="TacticalDeco"/> value, with optional per-variant alternates.
///
/// On construction, looks for PNGs under <c>&lt;gfxRoot&gt;/surface/&lt;name&gt;.png</c>
/// and <c>&lt;gfxRoot&gt;/deco/&lt;name&gt;.png</c> (lowercase enum names). For
/// variants, drop in <c>&lt;name&gt;_0.png</c>, <c>&lt;name&gt;_1.png</c>, … —
/// the chunk's per-tile <c>Variant</c> 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.
/// </summary>
public sealed class TacticalAtlas : IDisposable
{
private const int Px = C.TACTICAL_TILE_SPRITE_PX;
private readonly GraphicsDevice _gd;
private readonly Dictionary<TacticalSurface, Texture2D[]> _surfaces = new();
private readonly Dictionary<TacticalDeco, Texture2D[]> _decos = new();
private readonly Dictionary<TacticalSurface, Color> _surfaceAvg = new();
private readonly List<Texture2D> _owned = new();
private bool _disposed;
public TacticalAtlas(GraphicsDevice gd, string? gfxRoot = null)
{
_gd = gd;
LoadAll(gfxRoot);
}
/// <summary>Sprite for the given surface + per-tile variant. Always non-null.</summary>
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];
}
/// <summary>Sprite for the given deco + variant, or null for <see cref="TacticalDeco.None"/>.</summary>
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];
}
/// <summary>
/// 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 <see cref="TacticalRenderer"/>).
/// </summary>
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<TacticalSurface>())
{
if (s == TacticalSurface.None) continue;
_surfaces[s] = LoadVariants(gfxRoot, "surface", s.ToString().ToLowerInvariant(),
() => MakeSurfacePlaceholder(s));
}
foreach (TacticalDeco d in Enum.GetValues<TacticalDeco>())
{
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<Texture2D> placeholder)
{
var found = new List<Texture2D>();
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;
}
/// <summary>
/// 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 13 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.
/// </summary>
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();
}
}