6f47700820
Facing tick stuck at the initial angle. PlayerMarker._Draw was computing the tick direction from a FacingAngleRad auto-property, but Godot caches CanvasItem draw commands and only re-runs _Draw on QueueRedraw. Setter never called QueueRedraw → tick never rotated. Fixed by leaning on the Node2D transform instead: tick is drawn along the local +X axis, PlayScreen sets marker.Rotation = facing each frame. The transform rotation applies to the cached commands without re-invoking _Draw — efficient and correct. FacingAngleRad property removed; ShowFacingTick became a property with QueueRedraw on change (visibility toggle still needs to invalidate the cache). Tactical view double-drew roads. TacticalChunkGen.Pass2_Polylines already bakes roads + rivers + bridges into the surface tiles of each chunk. WorldRenderNode's Line2D overlay was still visible at tactical zoom, stroking the same path on top of the rasterised version — showed as a brown line over every road. Ported the MonoGame "suppress polyline overlay in tactical" rule into UpdateLayerVisibility: _polylineLayer and _bridgeLayer hide when zoom >= TacticalRenderZoomMin. WASD now pans the world map. Previously WASD did nothing in world-map mode — only right-drag / middle-drag / mouse-wheel worked. WASD is now context-sensitive: tactical mode steps the player (unchanged), world-map mode pans the camera at 400 screen px/sec (world-pixel speed scales as 1/zoom so the perceived rate stays constant). Diagonal motion is √2-normalised to match tactical step. Suppressed during click-to-travel since the camera-follow would clobber any pan input anyway. HUD hint updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
359 lines
14 KiB
C#
359 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Renders a generated <see cref="WorldState"/> 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 <see cref="PanZoomCamera"/> so callers can read zoom and
|
|
/// drive position uniformly.
|
|
///
|
|
/// Per M7 plan §6.2: extracted from the M2+M4 <see cref="WorldView"/> 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 <c>OnChunkLoaded</c>/<c>OnChunkEvicting</c>
|
|
/// and forwards into <see cref="AddChunkNode"/>/<see cref="RemoveChunkNode"/>.
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
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<ChunkCoord, TacticalChunkNode> _chunkNodes = new();
|
|
private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
|
|
private bool _initialised;
|
|
|
|
/// <summary>The camera owned by this node. Caller reads <c>Zoom</c> to
|
|
/// pick world-map vs. tactical UI behaviour, and sets <c>Position</c>
|
|
/// to follow the player.</summary>
|
|
public PanZoomCamera Camera => _camera!;
|
|
|
|
/// <summary>Initialise from a completed <see cref="WorldGenContext"/>.
|
|
/// Idempotent on repeat — second call is a no-op. <paramref name="initialZoom"/>
|
|
/// of 0 means "compute fit-to-viewport so the whole world is visible".</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Mount the visual for a freshly-streamed chunk. Caller
|
|
/// invokes from a <c>ChunkStreamer.OnChunkLoaded</c> subscription.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Tear down a chunk visual on eviction. Caller invokes from
|
|
/// <c>ChunkStreamer.OnChunkEvicting</c>.</summary>
|
|
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<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(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;
|
|
bool tactical = zoom >= TacticalRenderZoomMin;
|
|
|
|
if (_tacticalLayer is not null)
|
|
_tacticalLayer.Visible = tactical;
|
|
if (_settlementLayer is not null)
|
|
_settlementLayer.Visible = zoom < SettlementHideZoom;
|
|
|
|
// Polylines and bridges are baked into the tactical chunk surface
|
|
// tiles by TacticalChunkGen.Pass2_Polylines, so re-stroking the
|
|
// Line2D overlay at tactical zoom double-draws the road and shows
|
|
// as a brown line over top of the rasterised one. Hide the line
|
|
// overlay when tactical is active.
|
|
if (_polylineLayer is not null)
|
|
_polylineLayer.Visible = !tactical;
|
|
if (_bridgeLayer is not null)
|
|
_bridgeLayer.Visible = !tactical;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|