diff --git a/Theriapolis.Godot/GameSession.cs b/Theriapolis.Godot/GameSession.cs
new file mode 100644
index 0000000..11b0f1d
--- /dev/null
+++ b/Theriapolis.Godot/GameSession.cs
@@ -0,0 +1,55 @@
+using Godot;
+using Theriapolis.Core.Persistence;
+using Theriapolis.Core.Rules.Character;
+using Theriapolis.Core.World.Generation;
+
+namespace Theriapolis.GodotHost;
+
+///
+/// Autoload singleton. Holds the cross-scene state that outlives any
+/// single screen: the world seed and (post-worldgen) WorldGenContext,
+/// the pending character from the M6 wizard hand-off, and the pending
+/// save snapshot from the SaveLoadScreen load hand-off.
+///
+/// Per port-plan §M7 §4.3: TitleScreen + Wizard + SaveLoadScreen write
+/// pending fields; WorldGenProgressScreen + PlayScreen consume them and
+/// clear them.
+///
+/// Registered in project.godot under [autoload]; reachable
+/// from any scene via .
+///
+public partial class GameSession : Node
+{
+ /// World seed for the next worldgen run. Set by TitleScreen
+ /// (new game) or by SaveLoadScreen (from the loaded header).
+ public ulong Seed { get; set; }
+
+ /// Set by WorldGenProgressScreen on completion; consumed by
+ /// PlayScreen during _Ready.
+ public WorldGenContext? Ctx { get; set; }
+
+ /// Set by the Wizard hand-off (M6 → M7.1). PlayScreen
+ /// attaches this to the spawned player actor and clears the field.
+ public Character? PendingCharacter { get; set; }
+ public string PendingName { get; set; } = "Wanderer";
+
+ /// Set by SaveLoadScreen when the player picks a slot.
+ /// PlayScreen consumes via ApplyRestoredBody in _Ready.
+ public SaveBody? PendingRestore { get; set; }
+ public SaveHeader? PendingHeader { get; set; }
+
+ /// Convenience accessor — any node can grab the session via
+ /// GameSession.From(this) without hard-coding the autoload path.
+ public static GameSession From(Node anyNode)
+ => anyNode.GetNode("/root/GameSession");
+
+ /// Drop the per-run pending fields. Called on quit-to-title
+ /// so a fresh "New Character" run doesn't see stale handoff data.
+ public void ClearPending()
+ {
+ PendingCharacter = null;
+ PendingName = "Wanderer";
+ PendingRestore = null;
+ PendingHeader = null;
+ }
+}
diff --git a/Theriapolis.Godot/Input/PlayerController.cs b/Theriapolis.Godot/Input/PlayerController.cs
new file mode 100644
index 0000000..5d5f176
--- /dev/null
+++ b/Theriapolis.Godot/Input/PlayerController.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using Theriapolis.Core;
+using Theriapolis.Core.Entities;
+using Theriapolis.Core.Time;
+using Theriapolis.Core.Util;
+using Theriapolis.Core.World;
+
+namespace Theriapolis.GodotHost.Input;
+
+///
+/// Drives the player. World-map mode: click a destination, A* the path
+/// (via ), and animate the player along
+/// it while the WorldClock advances. Tactical mode: WASD step with
+/// axis-separated motion (wall-sliding) and encumbrance-aware speed.
+///
+/// Direct logic port of Theriapolis.Game/Input/PlayerController.cs;
+/// the Godot version takes pre-resolved dx/dy from the
+/// screen instead of poking InputManager + Camera2D
+/// (MonoGame types). Save-format determinism is unaffected — the only
+/// output that round-trips through saves is
+/// and , both of which are advanced
+/// by identical arithmetic in both ports.
+///
+public sealed class PlayerController
+{
+ private readonly PlayerActor _player;
+ private readonly WorldState _world;
+ private readonly WorldClock _clock;
+ private readonly WorldTravelPlanner _planner;
+
+ /// Optional callback installed by the screen once tactical
+ /// streaming is up. Returns whether the given tactical-tile coord
+ /// is walkable.
+ public Func? TacticalIsWalkable { get; set; }
+
+ private List<(int X, int Y)>? _path;
+ private int _pathIndex;
+
+ // Sub-second carry for the world clock — tactical motion is continuous,
+ // so a single frame may advance fewer than one in-game second; without
+ // this carry, slow movement would never tick the clock past 0.
+ private float _tacticalClockCarry;
+
+ public bool IsTraveling => _path is not null && _pathIndex < _path.Count;
+
+ public PlayerController(PlayerActor player, WorldState world, WorldClock clock)
+ {
+ _player = player;
+ _world = world;
+ _clock = clock;
+ _planner = new WorldTravelPlanner(world);
+ }
+
+ public void CancelTravel()
+ {
+ _path = null;
+ _pathIndex = 0;
+ }
+
+ /// Queue a click destination as a new travel plan. Returns
+ /// true if a path was found.
+ public bool RequestTravelTo(int tileX, int tileY)
+ {
+ int sx = (int)MathF.Floor(_player.Position.X / C.WORLD_TILE_PIXELS);
+ int sy = (int)MathF.Floor(_player.Position.Y / C.WORLD_TILE_PIXELS);
+ sx = Math.Clamp(sx, 0, C.WORLD_WIDTH_TILES - 1);
+ sy = Math.Clamp(sy, 0, C.WORLD_HEIGHT_TILES - 1);
+
+ var path = _planner.PlanTilePath(sx, sy, tileX, tileY);
+ if (path is null || path.Count < 2) return false;
+ _path = path;
+ _pathIndex = 1;
+ return true;
+ }
+
+ /// Per-frame tick. /
+ /// are the pre-resolved input direction (e.g. -1/0/+1 each, from WASD);
+ /// ignored when is false. The screen
+ /// is responsible for deciding tactical vs. world-map based on camera
+ /// zoom and for gating input when the window isn't focused.
+ public void Update(float dt, float dx, float dy, bool isTacticalMode, bool isFocused)
+ {
+ if (!isTacticalMode)
+ UpdateWorldMap(dt);
+ else
+ UpdateTactical(dt, dx, dy, isFocused);
+ }
+
+ private void UpdateWorldMap(float dt)
+ {
+ if (_path is null) return;
+ if (_pathIndex >= _path.Count) { _path = null; return; }
+
+ var (tx, ty) = _path[_pathIndex];
+ var target = WorldTravelPlanner.TileCenterToWorldPixel(tx, ty);
+ var curPos = _player.Position;
+ var diff = target - curPos;
+ float dist = diff.Length;
+ float move = _player.SpeedWorldPxPerSec * dt;
+
+ if (move >= dist)
+ {
+ int prevTileX = (int)MathF.Floor(curPos.X / C.WORLD_TILE_PIXELS);
+ int prevTileY = (int)MathF.Floor(curPos.Y / C.WORLD_TILE_PIXELS);
+ _player.Position = target;
+ if (dist > 1e-3f) _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
+ float legSeconds = _planner.EstimateSecondsForLeg(prevTileX, prevTileY, tx, ty);
+ _clock.Advance((long)MathF.Round(legSeconds));
+ _pathIndex++;
+ if (_pathIndex >= _path.Count) _path = null;
+ }
+ else
+ {
+ var step = diff.Normalized * move;
+ _player.Position = curPos + step;
+ _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
+ ref var dst = ref _world.TileAt(tx, ty);
+ float secondsThisFrame = move * _planner.SecondsPerPixel(dst);
+ _clock.Advance((long)MathF.Round(secondsThisFrame));
+ }
+ }
+
+ private void UpdateTactical(float dt, float dx, float dy, bool isFocused)
+ {
+ if (!isFocused || TacticalIsWalkable is null) return;
+ if (dx == 0f && dy == 0f) return;
+
+ // Normalize so diagonal isn't √2 faster than cardinal.
+ float invLen = (dx != 0f && dy != 0f) ? 0.70710678f : 1f;
+ float vx = dx * invLen;
+ float vy = dy * invLen;
+
+ // Apply encumbrance multiplier when a Character is attached.
+ // Carrying ≤ 100% of capacity walks at full speed; >100% is heavy
+ // (×0.66); >150% is over-encumbered (×0.50).
+ float encMult = _player.Character is not null
+ ? Theriapolis.Core.Rules.Stats.DerivedStats.TacticalSpeedMult(_player.Character)
+ : 1f;
+ float speed = C.TACTICAL_PLAYER_PX_PER_SEC * encMult;
+ float moveX = vx * speed * dt;
+ float moveY = vy * speed * dt;
+
+ var pos = _player.Position;
+
+ // Axis-separated motion gives wall-sliding for free: if X is blocked,
+ // Y still moves, and vice versa. Each axis tests the destination tile
+ // with a small body radius so the player doesn't visibly clip walls.
+ const float BodyRadius = 0.35f;
+ float newX = pos.X + moveX;
+ if (CanOccupy(newX, pos.Y, BodyRadius)) pos = new Vec2(newX, pos.Y);
+ float newY = pos.Y + moveY;
+ if (CanOccupy(pos.X, newY, BodyRadius)) pos = new Vec2(pos.X, newY);
+
+ _player.Position = pos;
+ _player.FacingAngleRad = MathF.Atan2(vy, vx);
+
+ // 1 tactical pixel walked = TACTICAL_STEP_SECONDS in-game seconds.
+ // Sub-second motion accumulates in _tacticalClockCarry so slow walking
+ // still ticks the clock cumulatively.
+ float walked = MathF.Sqrt(moveX * moveX + moveY * moveY);
+ float secondsThisFrame = walked * C.TACTICAL_STEP_SECONDS + _tacticalClockCarry;
+ long whole = (long)MathF.Floor(secondsThisFrame);
+ _tacticalClockCarry = secondsThisFrame - whole;
+ if (whole > 0) _clock.Advance(whole);
+ }
+
+ private bool CanOccupy(float x, float y, float r)
+ {
+ // Sample the four corners of the player's body AABB so we don't slip
+ // into walls when sliding past corners.
+ return TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y - r))
+ && TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y - r))
+ && TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y + r))
+ && TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y + r));
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/NpcMarker.cs b/Theriapolis.Godot/Rendering/NpcMarker.cs
new file mode 100644
index 0000000..e881889
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/NpcMarker.cs
@@ -0,0 +1,29 @@
+using Godot;
+using Theriapolis.Core;
+using Theriapolis.Core.Rules.Character;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// NPC marker — small dot tinted by allegiance. M7.2 stand-in for the
+/// MonoGame NpcSprite (which adds walking-cycle animation). Counter-scaled
+/// with zoom by the owner so the on-screen size stays constant.
+///
+public partial class NpcMarker : Node2D
+{
+ private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.4f;
+
+ public Allegiance Allegiance { get; set; } = Allegiance.Neutral;
+
+ public override void _Draw()
+ {
+ var fill = Allegiance switch
+ {
+ Allegiance.Hostile => new Color(0.78f, 0.18f, 0.20f),
+ Allegiance.Friendly => new Color(0.45f, 0.78f, 0.38f),
+ _ => new Color(0.70f, 0.70f, 0.68f),
+ };
+ DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f));
+ DrawCircle(Vector2.Zero, RadiusWorldPx * 0.80f, fill);
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/PlayerMarker.cs b/Theriapolis.Godot/Rendering/PlayerMarker.cs
new file mode 100644
index 0000000..b82e529
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/PlayerMarker.cs
@@ -0,0 +1,42 @@
+using Godot;
+using Theriapolis.Core;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// Player marker — small dot with a thin facing tick. Drawn at
+/// /2 wp; the owner sets
+/// = 1/zoom every frame so the on-screen size
+/// stays constant across the seamless zoom range.
+///
+/// Mirrors the MonoGame PlayerSprite's visual vocabulary — dark outline,
+/// faction-red fill, optional facing tick. Phase-7-styled `PlayerSprite`
+/// proper (walking animation frames) lands later.
+///
+public partial class PlayerMarker : Node2D
+{
+ private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f;
+ private const float FacingTickPx = RadiusWorldPx * 1.4f;
+
+ /// Facing direction in radians; 0 = +X. Drives the optional
+ /// tick rendered on the body's leading edge.
+ public float FacingAngleRad { get; set; }
+
+ /// When true, draws a small tick at the leading edge so the
+ /// player can read facing without a full sprite. Hidden at low zoom
+ /// to avoid clutter.
+ public bool ShowFacingTick { get; set; } = true;
+
+ 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));
+
+ if (ShowFacingTick)
+ {
+ var dir = new Vector2(Mathf.Cos(FacingAngleRad), Mathf.Sin(FacingAngleRad));
+ DrawLine(dir * (RadiusWorldPx * 0.4f), dir * FacingTickPx,
+ new Color(1f, 0.96f, 0.86f), width: RadiusWorldPx * 0.18f, antialiased: false);
+ }
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/SettlementDot.cs b/Theriapolis.Godot/Rendering/SettlementDot.cs
new file mode 100644
index 0000000..b55add5
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/SettlementDot.cs
@@ -0,0 +1,16 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Rendering;
+
+///
+/// Filled circle settlement marker on the world map. Sized in world-pixel
+/// space; the parent hides the layer above
+/// the tactical-zoom threshold so the dot doesn't clutter close-up views.
+///
+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);
+}
diff --git a/Theriapolis.Godot/Rendering/WorldRenderNode.cs b/Theriapolis.Godot/Rendering/WorldRenderNode.cs
new file mode 100644
index 0000000..d4456cf
--- /dev/null
+++ b/Theriapolis.Godot/Rendering/WorldRenderNode.cs
@@ -0,0 +1,346 @@
+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;
+
+///
+/// Renders a generated 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 so callers can read zoom and
+/// drive position uniformly.
+///
+/// Per M7 plan §6.2: extracted from the M2+M4 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 OnChunkLoaded/OnChunkEvicting
+/// and forwards into /.
+///
+/// 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.
+///
+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 _chunkNodes = new();
+ private readonly List<(Line2D line, float baseScreenWidth)> _scaledLines = new();
+ private bool _initialised;
+
+ /// The camera owned by this node. Caller reads Zoom to
+ /// pick world-map vs. tactical UI behaviour, and sets Position
+ /// to follow the player.
+ public PanZoomCamera Camera => _camera!;
+
+ /// Initialise from a completed .
+ /// Idempotent on repeat — second call is a no-op.
+ /// of 0 means "compute fit-to-viewport so the whole world is visible".
+ 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();
+ }
+
+ /// Mount the visual for a freshly-streamed chunk. Caller
+ /// invokes from a ChunkStreamer.OnChunkLoaded subscription.
+ 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;
+ }
+
+ /// Tear down a chunk visual on eviction. Caller invokes from
+ /// ChunkStreamer.OnChunkEvicting.
+ 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 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;
+ 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;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // 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,
+ };
+}
diff --git a/Theriapolis.Godot/Rendering/WorldView.cs b/Theriapolis.Godot/Rendering/WorldView.cs
index 9d8f414..146ee23 100644
--- a/Theriapolis.Godot/Rendering/WorldView.cs
+++ b/Theriapolis.Godot/Rendering/WorldView.cs
@@ -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;
///
-/// 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 --world-map and --tactical
+/// 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 > 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).
+/// The actual rendering — biome / polyline / settlement / chunk layers
+/// and the camera — lives in , which is
+/// shared with M7's . This shell just
+/// owns the demo's local player position and streaming loop.
///
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 _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 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,
- };
-}
-
-///
-/// 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).
-///
-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);
-}
-
-///
-/// Player marker. Drawn at /2 wp;
-/// parent WorldView sets = 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.
-///
-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));
- }
}
diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs
new file mode 100644
index 0000000..80656fd
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/PlayScreen.cs
@@ -0,0 +1,400 @@
+using System.Collections.Generic;
+using System.Linq;
+using Godot;
+using Theriapolis.Core;
+using Theriapolis.Core.Data;
+using Theriapolis.Core.Entities;
+using Theriapolis.Core.Rules.Combat;
+using Theriapolis.Core.Tactical;
+using Theriapolis.Core.Time;
+using Theriapolis.Core.Util;
+using Theriapolis.Core.World;
+using Theriapolis.Core.World.Generation;
+using Theriapolis.Core.World.Settlements;
+using Theriapolis.GodotHost.Input;
+using Theriapolis.GodotHost.Platform;
+using Theriapolis.GodotHost.Rendering;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes;
+
+///
+/// M7.2 — the play screen. Wraps with the
+/// game-state layer: player actor, world clock, chunk streamer, NPC
+/// markers, player controller, and a top-left HUD overlay. Click on the
+/// world map to travel; WASD to step at tactical zoom.
+///
+/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save
+/// can serialise its delta store) and the actor manager (so M7.5 can
+/// drive interact prompts). The world-map view and the tactical view
+/// are the same scene at different zoom levels — there is no separate
+/// WorldMapScreen, by design.
+///
+/// M7.2 omissions (deferred to later sub-milestones):
+/// - Save / load round-trip (M7.3)
+/// - Pause menu (M7.4)
+/// - Interact prompt + dialogue push (M7.5)
+/// - Encounter detection stub + autosave toast (M7.6)
+///
+public partial class PlayScreen : Control
+{
+ private const float ClickSlopPixels = 4f;
+
+ // Composed Core systems
+ private WorldGenContext _ctx = null!;
+ private ContentResolver _content = null!;
+ private InMemoryChunkDeltaStore _deltas = null!;
+ private ChunkStreamer _streamer = null!;
+ private ActorManager _actors = null!;
+ private WorldClock _clock = null!;
+ private PlayerController _controller = null!;
+ private AnchorRegistry _anchorRegistry = null!;
+
+ // Godot tree
+ private WorldRenderNode _render = null!;
+ private PlayerMarker _playerMarker = null!;
+ private readonly Dictionary _npcMarkers = new();
+ private Label _hudLabel = null!;
+ private PanelContainer _hudPanel = null!;
+
+ // Click-vs-drag state (left-click only; PanZoomCamera handles
+ // middle/right-drag pan independently).
+ private Vector2 _mouseDownPos;
+ private int _mouseDownTileX, _mouseDownTileY;
+ private bool _mouseDownTracked;
+
+ public override void _Ready()
+ {
+ var session = GameSession.From(this);
+ if (session.Ctx is null)
+ {
+ GD.PushError("[play] No WorldGenContext on session — falling back to title.");
+ BackToTitle();
+ return;
+ }
+ _ctx = session.Ctx;
+
+ Theme = CodexTheme.Build();
+ SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ // World render layer — biome + polylines + settlements + camera.
+ _render = new WorldRenderNode();
+ AddChild(_render);
+ _render.Initialize(_ctx.World);
+
+ // Core systems.
+ _content = new ContentResolver(
+ new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
+ _deltas = new InMemoryChunkDeltaStore();
+ _streamer = new ChunkStreamer(
+ _ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
+ _streamer.OnChunkLoaded += _render.AddChunkNode;
+ _streamer.OnChunkEvicting += _render.RemoveChunkNode;
+ _streamer.OnChunkLoaded += HandleChunkLoaded;
+ _streamer.OnChunkEvicting += HandleChunkEvicting;
+
+ _clock = new WorldClock();
+ _actors = new ActorManager();
+ _anchorRegistry = new AnchorRegistry();
+ _anchorRegistry.RegisterAllAnchors(_ctx.World);
+
+ // Spawn player at the Tier-1 anchor (Millhaven), or the centre of
+ // the world if no inhabited settlement exists.
+ var spawn = ChooseSpawn(_ctx.World);
+ if (session.PendingCharacter is not null)
+ {
+ var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
+ if (!string.IsNullOrWhiteSpace(session.PendingName))
+ p.Name = session.PendingName;
+ }
+ else
+ {
+ _actors.SpawnPlayer(spawn);
+ }
+
+ _controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
+ _controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
+
+ // Player marker.
+ _playerMarker = new PlayerMarker
+ {
+ Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
+ FacingAngleRad = _actors.Player.FacingAngleRad,
+ };
+ AddChild(_playerMarker);
+
+ _render.Camera.Position = _playerMarker.Position;
+ SetInitialZoom();
+ BuildHud();
+
+ // Clear pending so a quit-to-title doesn't see stale data.
+ session.PendingCharacter = null;
+ session.PendingName = "Wanderer";
+ session.PendingRestore = null;
+ session.PendingHeader = null;
+ }
+
+ public override void _Process(double delta)
+ {
+ if (_actors?.Player is null || _render is null) return;
+ float dt = (float)delta;
+
+ bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
+
+ // Tactical WASD direction (world-map mode ignores keys — middle-drag
+ // pans, click-to-travel sets the destination).
+ float dx = 0f, dy = 0f;
+ if (tactical)
+ {
+ if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f;
+ if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f;
+ if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f;
+ if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f;
+ }
+
+ _controller.Update(dt, dx, dy, tactical, isFocused: true);
+
+ // Sync the player marker from Core state.
+ var p = _actors.Player;
+ _playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
+ _playerMarker.FacingAngleRad = p.FacingAngleRad;
+
+ // Camera follow when traveling or in tactical (matches MonoGame).
+ if (_controller.IsTraveling || tactical)
+ _render.Camera.Position = _playerMarker.Position;
+
+ // Stream tactical chunks around the player when at tactical zoom.
+ if (tactical)
+ _streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
+
+ // Counter-scale markers so on-screen size stays constant.
+ float zoom = _render.Camera.Zoom.X;
+ if (zoom > 0f)
+ {
+ float inv = 1f / zoom;
+ _playerMarker.Scale = new Vector2(inv, inv);
+ _playerMarker.ShowFacingTick = tactical;
+ foreach (var marker in _npcMarkers.Values)
+ marker.Scale = new Vector2(inv, inv);
+ }
+
+ UpdateHud(tactical);
+ }
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
+ return;
+
+ if (mb.Pressed)
+ {
+ _mouseDownPos = mb.Position;
+ var worldPos = ScreenToWorld(mb.Position);
+ _mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
+ _mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
+ _mouseDownTracked = true;
+ return;
+ }
+
+ // Release.
+ if (!_mouseDownTracked) return;
+ _mouseDownTracked = false;
+ bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
+ if (!wasClick) return;
+
+ bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
+ if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
+ {
+ _controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
+ GetViewport().SetInputAsHandled();
+ }
+ }
+
+ private Vector2 ScreenToWorld(Vector2 screenPos)
+ => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
+
+ private static bool InBounds(int x, int y)
+ => (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Chunk → NPC lifecycle (Phase 5 M5)
+
+ private void HandleChunkLoaded(TacticalChunk chunk)
+ {
+ if (_content is null) return;
+ for (int i = 0; i < chunk.Spawns.Count; i++)
+ {
+ var spawn = chunk.Spawns[i];
+ if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
+
+ if (spawn.Kind == SpawnKind.Resident)
+ {
+ var resident = ResidentInstantiator.Spawn(
+ _ctx.World.WorldSeed, chunk, i, spawn,
+ _ctx.World, _content, _actors, _anchorRegistry);
+ if (resident is not null) MountNpcMarker(resident);
+ continue;
+ }
+
+ var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs);
+ if (template is null) continue;
+
+ int tx = chunk.OriginX + spawn.LocalX;
+ int ty = chunk.OriginY + spawn.LocalY;
+ var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
+ MountNpcMarker(npc);
+ }
+ }
+
+ private void HandleChunkEvicting(TacticalChunk chunk)
+ {
+ var toRemove = new List();
+ foreach (var npc in _actors.Npcs)
+ {
+ if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
+ {
+ toRemove.Add(npc.Id);
+ if (!string.IsNullOrEmpty(npc.RoleTag))
+ _anchorRegistry.UnregisterRole(npc.RoleTag);
+ }
+ }
+ foreach (int id in toRemove)
+ {
+ _actors.RemoveActor(id);
+ if (_npcMarkers.Remove(id, out var marker))
+ marker.QueueFree();
+ }
+ }
+
+ private void MountNpcMarker(NpcActor npc)
+ {
+ var marker = new NpcMarker
+ {
+ Position = new Vector2(npc.Position.X, npc.Position.Y),
+ Allegiance = npc.Allegiance,
+ };
+ AddChild(marker);
+ _npcMarkers[npc.Id] = marker;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Spawn + initial-zoom helpers
+
+ private static Vec2 ChooseSpawn(WorldState w)
+ {
+ var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
+ if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
+ var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
+ if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
+ return new Vec2(
+ C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
+ C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
+ }
+
+ private void SetInitialZoom()
+ {
+ // Frame ~24 tiles across the screen — comfortable overland zoom.
+ Vector2 viewport = GetViewport().GetVisibleRect().Size;
+ float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
+ targetZoom = Mathf.Clamp(targetZoom,
+ _render.Camera.MinZoom,
+ WorldRenderNode.TacticalRenderZoomMin * 0.95f);
+ _render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // HUD overlay (top-left panel, codex-styled)
+
+ private void BuildHud()
+ {
+ var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
+ AddChild(hudLayer);
+
+ _hudPanel = new PanelContainer
+ {
+ ThemeTypeVariation = "Card",
+ MouseFilter = MouseFilterEnum.Ignore,
+ OffsetLeft = 12, OffsetTop = 12,
+ OffsetRight = 420, OffsetBottom = 220,
+ };
+ hudLayer.AddChild(_hudPanel);
+
+ var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
+ margin.AddThemeConstantOverride("margin_left", 12);
+ margin.AddThemeConstantOverride("margin_top", 8);
+ margin.AddThemeConstantOverride("margin_right", 12);
+ margin.AddThemeConstantOverride("margin_bottom", 8);
+ _hudPanel.AddChild(margin);
+
+ _hudLabel = new Label
+ {
+ Text = "…",
+ ThemeTypeVariation = "CardBody",
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ margin.AddChild(_hudLabel);
+ }
+
+ private void UpdateHud(bool tactical)
+ {
+ var p = _actors.Player!;
+ int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
+ int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
+ int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
+ int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
+ ref var t = ref _ctx.World.TileAt(cx, cy);
+
+ string charBlock = "";
+ if (p.Character is { } pc)
+ {
+ int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
+ charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n";
+ }
+
+ string viewBlock = tactical
+ ? "View: Tactical (WASD to step)"
+ : "View: World Map (click a tile to travel)";
+
+ string status = _controller.IsTraveling
+ ? "Traveling…"
+ : tactical
+ ? "Mouse-wheel out to leave tactical."
+ : "Mouse-wheel in for tactical.";
+
+ _hudLabel.Text =
+ charBlock +
+ $"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
+ $"Player: ({ptx}, {pty}) {t.Biome}\n" +
+ $"{viewBlock}\n" +
+ $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
+ $"{status}\n" +
+ "M7.3 brings save/load. ESC pause arrives M7.4.";
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Quit path (M7.4 will wire ESC → pause; for now Esc returns to title)
+
+ public override void _Input(InputEvent @event)
+ {
+ if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
+ {
+ GetViewport().SetInputAsHandled();
+ BackToTitle();
+ }
+ }
+
+ private void BackToTitle()
+ {
+ var session = GameSession.From(this);
+ session.ClearPending();
+ session.Ctx = null;
+
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(new TitleScreen());
+ QueueFree();
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/PlayScreenStub.cs b/Theriapolis.Godot/Scenes/PlayScreenStub.cs
new file mode 100644
index 0000000..4904a39
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/PlayScreenStub.cs
@@ -0,0 +1,126 @@
+using Godot;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes;
+
+///
+/// M7.1 placeholder for the play screen. WorldGenProgressScreen swaps
+/// here on success; M7.2 will replace this with the real PlayScreen
+/// (walking character, chunk-streamed tactical view, HUD, save layer).
+///
+/// Reads and
+/// so the play-test confirms the M7.1 hand-off chain end-to-end:
+/// Title → Wizard → CharacterAssembler → WorldGenProgress → here.
+///
+public partial class PlayScreenStub : Control
+{
+ public override void _Ready()
+ {
+ Theme = CodexTheme.Build();
+ SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(bg);
+ bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+ MoveChild(bg, 0);
+
+ var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(center);
+ center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ var col = new VBoxContainer { CustomMinimumSize = new Vector2(640, 0) };
+ col.AddThemeConstantOverride("separation", 14);
+ center.AddChild(col);
+
+ var session = GameSession.From(this);
+
+ col.AddChild(new Label
+ {
+ Text = "PLAYSCREEN STUB · M7.1",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ col.AddChild(new Label
+ {
+ Text = "World generation complete.",
+ ThemeTypeVariation = "H2",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+
+ var ctx = session.Ctx;
+ if (ctx is not null)
+ {
+ var w = ctx.World;
+ col.AddChild(new Label
+ {
+ Text = $"Seed 0x{w.WorldSeed:X} · rivers {w.Rivers.Count} "
+ + $"roads {w.Roads.Count} rails {w.Rails.Count} "
+ + $"settlements {w.Settlements.Count} bridges {w.Bridges.Count}",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ });
+ }
+ else
+ {
+ col.AddChild(new Label
+ {
+ Text = "(No WorldGenContext on session — this stub was entered out-of-band.)",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ ThemeTypeVariation = "Eyebrow",
+ });
+ }
+
+ var character = session.PendingCharacter;
+ if (character is not null)
+ {
+ string hybridTag = character.Hybrid is not null ? "yes" : "no";
+ col.AddChild(new Label
+ {
+ Text = $"Character: {session.PendingName} · HP {character.MaxHp} "
+ + $"· class {character.ClassDef.Id} · hybrid: {hybridTag} "
+ + $"· skills: {character.SkillProficiencies.Count}",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ });
+ }
+ else
+ {
+ col.AddChild(new Label
+ {
+ Text = "(No character attached — load path will fill this in once M7.3 ships.)",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ ThemeTypeVariation = "Eyebrow",
+ });
+ }
+
+ col.AddChild(new Label
+ {
+ Text = "PlayScreen with walking character + chunk-streamed tactical view lands in M7.2.",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ });
+
+ var titleBtn = new Button
+ {
+ Text = "← Title",
+ CustomMinimumSize = new Vector2(220, 44),
+ SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
+ };
+ titleBtn.Pressed += BackToTitle;
+ col.AddChild(titleBtn);
+ }
+
+ private void BackToTitle()
+ {
+ var session = GameSession.From(this);
+ session.ClearPending();
+ session.Ctx = null;
+
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(new TitleScreen());
+ QueueFree();
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs
index 48c46d8..b88fc62 100644
--- a/Theriapolis.Godot/Scenes/TitleScreen.cs
+++ b/Theriapolis.Godot/Scenes/TitleScreen.cs
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
///
public partial class TitleScreen : Control
{
- private const string VersionLabel = "PORT / GODOT · M6.20";
+ private const string VersionLabel = "PORT / GODOT · M7.2";
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready()
@@ -121,10 +121,15 @@ public partial class TitleScreen : Control
if (sibling != this) sibling.QueueFree();
var wizardNode = packed.Instantiate();
parent.AddChild(wizardNode);
- // The wizard's "← Title" back-button (visible on step 0) emits
- // BackToTitle; reinstate this title screen when that fires.
if (wizardNode is Wizard wizard)
+ {
+ // "← Title" back-button (visible on step 0) emits BackToTitle.
wizard.BackToTitle += () => SwapBackToTitle(parent);
+ // M7.1 — Confirm & Begin in StepReview is forwarded by the
+ // wizard as CharacterConfirmed. Stash the built character on
+ // GameSession and hand off to WorldGenProgressScreen.
+ wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft);
+ }
QueueFree();
}
@@ -134,6 +139,27 @@ public partial class TitleScreen : Control
parent.AddChild(new TitleScreen());
}
+ /// M7.1 hand-off: snapshot the built character + chosen
+ /// name onto , default the seed (a seed-entry
+ /// UI lands later), and swap to .
+ private static void SwapToWorldGen(Node parent, UI.CharacterDraft draft)
+ {
+ var session = GameSession.From(parent);
+ // CharacterAssembler.LastBuilt is populated by StepReview's
+ // OnConfirmPressed → TryBuild call immediately before the
+ // CharacterConfirmed signal fires.
+ session.PendingCharacter = CharacterAssembler.LastBuilt;
+ session.PendingName = string.IsNullOrWhiteSpace(draft.CharacterName)
+ ? "Wanderer"
+ : draft.CharacterName;
+ session.Seed = 12345UL; // default for M7; seed-entry UI is M8+.
+ session.PendingRestore = null;
+ session.PendingHeader = null;
+
+ foreach (Node child in parent.GetChildren()) child.QueueFree();
+ parent.AddChild(new WorldGenProgressScreen());
+ }
+
private void OnContinue()
{
// M7 territory — the play-loop screens that consume the persisted
diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs
index f54a132..dd784db 100644
--- a/Theriapolis.Godot/Scenes/Wizard.cs
+++ b/Theriapolis.Godot/Scenes/Wizard.cs
@@ -15,6 +15,11 @@ public partial class Wizard : Control
{
[Signal] public delegate void BackToTitleEventHandler();
+ /// Forwarded from StepReview.CharacterConfirmed so
+ /// the wizard's owner (TitleScreen / Main) can hand off to the
+ /// WorldGenProgressScreen without reaching into the step tree.
+ [Signal] public delegate void CharacterConfirmedEventHandler(UI.CharacterDraft draft);
+
private static readonly string[] StepKeys =
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
private static readonly string[] StepNames =
@@ -117,6 +122,13 @@ public partial class Wizard : Control
_activeStep = instance;
instance.Bind(Character);
_stepHost.AddChild((Control)instance);
+
+ // Forward the final-step confirmation upward so TitleScreen
+ // (or whatever shell owns the wizard) can swap to M7.1's
+ // WorldGenProgressScreen without coupling to the step tree.
+ if (instance is Steps.StepReview review)
+ review.CharacterConfirmed += draft =>
+ EmitSignal(SignalName.CharacterConfirmed, draft);
}
UpdateChrome();
diff --git a/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs b/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs
new file mode 100644
index 0000000..60f3c77
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs
@@ -0,0 +1,251 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Godot;
+using Theriapolis.Core.Persistence;
+using Theriapolis.Core.World.Generation;
+using Theriapolis.GodotHost.Platform;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes;
+
+///
+/// M7.1 — runs the 23-stage worldgen pipeline on a background thread
+/// and shows per-stage progress. Transitions to
+/// (which M7.2 will replace with the real PlayScreen) on completion.
+///
+/// Mirrors Theriapolis.Game/Screens/WorldGenProgressScreen.cs:
+/// same volatile-field hand-off between the worker and the UI thread,
+/// same soft stage-hash warning when restoring from a saved header.
+///
+/// Inputs (from ):
+/// - Seed — required.
+/// - PendingHeader — present when restoring from save; triggers
+/// the post-gen stage-hash diff against WorldState.StageHashes.
+///
+/// Outputs:
+/// - session.Ctx set on success; consumed by the next screen.
+///
+/// Escape during generation: cancel the worker (honoured at the next
+/// stage boundary), return to Title.
+///
+public partial class WorldGenProgressScreen : Control
+{
+ private WorldGenContext? _ctx;
+ private Task? _genTask;
+ private CancellationTokenSource? _cts;
+ private volatile float _progress;
+ private volatile string _stageName = "Initialising…";
+ private volatile bool _complete;
+ private volatile string? _error;
+
+ private Label _titleLabel = null!;
+ private ProgressBar _progressBar = null!;
+ private Label _stageLabel = null!;
+ private bool _transitioned;
+
+ public override void _Ready()
+ {
+ Theme = CodexTheme.Build();
+ SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ // Backing panel so the dark palette Bg fills the viewport (the
+ // Control itself paints nothing). Mirrors TitleScreen.cs.
+ var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(bg);
+ bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+ MoveChild(bg, 0);
+
+ BuildUI();
+ StartGeneration();
+ }
+
+ private void BuildUI()
+ {
+ var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
+ AddChild(center);
+ center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+
+ var col = new VBoxContainer { CustomMinimumSize = new Vector2(480, 0) };
+ col.AddThemeConstantOverride("separation", 14);
+ center.AddChild(col);
+
+ var session = GameSession.From(this);
+ col.AddChild(new Label
+ {
+ Text = "FORGING THE WORLD",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ _titleLabel = new Label
+ {
+ Text = $"Seed 0x{session.Seed:X}",
+ ThemeTypeVariation = "H2",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+ col.AddChild(_titleLabel);
+
+ _progressBar = new ProgressBar
+ {
+ MinValue = 0,
+ MaxValue = 1,
+ Step = 0.001,
+ ShowPercentage = true,
+ CustomMinimumSize = new Vector2(0, 22),
+ };
+ col.AddChild(_progressBar);
+
+ _stageLabel = new Label
+ {
+ Text = "Starting…",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ };
+ col.AddChild(_stageLabel);
+
+ col.AddChild(new Label
+ {
+ Text = "Esc to cancel · returns to title.",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ }
+
+ private void StartGeneration()
+ {
+ _cts = new CancellationTokenSource();
+ var token = _cts.Token;
+ var session = GameSession.From(this);
+ ulong seed = session.Seed;
+ string dataDir = ContentPaths.DataDir;
+
+ _genTask = Task.Run(() =>
+ {
+ try
+ {
+ var ctx = new WorldGenContext(seed, dataDir)
+ {
+ ProgressCallback = (name, frac) =>
+ {
+ _stageName = name;
+ _progress = frac;
+ },
+ Log = msg => GD.Print($"[worldgen] {msg}"),
+ };
+ WorldGenerator.RunAll(ctx);
+ if (token.IsCancellationRequested) return;
+ _ctx = ctx;
+ _complete = true;
+ }
+ catch (Exception ex)
+ {
+ var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex;
+ _error = inner.ToString();
+ }
+ }, token);
+ }
+
+ public override void _Process(double delta)
+ {
+ if (_transitioned) return;
+ if (_error is not null)
+ {
+ ShowError(_error);
+ return;
+ }
+ if (_complete && _ctx is not null)
+ {
+ _transitioned = true;
+ Transition();
+ return;
+ }
+ _progressBar.Value = _progress;
+ _stageLabel.Text = _stageName;
+ }
+
+ private void Transition()
+ {
+ var session = GameSession.From(this);
+ if (session.PendingHeader is not null)
+ CompareStageHashes(session.PendingHeader);
+
+ session.Ctx = _ctx;
+
+ // M7.2 — the real PlayScreen. PlayScreenStub is kept around as
+ // a fallback for any future code path that hasn't been wired up
+ // (e.g. mid-development load flows), but the live hand-off lands
+ // in the play view.
+ SwapTo(new PlayScreen());
+ }
+
+ private void SwapTo(Node next)
+ {
+ var parent = GetParent();
+ if (parent is null) return;
+ foreach (Node sibling in parent.GetChildren())
+ if (sibling != this) sibling.QueueFree();
+ parent.AddChild(next);
+ QueueFree();
+ }
+
+ private void ShowError(string error)
+ {
+ _stageLabel.Text = "ERROR — press Escape to return to title";
+ _progressBar.Value = 0;
+ // Crop to the first line + 100 chars so the title label stays legible.
+ int newline = error.IndexOf('\n');
+ string headline = newline > 0 ? error[..newline] : error;
+ _titleLabel.Text = headline.Length > 100 ? headline[..100] + "…" : headline;
+
+ try
+ {
+ string logPath = ProjectSettings.GlobalizePath("user://worldgen_error.log");
+ File.WriteAllText(logPath, $"[{DateTime.Now:u}] WorldGen ERROR\n{error}\n");
+ GD.PushError($"[worldgen] Wrote {logPath}");
+ }
+ catch { /* best-effort */ }
+ }
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
+ {
+ _cts?.Cancel();
+ BackToTitle();
+ }
+ }
+
+ public override void _ExitTree()
+ {
+ _cts?.Cancel();
+ _cts?.Dispose();
+ _cts = null;
+ }
+
+ private void BackToTitle()
+ {
+ var session = GameSession.From(this);
+ session.ClearPending();
+ session.Ctx = null;
+ SwapTo(new TitleScreen());
+ }
+
+ private void CompareStageHashes(SaveHeader savedHeader)
+ {
+ if (_ctx is null) return;
+ int mismatches = 0;
+ foreach (var kv in _ctx.World.StageHashes)
+ {
+ if (!savedHeader.StageHashes.TryGetValue(kv.Key, out var sv)) continue;
+ string current = $"0x{kv.Value:X}";
+ if (!string.Equals(sv, current, StringComparison.OrdinalIgnoreCase))
+ {
+ mismatches++;
+ GD.PushWarning($"[save-migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}");
+ }
+ }
+ if (mismatches > 0)
+ GD.PushWarning($"[save-migration] {mismatches} stage(s) drifted; loading anyway (soft).");
+ }
+}
diff --git a/Theriapolis.Godot/project.godot b/Theriapolis.Godot/project.godot
index 4139a08..65e4501 100644
--- a/Theriapolis.Godot/project.godot
+++ b/Theriapolis.Godot/project.godot
@@ -21,6 +21,12 @@ window/size/mode=3
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
+[autoload]
+
+; M7.1 — cross-scene state (seed, post-worldgen ctx, pending character,
+; pending save snapshot). See GameSession.cs and the M7 plan §4.3.
+GameSession="*res://GameSession.cs"
+
[dotnet]
project/assembly_name="Theriapolis.Godot"
diff --git a/theriapolis-rpg-implementation-plan-godot-port-m7.md b/theriapolis-rpg-implementation-plan-godot-port-m7.md
new file mode 100644
index 0000000..bec762b
--- /dev/null
+++ b/theriapolis-rpg-implementation-plan-godot-port-m7.md
@@ -0,0 +1,1054 @@
+# Theriapolis — Godot Port — M7 — Design & Implementation Plan
+## Play loop screens: WorldGen progress, PlayScreen (seamless world+tactical), Pause, Save/Load, Interaction
+
+**Status:** Proposed (drafted 2026-05-10).
+Targets the codebase state at the close of **M6.21** on the `port/godot`
+branch:
+
+- M6 (Title + character creation) shipped through M6.21 — TitleScreen,
+ Wizard (8 steps), CodexTheme (dark only), CodexCard / CodexStepper /
+ CodexPopover widgets, `CharacterAssembler.TryBuild` producing a
+ runtime `Character` and dumping `user://character.json`.
+- M2 + M4 (world + tactical render) shipped as the `WorldView` demo
+ scene (`Theriapolis.Godot/Rendering/WorldView.cs`), runnable via the
+ `--world-map ` and `--tactical ` CLI flags. It
+ generates the world inline in `_Ready`, holds the player position as
+ a plain `Vec2`, and has no save / NPC / encounter / interact wiring.
+- `Theriapolis.Game/Screens/*` is still authoritative for play-loop
+ *behaviour* (see §3) — every screen we port wraps Core APIs that the
+ MonoGame screens already proved.
+
+**Audience:** the agent who will land M7. Read §3 (the per-screen
+behaviour table) and §6 (PlayScreen architecture) before writing code;
+PlayScreen is ~60% of the milestone and the only screen that needs new
+plumbing rather than a port. Read §10 (risks) before committing to a
+sub-milestone date.
+
+**Governing docs:**
+
+- `theriapolis-rpg-implementation-plan-godot-port.md` §5.M7 — the
+ one-page outline this document expands. The six screens listed there
+ (WorldGenProgress, WorldMap, Play, PauseMenu, SaveLoad, Interaction)
+ set the scope; the exit criterion (load-walk-save-reload bytes-identical
+ against the MonoGame build) is binding.
+- `theriapolis-rpg-implementation-plan.md` §12 — the binding hard rules.
+ No engine code in `Theriapolis.Core`, all RNG via `SeededRng`, all
+ magic numbers in `Constants.cs`, determinism contract, linear-feature
+ exclusion. The port changes the rendering host, not the rules.
+- `theriapolis-rpg-implementation-plan-phase4.md` §3.1 (coordinate model),
+ §3.4 (chunk streaming), §4 (camera + view-mode swap). PlayScreen
+ inherits this contract verbatim from `Theriapolis.Game/Screens/PlayScreen.cs`.
+- `theriapolis-rpg-implementation-plan-phase5.md` §3.4 (encounter
+ lifecycle + mid-combat save), §4.4 (Resolver), §4.6 (DangerZone). M7
+ must round-trip the encounter snapshot through save/load even though
+ combat HUD is M8 territory — a save written mid-fight by the MonoGame
+ build has to load in the Godot build.
+- `theriapolis-rpg-implementation-plan-phase6.md` §4.4 (quest engine),
+ §3.2 (no-scene-swap doctrine for buildings — preserved here),
+ §11 (deviations).
+- `theriapolis-rpg-implementation-plan-phase6-5.md` §3 (level-up flow,
+ SubclassId stamping, feature pool refills), §11 (deviation table).
+ PlayScreen needs to surface "Level Up" affordances from the pause menu
+ exactly as MonoGame's `PauseMenuScreen` does.
+- `theriapolis-rpg-implementation-plan-phase7.md` §3.1 (dialogue
+ runner contract — `start_quest` / `open_shop` / `set_flag` effects),
+ §3.4 (mid-combat save). InteractionScreen is the only M7 screen that
+ touches Phase-7 systems; shop + combat HUD slip to M8.
+- `CLAUDE.md` "Seamless Zoom Model" — the world-map vs. tactical
+ distinction is a *zoom range*, not a screen swap. This is the reason
+ §6 collapses §M7's six screens to **four scenes** plus one camera
+ mode.
+
+**All hard rules from the original plan §12 remain in force.**
+
+---
+
+## 1. Goals & non-goals
+
+### Goals
+
+1. **Wire `Theriapolis.Core` into a playable loop inside Godot.** A
+ character built by M6 reaches a generated world, walks around at
+ both zoom scales, opens dialogue with an NPC, saves, loads, and
+ quits to title — without any MonoGame artefact in the chain.
+2. **Save-format parity with the MonoGame build.** `SaveCodec` is in
+ Core and untouched. A save written by `Theriapolis.Desktop` loads
+ in the Godot build with byte-identical replay (player position,
+ clock, killed-spawn-indices, quest state, reputation, mid-combat
+ encounter). Vice versa. SAVE_SCHEMA_VERSION does not bump.
+3. **Adopt the codex design system established in M5–M6 across every
+ M7 screen.** HUD, pause overlay, save-slot picker, dialogue history
+ all draw from `CodexTheme.Build()` + the dark palette + the M5
+ widgets. No bespoke styling per screen.
+4. **Refactor the M2+M4 `WorldView` into a *re-usable* world node.**
+ The demo scene exists; the play screen wraps the same node with
+ the character/clock/streamer/save layer added on top. Worldgen
+ moves out of `WorldView._Ready` so the progress screen can drive
+ it on a background thread.
+5. **Behavioural parity for every shipped MonoGame screen this
+ milestone covers** — see §3 for the per-screen contract.
+6. **All tests still green.** `dotnet test` runs unchanged; the
+ architecture test continues to forbid `Microsoft.Xna` *and*
+ `Godot.*` namespaces inside `Theriapolis.Core`.
+
+### Non-goals
+
+- **No combat HUD.** `CombatHUDScreen` is M8. M7's encounter handling
+ is limited to *detection* (a hostile entering the trigger ring) and
+ *save-snapshot round-trip*. When a fight would start, M7 either
+ pushes a stub "TODO M8" panel (preferred) or refuses to start and
+ prints a console diagnostic — the user's call during the milestone.
+- **No shop screen.** `open_shop` dialogue effects are swallowed by
+ InteractionScreen with a "TODO M8" toast.
+- **No inventory / level-up / quest log / reputation / defeated
+ screens.** All M8. Pause-menu "★ Level Up" affordance is rendered
+ *disabled* (with eligible-state tooltip) until M8 lands the
+ LevelUpScreen.
+- **No new gameplay.** No tuning, no balance, no new dialogue trees,
+ no new world content. If a play-test reveals a bug in Core, file it;
+ don't fix it under M7's hood unless it blocks the load/save parity
+ test.
+- **No isometric tactical view.** Captured in
+ `memory/project_isometric_tactical_view.md` as a future exploration;
+ M7 ships orthographic tactical because that's what M4 ships.
+
+---
+
+## 2. Why this is the longest M-milestone
+
+§M7 is budgeted 5 days in the godot-port plan, but it is the first
+milestone where:
+
+- Worldgen runs *as part of the user's session* (not from a CLI
+ oneshot or a demo entry point);
+- A live `Character` from M6 has to be attached to a spawned actor;
+- The Save system round-trips for the first time on the Godot side;
+- The dialogue runner (Phase 6 M3) runs against live Core state.
+
+Each of those is independently low-risk because the Core API is
+already proven by `Theriapolis.Game/Screens/*`. The risk is in their
+*composition* — the order PlayScreen calls them and the lifecycle of
+the actor/streamer/encounter trio. That risk is concentrated in §6.
+
+---
+
+## 3. Per-screen behavioural contract
+
+Each row is a one-line restatement of what the MonoGame screen does;
+the Godot port preserves the *behaviour*, not the implementation.
+
+| MonoGame screen | LOC | M7 Godot equivalent | Notes |
+|---------------------------------|-----:|----------------------------------|-------|
+| `WorldGenProgressScreen.cs` | 196 | `Scenes/WorldGenProgressScreen.cs` | Background-thread worldgen + per-stage progress bar. Transitions to `PlayScreen` on completion. |
+| `WorldMapScreen.cs` | 188 | **Folded into `PlayScreen` as the zoomed-out camera mode** — see §4.2 below. Not a separate scene. |
+| `PlayScreen.cs` | 908 | `Scenes/PlayScreen.cs` (the big one) | Wraps the M2+M4 `WorldView`; adds player actor, clock, controller, chunk streamer, save layer, HUD, save-toast, F-to-talk, autosave hooks. |
+| `PauseMenuScreen.cs` | 195 | `Scenes/PauseMenuScreen.cs` | Popup overlay; Resume / Save / Quicksave / Quit. Level-Up entry stays disabled until M8. |
+| `SaveLoadScreen.cs` | 143 | `Scenes/SaveLoadScreen.cs` | Slot picker. Read-only header parse. Pushed by Title (load) and by Pause (save-as). |
+| `InteractionScreen.cs` | 395 | `Scenes/InteractionScreen.cs` | Dialogue history + numbered options + scent-literacy overlay. Phase-6 `DialogueRunner` unchanged. |
+| `ScreenManager.cs` | 68 | **Replaced by Godot scene tree** — see §4.1 below. |
+| `IScreen.cs` | 26 | **Deleted**. The Initialize/Deactivate/Reactivate lifecycle is subsumed by Godot's `_Ready` / `_ExitTree` / `_EnterTree` + signal wiring. |
+| `Platform/SavePaths.cs` | 52 | `Platform/SavePaths.cs` (port verbatim, no MonoGame deps in it today) |
+| `Platform/Clipboard.cs` | ~30 | `Platform/Clipboard.cs` — replace TextCopy calls with `DisplayServer.ClipboardSet` / `ClipboardGet`. |
+
+**Out of M7 scope (deferred to M8 per godot-port §M7 vs §M8 split):**
+`CombatHUDScreen`, `InventoryScreen`, `LevelUpScreen`, `ShopScreen`,
+`QuestLogScreen`, `ReputationScreen`, `DefeatedScreen`,
+`DungeonScreen` (Phase-7 surface).
+
+---
+
+## 4. Architecture
+
+### 4.1 No ScreenManager — Godot's Control tree replaces it
+
+`Theriapolis.Game.Screens.ScreenManager` is a Push/Pop stack of
+`IScreen` instances with deferred mutation. Godot's scene tree is
+already a stack-like hierarchy with proper input/process pause
+semantics, so M7 deletes the abstraction outright.
+
+**Replacement model:**
+
+- **Top-level swap** (e.g. Title → WorldGenProgress → Play): the
+ outgoing scene `QueueFree`s itself; the incoming scene is added to
+ `Main`. TitleScreen already uses this idiom (see
+ `Scenes/TitleScreen.cs:OnNewCharacter` and `SwapBackToTitle`).
+- **Overlay** (Pause, SaveLoad, Interaction): added as a *child*
+ `CanvasLayer` of the current scene at `Layer = 50` (UI, above world
+ layers but below `PopoverLayer.Layer = 100`). Overlays set
+ `process_mode = WhenPaused` on themselves and call
+ `GetTree().Paused = true` on enter, `false` on exit. PlayScreen's
+ `_Process` and `_PhysicsProcess` halt automatically while paused.
+- **Popup-within-overlay** (slot picker inside Pause): the second
+ overlay is a *child* of the first; closing it just `QueueFree`s
+ the child. No global "stack" state is needed.
+
+**Tree-paused doctrine:** Pause/SaveLoad/Interaction all run under
+`process_mode = WhenPaused` so they keep responding to input while
+the game clock halts. Sub-controls inside them inherit `Inherit`
+(the default), so the rule cascades automatically.
+
+### 4.2 The seamless-zoom collapse
+
+`CLAUDE.md`'s "Seamless Zoom Model" — one `Camera2D` covers world and
+tactical via continuous zoom — means there is no observable transition
+between "the world map" and "the play view". The MonoGame split into
+`WorldMapScreen` + `PlayScreen` predated the seamless model and now
+exists only because MonoGame's `Game1` couldn't easily mode-swap the
+input handler.
+
+**M7 collapses the two.** PlayScreen owns the whole zoom range. The
+zoomed-out view is the same scene with the camera at a low zoom
+factor; the HUD overlay adapts (the "tactical cursor read-out" block
+hides at low zoom; the "click-to-travel" hint shows). This is the
+contract the godot-port plan §4.4 already lays out — the M7 doc just
+makes it explicit that there is no `WorldMapScreen.tscn`.
+
+`WorldView` (the M2+M4 demo) stays as a *standalone* scene for
+debugging — it generates a world and lets you fly around without
+character or save state. PlayScreen does *not* extend or compose
+WorldView; instead, the layer-building code (biome image, polylines,
+bridges, settlements, chunk streamer wiring) is **extracted into a
+reusable `Rendering/WorldRenderNode.cs`** that both PlayScreen and
+WorldView mount. See §6.2.
+
+### 4.3 GameSession autoload
+
+Game-wide state that outlives any single scene goes into an autoload
+singleton, registered in `project.godot` as `GameSession`:
+
+```csharp
+namespace Theriapolis.GodotHost;
+
+public partial class GameSession : Node
+{
+ public ulong Seed;
+ public WorldGenContext? Ctx; // post-worldgen
+ public Character? PendingCharacter; // M6 hand-off
+ public string PendingName = "Wanderer";
+ public SaveBody? PendingRestore; // load-from-slot hand-off
+ public SaveHeader? PendingHeader;
+}
+```
+
+**Why an autoload, not a static class?** Godot autoloads participate
+in the scene tree lifecycle — `_Ready` runs once at engine start, the
+node is reachable from any scene via `GetNode("/root/GameSession")`,
+and the engine guarantees teardown order. `CharacterAssembler.LastBuilt`
+(a static) works for M6 because nothing else owns the character; once
+PlayScreen exists, the live `Character` flips between "the M6 draft"
+and "the actor's component", so an autoload that *both* read from is
+the cleaner cut.
+
+**Hand-off contract:**
+
+- TitleScreen → WorldGenProgressScreen: `Seed` set (12345 default for
+ M7; a seed-entry UI is M8 territory).
+- M6 wizard → WorldGenProgressScreen: `PendingCharacter`, `PendingName`
+ set after `CharacterAssembler.TryBuild`. The wizard pushes
+ `WorldGenProgressScreen` instead of returning to Title.
+- SaveLoadScreen (load) → WorldGenProgressScreen: `PendingRestore`,
+ `PendingHeader`, `Seed` set from the deserialised header.
+- WorldGenProgressScreen → PlayScreen: `Ctx` set, plus whichever of
+ `PendingCharacter` / `PendingRestore` was used. PlayScreen consumes
+ the pending* fields and clears them.
+
+The static `CharacterAssembler.LastBuilt` continues to exist for
+diagnostic / unit-test access; the wizard writes both.
+
+### 4.4 The architecture test
+
+No change required from M6 — the M0/M1 update to
+`Architecture/CoreNoDependencyTests.cs` (forbid `Godot.*` in addition
+to `Microsoft.Xna`) already covers M7. The test must remain green at
+every commit in this milestone.
+
+### 4.5 Determinism
+
+`Theriapolis.Core` is the deterministic boundary. M7 must not
+introduce a single `System.Random()`, `DateTime.Now`-seeded RNG, or
+ad-hoc `XorShift` in the Godot project. All RNG (e.g. for the
+save-flash toast colour cycle if we add one) goes through
+`Theriapolis.Core.Util.SeededRng` with a sub-stream declared in
+`Constants.cs`, **even though presentation RNG doesn't have to be
+deterministic** — keeping the discipline removes the "is this RNG
+load-bearing?" judgment call from every future PR.
+
+Per-frame `_Process` jitter, animation timing, and input-driven
+camera pan are non-deterministic by definition and don't go through
+SeededRng — those affect rendering only and never feed back into
+Core state.
+
+---
+
+## 5. WorldGenProgressScreen
+
+### 5.1 Behaviour
+
+Direct port of `Theriapolis.Game/Screens/WorldGenProgressScreen.cs`.
+Three states:
+
+1. **Generating:** progress bar + "Stage N of 23: HydrologyGenStage"
+ label, updated from `ctx.ProgressCallback` on a background thread.
+2. **Complete:** transitions to `PlayScreen`. If `_savedHeader` is set,
+ compare stage hashes (soft warning, not a hard fail — same as the
+ MonoGame source).
+3. **Error:** display the exception, halt on Escape. Write the full
+ trace to `user://worldgen_error.log` for post-mortem.
+
+### 5.2 Layout
+
+Centre-screen `VBoxContainer` mounted in a `Control` filling the
+viewport. Three labels:
+
+- Title: `"Generating world... (seed: 0x{seed:X})"` — `CodexTitle`
+ variation.
+- Progress bar: a `ProgressBar` with `MinValue=0`, `MaxValue=1` and
+ custom theme stylebox to match the codex (parchment-rule outline +
+ gild fill). A textual "[#### ] 40%" fallback is not needed
+ because `ProgressBar` is Godot-native.
+- Stage label: the active stage name, body-text size.
+
+Dark theme only (M5 contract); no theme switcher.
+
+### 5.3 Threading
+
+```csharp
+public override void _Ready()
+{
+ BuildUI();
+ StartGeneration(); // spawns System.Threading.Tasks.Task
+}
+
+public override void _Process(double delta)
+{
+ // Pump progress + completion check from the worker into UI.
+ if (_error is not null) { ShowError(); return; }
+ if (_complete) { Transition(); return; }
+ _progressBar.Value = _progress;
+ _stageLabel.Text = _stageName;
+}
+```
+
+Same volatile-field hand-off pattern as the MonoGame source. Godot's
+`_Process` runs on the main thread; the worker only mutates
+`volatile float _progress` / `volatile string _stageName` / `volatile
+bool _complete` / `volatile string? _error`. No locks needed.
+
+### 5.4 Transition
+
+```csharp
+private void Transition()
+{
+ var session = GetNode("/root/GameSession");
+ session.Ctx = _ctx;
+ SwapTo(new PlayScreen());
+}
+```
+
+`SwapTo` is the same idiom TitleScreen uses: clear sibling nodes,
+add the new scene, `QueueFree` self.
+
+### 5.5 Edge cases
+
+- **Escape during generation:** queue a cancellation token and check
+ it from the worker. Worldgen stages are non-cancellable mid-stage,
+ so the cancel is honoured at the next stage boundary (≤ 2s in
+ practice). On cancel, return to Title.
+- **App quit during generation:** Godot calls `_ExitTree` before
+ shutdown; pass the cancellation token through `_ExitTree` so the
+ worker exits cleanly. Worldgen drops its half-built `WorldState`
+ for GC; no on-disk state to clean up.
+- **Stage-hash mismatch:** log via `GD.PushWarning` for the same
+ "soft warning" semantics as the MonoGame source. Do not block load.
+
+---
+
+## 6. PlayScreen
+
+The hinge of M7. ~60% of the milestone's effort lives here.
+
+### 6.1 Scope
+
+PlayScreen owns:
+
+- The Camera2D + zoom range (seamless world ↔ tactical).
+- The biome backdrop, polylines, bridges, settlements (M2+M4 layers
+ via `WorldRenderNode`).
+- The chunk streamer + tactical chunk nodes (M4 work, currently in
+ `WorldView`).
+- The player actor (from M6's `CharacterAssembler` or from a save).
+- NPC actors (spawned by chunk-load events; despawned on chunk
+ eviction).
+- The world clock.
+- The player controller (mouse-click travel, WASD step).
+- The HUD overlay (codex-styled; see §6.7).
+- The interact prompt + dialogue push (F).
+- The autosave / quicksave / save-as plumbing.
+- The encounter trigger detector (Phase 5 M5) — but the *push to
+ CombatHUD* is M8.
+
+### 6.2 WorldRenderNode extraction
+
+Move from `WorldView` to a new `Rendering/WorldRenderNode.cs` the
+following purely-rendering work:
+
+- `BuildBiomeSprite` (the 256×256 biome image)
+- `BuildPolylines` (rivers/roads/rails)
+- `BuildBridges`
+- `BuildSettlements`
+- The `_scaledLines` list + `UpdateZoomScaledNodes` per-frame width
+ recalc
+- `UpdateLayerVisibility` (tactical vs settlements hide thresholds)
+- Chunk streamer ownership + `AddChunkNode` / `RemoveChunkNode`
+- `StreamIfTactical` (driven by an external "current zoom" reading)
+
+What stays in `WorldView` (the demo): the standalone-mode entry
+point, the demo player marker, the standalone movement code.
+
+What stays *outside* `WorldRenderNode` and lives in PlayScreen
+instead: the `ActorManager` driving live player + NPC sprites, the
+`PlayerController`, the clock tick, the save layer, the HUD.
+
+**Signal surface** of `WorldRenderNode` (the cuts between rendering
+and game state):
+
+```csharp
+[Signal] public delegate void ChunkLoadedEventHandler(int cx, int cy);
+[Signal] public delegate void ChunkEvictingEventHandler(int cx, int cy);
+
+public void Initialize(WorldGenContext ctx, ChunkStreamer streamer);
+public void SetPlayerPosition(Vec2 worldPx); // for streamer follow
+public void SetZoomTier(ZoomTier tier); // controls layer visibility
+public Camera2D Camera { get; } // PlayScreen reads this
+```
+
+`ChunkStreamer` ownership moves *out* of `WorldView` and into
+PlayScreen — PlayScreen needs to subscribe to chunk events to know
+when to spawn/despawn NPCs (per Phase 5 M5), so the streamer is
+better owned at the game-state layer and *injected* into
+`WorldRenderNode`.
+
+### 6.3 Initialisation order
+
+This is the order that matters; deviating from it breaks restore-
+from-save in subtle ways. The MonoGame source (`PlayScreen.Initialize`,
+lines 145–233) is the reference contract.
+
+```
+1. ctx = GameSession.Ctx (already worldgen-complete)
+2. Build Camera2D node, attach to scene tree
+3. Build WorldRenderNode, hand it ctx
+4. Build content resolver: ContentResolver(ContentLoader(dataDir))
+5. Build chunk streamer: new ChunkStreamer(seed, world, deltas,
+ content.Settlements)
+6. Build ActorManager + WorldClock + InMemoryChunkDeltaStore
+7. Build AnchorRegistry; RegisterAllAnchors(world)
+8. Build QuestContext (Phase 6 M4 — wraps content/actors/rep/flags/...)
+9. Wire chunk events: streamer.OnChunkLoaded += HandleChunkLoaded;
+ streamer.OnChunkEvicting += HandleChunkEvicting
+10. If restore:
+ ApplyRestoredBody(GameSession.PendingRestore)
+ else if new game from M6:
+ actor = ActorManager.SpawnPlayer(spawn, PendingCharacter)
+ actor.Name = PendingName
+ else:
+ actor = ActorManager.SpawnPlayer(ChooseSpawn(world))
+11. Build PlayerController; wire TacticalIsWalkable to streamer.SampleTile
+12. Centre camera on player; pick a comfortable initial zoom
+13. Build HUD overlay (§6.7)
+14. If restore had a pending encounter:
+ streamer.EnsureLoadedAround(player.Position, TACTICAL_WINDOW_WORLD_TILES)
+ RestoreEncounter(...) — pushes the M8 combat overlay; M7 stubs
+```
+
+Steps 1–13 are deterministic and stage-ordered exactly as MonoGame's
+`Initialize`. Step 14 is the deferred mid-combat restore that Phase 5
+M5 introduced; M7 wires it but the M8 CombatHUD push is the stub.
+
+### 6.4 PlayerController port
+
+`Theriapolis.Game/Input/PlayerController.cs` — verify it has no
+MonoGame deps (it shouldn't; movement is in world-pixel space and the
+input adapter feeds it `Vec2` deltas). Port the small wrapper that
+reads `Input.IsKeyPressed(Key.W)` etc. into a `PlayerInputAdapter` and
+hand it to the controller. Click-to-travel uses Godot's
+`InputEventMouseButton` events routed through `_UnhandledInput` —
+PlayScreen converts screen→world via the camera (the same
+`ScreenToWorld` math as MonoGame, just `camera.GetCanvasTransform()`
+on the Godot side).
+
+**Key bindings (provisional; final `InputMap.tres` lands in M9):**
+
+- W A S D / Arrows: pan camera at low zoom, step actor at tactical zoom.
+- Left-drag: pan camera.
+- Mouse-wheel: zoom (towards cursor) — already implemented in
+ `PanZoomCamera`.
+- Left-click on tile: world-map mode → request travel to tile.
+- F: talk to interact candidate (push InteractionScreen).
+- Tab: open inventory — **disabled in M7** (toast "Inventory ships
+ with M8") so the binding is reserved but not dead.
+- R: open reputation — **disabled in M7**.
+- J: open quest journal — **disabled in M7**.
+- F5: quicksave to autosave slot.
+- Esc: push PauseMenuScreen.
+
+### 6.5 Save / load round-trip
+
+`SaveCodec` / `SaveHeader` / `SaveBody` are in
+`Theriapolis.Core.Persistence` and untouched. `SavePaths` is the only
+piece that ports — move from `Theriapolis.Game/Platform/SavePaths.cs`
+to `Theriapolis.Godot/Platform/SavePaths.cs`, no API changes. The
+default save directory (`%LOCALAPPDATA%/Theriapolis/Saves` on Windows,
+`~/Library/Application Support/Theriapolis/Saves` on macOS,
+`$XDG_DATA_HOME/Theriapolis/saves` on Linux) is **deliberately
+identical to MonoGame's**, so a save written by either build is
+discoverable by the other. This is the binding constraint behind the
+M7 exit criterion (load-walk-save-reload bytes-identical).
+
+**Save body capture** (`CaptureBody`, PlayScreen.cs:351–392) ports
+verbatim — every line is Core API.
+
+**Save body restore** (`ApplyRestoredBody`, PlayScreen.cs:297–348)
+likewise verbatim. The one wrinkle is the deferred encounter restore:
+`_pendingEncounterRestore` is set in step 10, but the actual call to
+`RestoreEncounter` happens after `EnsureLoadedAround` so the NPC
+actors that the encounter references exist. The MonoGame source does
+this at the end of `Initialize`; M7 does the same.
+
+**Save flash toast** — when a save completes (or fails), surface a
+2.5-second floating label at the bottom-centre of the screen. M7 uses
+a `Label` + `Tween` to fade alpha; sized to the codex body font. The
+toast text mirrors the MonoGame source exactly:
+
+- "Saved to slot_03.trps" on slot save
+- "Quicksaved." on F5
+- "Save failed: {message}" on exception
+
+### 6.6 Encounter / interact tick
+
+Per-tick logic (`TickEncounterAndInteract`, PlayScreen.cs:455–489)
+runs only when `camera.zoom >= TacticalRenderZoomMin` (the M4
+threshold), per the MonoGame source's `_camera.Mode == ViewMode.Tactical`
+guard. PlayScreen reads the current zoom from `WorldRenderNode.Camera`
+each tick.
+
+- **Quest engine tick.** `_questEngine.Tick(_questCtx)`. Cheap, runs
+ every frame in tactical mode.
+- **Faction aggression update.**
+ `FactionAggression.UpdateAllegiances(...)`. Same.
+- **Hostile detection.** `EncounterTrigger.FindHostileTrigger(actors)`
+ — returns the closest hostile in the trigger ring, or null.
+- **Interact candidate.** `EncounterTrigger.FindInteractCandidate(actors)`
+ — friendly/neutral in the interact ring.
+
+On hostile detection, M7's **stub**:
+
+```csharp
+GD.Print($"[encounter] Would start fight with {hostile.DisplayName}");
+ShowToast("Combat HUD lands with M8 — encounter logged.");
+// Save the autosave anyway so M8 testing has fresh combat starts.
+SaveTo(SavePaths.AutosavePath());
+```
+
+(The exact stub form — whether to halt the actor, prevent further
+movement, or just log — is a user call at milestone kickoff. Default
+proposal: log + autosave + allow movement to continue, so M7 testing
+isn't blocked by every wolf encounter freezing the screen.)
+
+On interact-candidate F-press: push InteractionScreen as a
+`CanvasLayer` overlay. The play tree pauses; the dialogue handles its
+own input.
+
+### 6.7 HUD overlay
+
+The MonoGame HUD is a single `Label` with a black-180-alpha background
+at the top-left:
+
+```
+{PlayerName} HP {hp}/{max} AC {ac} [encumbered]
+Seed: 0x{seed}
+Player: ({tx},{ty}) {biome}
+Cursor: ({cx},{cy}) ...
+View: WorldMap zoom=0.5
+Time: Day 12, 14:32:08
+Click a tile to travel. Mouse-wheel in for tactical.
+F5 = Quicksave · TAB = Inventory · ESC = Pause Menu
+[F] Talk to Innkeeper Marra (Friendly +6)
+ clade +2 size 0 faction +1 personal +3
+[ Saved to slot_03.trps ]
+```
+
+The Godot port preserves the same content but applies codex styling:
+
+- Mount as a child `CanvasLayer` (`Layer = 50`) so it floats above
+ the world but below popovers and pause overlays.
+- Anchor a `PanelContainer` to the top-left, `MarginContainer 12px`,
+ `StyleBoxFlat` from the codex theme's `Card` variation but with the
+ dark palette's `Bg2` colour at 0.78 alpha (mirrors the MonoGame
+ black-180 background).
+- Body text uses the `CardBody` variation (Crimson Pro). Lines that
+ show key bindings use `Eyebrow` (smaller, ink-mute).
+- The interact prompt block animates in/out via alpha tween when the
+ candidate changes — same data, just less jarring than instant.
+
+Save-flash toast is a *separate* `Label` mounted at bottom-centre,
+NOT inside the HUD panel — keeps the HUD's bounds stable when the
+toast appears.
+
+### 6.8 Zoom-mode UI changes
+
+At zoomed-out (world-map) zoom levels:
+- Cursor read-out shows `Tile (tx, ty)`, no tactical surface info.
+- "Click a tile to travel" hint visible.
+- Tactical chunk nodes hidden by `WorldRenderNode.UpdateLayerVisibility`.
+
+At zoomed-in (tactical) zoom levels:
+- Cursor read-out shows `Tile (cx, cy) Surface: grass (v2) Deco: shrub Move: walkable`.
+- "WASD to step" hint visible.
+- Settlement dots hidden; tactical chunks visible.
+- Encounter/interact tick runs.
+
+The transition is continuous (the camera's `Zoom.X` is a float, no
+threshold flip); only the *hint text* and the *streamer activity*
+gate on the zoom range. The MonoGame `ViewMode` enum can be ported
+verbatim or replaced with `float Camera.Zoom >= THRESHOLD` direct
+reads; M7 chooses **direct reads** because there is no Godot-side
+abstraction to maintain.
+
+### 6.9 What PlayScreen does NOT do
+
+For clarity (and to keep the §6 surface bounded):
+
+- It does not own the inventory UI — `Tab` opens nothing in M7.
+- It does not run level-up — Pause menu's "★ Level Up" button is
+ rendered disabled with a "Available in M8" tooltip.
+- It does not push CombatHUD — see §6.6 stub.
+- It does not own the world-gen pipeline — that lives in
+ WorldGenProgressScreen.
+- It does not own the dialogue runner — that lives in
+ InteractionScreen. PlayScreen only pushes the overlay; the overlay
+ reads back via the same `GetParent() as PlayScreen` pattern the
+ MonoGame source uses.
+
+---
+
+## 7. PauseMenuScreen
+
+### 7.1 Behaviour
+
+Direct port of `Theriapolis.Game/Screens/PauseMenuScreen.cs`. Two
+sub-states: main menu and slot picker. ESC backs out of slot picker
+to main, then closes the overlay.
+
+### 7.2 Layout
+
+Mount as a `CanvasLayer` child of PlayScreen with `Layer = 50` and
+`process_mode = WhenPaused`. On enter: `GetTree().Paused = true`.
+On exit: `GetTree().Paused = false`.
+
+The main panel is a centred `PanelContainer` over a half-opaque
+black backdrop (so the world is still legibly visible behind it).
+Button stack reuses M6's `MakeMenuButton(text, primary)` from
+`TitleScreen.cs:96`.
+
+Button rows:
+
+1. **Resume** (primary) — pops the overlay.
+2. **★ Level Up (N → N+1)** — visible only when
+ `LevelUpFlow.CanLevelUp(pc)` returns true. **Disabled in M7** with
+ a tooltip "Level-up screen ships with M8".
+3. **Save Game** — flips to the slot picker sub-state.
+4. **Quicksave (autosave slot)** — calls `playScreen.SaveTo(AutosavePath())`.
+5. **Quit to Title** — autosaves first (matches MonoGame), then
+ double-pop (Pause → Play → Title).
+
+Status label below the button stack displays the most recent
+"Quicksaved." / "Save failed." string for 2.5 seconds (same timer
+as the in-HUD flash).
+
+### 7.3 Slot picker sub-state
+
+Replace the panel's `VBoxContainer` contents with the slot list:
+
+- `for i in 1..C.SAVE_SLOT_COUNT`: row label = "Slot 02 — Folio II,
+ Hightown, Day 12 14:32". Reads the slot's header via
+ `SaveCodec.DeserializeHeaderOnly(bytes)`; failing reads label
+ "Slot 02 — ". Empty slots label "Slot 02 — ".
+- Click writes via `playScreen.SaveTo(SavePaths.SlotPath(i))`, shows
+ the toast, returns to the main panel.
+- Back button restores the main panel.
+
+### 7.4 Edge cases
+
+- **ESC during slot picker:** back to main panel, not close.
+- **ESC during main:** close overlay (resume).
+- **Pause-while-paused:** PlayScreen's Esc handler is gated on
+ `GetTree().Paused == false`; pause-while-paused is impossible.
+- **Quit-to-Title autosave failure:** still proceed to Title (the
+ MonoGame source does this) — the user's intent is "leave" and a
+ blocked exit is worse than a blocked save.
+
+---
+
+## 8. SaveLoadScreen
+
+### 8.1 Behaviour
+
+Read-only slot picker for *load* (M7 scope). Pushed by TitleScreen's
+"Continue" entry point — which today is a `GD.Print` stub
+(`TitleScreen.cs:OnContinue`) and gates on
+`FileAccess.FileExists(CharacterAssembler.PersistedStatePath)`. M7
+replaces both:
+
+- "Continue" enables when *any* slot under `SavesDir` has a
+ compatible header — `Directory.EnumerateFiles(savesDir, "*.trps")`
+ + `SaveCodec.IsCompatible(header)`.
+- "Continue" push goes to SaveLoadScreen, not to a stub print.
+
+### 8.2 Layout
+
+Same `CanvasLayer` overlay pattern as Pause. Heading "LOAD GAME"
+(`CodexTitle`), then a `VBoxContainer` with one row per slot:
+
+- Autosave row first.
+- `Slot 01..C.SAVE_SLOT_COUNT` after.
+- Each row: a `Button` (Card variation) with the slot label, disabled
+ when empty/unreadable/incompatible.
+- Footer: Back button → pop overlay (back to Title).
+
+### 8.3 Slot label format
+
+Exactly mirrors MonoGame: `header.SlotLabel()` is a Core method that
+formats `"{PlayerName} · Folio {Tier}, Day {DayN} {Time}"`. M7 calls
+it unchanged.
+
+### 8.4 Load flow
+
+```csharp
+private void LoadSlot(string path)
+{
+ var bytes = File.ReadAllBytes(path);
+ var (header, body) = SaveCodec.Deserialize(bytes);
+ if (!SaveCodec.IsCompatible(header)) { /* error label */ return; }
+
+ var session = GetNode("/root/GameSession");
+ session.Seed = header.ParseSeed();
+ session.PendingRestore = body;
+ session.PendingHeader = header;
+
+ // Swap Title → WorldGenProgress → PlayScreen.
+ var main = GetTree().Root.GetNode("Main");
+ foreach (Node c in main.GetChildren()) c.QueueFree();
+ main.AddChild(new WorldGenProgressScreen());
+ QueueFree();
+}
+```
+
+### 8.5 Save-from-game
+
+Out of scope here — that path lives inside Pause (§7.3). Splitting
+save-from-title and save-from-game keeps each picker single-purpose.
+
+---
+
+## 9. InteractionScreen
+
+### 9.1 Behaviour
+
+Direct port of `Theriapolis.Game/Screens/InteractionScreen.cs` (395
+lines, biggest text-rendering surface after character creation).
+
+### 9.2 Layout
+
+`CanvasLayer` overlay (`Layer = 50`, `process_mode = WhenPaused`).
+Centre panel ~760 px wide; three vertical zones:
+
+1. **Header** — NPC name (`CodexTitle`-mid), role line ("Innkeeper of
+ Millhaven"), bias-profile + disposition tag, optional Scent
+ Literacy overlay (`⊙ Scent: ...`).
+2. **History** — last `C.DIALOGUE_HISTORY_LINES` entries from
+ `_runner.History`, each `Label` with `AutowrapMode = WordSmart`
+ and a per-speaker text colour:
+ - NPC: `palette.Ink`
+ - PC: pale blue (matches MonoGame `Color(170, 200, 220)`)
+ - Narration: muted green (matches `Color(160, 180, 140)`)
+3. **Options** — numbered buttons (`1. ...`, `2. ...`), one per
+ visible option from `_runner.VisibleOptions()`. Skill-check
+ options render `[STR DC 12] ...` prefix. Capped at
+ `C.DIALOGUE_MAX_OPTIONS_PER_NODE` (the Core constant).
+
+Footer: `"(1-9 to choose · Esc to leave · F also closes)"`.
+
+### 9.3 DialogueRunner construction
+
+Same as MonoGame (`TryBuildRunner` at `InteractionScreen.cs:59`):
+
+```csharp
+var ctx = new DialogueContext(npc, pc, playScreen.Reputation,
+ playScreen.Flags, content)
+{
+ PlayerWorldTileX = (int)(playerPos.X / C.WORLD_TILE_PIXELS),
+ PlayerWorldTileY = (int)(playerPos.Y / C.WORLD_TILE_PIXELS),
+ WorldClockSeconds = playScreen.ClockSeconds(),
+};
+return new DialogueRunner(tree, ctx, playScreen.WorldSeed());
+```
+
+PlayScreen exposes `Reputation`, `Flags`, `World`, `WorldSeed`, etc.
+via internal properties — port the same accessor pattern from the
+MonoGame source (lines 62–89).
+
+### 9.4 Effect routing
+
+`DialogueRunner.ChooseOption(idx)` mutates the runner's context. M7
+must drain three effect channels after each choice:
+
+1. **`start_quest`** — `context.StartQuestRequests` holds quest ids to
+ start. Loop, calling `playScreen.QuestEngine.Start(qid, qctx)` for
+ each; clear the list.
+2. **`open_shop`** — `context.ShopRequested == true` means push the
+ ShopScreen. **M7 stub:** show a toast "Shop opens with M8 — Marra
+ waits patiently" and clear the flag.
+3. **`set_flag`** / others — already applied to `playScreen.Flags` by
+ the runner; no work for the screen.
+
+### 9.5 Input
+
+- Number keys (1-9, both top-row and numpad): pick option N.
+- Enter: dismiss when `_runner.IsOver`.
+- ESC or F: close overlay (matches MonoGame).
+
+Edge-detect ALL key presses — a held key must not fire twice. M7
+mirrors the MonoGame `_numWasDown[10]` array pattern.
+
+### 9.6 Stub NPCs
+
+When `_runner is null` (NPC has no dialogue tree), the panel renders
+the MonoGame fallback verbatim: "(They have nothing to say yet.)" +
+"— No dialogue tree authored for this NPC yet." + a single "1. Goodbye"
+button.
+
+---
+
+## 10. Risks
+
+### High
+
+- **Save-format parity break.** A subtle Godot-side reorder (e.g.
+ capturing actor position *after* a Tween animation lerp completes
+ vs. at tick boundary) could shift a save by one tick and break the
+ exit-criterion bytes-identical test. *Mitigation:* always capture
+ from Core data, never from Godot transforms. The MonoGame source
+ reads `_actors.Player.Position` (a Core `Vec2`), not the sprite's
+ on-screen position. Port that discipline.
+- **Tree-paused gotchas.** Setting `GetTree().Paused = true` halts
+ `_Process` on every non-`WhenPaused` node, *including* `_Tween`
+ animations on the HUD. If the save-flash toast tweens its alpha
+ while paused, it'll freeze mid-fade. *Mitigation:* the toast lives
+ on the pause overlay's `CanvasLayer` and inherits `WhenPaused`, OR
+ the toast is implemented via `_Process(delta)` decay on PlayScreen
+ itself with `process_mode = Always` set only on the toast. Pick one
+ during M7.1 prototype and stick with it.
+- **Mid-combat save restore.** Phase 5 M5's deferred encounter
+ rehydration (PlayScreen.cs:227–232) only works if the chunk-load
+ signal fires synchronously when `EnsureLoadedAround` is called from
+ Initialize. Godot's signal dispatch is synchronous for direct
+ `EmitSignal` calls within the same frame, so this should work — but
+ *verify* with a unit-test save covering the encounter-in-progress
+ case before declaring the exit criterion met.
+
+### Medium
+
+- **Multiple input adapters.** PlayerController, the camera pan/zoom
+ logic, and the overlay screens all read input differently. Without
+ a single adapter, F5/ESC handling can race the pause overlay's own
+ ESC handler. *Mitigation:* PlayScreen consumes input only via
+ `_UnhandledInput` (events propagate from leaf to root, so an open
+ overlay's `_GuiInput` consumes first). Overlays must `AcceptEvent()`
+ on the input event they handle.
+- **Chunk streamer ownership.** Today the streamer lives in
+ `WorldView`. Moving it to PlayScreen for M7 means `WorldView` either
+ (a) still has its own streamer for the demo path or (b) gets
+ retired in favour of running PlayScreen with a stub character.
+ Option (a) preserves the demo entry points; option (b) is cleaner
+ but means `--world-map`/`--tactical` CLI flags lose their meaning.
+ *Recommendation:* (a) — `WorldView` keeps its own streamer; the
+ shared code is `WorldRenderNode`, not the streamer.
+
+### Low
+
+- **Codex theme parity at low zoom.** The HUD overlay was designed
+ against tactical-zoom screenshots; at very low zoom, the world is
+ visually dominated by parchment-ish biome colours that may clash
+ with the dark palette HUD. *Mitigation:* the HUD's `Bg2` background
+ is already at 0.78 alpha so the world bleeds through; that should
+ be enough.
+
+---
+
+## 11. Verification
+
+### 11.1 Manual exit criterion
+
+The godot-port plan's binding criterion (§5.M7):
+
+> "Load a save from the MonoGame build, walk around, save, reload —
+> bytes identical."
+
+Reproduce as follows:
+
+1. Build `Theriapolis.Desktop` (MonoGame) and `Theriapolis.Godot`.
+2. In the MonoGame build, start a new game (seed 12345), name the
+ character "M7Test", walk three tiles east, F5 quicksave, quit.
+3. Verify `autosave.trps` exists in the shared `SavesDir`.
+4. Launch the Godot build, click Continue, pick the autosave row,
+ confirm the character spawns at the expected tile.
+5. Walk one tile north in the Godot build, F5 quicksave again.
+6. Quit. Launch the MonoGame build. Continue → autosave. Confirm
+ character is at the expected tile (3E, 1N from spawn).
+7. Diff the autosave bytes from step 5 with a fresh autosave taken
+ from MonoGame after the same input sequence. *They must be
+ identical.* Any mismatch is a regression.
+
+A scripted version of this test (driving each build via its CLI
+smoke-test flags) would be ideal but is **not** an M7 deliverable —
+add to the M9 platform-layer milestone if it's needed for CI.
+
+### 11.2 Per-sub-milestone tests
+
+For each sub-milestone in §12, the agent must run `dotnet test`
+before committing. The architecture test, every determinism test,
+and every existing save-round-trip test must remain green. M7 does
+not add new tests to `Theriapolis.Tests` because every screen behaviour
+is already covered by the underlying Core tests; the Godot-side
+*scene wiring* tests live in the manual exit criterion above.
+
+If `dotnet test` runs >7 minutes, that's expected — the test suite is
+expensive and that's an M7 unrelated concern.
+
+### 11.3 Smoke tests to add
+
+`Main.cs` already has a `--smoke-test ` flag (`SmokeTest.Run`,
+M0-vintage). Extend it for M7:
+
+- `--smoke-play ` — boot through TitleScreen → New Character
+ → all-default wizard → PlayScreen with a 5-second walk; verify no
+ exception; quit 0.
+- `--smoke-load ` — load the given save, walk five tiles,
+ re-save, verify byte-equality with an oracle run.
+
+Both are optional — useful for CI but not blocking M7's exit.
+
+---
+
+## 12. Sub-milestones
+
+5 calendar days at the godot-port plan's pace. Sub-milestones are
+incrementally demoable; each ends at a usable state.
+
+### M7.1 — WorldGenProgressScreen (½ day)
+
+- Port the MonoGame screen.
+- Wire the autoload `GameSession` (§4.3).
+- Replace the wizard's M6 hand-off so "Confirm & Begin" pushes
+ WorldGenProgressScreen with `GameSession.PendingCharacter` set,
+ not a debug print.
+- Demoable: from the wizard, hitting Confirm produces a working
+ progress bar that runs the 23-stage pipeline and prints the final
+ WorldState summary. Transition target is a placeholder
+ `PlayScreenStub` that just labels "PlayScreen lands in M7.2".
+
+### M7.2 — PlayScreen skeleton + WorldRenderNode extraction (1½ days)
+
+- Extract `WorldRenderNode` from `WorldView` (§6.2).
+- Build PlayScreen with the M6.X hand-off path (new character, no
+ restore). Player actor spawns, walks, camera follows. No save, no
+ NPCs yet.
+- Wire chunk streamer at PlayScreen level; NPCs spawn from chunk-load
+ events. No encounter trigger / interact prompt yet.
+- HUD overlay shows the player block, seed, tile coords, time, hints.
+- Demoable: full Title → Wizard → WorldGen → Play loop with a
+ walking character.
+
+### M7.3 — Save / load (1 day)
+
+- Port `SavePaths` to `Theriapolis.Godot/Platform/`.
+- Implement `PlayScreen.SaveTo(path)` (verbatim from MonoGame).
+- Implement `ApplyRestoredBody` (verbatim).
+- Build SaveLoadScreen (§8). Wire Title → SaveLoadScreen.
+- F5 quicksave works in PlayScreen.
+- Demoable: save, restart Godot, load, character is back where it
+ was. Manual byte-diff against MonoGame save passes.
+
+### M7.4 — PauseMenuScreen + save-from-pause (½ day)
+
+- Port PauseMenuScreen (§7).
+- Slot picker reuses the SaveLoadScreen layout but in *write* mode.
+ Decide at this point whether to share code or copy — recommend
+ *copy* because the read vs. write call sites diverge (load swaps
+ scenes; save stays in place).
+- ★ Level Up button visible-but-disabled.
+- Demoable: Esc opens pause, Save Game writes to chosen slot, Quit
+ to Title autosaves and returns.
+
+### M7.5 — Interact + dialogue (1 day)
+
+- Port InteractionScreen (§9).
+- Wire `EncounterTrigger.FindInteractCandidate` into PlayScreen's
+ tactical-mode tick.
+- Wire F-press → push overlay.
+- Implement the `start_quest` drain and the `open_shop` stub toast.
+- Demoable: walk up to an NPC, press F, see their dialogue, choose
+ options, exit. Quest journal entries land in `_questEngine` even
+ though the journal UI is M8.
+
+### M7.6 — Polish + parity test (½ day)
+
+- Save-flash toast (§6.5).
+- Encounter-detection stub for hostiles (§6.6).
+- Mid-combat save round-trip (§6.3 step 14) — the encounter is
+ *captured* on save and *restored* on load; the push to CombatHUD
+ is the M8 stub.
+- Run the §11.1 exit-criterion test against the MonoGame build.
+- Decide with the user: do we accept the §6.6 hostile stub
+ (default), or block the milestone on it?
+
+---
+
+## 13. Open questions
+
+These need a user decision before or during M7 kickoff; M7 ships
+under the *default* if no decision is made.
+
+1. **Hostile-encounter stub form.** Log + autosave + allow movement
+ (default), or hard-halt the actor at the trigger?
+2. **WorldView demo retention.** Keep `--world-map`/`--tactical`
+ flags as standalone scenes (default), or fold them into
+ PlayScreen with a `--demo` flag that mounts a stub character?
+3. **HUD typography density.** Match MonoGame's tight 7-line block
+ (default), or break into a top bar + right-rail layout? The
+ right-rail would echo M6's Aside but costs ~½ day. Recommend
+ default.
+4. **Save-slot picker code sharing.** Share between Title-load and
+ Pause-save (one widget, two modes), or copy. Recommend *copy*
+ (see M7.4).
+
+---
+
+## 14. What lands at the end of M7
+
+- **One playable session** from Title → Wizard → World → Play, with
+ save/load round-trip against MonoGame.
+- **Four new Godot scenes:** WorldGenProgressScreen, PlayScreen,
+ PauseMenuScreen, SaveLoadScreen, InteractionScreen. (Five, if you
+ count InteractionScreen separately from PlayScreen — which §3 does.)
+- **One refactor:** `WorldView` → `WorldRenderNode` + a thin demo
+ shell.
+- **One autoload:** `GameSession`.
+- **One platform port:** `SavePaths.cs`.
+- **No new Core code** beyond what's already in main.
+
+When this milestone closes, the only screens missing from a complete
+play-loop port are the M8 set (Combat HUD, Inventory, Level Up,
+Shop, Quest Log, Reputation, Defeated, Dungeon) — each of which has
+a working stub or disabled affordance after M7.