M4: Tactical render + unified seamless-zoom WorldView
Implements the seamless-zoom contract from CLAUDE.md: one Camera2D
covers both world-map and tactical scales; layers fade in/out at zoom
thresholds; polyline widths and the player marker counter-scale with
zoom so on-screen sizing stays consistent across the full range.
Layers (bottom-up in WorldView):
Biome sprite — 256x256 ImageTexture scaled by WORLD_TILE_PIXELS;
always visible (acts as backdrop past the tactical
streaming radius).
TacticalChunks — TacticalChunkNode children added on chunk-loaded
event; visible only when zoom ≥ 4.
Polylines/Bridge — Line2D children; always visible. Width recomputed
each frame as baseScreenPx / camera.Zoom so the
on-screen stroke is constant (4 px highway, 3 px
post road, 2 px dirt road, 4.5/3/2 for major-river/
river/stream, 4/2 for rail tie/line, 6 for bridge).
Settlements — SettlementDot children; hidden when zoom ≥ 2 (you
are visually "inside" them at tactical scale).
PlayerMarker — Always visible; Scale = 1/zoom keeps it at
PLAYER_MARKER_SCREEN_PX on-screen across all zooms.
TacticalAtlas:
Loads PNGs from Content/Gfx/tactical/{surface,deco}/ via ContentLoader
with name_0.png/name_1.png/... variant probing (silent miss). Falls
back to procedurally-generated solid placeholders matching MonoGame's
TacticalAtlas colour table so missing art doesn't break rendering.
TacticalChunkNode:
One Node2D per cached chunk, positioned at (OriginX, OriginY) in
world-pixel space. _Draw iterates the 64x64 tile grid once and Godot
caches the rasterised CanvasItem; subsequent frames blit instead of
re-issuing 4096 DrawTextureRect calls.
ChunkStreamer integration:
WorldView listens to OnChunkLoaded / OnChunkEvicting and adds /
removes TacticalChunkNode children. Streaming radius is computed
dynamically from the viewport size and camera zoom plus a 2-tile
buffer, so chunk loads always cover the visible viewport with margin.
Chunks only stream when zoom ≥ 4 (tactical is visible).
Main.cs:
--world-map [seed] → WorldView, fit-to-viewport zoom
--tactical [seed] [tx] [ty] → WorldView, zoom 32 at given tile
Both flags converge on the same scene; mouse wheel transitions
seamlessly between modes.
ContentLoader silent miss:
Removed the "Missing texture" PrintErr — atlas variant probing
legitimately tries name_3.png that doesn't exist, and the noise
drowned the console. Genuine asset failures still surface via
AssetTest's count summary.
Deleted (replaced by WorldView):
Theriapolis.Godot/Rendering/WorldMapView.cs
Theriapolis.Godot/Rendering/TacticalView.cs (created earlier in M4,
never committed — superseded before commit).
Closes M4 of theriapolis-rpg-implementation-plan-godot-port.md.
Next: M5 (codex design system).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Godot;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.GodotHost.Platform;
|
||||
|
||||
namespace Theriapolis.GodotHost.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class TacticalAtlas
|
||||
{
|
||||
private static readonly Dictionary<TacticalSurface, Texture2D[]> _surfaces = new();
|
||||
private static readonly Dictionary<TacticalDeco, Texture2D[]> _decos = new();
|
||||
private static bool _loaded;
|
||||
|
||||
public static void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
|
||||
foreach (TacticalSurface s in Enum.GetValues<TacticalSurface>())
|
||||
_surfaces[s] = LoadVariants("surface", s.ToString().ToLowerInvariant(), () => SurfacePlaceholder(s));
|
||||
|
||||
foreach (TacticalDeco d in Enum.GetValues<TacticalDeco>())
|
||||
{
|
||||
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<Texture2D> placeholder)
|
||||
{
|
||||
var found = new List<Texture2D>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Godot;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Tactical;
|
||||
|
||||
namespace Theriapolis.GodotHost.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>dotnet run --project Theriapolis.Tools -- worldgen-dump --seed N</c>:
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Vec2> 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,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filled circle used as a settlement marker on the world map.
|
||||
/// </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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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 > 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).
|
||||
/// </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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user