diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs
index a98cb87..1d549c8 100644
--- a/Theriapolis.Godot/Main.cs
+++ b/Theriapolis.Godot/Main.cs
@@ -20,6 +20,7 @@ public partial class Main : Node
ulong? smokeTestSeed = null;
ulong? worldMapSeed = null;
bool runAssetTest = false;
+ (ulong seed, int tx, int ty)? tacticalArgs = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--smoke-test")
@@ -43,6 +44,19 @@ public partial class Main : Node
worldMapSeed = seed;
break;
}
+ if (args[i] == "--tactical")
+ {
+ ulong seed = 12345UL;
+ int tx = 128, ty = 128;
+ if (i + 1 < args.Length && ulong.TryParse(args[i + 1], out var s))
+ seed = s;
+ if (i + 2 < args.Length && int.TryParse(args[i + 2], out var x))
+ tx = x;
+ if (i + 3 < args.Length && int.TryParse(args[i + 3], out var y))
+ ty = y;
+ tacticalArgs = (seed, tx, ty);
+ break;
+ }
}
if (smokeTestSeed.HasValue)
@@ -61,10 +75,22 @@ public partial class Main : Node
if (worldMapSeed.HasValue)
{
- // Replace the M0 hello-world children with the M2 world-map view.
+ // M4: unified seamless-zoom view. --world-map starts zoomed out
+ // (fit-to-viewport, initialZoom=0 = compute fit), --tactical
+ // starts at native sprite zoom 32 with the player at the given
+ // tile. Wheel between them seamlessly.
foreach (Node child in GetChildren())
child.QueueFree();
- AddChild(new WorldMapView(worldMapSeed.Value));
+ AddChild(new WorldView(worldMapSeed.Value));
+ return;
+ }
+
+ if (tacticalArgs.HasValue)
+ {
+ foreach (Node child in GetChildren())
+ child.QueueFree();
+ var (seed, tx, ty) = tacticalArgs.Value;
+ AddChild(new WorldView(seed, tx, ty, initialZoom: 32f));
return;
}
diff --git a/Theriapolis.Godot/Platform/ContentLoader.cs b/Theriapolis.Godot/Platform/ContentLoader.cs
index f4184d5..0d86970 100644
--- a/Theriapolis.Godot/Platform/ContentLoader.cs
+++ b/Theriapolis.Godot/Platform/ContentLoader.cs
@@ -32,7 +32,9 @@ public static class ContentLoader
string absolute = Path.Combine(ContentPaths.GfxDir, relativePath);
if (!File.Exists(absolute))
{
- GD.PrintErr($"[ContentLoader] Missing texture: {absolute}");
+ // Silent miss — callers (e.g. atlas variant probes) treat null
+ // as "no more variants" and shouldn't generate error noise.
+ // Genuine missing-asset diagnostics live in AssetTest's summary.
return null;
}
diff --git a/Theriapolis.Godot/Rendering/TacticalAtlas.cs b/Theriapolis.Godot/Rendering/TacticalAtlas.cs
new file mode 100644
index 0000000..c7de0d8
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/TacticalAtlas.cs
@@ -0,0 +1,146 @@
+using Godot;
+using System;
+using System.Collections.Generic;
+using Theriapolis.Core.Tactical;
+using Theriapolis.GodotHost.Platform;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// Loads and caches PNG textures for every TacticalSurface and TacticalDeco
+/// value, with optional per-variant alternates (name_0.png, name_1.png, ...).
+/// Falls back to a procedurally generated solid-colour image when no art is
+/// on disk so the renderer always has something to draw.
+///
+/// Mirrors Theriapolis.Game/Rendering/TacticalAtlas.cs lookup contract.
+/// PNGs are loaded via ContentLoader, which caches at the file level — this
+/// class just adds the per-enum dispatch and variant arrays.
+///
+public static class TacticalAtlas
+{
+ private static readonly Dictionary _surfaces = new();
+ private static readonly Dictionary _decos = new();
+ private static bool _loaded;
+
+ public static void EnsureLoaded()
+ {
+ if (_loaded) return;
+ _loaded = true;
+
+ foreach (TacticalSurface s in Enum.GetValues())
+ _surfaces[s] = LoadVariants("surface", s.ToString().ToLowerInvariant(), () => SurfacePlaceholder(s));
+
+ foreach (TacticalDeco d in Enum.GetValues())
+ {
+ if (d == TacticalDeco.None) continue;
+ _decos[d] = LoadVariants("deco", d.ToString().ToLowerInvariant(), () => DecoPlaceholder(d));
+ }
+ }
+
+ public static Texture2D GetSurface(TacticalSurface s, byte variant)
+ {
+ EnsureLoaded();
+ if (!_surfaces.TryGetValue(s, out var arr) || arr.Length == 0)
+ arr = _surfaces[TacticalSurface.None];
+ return arr[variant % arr.Length];
+ }
+
+ public static Texture2D? GetDeco(TacticalDeco d, byte variant)
+ {
+ EnsureLoaded();
+ if (d == TacticalDeco.None) return null;
+ if (!_decos.TryGetValue(d, out var arr) || arr.Length == 0) return null;
+ return arr[variant % arr.Length];
+ }
+
+ private static Texture2D[] LoadVariants(string subdir, string name, Func placeholder)
+ {
+ var found = new List();
+
+ // Variant suffix files first: name_0.png, name_1.png, ...
+ for (int i = 0; ; i++)
+ {
+ var tex = ContentLoader.LoadGfx($"tactical/{subdir}/{name}_{i}.png");
+ if (tex is null) break;
+ found.Add(tex);
+ }
+ // Fallback to a single name.png if no _N variants exist.
+ if (found.Count == 0)
+ {
+ var tex = ContentLoader.LoadGfx($"tactical/{subdir}/{name}.png");
+ if (tex is not null) found.Add(tex);
+ }
+
+ if (found.Count == 0) found.Add(placeholder());
+ return found.ToArray();
+ }
+
+ private static Texture2D SurfacePlaceholder(TacticalSurface s) =>
+ SolidTexture(SurfaceColor(s), Theriapolis.Core.C.TACTICAL_TILE_SPRITE_PX);
+
+ private static Texture2D DecoPlaceholder(TacticalDeco d)
+ {
+ var (color, fillFraction) = DecoStyle(d);
+ int px = Theriapolis.Core.C.TACTICAL_TILE_SPRITE_PX;
+ var image = Image.CreateEmpty(px, px, false, Image.Format.Rgba8);
+ float cx = (px - 1) * 0.5f;
+ float r = px * 0.5f * fillFraction;
+ for (int y = 0; y < px; y++)
+ {
+ for (int x = 0; x < px; x++)
+ {
+ float dx = x - cx, dy = y - cx;
+ image.SetPixel(x, y, (dx * dx + dy * dy) <= r * r ? color : new Color(0, 0, 0, 0));
+ }
+ }
+ return ImageTexture.CreateFromImage(image);
+ }
+
+ private static Texture2D SolidTexture(Color c, int size)
+ {
+ var image = Image.CreateEmpty(size, size, false, Image.Format.Rgba8);
+ image.Fill(c);
+ return ImageTexture.CreateFromImage(image);
+ }
+
+ // Mirrors Theriapolis.Game/Rendering/TacticalAtlas.SurfaceColor for placeholder parity.
+ private static Color SurfaceColor(TacticalSurface s) => s switch
+ {
+ TacticalSurface.DeepWater => ColorByte(20, 60, 130),
+ TacticalSurface.ShallowWater => ColorByte(60, 120, 180),
+ TacticalSurface.Marsh => ColorByte(70, 100, 80),
+ TacticalSurface.Mud => ColorByte(100, 80, 60),
+ TacticalSurface.Sand => ColorByte(220, 200, 150),
+ TacticalSurface.Snow => ColorByte(230, 235, 240),
+ TacticalSurface.Rock => ColorByte(120, 115, 110),
+ TacticalSurface.Cobble => ColorByte(170, 150, 120),
+ TacticalSurface.Gravel => ColorByte(150, 140, 110),
+ TacticalSurface.Wall => ColorByte(60, 55, 50),
+ TacticalSurface.Floor => ColorByte(180, 160, 130),
+ TacticalSurface.Dirt => ColorByte(120, 95, 60),
+ TacticalSurface.TroddenDirt => ColorByte(140, 110, 70),
+ TacticalSurface.TallGrass => ColorByte(80, 140, 60),
+ TacticalSurface.Grass => ColorByte(110, 160, 70),
+ TacticalSurface.DungeonFloor => ColorByte(110, 90, 70),
+ TacticalSurface.DungeonRubble=> ColorByte(80, 70, 60),
+ TacticalSurface.DungeonTile => ColorByte(140, 130, 110),
+ TacticalSurface.Cave => ColorByte(70, 60, 55),
+ TacticalSurface.MineFloor => ColorByte(95, 85, 70),
+ _ => ColorByte(255, 0, 255),
+ };
+
+ private static (Color color, float fillFraction) DecoStyle(TacticalDeco d) => d switch
+ {
+ TacticalDeco.Tree => (ColorByte(20, 80, 30), 0.85f),
+ TacticalDeco.Bush => (ColorByte(70, 110, 50), 0.55f),
+ TacticalDeco.Boulder => (ColorByte(110, 100, 90), 0.65f),
+ TacticalDeco.Rock => (ColorByte(140, 130, 110), 0.35f),
+ TacticalDeco.Flower => (ColorByte(220, 180, 210), 0.25f),
+ TacticalDeco.Crop => (ColorByte(180, 160, 60), 0.40f),
+ TacticalDeco.Reed => (ColorByte(120, 140, 60), 0.40f),
+ TacticalDeco.Snag => (ColorByte(80, 60, 40), 0.45f),
+ _ => (ColorByte(255, 0, 255), 0.5f),
+ };
+
+ private static Color ColorByte(byte r, byte g, byte b) => new(r / 255f, g / 255f, b / 255f);
+}
diff --git a/Theriapolis.Godot/Rendering/TacticalChunkNode.cs b/Theriapolis.Godot/Rendering/TacticalChunkNode.cs
new file mode 100644
index 0000000..616ab7c
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/TacticalChunkNode.cs
@@ -0,0 +1,57 @@
+using Godot;
+using Theriapolis.Core;
+using Theriapolis.Core.Tactical;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// One Node2D per tactical chunk. Positioned at (chunk.OriginX, chunk.OriginY)
+/// in world-pixel space; renders all CHUNK_SIZE x CHUNK_SIZE tiles via _Draw.
+///
+/// Godot caches CanvasItem _Draw output, so _Draw runs once on first paint
+/// and the rasterised result is reused every frame thereafter. We only call
+/// QueueRedraw() if the chunk's delta changes (M4 doesn't yet — chunks are
+/// static once generated).
+///
+/// Each tactical tile is 1 world pixel wide; the source sprite is
+/// TACTICAL_TILE_SPRITE_PX² and gets squashed into that 1×1 cell via
+/// DrawTextureRect's destination rect. The camera's zoom (32x for tactical
+/// view) magnifies the rasterisation back to native sprite resolution.
+///
+public partial class TacticalChunkNode : Node2D
+{
+ private TacticalChunk? _chunk;
+
+ public void Bind(TacticalChunk chunk)
+ {
+ _chunk = chunk;
+ Position = new Vector2(chunk.OriginX, chunk.OriginY);
+ QueueRedraw();
+ }
+
+ public override void _Draw()
+ {
+ if (_chunk is null) return;
+
+ int size = C.TACTICAL_CHUNK_SIZE;
+
+ // Pass 1: surfaces.
+ for (int ly = 0; ly < size; ly++)
+ for (int lx = 0; lx < size; lx++)
+ {
+ ref var t = ref _chunk.Tiles[lx, ly];
+ var tex = TacticalAtlas.GetSurface(t.Surface, t.Variant);
+ DrawTextureRect(tex, new Rect2(lx, ly, 1, 1), false);
+ }
+
+ // Pass 2: decos on top.
+ for (int ly = 0; ly < size; ly++)
+ for (int lx = 0; lx < size; lx++)
+ {
+ ref var t = ref _chunk.Tiles[lx, ly];
+ var tex = TacticalAtlas.GetDeco(t.Deco, t.Variant);
+ if (tex is null) continue;
+ DrawTextureRect(tex, new Rect2(lx, ly, 1, 1), false);
+ }
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/WorldMapView.cs b/Theriapolis.Godot/Rendering/WorldMapView.cs
deleted file mode 100644
index ad780fc..0000000
--- a/Theriapolis.Godot/Rendering/WorldMapView.cs
+++ /dev/null
@@ -1,301 +0,0 @@
-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);
-}
diff --git a/Theriapolis.Godot/Rendering/WorldView.cs b/Theriapolis.Godot/Rendering/WorldView.cs
new file mode 100644
index 0000000..9d8f414
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/WorldView.cs
@@ -0,0 +1,468 @@
+using Godot;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Theriapolis.Core;
+using Theriapolis.Core.Tactical;
+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;
+
+///
+/// Unified seamless-zoom view (CLAUDE.md "Seamless Zoom Model"). One scene
+/// covers world-map and tactical scales; layers fade in/out at zoom
+/// thresholds. Polyline widths and the player marker counter-scale with
+/// zoom so they stay visually consistent across the full range.
+///
+/// Layers, bottom-up:
+/// BiomeLayer — 256x256 biome image, scaled by WORLD_TILE_PIXELS;
+/// always visible. Acts as the backdrop past the
+/// tactical streaming radius.
+/// TacticalChunks — TacticalChunkNode children added on chunk load;
+/// visible only when zoom > TacticalRenderZoomMin.
+/// Polylines/Bridges — Line2D children; always visible. Widths counter-
+/// scaled per frame.
+/// Settlements — SettlementDot children; visible only when zoom
+/// < SettlementHideZoom.
+/// Player — Always visible; counter-scaled.
+///
+/// Camera follows the player at all zooms; right-drag temporarily pans
+/// (PanZoomCamera handles drag input).
+///
+public partial class WorldView : Node2D
+{
+ private readonly ulong _seed;
+ private readonly int _startWorldTileX;
+ private readonly int _startWorldTileY;
+ private readonly float _initialZoom;
+
+ // Zoom thresholds, in Camera2D zoom units (1.0 = 1 world px per screen px,
+ // 32.0 = sprite-native tactical view, ~0.07 = world fits 1080p).
+ private const float TacticalRenderZoomMin = 4.0f;
+ private const float SettlementHideZoom = 2.0f;
+ private const float StreamRadiusZoomMin = 4.0f;
+
+ // World-pixel movement speed. 32 wp = 1 world tile, so 96 = ~3 tiles/sec.
+ private const float MoveSpeedWorldPx = 96f;
+ private const int StreamingBufferWorldTiles = 2;
+
+ // Polyline base widths in *screen* pixels (counter-scaled to world space
+ // per frame). Mirrors the differentiation in LineFeatureRenderer.cs.
+ private const float HighwayScreenPx = 4f;
+ private const float PostRoadScreenPx = 3f;
+ private const float DirtRoadScreenPx = 2f;
+ private const float RiverMajorScreenPx = 4.5f;
+ private const float RiverScreenPx = 3f;
+ private const float StreamScreenPx = 2f;
+ private const float RailTieScreenPx = 4f;
+ private const float RailLineScreenPx = 2f;
+ private const float BridgeScreenPx = 6f;
+
+ // Polyline colours mirror LineFeatureRenderer.cs / WorldgenDump.cs.
+ 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 RailTieColour = ColorByte(120, 100, 80);
+ private static readonly Color RailColour = ColorByte(80, 65, 50);
+ private static readonly Color BridgeColour = ColorByte(160, 140, 100);
+
+ private ChunkStreamer? _streamer;
+ private Vec2 _playerPos;
+ private PanZoomCamera? _camera;
+ private Node2D? _tacticalLayer;
+ private Node2D? _polylineLayer;
+ private Node2D? _bridgeLayer;
+ private Node2D? _settlementLayer;
+ private PlayerMarker? _playerMarker;
+ private readonly Dictionary _chunkNodes = new();
+ private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
+
+ public WorldView(ulong seed, int startWorldTileX = 128, int startWorldTileY = 128, float initialZoom = 0f)
+ {
+ _seed = seed;
+ _startWorldTileX = startWorldTileX;
+ _startWorldTileY = startWorldTileY;
+ _initialZoom = initialZoom; // 0 = compute fit-to-viewport
+ }
+
+ public override void _Ready()
+ {
+ string dataDir = ContentPaths.DataDir;
+ if (!Directory.Exists(dataDir))
+ {
+ GD.PrintErr($"[world] Data directory not found: {dataDir}");
+ return;
+ }
+
+ GD.Print($"[world] seed=0x{_seed:X} start-tile=({_startWorldTileX},{_startWorldTileY})");
+ var ctx = new WorldGenContext(_seed, dataDir);
+ WorldGenerator.RunAll(ctx);
+ var world = ctx.World;
+ GD.Print($"[world] worldgen done — rivers={world.Rivers.Count} " +
+ $"roads={world.Roads.Count} rails={world.Rails.Count} " +
+ $"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
+
+ _streamer = new ChunkStreamer(_seed, world, new InMemoryChunkDeltaStore());
+ _streamer.OnChunkLoaded += AddChunkNode;
+ _streamer.OnChunkEvicting += RemoveChunkNode;
+
+ TacticalAtlas.EnsureLoaded();
+
+ BuildBiomeSprite(world);
+ _tacticalLayer = AddNamedLayer("TacticalChunks");
+ BuildPolylines(world);
+ BuildBridges(world);
+ BuildSettlements(world);
+
+ _playerPos = new Vec2(
+ _startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
+ _startWorldTileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f);
+
+ _playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
+ AddChild(_playerMarker);
+
+ AddCamera();
+ UpdateLayerVisibility();
+ StreamIfTactical();
+ }
+
+ public override void _Process(double delta)
+ {
+ if (_camera is null || _playerMarker is null) return;
+
+ Vector2 dir = Vector2.Zero;
+ if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
+ if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y += 1;
+ if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X -= 1;
+ if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X += 1;
+
+ if (dir != Vector2.Zero)
+ {
+ dir = dir.Normalized();
+ float step = MoveSpeedWorldPx * (float)delta;
+ _playerPos = new Vec2(_playerPos.X + dir.X * step, _playerPos.Y + dir.Y * step);
+
+ float maxX = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS - 1f;
+ float maxY = C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS - 1f;
+ _playerPos = new Vec2(
+ Mathf.Clamp(_playerPos.X, 0f, maxX),
+ Mathf.Clamp(_playerPos.Y, 0f, maxY));
+
+ StreamIfTactical();
+ }
+
+ var pos = new Vector2(_playerPos.X, _playerPos.Y);
+ _playerMarker.Position = pos;
+ _camera.Position = pos;
+
+ UpdateLayerVisibility();
+ UpdateZoomScaledNodes();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Layer construction
+
+ private void BuildBiomeSprite(WorldState world)
+ {
+ int W = C.WORLD_WIDTH_TILES;
+ int H = C.WORLD_HEIGHT_TILES;
+
+ 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);
+ image.SetPixel(x, y, c);
+ }
+
+ var sprite = new Sprite2D
+ {
+ Texture = ImageTexture.CreateFromImage(image),
+ Centered = false,
+ Scale = new Vector2(C.WORLD_TILE_PIXELS, C.WORLD_TILE_PIXELS),
+ TextureFilter = TextureFilterEnum.Nearest,
+ Name = "Biome",
+ };
+ AddChild(sprite);
+ }
+
+ private Node2D AddNamedLayer(string name)
+ {
+ var n = new Node2D { Name = name };
+ AddChild(n);
+ return n;
+ }
+
+ private void BuildPolylines(WorldState world)
+ {
+ _polylineLayer = AddNamedLayer("Polylines");
+
+ foreach (var road in world.Roads.OrderBy(RoadDrawRank))
+ {
+ var (color, screenPx) = road.RoadClassification switch
+ {
+ RoadType.Highway => (HighwayColour, HighwayScreenPx),
+ RoadType.PostRoad => (PostRoadColour, PostRoadScreenPx),
+ _ => (DirtRoadColour, DirtRoadScreenPx),
+ };
+ AddScaledLine(_polylineLayer, road.Points, color, screenPx);
+ }
+
+ foreach (var river in world.Rivers)
+ {
+ var (color, screenPx) = river.RiverClassification switch
+ {
+ RiverClass.MajorRiver => (RiverMajorColour, RiverMajorScreenPx),
+ RiverClass.River => (RiverColour, RiverScreenPx),
+ _ => (StreamColour, StreamScreenPx),
+ };
+ float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
+ AddScaledLine(_polylineLayer, river.Points, color,
+ Mathf.Min(screenPx * flowScale, RiverMajorScreenPx * 1.5f));
+ }
+
+ foreach (var rail in world.Rails)
+ {
+ AddScaledLine(_polylineLayer, rail.Points, RailTieColour, RailTieScreenPx);
+ AddScaledLine(_polylineLayer, rail.Points, RailColour, RailLineScreenPx);
+ }
+ }
+
+ private void BuildBridges(WorldState world)
+ {
+ if (world.Bridges.Count == 0) return;
+ _bridgeLayer = AddNamedLayer("Bridges");
+
+ foreach (var bridge in world.Bridges)
+ {
+ var line = new Line2D
+ {
+ DefaultColor = BridgeColour,
+ JointMode = Line2D.LineJointMode.Round,
+ };
+ line.AddPoint(new Vector2(bridge.Start.X, bridge.Start.Y));
+ line.AddPoint(new Vector2(bridge.End.X, bridge.End.Y));
+ _bridgeLayer.AddChild(line);
+ _scaledLines.Add((line, BridgeScreenPx));
+ }
+ }
+
+ private void BuildSettlements(WorldState world)
+ {
+ if (world.Settlements.Count == 0) return;
+ _settlementLayer = AddNamedLayer("Settlements");
+
+ foreach (var s in world.Settlements)
+ {
+ var (colour, tileRadius) = s.Tier switch
+ {
+ 1 => (ColorByte(255, 215, 0), 2.5f),
+ 2 => (ColorByte(230, 230, 230), 1.8f),
+ 3 => (ColorByte(150, 200, 255), 1.3f),
+ 4 => (ColorByte(200, 200, 200), 0.8f),
+ _ => (ColorByte(200, 60, 60), 0.7f),
+ };
+ 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,
+ };
+ _settlementLayer.AddChild(dot);
+ }
+ }
+
+ private void AddScaledLine(Node2D parent, IReadOnlyList pts, Color colour, float screenPx)
+ {
+ var line = new Line2D
+ {
+ DefaultColor = colour,
+ 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));
+ parent.AddChild(line);
+ _scaledLines.Add((line, screenPx));
+ }
+
+ private void AddCamera()
+ {
+ Vector2 viewport = GetViewport().GetVisibleRect().Size;
+ Vector2 worldSize = new(
+ C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS,
+ C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS);
+ float fitZoom = Mathf.Min(viewport.X / worldSize.X, viewport.Y / worldSize.Y) * 0.95f;
+ float startZoom = _initialZoom > 0f ? _initialZoom : fitZoom;
+
+ _camera = new PanZoomCamera
+ {
+ Position = new Vector2(_playerPos.X, _playerPos.Y),
+ Zoom = new Vector2(startZoom, startZoom),
+ MinZoom = fitZoom * 0.5f,
+ MaxZoom = 64f,
+ };
+ AddChild(_camera);
+ _camera.MakeCurrent();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Per-frame updates
+
+ private void UpdateLayerVisibility()
+ {
+ if (_camera is null) return;
+ float zoom = _camera.Zoom.X;
+ if (_tacticalLayer is not null)
+ _tacticalLayer.Visible = zoom >= TacticalRenderZoomMin;
+ if (_settlementLayer is not null)
+ _settlementLayer.Visible = zoom < SettlementHideZoom;
+ }
+
+ private void UpdateZoomScaledNodes()
+ {
+ if (_camera is null) return;
+ float zoom = _camera.Zoom.X;
+ if (zoom <= 0f) return;
+ float invZoom = 1f / zoom;
+
+ foreach (var (line, baseScreenPx) in _scaledLines)
+ line.Width = baseScreenPx * invZoom;
+
+ if (_playerMarker is not null)
+ _playerMarker.Scale = new Vector2(invZoom, invZoom);
+ }
+
+ private void StreamIfTactical()
+ {
+ if (_streamer is null) return;
+ if (_camera is null || _camera.Zoom.X < StreamRadiusZoomMin)
+ {
+ // Optional: evict everything outside a small fallback set so we
+ // don't keep a stale tactical cache when zoomed out for a long
+ // time. Skipping for M4 — soft cap in the streamer handles it.
+ return;
+ }
+
+ Vector2 viewport = GetViewport().GetVisibleRect().Size;
+ float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _camera.Zoom.X * 0.5f;
+ int halfExtentTiles = Mathf.CeilToInt(halfExtentWorldPx / C.WORLD_TILE_PIXELS);
+ int radius = halfExtentTiles + StreamingBufferWorldTiles;
+
+ _streamer.EnsureLoadedAround(_playerPos, radius);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Chunk node lifecycle
+
+ private void AddChunkNode(TacticalChunk chunk)
+ {
+ if (_tacticalLayer is null) return;
+ if (_chunkNodes.ContainsKey(chunk.Coord)) return;
+
+ var node = new TacticalChunkNode { Name = $"Chunk{chunk.Coord.X}_{chunk.Coord.Y}" };
+ _tacticalLayer.AddChild(node);
+ node.Bind(chunk);
+ _chunkNodes[chunk.Coord] = node;
+ }
+
+ private void RemoveChunkNode(TacticalChunk chunk)
+ {
+ if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return;
+ node.QueueFree();
+ _chunkNodes.Remove(chunk.Coord);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Helpers
+
+ 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 settlement marker on the world map. Sized in world-pixel
+/// space (parent layer's visibility flag handles the world-vs-tactical
+/// hide threshold).
+///
+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);
+}
+
+///
+/// Player marker. Drawn at /2 wp;
+/// parent WorldView sets = 1/zoom every frame
+/// so the on-screen size stays constant (~24 px radius / 48 px diameter,
+/// matching MonoGame's PlayerSprite) across the seamless zoom range.
+///
+public partial class PlayerMarker : Node2D
+{
+ private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
+
+ public override void _Draw()
+ {
+ DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
+ DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f));
+ }
+}