203 lines
7.4 KiB
C#
203 lines
7.4 KiB
C#
|
|
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();
|
||
|
|
}
|
||
|
|
}
|