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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
using Microsoft.Xna.Framework;
namespace Theriapolis.Game.Rendering;
public enum ViewMode { WorldMap, Tactical }
/// <summary>
/// 2D orthographic camera. Position is in world-pixel space.
/// Both WorldMap and Tactical views share the same camera; only the renderer changes.
/// </summary>
public sealed class Camera2D
{
private readonly GraphicsDeviceWrapper _gd;
/// <summary>Camera position in world-pixel space (top-left of view at zoom 1).</summary>
public Vector2 Position { get; set; } = Vector2.Zero;
/// <summary>Zoom level. 1.0 = 1 world pixel per screen pixel.</summary>
public float Zoom { get; private set; } = 1f / Theriapolis.Core.C.WORLD_TILE_PIXELS;
public ViewMode Mode { get; set; } = ViewMode.WorldMap;
// Re-exports of the canonical zoom constants in C.* so existing call sites
// (Camera2D.MinZoom, etc.) keep working without churn.
public const float MinZoom = Theriapolis.Core.C.CAMERA_MIN_ZOOM;
public const float MaxZoom = Theriapolis.Core.C.CAMERA_MAX_ZOOM;
public const float TacticalThreshold = Theriapolis.Core.C.CAMERA_TACTICAL_THRESHOLD;
public Camera2D(GraphicsDeviceWrapper gd)
{
_gd = gd;
}
public int ScreenWidth => _gd.Width;
public int ScreenHeight => _gd.Height;
/// <summary>SpriteBatch transform matrix for this camera.</summary>
public Matrix TransformMatrix =>
Matrix.CreateTranslation(-Position.X, -Position.Y, 0f)
* Matrix.CreateScale(Zoom, Zoom, 1f)
* Matrix.CreateTranslation(ScreenWidth * 0.5f, ScreenHeight * 0.5f, 0f);
public Vector2 WorldToScreen(Vector2 world)
{
var v = Vector2.Transform(world, TransformMatrix);
return v;
}
public Vector2 ScreenToWorld(Vector2 screen)
{
var inv = Matrix.Invert(TransformMatrix);
return Vector2.Transform(screen, inv);
}
public void AdjustZoom(float delta, Vector2 screenFocus)
{
// Keep the world point under screenFocus stationary
var worldFocus = ScreenToWorld(screenFocus);
Zoom = Math.Clamp(Zoom * (1f + delta), MinZoom, MaxZoom);
var newScreen = WorldToScreen(worldFocus);
Position += (screenFocus - newScreen) / Zoom;
// Update view mode based on zoom threshold
Mode = Zoom >= TacticalThreshold ? ViewMode.Tactical : ViewMode.WorldMap;
}
public void Pan(Vector2 worldDelta)
{
Position += worldDelta;
}
/// <summary>
/// Returns the visible rectangle in world-tile coordinates.
/// </summary>
public (int x0, int y0, int x1, int y1) VisibleTileRect()
{
var tl = ScreenToWorld(Vector2.Zero);
var br = ScreenToWorld(new Vector2(ScreenWidth, ScreenHeight));
int px = Theriapolis.Core.C.WORLD_TILE_PIXELS;
int x0 = Math.Max(0, (int)MathF.Floor(tl.X / px));
int y0 = Math.Max(0, (int)MathF.Floor(tl.Y / px));
int x1 = Math.Min(Theriapolis.Core.C.WORLD_WIDTH_TILES - 1, (int)MathF.Ceiling(br.X / px));
int y1 = Math.Min(Theriapolis.Core.C.WORLD_HEIGHT_TILES - 1, (int)MathF.Ceiling(br.Y / px));
return (x0, y0, x1, y1);
}
}
/// <summary>Thin wrapper so Camera2D doesn't reference the MonoGame GraphicsDevice directly.</summary>
public sealed class GraphicsDeviceWrapper
{
private readonly Microsoft.Xna.Framework.Graphics.GraphicsDevice _device;
public int Width => _device.Viewport.Width;
public int Height => _device.Viewport.Height;
public GraphicsDeviceWrapper(Microsoft.Xna.Framework.Graphics.GraphicsDevice device) => _device = device;
}
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Common interface for world-map and tactical renderers.
/// Both share the same Camera2D and render the same polyline data.
/// </summary>
public interface IMapView
{
void Draw(SpriteBatch spriteBatch, Camera2D camera, GameTime gameTime);
}
@@ -0,0 +1,202 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
using Theriapolis.Core.Util;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Renders rivers, roads, and rail lines as thick world-space polylines.
/// Uses simplified LOD geometry at low zoom levels.
/// </summary>
public sealed class LineFeatureRenderer : IDisposable
{
private readonly GraphicsDevice _gd;
private readonly WorldGenContext _ctx;
private Texture2D _pixel = null!;
private bool _disposed;
// Zoom threshold below which SimplifiedPoints are used
private const float LodSwitchZoom = 0.15f;
// Line widths in world pixels at full zoom (scaled by camera zoom)
private const float RiverMajorWidth = 6f;
private const float RiverWidth = 3.5f;
private const float StreamWidth = 1.5f;
private const float RailWidth = 3f;
private const float HighwayWidth = 4f;
private const float PostRoadWidth = 2.5f;
private const float DirtRoadWidth = 1.5f;
// Line colors
private static readonly Color RiverColor = new(60, 120, 200);
private static readonly Color RailColor = new(120, 100, 80);
private static readonly Color RailTieColor = new(80, 70, 60);
private static readonly Color HighwayColor = new(210, 180, 80);
private static readonly Color PostRoadColor = new(180, 155, 70);
private static readonly Color DirtRoadColor = new(150, 130, 90);
private static readonly Color BridgeDeckColor = new(160, 140, 100);
private static readonly Color BridgeRailColor = new(100, 85, 60);
private const float BridgeDeckWidth = 6f; // wider than HighwayWidth so the deck fully covers the road underneath
public LineFeatureRenderer(GraphicsDevice gd, WorldGenContext ctx)
{
_gd = gd;
_ctx = ctx;
BuildPixel();
}
private void BuildPixel()
{
_pixel = new Texture2D(_gd, 1, 1);
_pixel.SetData(new[] { Color.White });
}
public void Draw(SpriteBatch sb, Camera2D camera)
{
bool useLod = camera.Zoom < LodSwitchZoom;
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.PointClamp,
sortMode: SpriteSortMode.Deferred,
blendState: BlendState.AlphaBlend);
// Draw order: roads → rivers → bridges → rail (rail on top)
DrawRoads(sb, useLod, camera.Zoom);
DrawRivers(sb, useLod, camera.Zoom);
DrawBridges(sb, camera.Zoom);
DrawRail(sb, useLod, camera.Zoom);
sb.End();
}
private void DrawRoads(SpriteBatch sb, bool useLod, float zoom)
{
// Smallest first, biggest last — so when a smaller road has been merged
// onto a larger road by PolylineCleanupStage, the larger road's wider
// stroke covers the overdraw and the junction looks clean.
foreach (var road in _ctx.World.Roads.OrderBy(RoadDrawRank))
{
var (color, width) = road.RoadClassification switch
{
RoadType.Highway => (HighwayColor, HighwayWidth),
RoadType.PostRoad => (PostRoadColor, PostRoadWidth),
_ => (DirtRoadColor, DirtRoadWidth),
};
DrawPolyline(sb, road, color, width, useLod);
}
}
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
{
RoadType.Footpath => 0,
RoadType.DirtRoad => 1,
RoadType.PostRoad => 2,
RoadType.Highway => 3,
_ => 1,
};
private void DrawRivers(SpriteBatch sb, bool useLod, float zoom)
{
foreach (var river in _ctx.World.Rivers)
{
var (color, width) = river.RiverClassification switch
{
RiverClass.MajorRiver => (RiverColor, RiverMajorWidth),
RiverClass.River => (RiverColor, RiverWidth),
_ => (RiverColor, StreamWidth),
};
// Scale river width slightly by flow for a natural look
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
DrawPolyline(sb, river, color, Math.Min(width * flowScale, RiverMajorWidth * 1.5f), useLod);
}
}
private void DrawRail(SpriteBatch sb, bool useLod, float zoom)
{
foreach (var rail in _ctx.World.Rails)
{
// Draw tie marks underneath, then the rail line on top
DrawPolyline(sb, rail, RailTieColor, RailWidth + 2f, useLod);
DrawPolyline(sb, rail, RailColor, RailWidth * 0.5f, useLod);
}
}
private void DrawBridges(SpriteBatch sb, float zoom)
{
foreach (var bridge in _ctx.World.Bridges)
{
Vector2 start = new(bridge.Start.X, bridge.Start.Y);
Vector2 end = new(bridge.End.X, bridge.End.Y);
Vector2 span = end - start;
float len = span.Length();
if (len < 0.5f) continue;
Vector2 roadDir = span / len;
Vector2 perpDir = new(-roadDir.Y, roadDir.X);
// Bridge deck: follows the actual road polyline at the crossing.
DrawSegment(sb, start, end, BridgeDeckColor, BridgeDeckWidth);
// Bridge abutments: short perpendicular bars at each end.
float halfBar = BridgeDeckWidth * 1.5f;
DrawSegment(sb, start - perpDir * halfBar, start + perpDir * halfBar, BridgeRailColor, 1f);
DrawSegment(sb, end - perpDir * halfBar, end + perpDir * halfBar, BridgeRailColor, 1f);
}
}
private void DrawPolyline(SpriteBatch sb, Polyline polyline, Color color, float worldWidth, bool useLod)
{
var pts = (useLod && polyline.SimplifiedPoints is { Count: >= 2 })
? polyline.SimplifiedPoints
: polyline.Points;
if (pts.Count < 2) return;
for (int i = 0; i < pts.Count - 1; i++)
{
DrawSegment(sb,
new Vector2(pts[i].X, pts[i].Y),
new Vector2(pts[i + 1].X, pts[i + 1].Y),
color, worldWidth);
}
}
private void DrawSegment(SpriteBatch sb, Vector2 from, Vector2 to, Color color, float width)
{
Vector2 diff = to - from;
float len = diff.Length();
if (len < 0.5f) return;
// Extend segment by half-width at both ends so consecutive segments
// overlap at joints, filling the triangular gap that appears when
// two thick rotated rectangles meet at an angle.
float extend = width * 0.5f;
Vector2 dir = diff / len;
Vector2 start = from - dir * extend;
float extLen = len + 2f * extend;
float angle = MathF.Atan2(diff.Y, diff.X);
sb.Draw(
texture: _pixel,
position: start,
sourceRectangle: null,
color: color,
rotation: angle,
origin: new Vector2(0f, 0.5f),
scale: new Vector2(extLen, width),
effects: SpriteEffects.None,
layerDepth: 0f);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_pixel?.Dispose();
}
}
+135
View File
@@ -0,0 +1,135 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Phase 6 M4 — renders <see cref="NpcActor"/>s on the tactical map (and
/// as muted dots on the world map at low zoom). Mirrors
/// <see cref="PlayerSprite"/>'s counter-scale-by-1/Zoom approach so NPCs
/// stay constant-on-screen-size at any zoom level.
///
/// Body colour encodes allegiance:
/// Hostile → red
/// Neutral → grey
/// Friendly → green
/// Allied → cyan
/// Player allegiance never shows here (only the player sprite renders).
///
/// A coloured outer ring is drawn beneath each body so NPCs remain
/// visible against similar-toned terrain (settlement cobble, rock,
/// snow). Generic placeholder art — Phase 9 polish swaps for real
/// per-species sprites.
/// </summary>
public sealed class NpcSprite : IDisposable
{
private readonly GraphicsDevice _gd;
private Texture2D _hostile = null!;
private Texture2D _neutral = null!;
private Texture2D _friendly = null!;
private Texture2D _allied = null!;
private Texture2D _ring = null!;
private bool _disposed;
private const int MarkerPx = C.PLAYER_MARKER_SCREEN_PX;
public NpcSprite(GraphicsDevice gd)
{
_gd = gd;
BuildTextures();
}
private void BuildTextures()
{
_hostile = BuildBody(new Color(220, 60, 50));
_neutral = BuildBody(new Color(180, 180, 180));
_friendly = BuildBody(new Color( 90, 180, 90));
_allied = BuildBody(new Color( 90, 200, 220));
_ring = BuildRing();
}
private Texture2D BuildBody(Color body)
{
int s = MarkerPx;
var pixels = new Color[s * s];
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
// Slightly smaller body than player (0.78 vs 0.85) so player reads as
// the protagonist when next to NPCs.
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float nx = x - cx, ny = y - cy;
float dist = MathF.Sqrt(nx * nx + ny * ny);
pixels[y * s + x] = dist <= r * 0.78f ? body : Color.Transparent;
}
var tex = new Texture2D(_gd, s, s);
tex.SetData(pixels);
return tex;
}
private Texture2D BuildRing()
{
int s = MarkerPx;
var pixels = new Color[s * s];
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float nx = x - cx, ny = y - cy;
float dist = MathF.Sqrt(nx * nx + ny * ny);
pixels[y * s + x] = (dist > r * 0.78f && dist <= r) ? new Color(0, 0, 0, 200) : Color.Transparent;
}
var tex = new Texture2D(_gd, s, s);
tex.SetData(pixels);
return tex;
}
/// <summary>Draw every live NPC. Caller wraps SpriteBatch.Begin/End around it.</summary>
public void Draw(SpriteBatch sb, Camera2D camera, IEnumerable<NpcActor> npcs)
{
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.PointClamp,
sortMode: SpriteSortMode.Deferred,
blendState: BlendState.AlphaBlend);
int s = MarkerPx;
var origin = new Vector2(s * 0.5f, s * 0.5f);
float screenScale = camera.Zoom > 1e-6f ? 1f / camera.Zoom : 1f;
foreach (var npc in npcs)
{
if (!npc.IsAlive) continue;
var pos = new Vector2(npc.Position.X, npc.Position.Y);
var body = TextureFor(npc.Allegiance);
sb.Draw(_ring, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
sb.Draw(body, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
}
sb.End();
}
private Texture2D TextureFor(Allegiance a) => a switch
{
Allegiance.Hostile => _hostile,
Allegiance.Allied => _allied,
Allegiance.Friendly => _friendly,
Allegiance.Neutral => _neutral,
_ => _neutral,
};
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_hostile?.Dispose();
_neutral?.Dispose();
_friendly?.Dispose();
_allied?.Dispose();
_ring?.Dispose();
}
}
@@ -0,0 +1,89 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core;
using Theriapolis.Core.Entities;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Renders the player actor in either view. Both world-map and tactical use
/// the same camera (world-pixel space), but the sprite is counter-scaled by
/// 1/camera.Zoom so it stays a constant on-screen size at every zoom level.
/// Otherwise the marker would become tiny when zoomed out and screen-filling
/// at CAMERA_MAX_ZOOM.
/// </summary>
public sealed class PlayerSprite : IDisposable
{
private readonly GraphicsDevice _gd;
private Texture2D _arrow = null!;
private Texture2D _ring = null!;
private bool _disposed;
// Texture size = target on-screen size. Convenient because the
// counter-scale formula reduces to (1 / camera.Zoom) at draw time.
private const int MarkerPx = C.PLAYER_MARKER_SCREEN_PX;
public PlayerSprite(GraphicsDevice gd)
{
_gd = gd;
BuildTextures();
}
private void BuildTextures()
{
// Filled arrow — orientation comes from sprite rotation at draw time.
int s = MarkerPx;
var arrow = new Color[s * s];
var ring = new Color[s * s];
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
for (int y = 0; y < s; y++)
for (int x = 0; x < s; x++)
{
float nx = x - cx, ny = y - cy;
float dist = MathF.Sqrt(nx * nx + ny * ny);
// Filled disc (body) plus a small notch on the +X side to indicate facing.
bool body = dist <= r * 0.85f;
bool notch = nx > 0 && MathF.Abs(ny) < (r - nx) * 0.6f;
arrow[y * s + x] = body
? (notch ? new Color(255, 230, 180) : new Color(220, 80, 60))
: Color.Transparent;
// Outer ring drawn beneath the body so it remains visible against
// similarly-coloured terrain at low zoom (settlement icons sit
// close to the marker on the world map).
ring[y * s + x] = (dist > r * 0.85f && dist <= r) ? new Color(0, 0, 0, 200) : Color.Transparent;
}
_arrow = new Texture2D(_gd, s, s); _arrow.SetData(arrow);
_ring = new Texture2D(_gd, s, s); _ring.SetData(ring);
}
public void Draw(SpriteBatch sb, Camera2D camera, Actor a)
{
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.PointClamp,
sortMode: SpriteSortMode.Deferred,
blendState: BlendState.AlphaBlend);
int s = MarkerPx;
var origin = new Vector2(s * 0.5f, s * 0.5f);
var pos = new Vector2(a.Position.X, a.Position.Y);
// Counter-scale by 1/Zoom so the camera transform's Zoom multiplier
// cancels out, leaving a constant MarkerPx-pixel on-screen size.
// Guard against zero just in case (shouldn't happen — clamped by C.CAMERA_MIN_ZOOM).
float screenScale = camera.Zoom > 1e-6f ? 1f / camera.Zoom : 1f;
sb.Draw(_ring, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
sb.Draw(_arrow, pos, null, Color.White, a.FacingAngleRad, origin, screenScale, SpriteEffects.None, 0f);
sb.End();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_arrow?.Dispose();
_ring?.Dispose();
}
}
+277
View File
@@ -0,0 +1,277 @@
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>&lt;gfxRoot&gt;/surface/&lt;name&gt;.png</c>
/// and <c>&lt;gfxRoot&gt;/deco/&lt;name&gt;.png</c> (lowercase enum names). For
/// variants, drop in <c>&lt;name&gt;_0.png</c>, <c>&lt;name&gt;_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 13 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();
}
}
@@ -0,0 +1,265 @@
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.
}
}
+139
View File
@@ -0,0 +1,139 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core.Data;
using Theriapolis.Core.World;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Manages the biome tile textures used by the world-map renderer.
/// For Phase 0/1, all tiles are generated at runtime as flat-colour 32×32 squares
/// with a 1px darker border and a single centered letter.
/// Final art will replace these by swapping file contents; no code changes needed.
/// </summary>
public sealed class TileAtlas : IDisposable
{
private readonly GraphicsDevice _gd;
private readonly Dictionary<BiomeId, Texture2D> _textures = new();
private readonly Dictionary<int, Texture2D> _settlementIcons = new(); // keyed by tier
private SpriteFont? _font;
private bool _disposed;
public GraphicsDevice GraphicsDevice => _gd;
// Fallback solid-color texture for any biome that has no dedicated entry
private Texture2D? _fallback;
public TileAtlas(GraphicsDevice gd)
{
_gd = gd;
}
/// <summary>
/// Generate all placeholder textures from the loaded BiomeDef array.
/// Call once after content is loaded.
/// </summary>
public void GeneratePlaceholders(BiomeDef[] biomes, SpriteFont? font = null)
{
_font = font;
foreach (var def in biomes)
{
var biomeId = BiomeAssignHelper.ParseBiomeId(def.Id);
if (_textures.ContainsKey(biomeId)) continue;
_textures[biomeId] = MakeTile(def);
}
_fallback = MakeSolidColor(Color.HotPink); // obvious "missing art" colour
GenerateSettlementIcons();
}
/// <summary>Returns the settlement icon texture for the given tier (15).</summary>
public Texture2D GetSettlementIcon(int tier)
{
if (_settlementIcons.TryGetValue(tier, out var tex)) return tex;
return _fallback ?? _textures.Values.First();
}
private void GenerateSettlementIcons()
{
// Tier 1: large gold diamond (capital)
_settlementIcons[1] = MakeSettlementIcon(20, new Color(255, 215, 0), diamond: true);
// Tier 2: white square (city)
_settlementIcons[2] = MakeSettlementIcon(14, new Color(230, 230, 230), diamond: false);
// Tier 3: light-blue circle (town)
_settlementIcons[3] = MakeSettlementIcon(10, new Color(150, 200, 255), diamond: false);
// Tier 4: pale dot (village)
_settlementIcons[4] = MakeSettlementIcon(6, new Color(200, 200, 200), diamond: false);
// Tier 5 (PoI): small red circle
_settlementIcons[5] = MakeSettlementIcon(5, new Color(200, 60, 60), diamond: false);
}
private Texture2D MakeSettlementIcon(int size, Color fill, bool diamond)
{
var tex = new Texture2D(_gd, size, size);
var pixels = new Color[size * size];
float cx = (size - 1) * 0.5f;
float cy = (size - 1) * 0.5f;
for (int py = 0; py < size; py++)
for (int px = 0; px < size; px++)
{
float nx = px - cx, ny = py - cy;
bool inside = diamond
? (MathF.Abs(nx) + MathF.Abs(ny)) <= size * 0.5f
: (nx * nx + ny * ny) <= (cx * cx);
pixels[py * size + px] = inside ? fill : Color.Transparent;
}
tex.SetData(pixels);
return tex;
}
/// <summary>Returns the texture for the given biome, falling back to the error texture.</summary>
public Texture2D GetTile(BiomeId biome)
{
if (_textures.TryGetValue(biome, out var tex)) return tex;
return _fallback ?? _textures.Values.First();
}
// ── Texture generation helpers ────────────────────────────────────────────
private Texture2D MakeTile(BiomeDef def)
{
int size = Theriapolis.Core.C.WORLD_TILE_PIXELS;
var (r, g, b) = def.ParsedColor();
var fillColor = new Color(r, g, b);
var tex = new Texture2D(_gd, size, size);
var pixels = new Color[size * size];
Array.Fill(pixels, fillColor);
tex.SetData(pixels);
return tex;
}
private Texture2D MakeSolidColor(Color color)
{
int size = Theriapolis.Core.C.WORLD_TILE_PIXELS;
var tex = new Texture2D(_gd, size, size);
var pixels = new Color[size * size];
Array.Fill(pixels, color);
tex.SetData(pixels);
return tex;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var tex in _textures.Values) tex.Dispose();
foreach (var tex in _settlementIcons.Values) tex.Dispose();
_fallback?.Dispose();
}
}
/// <summary>Internal helper to avoid exposing the private static method on BiomeAssignStage.</summary>
internal static class BiomeAssignHelper
{
public static BiomeId ParseBiomeId(string id)
=> Theriapolis.Core.World.Generation.Stages.BiomeAssignStage.ParseBiomeId(id);
}
@@ -0,0 +1,102 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
namespace Theriapolis.Game.Rendering;
/// <summary>
/// Renders the world map: biome tiles, rivers, roads, rail, and settlement icons.
/// Draw order: terrain → roads → rivers → rail → settlements.
/// </summary>
public sealed class WorldMapRenderer : IMapView, IDisposable
{
private readonly WorldGenContext _ctx;
private readonly TileAtlas _atlas;
private readonly LineFeatureRenderer _lineRenderer;
private bool _disposed;
// Zoom level below which settlement labels are hidden to avoid clutter
private const float LabelMinZoom = 0.5f;
// Min tier to show at low zoom (hide tier 4 villages when zoomed out)
private const float SettleHideZoom = 0.08f;
public WorldMapRenderer(WorldGenContext ctx, TileAtlas atlas)
{
_ctx = ctx;
_atlas = atlas;
_lineRenderer = new LineFeatureRenderer(atlas.GraphicsDevice, ctx);
}
public void Draw(SpriteBatch sb, Camera2D camera, GameTime gameTime)
{
DrawTerrain(sb, camera);
_lineRenderer.Draw(sb, camera);
DrawSettlements(sb, camera);
}
private void DrawTerrain(SpriteBatch sb, Camera2D camera)
{
var (x0, y0, x1, y1) = camera.VisibleTileRect();
int tilePixels = C.WORLD_TILE_PIXELS;
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.PointClamp,
sortMode: SpriteSortMode.Deferred);
for (int ty = y0; ty <= y1; ty++)
for (int tx = x0; tx <= x1; tx++)
{
ref var tile = ref _ctx.World.TileAt(tx, ty);
var tex = _atlas.GetTile(tile.Biome);
var dest = new Rectangle(tx * tilePixels, ty * tilePixels, tilePixels, tilePixels);
sb.Draw(tex, dest, Color.White);
}
sb.End();
}
private void DrawSettlements(SpriteBatch sb, Camera2D camera)
{
if (_ctx.World.Settlements.Count == 0) return;
sb.Begin(
transformMatrix: camera.TransformMatrix,
samplerState: SamplerState.LinearClamp,
sortMode: SpriteSortMode.Deferred,
blendState: BlendState.AlphaBlend);
bool hideSmall = camera.Zoom < SettleHideZoom;
foreach (var s in _ctx.World.Settlements)
{
if (hideSmall && s.Tier >= 4) continue;
var icon = _atlas.GetSettlementIcon(s.Tier);
float wx = s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
float wy = s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
var origin = new Vector2(icon.Width * 0.5f, icon.Height * 0.5f);
sb.Draw(icon,
position: new Vector2(wx, wy),
sourceRectangle: null,
color: Color.White,
rotation: 0f,
origin: origin,
scale: 1f,
effects: SpriteEffects.None,
layerDepth: 0f);
}
sb.End();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_lineRenderer.Dispose();
}
}