Files
TheriapolisV3/Theriapolis.Game/Rendering/TacticalRenderer.cs
T
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

266 lines
11 KiB
C#
Raw 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>
/// Renders the streamed tactical view: per-tile surface + decoration sprites,
/// with a soft edge-blend pass between dissimilar adjacent surfaces.
///
/// Each tactical tile occupies 1×1 world pixel (the canonical coord system).
/// Sprites authored at <see cref="C.TACTICAL_TILE_SPRITE_PX"/>² are drawn at
/// scale = 1 / TACTICAL_TILE_SPRITE_PX so the source texture fits inside that
/// 1×1 cell.
///
/// Render order, per visible chunk:
/// 1. Base surface tile (its own texture).
/// 2. Edge blend overlays — for each cardinal neighbour with a different
/// surface, draw a per-edge alpha-gradient mask tinted with the
/// neighbour's average color. This softens the otherwise hard seam
/// between dissimilar surfaces (the "wallpaper grid" look).
/// 3. Decoration sprite, if any.
///
/// This is "Option B" autotiling per the Phase 4 plan — short-term smoothing
/// using flat color blends. The longer-term plan is "Option C": full Wang
/// corner-based autotiling driven by Pixellab's `create_topdown_tileset`,
/// where each cell picks one of 16 transition tiles based on its 4-corner
/// terrain sample. Tracked in `theriapolis-tactical-tile-art-request.md`.
///
/// Rivers/roads/rail are NOT redrawn here; the polyline burn-in already
/// embedded them in the chunk's surface tiles, and LineFeatureRenderer keeps
/// drawing the source polylines on top so the shared visual is unbroken.
/// </summary>
public sealed class TacticalRenderer : IMapView, IDisposable
{
private readonly GraphicsDevice _gd;
private readonly ChunkStreamer _streamer;
private readonly TacticalAtlas _atlas;
private bool _disposed;
// 1/SpritePx — multiplied by sprite source size (32) to land at 1×1 world pixel.
private static readonly Vector2 SpriteScale =
new(1f / C.TACTICAL_TILE_SPRITE_PX, 1f / C.TACTICAL_TILE_SPRITE_PX);
// Toggle for the Option B edge-blend pass. Currently OFF — the first
// tuning produced washed-out tiles when many neighbouring surfaces had
// saturated placeholder colours (snow≈white, sand=cream). Two issues to
// fix before re-enabling:
// 1. 4 overlapping masks compound into ~4× alpha at tile corners with
// 4 different neighbours — needs cap or non-overlapping geometry.
// 2. Tint alpha (0.55) and 16-px falloff are both too aggressive when
// the neighbour colour is nothing like our own.
// Defer re-tuning until the per-tile art set is filled in; the placeholder
// colour palette isn't a fair test bed.
// static readonly (not const) so the guard evaluates at runtime — avoids
// CS0162 unreachable-code warnings on the gated branch.
private static readonly bool EdgeBlendEnabled = false;
// Edge masks — 32×32 textures with white RGB and an alpha gradient that
// fades from the named edge inward. Drawn over a tile (tinted with the
// neighbour's avg color) to bleed neighbour colour over our own.
private Texture2D _edgeN = null!, _edgeE = null!, _edgeS = null!, _edgeW = null!;
public TacticalRenderer(GraphicsDevice gd, ChunkStreamer streamer, TacticalAtlas atlas)
{
_gd = gd;
_streamer = streamer;
_atlas = atlas;
BuildEdgeMasks();
}
private void BuildEdgeMasks()
{
_edgeN = MakeMask((x, y, sz) => 1f - (float)y / (sz / 2));
_edgeS = MakeMask((x, y, sz) => 1f - (float)(sz - 1 - y) / (sz / 2));
_edgeW = MakeMask((x, y, sz) => 1f - (float)x / (sz / 2));
_edgeE = MakeMask((x, y, sz) => 1f - (float)(sz - 1 - x) / (sz / 2));
}
private Texture2D MakeMask(Func<int, int, int, float> alphaAt)
{
int sz = C.TACTICAL_TILE_SPRITE_PX;
var pixels = new Color[sz * sz];
for (int y = 0; y < sz; y++)
for (int x = 0; x < sz; x++)
{
float a = MathHelper.Clamp(alphaAt(x, y, sz), 0f, 1f);
// Quadratic falloff feels softer than linear at the seam itself.
a = a * a;
pixels[y * sz + x] = new Color((byte)255, (byte)255, (byte)255, (byte)(a * 255));
}
var tex = new Texture2D(_gd, sz, sz);
tex.SetData(pixels);
return tex;
}
public void Draw(SpriteBatch sb, Camera2D camera, GameTime gameTime)
{
// Visible AABB in tactical-tile (world-pixel) coords.
var tl = camera.ScreenToWorld(Vector2.Zero);
var br = camera.ScreenToWorld(new Vector2(camera.ScreenWidth, camera.ScreenHeight));
int x0 = (int)MathF.Floor(tl.X) - 1;
int y0 = (int)MathF.Floor(tl.Y) - 1;
int x1 = (int)MathF.Ceiling(br.X) + 1;
int y1 = (int)MathF.Ceiling(br.Y) + 1;
// Clamp to the world.
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
x0 = Math.Clamp(x0, 0, worldPxW);
y0 = Math.Clamp(y0, 0, worldPxH);
x1 = Math.Clamp(x1, 0, worldPxW);
y1 = Math.Clamp(y1, 0, worldPxH);
if (x0 >= x1 || y0 >= y1) return;
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.PointClamp,
sortMode: SpriteSortMode.Deferred,
blendState: BlendState.AlphaBlend);
// Iterate chunk-by-chunk so we touch each cached chunk array directly.
var ccTl = ChunkCoord.ForTactical(x0, y0);
var ccBr = ChunkCoord.ForTactical(x1 - 1, y1 - 1);
// Pass 1: base surfaces.
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
{
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
DrawChunkSurfaces(sb, chunk, x0, y0, x1, y1);
}
// Pass 2: edge blends — gated on EdgeBlendEnabled (currently false).
if (EdgeBlendEnabled)
{
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
{
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
DrawChunkEdgeBlends(sb, chunk, x0, y0, x1, y1);
}
}
// Pass 3: decorations.
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
{
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
DrawChunkDecos(sb, chunk, x0, y0, x1, y1);
}
sb.End();
}
private void DrawChunkSurfaces(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
{
int ox = chunk.OriginX;
int oy = chunk.OriginY;
int sx = Math.Max(0, vx0 - ox);
int sy = Math.Max(0, vy0 - oy);
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
for (int ly = sy; ly < ey; ly++)
for (int lx = sx; lx < ex; lx++)
{
ref var t = ref chunk.Tiles[lx, ly];
var tex = _atlas.GetSurface(t.Surface, t.Variant);
sb.Draw(tex,
position: new Vector2(ox + lx, oy + ly),
sourceRectangle: null,
color: Color.White,
rotation: 0f,
origin: Vector2.Zero,
scale: SpriteScale,
effects: SpriteEffects.None,
layerDepth: 0f);
}
}
private void DrawChunkEdgeBlends(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
{
int ox = chunk.OriginX;
int oy = chunk.OriginY;
int sx = Math.Max(0, vx0 - ox);
int sy = Math.Max(0, vy0 - oy);
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
for (int ly = sy; ly < ey; ly++)
for (int lx = sx; lx < ex; lx++)
{
ref var t = ref chunk.Tiles[lx, ly];
int tx = ox + lx, ty = oy + ly;
// Sample 4 cardinal neighbours via the streamer (handles cross-chunk).
var nN = _streamer.SampleTile(tx, ty - 1).Surface;
var nS = _streamer.SampleTile(tx, ty + 1).Surface;
var nW = _streamer.SampleTile(tx - 1, ty ).Surface;
var nE = _streamer.SampleTile(tx + 1, ty ).Surface;
if (nN != t.Surface) BlendEdge(sb, tx, ty, _edgeN, _atlas.GetSurfaceAverageColor(nN));
if (nS != t.Surface) BlendEdge(sb, tx, ty, _edgeS, _atlas.GetSurfaceAverageColor(nS));
if (nW != t.Surface) BlendEdge(sb, tx, ty, _edgeW, _atlas.GetSurfaceAverageColor(nW));
if (nE != t.Surface) BlendEdge(sb, tx, ty, _edgeE, _atlas.GetSurfaceAverageColor(nE));
}
}
private void BlendEdge(SpriteBatch sb, int tx, int ty, Texture2D mask, Color tint)
{
if (tint.A == 0) return;
// Cap blend strength so the seam softens but we don't drown out our own
// surface. 0.55 is a comfortable mid-point — stronger than a hint, weaker
// than a 50/50 blend.
var c = new Color(tint.R, tint.G, tint.B, (byte)(0.55f * 255));
sb.Draw(mask,
position: new Vector2(tx, ty),
sourceRectangle: null,
color: c,
rotation: 0f,
origin: Vector2.Zero,
scale: SpriteScale,
effects: SpriteEffects.None,
layerDepth: 0f);
}
private void DrawChunkDecos(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
{
int ox = chunk.OriginX;
int oy = chunk.OriginY;
int sx = Math.Max(0, vx0 - ox);
int sy = Math.Max(0, vy0 - oy);
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
for (int ly = sy; ly < ey; ly++)
for (int lx = sx; lx < ex; lx++)
{
ref var t = ref chunk.Tiles[lx, ly];
var tex = _atlas.GetDeco(t.Deco, t.Variant);
if (tex is null) continue;
sb.Draw(tex,
position: new Vector2(ox + lx, oy + ly),
sourceRectangle: null,
color: Color.White,
rotation: 0f,
origin: Vector2.Zero,
scale: SpriteScale,
effects: SpriteEffects.None,
layerDepth: 0f);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_edgeN?.Dispose();
_edgeE?.Dispose();
_edgeS?.Dispose();
_edgeW?.Dispose();
// _atlas is owned by PlayScreen; do not dispose here.
}
}