1055 lines
44 KiB
Markdown
1055 lines
44 KiB
Markdown
|
|
# 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 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<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 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 — <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 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 <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.
|