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>
266 lines
11 KiB
C#
266 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>
|
||
/// 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.
|
||
}
|
||
}
|