From bf0041605fdab483d288b098f9de91f7c11537df Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 18:07:28 -0700 Subject: [PATCH] =?UTF-8?q?M7.1-7.2:=20Play-loop=20hand-off=20=E2=80=94=20?= =?UTF-8?q?Wizard=20=E2=86=92=20WorldGen=20=E2=86=92=20PlayScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the M7 plan's first two sub-milestones on port/godot. theriapolis-rpg-implementation-plan-godot-port-m7.md is the design doc (six screens collapse to four scenes + a camera mode, with per-screen behavioural contracts and a six-step sub-milestone breakdown). M7.1 — WorldGenProgressScreen + GameSession autoload + wizard hand-off rewrite. GameSession holds the cross-scene state that outlives any single screen: seed, post-worldgen Ctx, pending character (from the M6 wizard) and pending save snapshot (for M7.3's load path). Wizard forwards StepReview.CharacterConfirmed upward, and TitleScreen swaps to the progress screen instead of just printing the build summary. The progress screen runs the 23-stage pipeline on a background thread, drives a ProgressBar from ctx.ProgressCallback, and writes the full exception trace to user://worldgen_error.log on failure. Escape cancels at the next stage boundary and returns to title. M7.2 — PlayScreen with a walking character. Extracted WorldRenderNode from the M2+M4 WorldView demo so PlayScreen and WorldView mount the same renderer (biome image + polylines + bridges + settlement dots + tactical chunk lifecycle + PanZoomCamera + per-frame layer visibility + line-width counter-scaling). PlayScreen owns the streamer (M7.3 save needs it), composes ContentResolver + ActorManager + WorldClock + AnchorRegistry + PlayerController, spawns the player at the Tier-1 anchor, and wires resident + non-resident NPC spawning from chunk-load events with allegiance-tinted markers. PlayerController ported engine-agnostic to Theriapolis.Godot/Input/. Takes pre-resolved dx/dy/dt/isTactical/isFocused instead of poking MonoGame InputManager + Camera2D, so the arithmetic that advances PlayerActor.Position and WorldClock.InGameSeconds is bit-identical to the MonoGame version — saves round-trip cleanly. Click-to-travel in world-map mode (camera zoom < TacticalRenderZoomMin), WASD step in tactical mode with axis- separated motion + encumbrance + sub-second clock carry. HUD overlay top-left shows HP/AC/seed/tile/biome/view-mode/time. Esc returns to title (M7.4 replaces this with a pause menu). Namespace gotcha: Theriapolis.GodotHost.Input shadows the engine's Godot.Input static class for any file under the GodotHost namespace tree. Files needing keyboard polls (WorldView, PlayScreen) fully qualify as Godot.Input.IsKeyPressed. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/GameSession.cs | 55 + Theriapolis.Godot/Input/PlayerController.cs | 177 +++ Theriapolis.Godot/Rendering/NpcMarker.cs | 29 + Theriapolis.Godot/Rendering/PlayerMarker.cs | 42 + Theriapolis.Godot/Rendering/SettlementDot.cs | 16 + .../Rendering/WorldRenderNode.cs | 346 ++++++ Theriapolis.Godot/Rendering/WorldView.cs | 403 +------ Theriapolis.Godot/Scenes/PlayScreen.cs | 400 +++++++ Theriapolis.Godot/Scenes/PlayScreenStub.cs | 126 ++ Theriapolis.Godot/Scenes/TitleScreen.cs | 32 +- Theriapolis.Godot/Scenes/Wizard.cs | 12 + .../Scenes/WorldGenProgressScreen.cs | 251 ++++ Theriapolis.Godot/project.godot | 6 + ...s-rpg-implementation-plan-godot-port-m7.md | 1054 +++++++++++++++++ 14 files changed, 2576 insertions(+), 373 deletions(-) create mode 100644 Theriapolis.Godot/GameSession.cs create mode 100644 Theriapolis.Godot/Input/PlayerController.cs create mode 100644 Theriapolis.Godot/Rendering/NpcMarker.cs create mode 100644 Theriapolis.Godot/Rendering/PlayerMarker.cs create mode 100644 Theriapolis.Godot/Rendering/SettlementDot.cs create mode 100644 Theriapolis.Godot/Rendering/WorldRenderNode.cs create mode 100644 Theriapolis.Godot/Scenes/PlayScreen.cs create mode 100644 Theriapolis.Godot/Scenes/PlayScreenStub.cs create mode 100644 Theriapolis.Godot/Scenes/WorldGenProgressScreen.cs create mode 100644 theriapolis-rpg-implementation-plan-godot-port-m7.md 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.