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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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><gfxRoot>/surface/<name>.png</c>
|
||||
/// and <c><gfxRoot>/deco/<name>.png</c> (lowercase enum names). For
|
||||
/// variants, drop in <c><name>_0.png</c>, <c><name>_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 1–3 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.
|
||||
}
|
||||
}
|
||||
@@ -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 (1–5).</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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user