Files
TheriapolisV3/theriapolis-rpg-implementation-plan-godot-port-m7.md
T
Christopher Wiebe bf0041605f M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen
Lands the M7 plan's first two sub-milestones on port/godot.
theriapolis-rpg-implementation-plan-godot-port-m7.md is the design
doc (six screens collapse to four scenes + a camera mode, with
per-screen behavioural contracts and a six-step sub-milestone
breakdown).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:07:28 -07:00

44 KiB
Raw Blame History

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 <seed> and --tactical <seed> <tx> <ty> 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 M5M6 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 QueueFrees 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 QueueFrees 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:

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

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

private void Transition()
{
    var session = GetNode<GameSession>("/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):

[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 145233) 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 113 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:351392) ports verbatim — every line is Core API.

Save body restore (ApplyRestoredBody, PlayScreen.cs:297348) 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:455489) 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:

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

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<GameSession>("/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):

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 6289).

9.4 Effect routing

DialogueRunner.ChooseOption(idx) mutates the runner's context. M7 must drain three effect channels after each choice:

  1. start_questcontext.StartQuestRequests holds quest ids to start. Loop, calling playScreen.QuestEngine.Start(qid, qctx) for each; clear the list.
  2. open_shopcontext.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:227232) 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 <seed> flag (SmokeTest.Run, M0-vintage). Extend it for M7:

  • --smoke-play <seed> — boot through TitleScreen → New Character → all-default wizard → PlayScreen with a 5-second walk; verify no exception; quit 0.
  • --smoke-load <slot-path> — 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: WorldViewWorldRenderNode + 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.