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