using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Theriapolis.Core; using Theriapolis.Core.Tactical; namespace Theriapolis.Game.Rendering; /// /// 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 ² 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. /// 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 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. } }