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:
Christopher Wiebe
2026-05-01 20:08:14 -07:00
parent f57ea0b70c
commit 42d66c00c3
6 changed files with 702 additions and 304 deletions
@@ -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);
}
}
}