using Godot;
using System.IO;
using System.Linq;
using Theriapolis.Core;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.Rendering;
///
/// M2 world-map view. Runs the full worldgen pipeline for a seed and
/// renders it: biome tiles as a scaled ImageTexture sprite, polylines
/// (rivers/roads/rails) as Line2D children, settlements + bridges as
/// dot markers. Camera fits the whole world on first frame and supports
/// pan + zoom.
///
/// The visual diff target is
/// dotnet run --project Theriapolis.Tools -- worldgen-dump --seed N:
/// same biome colours, same polyline topology and classification colours.
/// Widths are tuned for visibility at world-map zoom (one tile ≈ 1 screen
/// pixel) — they become chunky at tactical zoom, which M4 will fix with
/// zoom-aware width scaling.
///
public partial class WorldMapView : Node2D
{
private readonly ulong _seed;
// Polyline colours mirror Theriapolis.Game/Rendering/LineFeatureRenderer.cs
// and Theriapolis.Tools/Commands/WorldgenDump.cs (the PNG visual-diff target).
private static readonly Color RiverMajorColour = ColorByte(40, 100, 200);
private static readonly Color RiverColour = ColorByte(60, 120, 200);
private static readonly Color StreamColour = ColorByte(100, 150, 220);
private static readonly Color HighwayColour = ColorByte(210, 180, 80);
private static readonly Color PostRoadColour = ColorByte(180, 155, 70);
private static readonly Color DirtRoadColour = ColorByte(150, 130, 90);
private static readonly Color RailColour = ColorByte(80, 65, 50);
private static readonly Color BridgeColour = ColorByte(160, 140, 100);
// Line widths in world-pixel space, tuned for visibility at world-map
// zoom. WORLD_TILE_PIXELS is 32, so a 32-px line is "1 tile thick".
private const float RiverMajorWidth = 48f;
private const float RiverWidth = 32f;
private const float StreamWidth = 20f;
private const float HighwayWidth = 32f;
private const float PostRoadWidth = 24f;
private const float DirtRoadWidth = 16f;
private const float RailTieWidth = 32f;
private const float RailLineWidth = 14f;
public WorldMapView(ulong seed) => _seed = seed;
public override void _Ready()
{
string dataDir = ContentPaths.DataDir;
if (!Directory.Exists(dataDir))
{
GD.PrintErr($"[world-map] Data directory not found: {dataDir}");
return;
}
GD.Print($"[world-map] seed=0x{_seed:X} data-dir={dataDir}");
var ctx = new WorldGenContext(_seed, dataDir);
WorldGenerator.RunAll(ctx);
var world = ctx.World;
GD.Print($"[world-map] worldgen done — rivers={world.Rivers.Count} " +
$"roads={world.Roads.Count} rails={world.Rails.Count} " +
$"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
BuildTileSprite(world);
BuildPolylines(world);
BuildBridges(world);
BuildSettlements(world);
AddCamera();
}
private void BuildTileSprite(WorldState world)
{
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
// Build biome -> Color map from the loaded BiomeDef[]. Mirrors
// WorldgenDump.BuildColorMap so the PNG and the Godot view share
// the exact same palette source.
var palette = new Color[(int)BiomeId.Mangrove + 1];
foreach (var def in world.BiomeDefs!)
{
var (r, g, b) = def.ParsedColor();
int id = (int)ParseBiomeId(def.Id);
if (id >= 0 && id < palette.Length) palette[id] = ColorByte(r, g, b);
}
var image = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
for (int y = 0; y < H; y++)
{
for (int x = 0; x < W; x++)
{
int id = (int)world.Tiles[x, y].Biome;
Color c = (id >= 0 && id < palette.Length && palette[id].A > 0f)
? palette[id]
: ColorByte(255, 0, 255); // magenta for any unmapped biome
image.SetPixel(x, y, c);
}
}
var tex = ImageTexture.CreateFromImage(image);
var sprite = new Sprite2D
{
Texture = tex,
Centered = false,
// Scale so 1 image pixel = WORLD_TILE_PIXELS world pixels,
// matching the polyline coordinate space.
Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS),
TextureFilter = TextureFilterEnum.Nearest,
};
AddChild(sprite);
}
private void BuildPolylines(WorldState world)
{
var polylineLayer = new Node2D { Name = "Polylines" };
AddChild(polylineLayer);
// Draw order: roads (smaller first) → rivers → rail. Rail on top
// mirrors LineFeatureRenderer's order so junctions look the same.
foreach (var road in world.Roads.OrderBy(RoadDrawRank))
{
var (color, width) = road.RoadClassification switch
{
RoadType.Highway => (HighwayColour, HighwayWidth),
RoadType.PostRoad => (PostRoadColour, PostRoadWidth),
_ => (DirtRoadColour, DirtRoadWidth),
};
polylineLayer.AddChild(MakeLine(road.Points, color, width));
}
foreach (var river in world.Rivers)
{
var (color, baseWidth) = river.RiverClassification switch
{
RiverClass.MajorRiver => (RiverMajorColour, RiverMajorWidth),
RiverClass.River => (RiverColour, RiverWidth),
_ => (StreamColour, StreamWidth),
};
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
float width = Mathf.Min(baseWidth * flowScale, RiverMajorWidth * 1.5f);
polylineLayer.AddChild(MakeLine(river.Points, color, width));
}
foreach (var rail in world.Rails)
{
// Tie underlay first, rail line on top. Same two-pass approach
// LineFeatureRenderer.DrawRail uses.
polylineLayer.AddChild(MakeLine(rail.Points, ColorByte(120, 100, 80), RailTieWidth));
polylineLayer.AddChild(MakeLine(rail.Points, RailColour, RailLineWidth));
}
}
private void BuildBridges(WorldState world)
{
if (world.Bridges.Count == 0) return;
var layer = new Node2D { Name = "Bridges" };
AddChild(layer);
foreach (var bridge in world.Bridges)
{
var line = new Line2D
{
Width = 56f, // wider than HighwayWidth so the deck visibly covers the road
DefaultColor = BridgeColour,
};
line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y));
line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y));
layer.AddChild(line);
}
}
private void BuildSettlements(WorldState world)
{
if (world.Settlements.Count == 0) return;
var layer = new Node2D { Name = "Settlements" };
AddChild(layer);
foreach (var s in world.Settlements)
{
// Radii are in tile units (matching WorldgenDump.cs DrawDot which
// works at 1 px/tile). Multiplied by WORLD_TILE_PIXELS to translate
// into the polyline coordinate space; the dots then scale naturally
// with the Camera2D zoom (1 tile-unit = WORLD_TILE_PIXELS world px,
// scales down to ~1 screen px at world-map zoom, up at tactical zoom).
var (colour, tileRadius) = s.Tier switch
{
1 => (ColorByte(255, 215, 0), 2.5f), // gold, capital
2 => (ColorByte(230, 230, 230), 1.8f),// white, city
3 => (ColorByte(150, 200, 255), 1.3f),// blue, town
4 => (ColorByte(200, 200, 200), 0.8f),// grey, village
_ => (ColorByte(200, 60, 60), 0.7f), // red, PoI
};
float radius = tileRadius * C.WORLD_TILE_PIXELS;
var dot = new SettlementDot
{
Position = new Vector2(
s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f),
Radius = radius,
FillColor = colour,
};
layer.AddChild(dot);
}
}
private void AddCamera()
{
var worldSizeWorldPixels = new Vector2(
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS,
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS);
Vector2 viewport = GetViewport().GetVisibleRect().Size;
float fitZoom = Mathf.Min(
viewport.X / worldSizeWorldPixels.X,
viewport.Y / worldSizeWorldPixels.Y) * 0.95f;
var camera = new PanZoomCamera
{
Position = worldSizeWorldPixels * 0.5f,
Zoom = new Vector2(fitZoom, fitZoom),
MinZoom = fitZoom * 0.5f,
MaxZoom = 4.0f,
};
AddChild(camera);
camera.MakeCurrent();
}
private static Line2D MakeLine(System.Collections.Generic.IReadOnlyList pts, Color color, float width)
{
var line = new Line2D
{
Width = width,
DefaultColor = color,
JointMode = Line2D.LineJointMode.Round,
BeginCapMode = Line2D.LineCapMode.Round,
EndCapMode = Line2D.LineCapMode.Round,
Antialiased = false,
};
for (int i = 0; i < pts.Count; i++)
line.AddPoint(new Vector2(pts[i].X, pts[i].Y));
return line;
}
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
{
RoadType.Footpath => 0,
RoadType.DirtRoad => 1,
RoadType.PostRoad => 2,
RoadType.Highway => 3,
_ => 1,
};
private static Color ColorByte(byte r, byte g, byte b) =>
new(r / 255f, g / 255f, b / 255f);
private static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
{
"ocean" => BiomeId.Ocean,
"tundra" => BiomeId.Tundra,
"boreal" => BiomeId.Boreal,
"temperate_deciduous" => BiomeId.TemperateDeciduous,
"temperate_grassland" => BiomeId.TemperateGrassland,
"mountain_alpine" => BiomeId.MountainAlpine,
"mountain_forested" => BiomeId.MountainForested,
"subtropical_forest" => BiomeId.SubtropicalForest,
"wetland" => BiomeId.Wetland,
"coastal" => BiomeId.Coastal,
"river_valley" => BiomeId.RiverValley,
"scrubland" => BiomeId.Scrubland,
"desert_cold" => BiomeId.DesertCold,
"forest_edge" => BiomeId.ForestEdge,
"foothills" => BiomeId.Foothills,
"marsh_edge" => BiomeId.MarshEdge,
"beach" => BiomeId.Beach,
"cliff" => BiomeId.Cliff,
"tidal_flat" => BiomeId.TidalFlat,
"mangrove" => BiomeId.Mangrove,
_ => BiomeId.TemperateGrassland,
};
}
///
/// Filled circle used as a settlement marker on the world map.
///
public partial class SettlementDot : Node2D
{
public float Radius { get; set; } = 8f;
public Color FillColor { get; set; } = Colors.White;
public override void _Draw() =>
DrawCircle(Vector2.Zero, Radius, FillColor);
}