Files
TheriapolisV3/Theriapolis.Godot/Rendering/WorldView.cs
T

469 lines
18 KiB
C#
Raw Normal View History

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;
/// <summary>
/// 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 &gt; TacticalRenderZoomMin.
/// Polylines/Bridges — Line2D children; always visible. Widths counter-
/// scaled per frame.
/// Settlements — SettlementDot children; visible only when zoom
/// &lt; SettlementHideZoom.
/// Player — Always visible; counter-scaled.
///
/// Camera follows the player at all zooms; right-drag temporarily pans
/// (PanZoomCamera handles drag input).
/// </summary>
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<ChunkCoord, TacticalChunkNode> _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<Vec2> 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,
};
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// Player marker. Drawn at <see cref="C.PLAYER_MARKER_SCREEN_PX"/>/2 wp;
/// parent WorldView sets <see cref="Node2D.Scale"/> = 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.
/// </summary>
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));
}
}