using System.Collections.Generic; using System.Linq; using Godot; using Theriapolis.Core; using Theriapolis.Core.Tactical; using Theriapolis.Core.Util; using Theriapolis.Core.World; using Theriapolis.Core.World.Polylines; namespace Theriapolis.GodotHost.Rendering; /// /// Renders a generated across the seamless zoom /// range — biome backdrop, polylines (rivers / roads / rails), bridges, /// settlement dots, and the tactical-chunk layer that streams in close-up. /// Owns its own so callers can read zoom and /// drive position uniformly. /// /// Per M7 plan §6.2: extracted from the M2+M4 demo /// so PlayScreen and the standalone demo both mount the same renderer. /// The chunk streamer itself is owned by the *caller* — PlayScreen needs /// the streamer for NPC lifecycle separately from the visual layer — so /// the caller subscribes to OnChunkLoaded/OnChunkEvicting /// and forwards into /. /// /// Per-frame: hides/shows the tactical and settlement layers based on /// camera zoom, and counter-scales every Line2D width so polyline widths /// stay visually consistent regardless of zoom. /// public partial class WorldRenderNode : Node2D { // 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). public const float TacticalRenderZoomMin = 4.0f; public const float SettlementHideZoom = 2.0f; // 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 Node2D? _tacticalLayer; private Node2D? _polylineLayer; private Node2D? _bridgeLayer; private Node2D? _settlementLayer; private PanZoomCamera? _camera; private readonly Dictionary _chunkNodes = new(); private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new(); private bool _initialised; /// The camera owned by this node. Caller reads Zoom to /// pick world-map vs. tactical UI behaviour, and sets Position /// to follow the player. public PanZoomCamera Camera => _camera!; /// Initialise from a completed . /// Idempotent on repeat — second call is a no-op. /// of 0 means "compute fit-to-viewport so the whole world is visible". public void Initialize(WorldState world, float initialZoom = 0f) { if (_initialised) return; _initialised = true; TacticalAtlas.EnsureLoaded(); BuildBiomeSprite(world); _tacticalLayer = AddNamedLayer("TacticalChunks"); BuildPolylines(world); BuildBridges(world); BuildSettlements(world); AddCamera(initialZoom); } public override void _Process(double delta) { if (!_initialised) return; UpdateLayerVisibility(); UpdateZoomScaledNodes(); } /// Mount the visual for a freshly-streamed chunk. Caller /// invokes from a ChunkStreamer.OnChunkLoaded subscription. public 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; } /// Tear down a chunk visual on eviction. Caller invokes from /// ChunkStreamer.OnChunkEvicting. public void RemoveChunkNode(TacticalChunk chunk) { if (!_chunkNodes.TryGetValue(chunk.Coord, out var node)) return; node.QueueFree(); _chunkNodes.Remove(chunk.Coord); } // ────────────────────────────────────────────────────────────────────── // 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(float initialZoom) { 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 = worldSize * 0.5f, // caller can reposition immediately after Initialize 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; } // ────────────────────────────────────────────────────────────────────── // 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, }; }