From 59784048cde76849926f7e1bdb8956d71a9446d1 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Fri, 1 May 2026 19:04:02 -0700 Subject: [PATCH] M2: World map render in Godot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the full worldgen output as a Godot scene at visual parity with worldgen-dump's PNG output: biome tiles, rivers/roads/rails as Line2D polylines, settlements as filled circles. Pan + zoom via Camera2D. WorldMapView.cs: - Loads Content/Data via res:// walk-up, runs WorldGenerator.RunAll - Tile palette built from BiomeDef.ParsedColor() — same source as the PNG dump, so colours are identical - Tiles rendered as a 256x256 Image scaled by WORLD_TILE_PIXELS to cover world-pixel space (matches polyline coord system) - Polyline draw order mirrors LineFeatureRenderer.cs: roads (smaller first) -> rivers -> rail tie underlay -> rail line. Bridges as short Line2Ds; settlements as SettlementDot (Node2D + _Draw circle) - Line widths in world-pixel space, tuned for visibility at world-map zoom; M4 will add zoom-aware width scaling for tactical view - Camera fits the whole world (95% of viewport) on first frame PanZoomCamera.cs: - Mouse-wheel zoom centered on cursor (cursor world-point stays fixed) - Middle/right click + drag to pan - MinZoom/MaxZoom configurable per-instance Main.cs: - --world-map [seed] flag launches the view (default seed 12345) - Arg parser now reads both GetCmdlineArgs and GetCmdlineUserArgs so callers don't need to remember the "--" separator - --smoke-test path and M0 hello-world fallback unchanged Visual diff against world_seed12345.png (generated by worldgen-dump --seed 12345) confirmed manually: same biome palette, same rivers/roads topology, same settlement placement and tier colours. 3 rivers, 91 roads, 226 settlements (138 PoIs), 0 rails (ENABLE_RAIL=false), 0 bridges (this seed has no road/river crossings). All match the PNG. Settlement dot sizes iterated twice from user feedback — final values in tile units, scaled to world-pixel space, so they shrink at world-map zoom and grow toward tactical zoom (the right "scale with the map" behaviour). Closes M2 of theriapolis-rpg-implementation-plan-godot-port.md. Next: M3 (asset pipeline). Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Main.cs | 29 +- Theriapolis.Godot/Rendering/PanZoomCamera.cs | 69 ++++ Theriapolis.Godot/Rendering/WorldMapView.cs | 316 +++++++++++++++++++ 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 Theriapolis.Godot/Rendering/PanZoomCamera.cs create mode 100644 Theriapolis.Godot/Rendering/WorldMapView.cs 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); +}