42d66c00c3
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>
147 lines
6.0 KiB
C#
147 lines
6.0 KiB
C#
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);
|
|
}
|