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; /// /// Renders rivers, roads, and rail lines as thick world-space polylines. /// Uses simplified LOD geometry at low zoom levels. /// 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(); } }