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); }