M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen

Lands the M7 plan's first two sub-milestones on port/godot.
theriapolis-rpg-implementation-plan-godot-port-m7.md is the design
doc (six screens collapse to four scenes + a camera mode, with
per-screen behavioural contracts and a six-step sub-milestone
breakdown).

M7.1 — WorldGenProgressScreen + GameSession autoload + wizard
hand-off rewrite. GameSession holds the cross-scene state that
outlives any single screen: seed, post-worldgen Ctx, pending
character (from the M6 wizard) and pending save snapshot (for
M7.3's load path). Wizard forwards StepReview.CharacterConfirmed
upward, and TitleScreen swaps to the progress screen instead of
just printing the build summary. The progress screen runs the
23-stage pipeline on a background thread, drives a ProgressBar
from ctx.ProgressCallback, and writes the full exception trace to
user://worldgen_error.log on failure. Escape cancels at the next
stage boundary and returns to title.

M7.2 — PlayScreen with a walking character. Extracted
WorldRenderNode from the M2+M4 WorldView demo so PlayScreen and
WorldView mount the same renderer (biome image + polylines +
bridges + settlement dots + tactical chunk lifecycle + PanZoomCamera
+ per-frame layer visibility + line-width counter-scaling).
PlayScreen owns the streamer (M7.3 save needs it), composes
ContentResolver + ActorManager + WorldClock + AnchorRegistry +
PlayerController, spawns the player at the Tier-1 anchor, and
wires resident + non-resident NPC spawning from chunk-load events
with allegiance-tinted markers.

PlayerController ported engine-agnostic to Theriapolis.Godot/Input/.
Takes pre-resolved dx/dy/dt/isTactical/isFocused instead of poking
MonoGame InputManager + Camera2D, so the arithmetic that advances
PlayerActor.Position and WorldClock.InGameSeconds is bit-identical
to the MonoGame version — saves round-trip cleanly.

Click-to-travel in world-map mode (camera zoom <
TacticalRenderZoomMin), WASD step in tactical mode with axis-
separated motion + encumbrance + sub-second clock carry. HUD
overlay top-left shows HP/AC/seed/tile/biome/view-mode/time. Esc
returns to title (M7.4 replaces this with a pause menu).

Namespace gotcha: Theriapolis.GodotHost.Input shadows the engine's
Godot.Input static class for any file under the GodotHost
namespace tree. Files needing keyboard polls (WorldView,
PlayScreen) fully qualify as Godot.Input.IsKeyPressed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-10 18:07:28 -07:00
parent 83c6343783
commit bf0041605f
14 changed files with 2576 additions and 373 deletions
+33 -370
View File
@@ -1,37 +1,24 @@
using Godot;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Godot;
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.
/// Standalone demo entry point for the M2+M4 unified seamless-zoom view.
/// Runs worldgen inline, spawns a placeholder player, and lets you walk
/// around with WASD. Used by the <c>--world-map</c> and <c>--tactical</c>
/// CLI flags for headless / debug viewing without going through the
/// title → wizard → progress flow.
///
/// 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).
/// The actual rendering — biome / polyline / settlement / chunk layers
/// and the camera — lives in <see cref="WorldRenderNode"/>, which is
/// shared with M7's <see cref="Scenes.PlayScreen"/>. This shell just
/// owns the demo's local player position and streaming loop.
/// </summary>
public partial class WorldView : Node2D
{
@@ -40,56 +27,22 @@ public partial class WorldView : Node2D
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 const float StreamRadiusZoomMin = WorldRenderNode.TacticalRenderZoomMin;
private ChunkStreamer? _streamer;
private WorldRenderNode? _render;
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
_initialZoom = initialZoom;
}
public override void _Ready()
@@ -109,17 +62,13 @@ public partial class WorldView : Node2D
$"roads={world.Roads.Count} rails={world.Rails.Count} " +
$"settlements={world.Settlements.Count} bridges={world.Bridges.Count}");
_render = new WorldRenderNode();
AddChild(_render);
_render.Initialize(world, _initialZoom);
_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);
_streamer.OnChunkLoaded += _render.AddChunkNode;
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
_playerPos = new Vec2(
_startWorldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
@@ -128,20 +77,19 @@ public partial class WorldView : Node2D
_playerMarker = new PlayerMarker { Position = new Vector2(_playerPos.X, _playerPos.Y) };
AddChild(_playerMarker);
AddCamera();
UpdateLayerVisibility();
_render.Camera.Position = new Vector2(_playerPos.X, _playerPos.Y);
StreamIfTactical();
}
public override void _Process(double delta)
{
if (_camera is null || _playerMarker is null) return;
if (_render 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 (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dir.Y -= 1;
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dir.Y += 1;
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dir.X -= 1;
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dir.X += 1;
if (dir != Vector2.Zero)
{
@@ -160,309 +108,24 @@ public partial class WorldView : Node2D
var pos = new Vector2(_playerPos.X, _playerPos.Y);
_playerMarker.Position = pos;
_camera.Position = pos;
_render.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);
// Counter-scale the marker so its on-screen size stays constant.
float zoom = _render.Camera.Zoom.X;
if (zoom > 0f)
_playerMarker.Scale = new Vector2(1f / zoom, 1f / zoom);
}
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;
}
if (_streamer is null || _render is null) return;
if (_render.Camera.Zoom.X < StreamRadiusZoomMin) return;
Vector2 viewport = GetViewport().GetVisibleRect().Size;
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _camera.Zoom.X * 0.5f;
float halfExtentWorldPx = Mathf.Max(viewport.X, viewport.Y) / _render.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));
}
}