diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs
index 7edb69b..e4f8364 100644
--- a/Theriapolis.Godot/Main.cs
+++ b/Theriapolis.Godot/Main.cs
@@ -1,4 +1,5 @@
using Godot;
+using Theriapolis.GodotHost.Rendering;
namespace Theriapolis.GodotHost;
@@ -6,8 +7,17 @@ public partial class Main : Node
{
public override void _Ready()
{
- var args = OS.GetCmdlineUserArgs();
+ // GetCmdlineArgs returns every arg (Godot's own flags + ours);
+ // GetCmdlineUserArgs only returns args after a "--" separator.
+ // Use the union so users don't have to remember the separator.
+ var userArgs = OS.GetCmdlineUserArgs();
+ var allArgs = OS.GetCmdlineArgs();
+ var args = new string[userArgs.Length + allArgs.Length];
+ userArgs.CopyTo(args, 0);
+ allArgs.CopyTo(args, userArgs.Length);
+
ulong? smokeTestSeed = null;
+ ulong? worldMapSeed = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--smoke-test")
@@ -18,6 +28,14 @@ public partial class Main : Node
smokeTestSeed = seed;
break;
}
+ if (args[i] == "--world-map")
+ {
+ ulong seed = 12345UL;
+ if (i + 1 < args.Length && ulong.TryParse(args[i + 1], out var parsed))
+ seed = parsed;
+ worldMapSeed = seed;
+ break;
+ }
}
if (smokeTestSeed.HasValue)
@@ -27,6 +45,15 @@ public partial class Main : Node
return;
}
+ if (worldMapSeed.HasValue)
+ {
+ // Replace the M0 hello-world children with the M2 world-map view.
+ foreach (Node child in GetChildren())
+ child.QueueFree();
+ AddChild(new WorldMapView(worldMapSeed.Value));
+ return;
+ }
+
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
}
diff --git a/Theriapolis.Godot/Rendering/PanZoomCamera.cs b/Theriapolis.Godot/Rendering/PanZoomCamera.cs
new file mode 100644
index 0000000..fbad3f8
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/PanZoomCamera.cs
@@ -0,0 +1,69 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// Camera2D with mouse-wheel zoom and middle/right-button click-drag pan.
+/// Zoom is centered on the cursor so zooming feels natural. Bounds:
+/// the zoom factor is clamped between MinZoom (everything fits) and
+/// MaxZoom (1 tile fills the screen).
+///
+public partial class PanZoomCamera : Camera2D
+{
+ [Export] public float MinZoom { get; set; } = 0.04f;
+ [Export] public float MaxZoom { get; set; } = 4.0f;
+ [Export] public float ZoomStep { get; set; } = 1.15f;
+
+ private bool _dragging;
+ private Vector2 _dragStartCursor;
+ private Vector2 _dragStartCameraPos;
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ switch (@event)
+ {
+ case InputEventMouseButton mb when mb.Pressed:
+ HandleMouseButtonPressed(mb);
+ break;
+ case InputEventMouseButton mb when !mb.Pressed:
+ if (mb.ButtonIndex is MouseButton.Middle or MouseButton.Right)
+ _dragging = false;
+ break;
+ case InputEventMouseMotion mm when _dragging:
+ Position = _dragStartCameraPos
+ + (_dragStartCursor - mm.Position) / Zoom;
+ break;
+ }
+ }
+
+ private void HandleMouseButtonPressed(InputEventMouseButton mb)
+ {
+ switch (mb.ButtonIndex)
+ {
+ case MouseButton.WheelUp:
+ ApplyZoom(ZoomStep, mb.Position);
+ break;
+ case MouseButton.WheelDown:
+ ApplyZoom(1f / ZoomStep, mb.Position);
+ break;
+ case MouseButton.Middle:
+ case MouseButton.Right:
+ _dragging = true;
+ _dragStartCursor = mb.Position;
+ _dragStartCameraPos = Position;
+ break;
+ }
+ }
+
+ private void ApplyZoom(float factor, Vector2 cursorScreen)
+ {
+ float newZoom = Mathf.Clamp(Zoom.X * factor, MinZoom, MaxZoom);
+ if (Mathf.IsEqualApprox(newZoom, Zoom.X)) return;
+
+ // Zoom toward the cursor: keep the world point under the cursor fixed.
+ Vector2 worldBefore = GetCanvasTransform().AffineInverse() * cursorScreen;
+ Zoom = new Vector2(newZoom, newZoom);
+ Vector2 worldAfter = GetCanvasTransform().AffineInverse() * cursorScreen;
+ Position += worldBefore - worldAfter;
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/WorldMapView.cs b/Theriapolis.Godot/Rendering/WorldMapView.cs
new file mode 100644
index 0000000..abc55a2
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/WorldMapView.cs
@@ -0,0 +1,316 @@
+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;
+
+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 = ResolveDataDir();
+ 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,
+ };
+
+ private static string ResolveDataDir()
+ {
+ string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data");
+ if (Directory.Exists(fromRes)) return fromRes;
+
+ string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\');
+ for (int i = 0; i < 6; i++)
+ {
+ if (string.IsNullOrEmpty(dir)) break;
+ string candidate = Path.Combine(dir, "Content", "Data");
+ if (Directory.Exists(candidate)) return candidate;
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ return fromRes;
+ }
+}
+
+///
+/// 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);
+}