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