b451f83174
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>
278 lines
11 KiB
C#
278 lines
11 KiB
C#
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><gfxRoot>/surface/<name>.png</c>
|
||
/// and <c><gfxRoot>/deco/<name>.png</c> (lowercase enum names). For
|
||
/// variants, drop in <c><name>_0.png</c>, <c><name>_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 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.
|
||
/// </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();
|
||
}
|
||
}
|