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.
|
|||
|
|
}
|
|||
|
|
}
|