Files

1055 lines
44 KiB
Markdown
Raw Permalink Normal View 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 `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<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):
```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 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**:
```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 — <unreadable>". Empty slots label "Slot 02 — <empty>".
- 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<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`):
```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 6289).
### 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: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:** `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.