M7.1-7.2: Play-loop hand-off — Wizard → WorldGen → PlayScreen

Lands the M7 plan's first two sub-milestones on port/godot.
theriapolis-rpg-implementation-plan-godot-port-m7.md is the design
doc (six screens collapse to four scenes + a camera mode, with
per-screen behavioural contracts and a six-step sub-milestone
breakdown).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-10 18:07:28 -07:00
parent 83c6343783
commit bf0041605f
14 changed files with 2576 additions and 373 deletions
+400
View File
@@ -0,0 +1,400 @@
using System.Collections.Generic;
using System.Linq;
using Godot;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Settlements;
using Theriapolis.GodotHost.Input;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.2 — the play screen. Wraps <see cref="WorldRenderNode"/> with the
/// game-state layer: player actor, world clock, chunk streamer, NPC
/// markers, player controller, and a top-left HUD overlay. Click on the
/// world map to travel; WASD to step at tactical zoom.
///
/// Per the M7 plan §6: PlayScreen owns the chunk streamer (so M7.3 save
/// can serialise its delta store) and the actor manager (so M7.5 can
/// drive interact prompts). The world-map view and the tactical view
/// are the same scene at different zoom levels — there is no separate
/// WorldMapScreen, by design.
///
/// M7.2 omissions (deferred to later sub-milestones):
/// - Save / load round-trip (M7.3)
/// - Pause menu (M7.4)
/// - Interact prompt + dialogue push (M7.5)
/// - Encounter detection stub + autosave toast (M7.6)
/// </summary>
public partial class PlayScreen : Control
{
private const float ClickSlopPixels = 4f;
// Composed Core systems
private WorldGenContext _ctx = null!;
private ContentResolver _content = null!;
private InMemoryChunkDeltaStore _deltas = null!;
private ChunkStreamer _streamer = null!;
private ActorManager _actors = null!;
private WorldClock _clock = null!;
private PlayerController _controller = null!;
private AnchorRegistry _anchorRegistry = null!;
// Godot tree
private WorldRenderNode _render = null!;
private PlayerMarker _playerMarker = null!;
private readonly Dictionary<int, NpcMarker> _npcMarkers = new();
private Label _hudLabel = null!;
private PanelContainer _hudPanel = null!;
// Click-vs-drag state (left-click only; PanZoomCamera handles
// middle/right-drag pan independently).
private Vector2 _mouseDownPos;
private int _mouseDownTileX, _mouseDownTileY;
private bool _mouseDownTracked;
public override void _Ready()
{
var session = GameSession.From(this);
if (session.Ctx is null)
{
GD.PushError("[play] No WorldGenContext on session — falling back to title.");
BackToTitle();
return;
}
_ctx = session.Ctx;
Theme = CodexTheme.Build();
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
// World render layer — biome + polylines + settlements + camera.
_render = new WorldRenderNode();
AddChild(_render);
_render.Initialize(_ctx.World);
// Core systems.
_content = new ContentResolver(
new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir));
_deltas = new InMemoryChunkDeltaStore();
_streamer = new ChunkStreamer(
_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
_streamer.OnChunkLoaded += _render.AddChunkNode;
_streamer.OnChunkEvicting += _render.RemoveChunkNode;
_streamer.OnChunkLoaded += HandleChunkLoaded;
_streamer.OnChunkEvicting += HandleChunkEvicting;
_clock = new WorldClock();
_actors = new ActorManager();
_anchorRegistry = new AnchorRegistry();
_anchorRegistry.RegisterAllAnchors(_ctx.World);
// Spawn player at the Tier-1 anchor (Millhaven), or the centre of
// the world if no inhabited settlement exists.
var spawn = ChooseSpawn(_ctx.World);
if (session.PendingCharacter is not null)
{
var p = _actors.SpawnPlayer(spawn, session.PendingCharacter);
if (!string.IsNullOrWhiteSpace(session.PendingName))
p.Name = session.PendingName;
}
else
{
_actors.SpawnPlayer(spawn);
}
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
// Player marker.
_playerMarker = new PlayerMarker
{
Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
FacingAngleRad = _actors.Player.FacingAngleRad,
};
AddChild(_playerMarker);
_render.Camera.Position = _playerMarker.Position;
SetInitialZoom();
BuildHud();
// Clear pending so a quit-to-title doesn't see stale data.
session.PendingCharacter = null;
session.PendingName = "Wanderer";
session.PendingRestore = null;
session.PendingHeader = null;
}
public override void _Process(double delta)
{
if (_actors?.Player is null || _render is null) return;
float dt = (float)delta;
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
// Tactical WASD direction (world-map mode ignores keys — middle-drag
// pans, click-to-travel sets the destination).
float dx = 0f, dy = 0f;
if (tactical)
{
if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f;
if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f;
if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f;
if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f;
}
_controller.Update(dt, dx, dy, tactical, isFocused: true);
// Sync the player marker from Core state.
var p = _actors.Player;
_playerMarker.Position = new Vector2(p.Position.X, p.Position.Y);
_playerMarker.FacingAngleRad = p.FacingAngleRad;
// Camera follow when traveling or in tactical (matches MonoGame).
if (_controller.IsTraveling || tactical)
_render.Camera.Position = _playerMarker.Position;
// Stream tactical chunks around the player when at tactical zoom.
if (tactical)
_streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
// Counter-scale markers so on-screen size stays constant.
float zoom = _render.Camera.Zoom.X;
if (zoom > 0f)
{
float inv = 1f / zoom;
_playerMarker.Scale = new Vector2(inv, inv);
_playerMarker.ShowFacingTick = tactical;
foreach (var marker in _npcMarkers.Values)
marker.Scale = new Vector2(inv, inv);
}
UpdateHud(tactical);
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is not InputEventMouseButton mb || mb.ButtonIndex != MouseButton.Left)
return;
if (mb.Pressed)
{
_mouseDownPos = mb.Position;
var worldPos = ScreenToWorld(mb.Position);
_mouseDownTileX = (int)Mathf.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
_mouseDownTileY = (int)Mathf.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
_mouseDownTracked = true;
return;
}
// Release.
if (!_mouseDownTracked) return;
_mouseDownTracked = false;
bool wasClick = mb.Position.DistanceTo(_mouseDownPos) <= ClickSlopPixels;
if (!wasClick) return;
bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin;
if (!tactical && InBounds(_mouseDownTileX, _mouseDownTileY))
{
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
GetViewport().SetInputAsHandled();
}
}
private Vector2 ScreenToWorld(Vector2 screenPos)
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
private static bool InBounds(int x, int y)
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
// ──────────────────────────────────────────────────────────────────────
// Chunk → NPC lifecycle (Phase 5 M5)
private void HandleChunkLoaded(TacticalChunk chunk)
{
if (_content is null) return;
for (int i = 0; i < chunk.Spawns.Count; i++)
{
var spawn = chunk.Spawns[i];
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
if (spawn.Kind == SpawnKind.Resident)
{
var resident = ResidentInstantiator.Spawn(
_ctx.World.WorldSeed, chunk, i, spawn,
_ctx.World, _content, _actors, _anchorRegistry);
if (resident is not null) MountNpcMarker(resident);
continue;
}
var template = NpcInstantiator.PickTemplate(spawn.Kind, chunk.DangerZone, _content.Npcs);
if (template is null) continue;
int tx = chunk.OriginX + spawn.LocalX;
int ty = chunk.OriginY + spawn.LocalY;
var npc = _actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
MountNpcMarker(npc);
}
}
private void HandleChunkEvicting(TacticalChunk chunk)
{
var toRemove = new List<int>();
foreach (var npc in _actors.Npcs)
{
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
{
toRemove.Add(npc.Id);
if (!string.IsNullOrEmpty(npc.RoleTag))
_anchorRegistry.UnregisterRole(npc.RoleTag);
}
}
foreach (int id in toRemove)
{
_actors.RemoveActor(id);
if (_npcMarkers.Remove(id, out var marker))
marker.QueueFree();
}
}
private void MountNpcMarker(NpcActor npc)
{
var marker = new NpcMarker
{
Position = new Vector2(npc.Position.X, npc.Position.Y),
Allegiance = npc.Allegiance,
};
AddChild(marker);
_npcMarkers[npc.Id] = marker;
}
// ──────────────────────────────────────────────────────────────────────
// Spawn + initial-zoom helpers
private static Vec2 ChooseSpawn(WorldState w)
{
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
return new Vec2(
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
}
private void SetInitialZoom()
{
// Frame ~24 tiles across the screen — comfortable overland zoom.
Vector2 viewport = GetViewport().GetVisibleRect().Size;
float targetZoom = viewport.X / (24f * C.WORLD_TILE_PIXELS);
targetZoom = Mathf.Clamp(targetZoom,
_render.Camera.MinZoom,
WorldRenderNode.TacticalRenderZoomMin * 0.95f);
_render.Camera.Zoom = new Vector2(targetZoom, targetZoom);
}
// ──────────────────────────────────────────────────────────────────────
// HUD overlay (top-left panel, codex-styled)
private void BuildHud()
{
var hudLayer = new CanvasLayer { Layer = 50, Name = "Hud" };
AddChild(hudLayer);
_hudPanel = new PanelContainer
{
ThemeTypeVariation = "Card",
MouseFilter = MouseFilterEnum.Ignore,
OffsetLeft = 12, OffsetTop = 12,
OffsetRight = 420, OffsetBottom = 220,
};
hudLayer.AddChild(_hudPanel);
var margin = new MarginContainer { MouseFilter = MouseFilterEnum.Ignore };
margin.AddThemeConstantOverride("margin_left", 12);
margin.AddThemeConstantOverride("margin_top", 8);
margin.AddThemeConstantOverride("margin_right", 12);
margin.AddThemeConstantOverride("margin_bottom", 8);
_hudPanel.AddChild(margin);
_hudLabel = new Label
{
Text = "…",
ThemeTypeVariation = "CardBody",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
MouseFilter = MouseFilterEnum.Ignore,
};
margin.AddChild(_hudLabel);
}
private void UpdateHud(bool tactical)
{
var p = _actors.Player!;
int ptx = (int)Mathf.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
int pty = (int)Mathf.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
int cx = Mathf.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1);
int cy = Mathf.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1);
ref var t = ref _ctx.World.TileAt(cx, cy);
string charBlock = "";
if (p.Character is { } pc)
{
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}\n";
}
string viewBlock = tactical
? "View: Tactical (WASD to step)"
: "View: World Map (click a tile to travel)";
string status = _controller.IsTraveling
? "Traveling…"
: tactical
? "Mouse-wheel out to leave tactical."
: "Mouse-wheel in for tactical.";
_hudLabel.Text =
charBlock +
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
$"Player: ({ptx}, {pty}) {t.Biome}\n" +
$"{viewBlock}\n" +
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
$"{status}\n" +
"M7.3 brings save/load. ESC pause arrives M7.4.";
}
// ──────────────────────────────────────────────────────────────────────
// Quit path (M7.4 will wire ESC → pause; for now Esc returns to title)
public override void _Input(InputEvent @event)
{
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
{
GetViewport().SetInputAsHandled();
BackToTitle();
}
}
private void BackToTitle()
{
var session = GameSession.From(this);
session.ClearPending();
session.Ctx = null;
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(new TitleScreen());
QueueFree();
}
}
+126
View File
@@ -0,0 +1,126 @@
using Godot;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.1 placeholder for the play screen. WorldGenProgressScreen swaps
/// here on success; M7.2 will replace this with the real PlayScreen
/// (walking character, chunk-streamed tactical view, HUD, save layer).
///
/// Reads <see cref="GameSession.Ctx"/> and <see cref="GameSession.PendingCharacter"/>
/// so the play-test confirms the M7.1 hand-off chain end-to-end:
/// Title → Wizard → CharacterAssembler → WorldGenProgress → here.
/// </summary>
public partial class PlayScreenStub : Control
{
public override void _Ready()
{
Theme = CodexTheme.Build();
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
AddChild(bg);
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
MoveChild(bg, 0);
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
AddChild(center);
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var col = new VBoxContainer { CustomMinimumSize = new Vector2(640, 0) };
col.AddThemeConstantOverride("separation", 14);
center.AddChild(col);
var session = GameSession.From(this);
col.AddChild(new Label
{
Text = "PLAYSCREEN STUB · M7.1",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
col.AddChild(new Label
{
Text = "World generation complete.",
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
});
var ctx = session.Ctx;
if (ctx is not null)
{
var w = ctx.World;
col.AddChild(new Label
{
Text = $"Seed 0x{w.WorldSeed:X} · rivers {w.Rivers.Count} "
+ $"roads {w.Roads.Count} rails {w.Rails.Count} "
+ $"settlements {w.Settlements.Count} bridges {w.Bridges.Count}",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
}
else
{
col.AddChild(new Label
{
Text = "(No WorldGenContext on session — this stub was entered out-of-band.)",
HorizontalAlignment = HorizontalAlignment.Center,
ThemeTypeVariation = "Eyebrow",
});
}
var character = session.PendingCharacter;
if (character is not null)
{
string hybridTag = character.Hybrid is not null ? "yes" : "no";
col.AddChild(new Label
{
Text = $"Character: {session.PendingName} · HP {character.MaxHp} "
+ $"· class {character.ClassDef.Id} · hybrid: {hybridTag} "
+ $"· skills: {character.SkillProficiencies.Count}",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
}
else
{
col.AddChild(new Label
{
Text = "(No character attached — load path will fill this in once M7.3 ships.)",
HorizontalAlignment = HorizontalAlignment.Center,
ThemeTypeVariation = "Eyebrow",
});
}
col.AddChild(new Label
{
Text = "PlayScreen with walking character + chunk-streamed tactical view lands in M7.2.",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
var titleBtn = new Button
{
Text = "← Title",
CustomMinimumSize = new Vector2(220, 44),
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
};
titleBtn.Pressed += BackToTitle;
col.AddChild(titleBtn);
}
private void BackToTitle()
{
var session = GameSession.From(this);
session.ClearPending();
session.Ctx = null;
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(new TitleScreen());
QueueFree();
}
}
+29 -3
View File
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
/// </summary>
public partial class TitleScreen : Control
{
private const string VersionLabel = "PORT / GODOT · M6.20";
private const string VersionLabel = "PORT / GODOT · M7.2";
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready()
@@ -121,10 +121,15 @@ public partial class TitleScreen : Control
if (sibling != this) sibling.QueueFree();
var wizardNode = packed.Instantiate();
parent.AddChild(wizardNode);
// The wizard's "← Title" back-button (visible on step 0) emits
// BackToTitle; reinstate this title screen when that fires.
if (wizardNode is Wizard wizard)
{
// "← Title" back-button (visible on step 0) emits BackToTitle.
wizard.BackToTitle += () => SwapBackToTitle(parent);
// M7.1 — Confirm & Begin in StepReview is forwarded by the
// wizard as CharacterConfirmed. Stash the built character on
// GameSession and hand off to WorldGenProgressScreen.
wizard.CharacterConfirmed += draft => SwapToWorldGen(parent, draft);
}
QueueFree();
}
@@ -134,6 +139,27 @@ public partial class TitleScreen : Control
parent.AddChild(new TitleScreen());
}
/// <summary>M7.1 hand-off: snapshot the built character + chosen
/// name onto <see cref="GameSession"/>, default the seed (a seed-entry
/// UI lands later), and swap to <see cref="WorldGenProgressScreen"/>.</summary>
private static void SwapToWorldGen(Node parent, UI.CharacterDraft draft)
{
var session = GameSession.From(parent);
// CharacterAssembler.LastBuilt is populated by StepReview's
// OnConfirmPressed → TryBuild call immediately before the
// CharacterConfirmed signal fires.
session.PendingCharacter = CharacterAssembler.LastBuilt;
session.PendingName = string.IsNullOrWhiteSpace(draft.CharacterName)
? "Wanderer"
: draft.CharacterName;
session.Seed = 12345UL; // default for M7; seed-entry UI is M8+.
session.PendingRestore = null;
session.PendingHeader = null;
foreach (Node child in parent.GetChildren()) child.QueueFree();
parent.AddChild(new WorldGenProgressScreen());
}
private void OnContinue()
{
// M7 territory — the play-loop screens that consume the persisted
+12
View File
@@ -15,6 +15,11 @@ public partial class Wizard : Control
{
[Signal] public delegate void BackToTitleEventHandler();
/// <summary>Forwarded from <c>StepReview.CharacterConfirmed</c> so
/// the wizard's owner (TitleScreen / Main) can hand off to the
/// WorldGenProgressScreen without reaching into the step tree.</summary>
[Signal] public delegate void CharacterConfirmedEventHandler(UI.CharacterDraft draft);
private static readonly string[] StepKeys =
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
private static readonly string[] StepNames =
@@ -117,6 +122,13 @@ public partial class Wizard : Control
_activeStep = instance;
instance.Bind(Character);
_stepHost.AddChild((Control)instance);
// Forward the final-step confirmation upward so TitleScreen
// (or whatever shell owns the wizard) can swap to M7.1's
// WorldGenProgressScreen without coupling to the step tree.
if (instance is Steps.StepReview review)
review.CharacterConfirmed += draft =>
EmitSignal(SignalName.CharacterConfirmed, draft);
}
UpdateChrome();
@@ -0,0 +1,251 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Godot;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.World.Generation;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// M7.1 — runs the 23-stage worldgen pipeline on a background thread
/// and shows per-stage progress. Transitions to <see cref="PlayScreenStub"/>
/// (which M7.2 will replace with the real PlayScreen) on completion.
///
/// Mirrors <c>Theriapolis.Game/Screens/WorldGenProgressScreen.cs</c>:
/// same volatile-field hand-off between the worker and the UI thread,
/// same soft stage-hash warning when restoring from a saved header.
///
/// Inputs (from <see cref="GameSession"/>):
/// - <c>Seed</c> — required.
/// - <c>PendingHeader</c> — present when restoring from save; triggers
/// the post-gen stage-hash diff against <c>WorldState.StageHashes</c>.
///
/// Outputs:
/// - <c>session.Ctx</c> set on success; consumed by the next screen.
///
/// Escape during generation: cancel the worker (honoured at the next
/// stage boundary), return to Title.
/// </summary>
public partial class WorldGenProgressScreen : Control
{
private WorldGenContext? _ctx;
private Task? _genTask;
private CancellationTokenSource? _cts;
private volatile float _progress;
private volatile string _stageName = "Initialising…";
private volatile bool _complete;
private volatile string? _error;
private Label _titleLabel = null!;
private ProgressBar _progressBar = null!;
private Label _stageLabel = null!;
private bool _transitioned;
public override void _Ready()
{
Theme = CodexTheme.Build();
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
// Backing panel so the dark palette Bg fills the viewport (the
// Control itself paints nothing). Mirrors TitleScreen.cs.
var bg = new Panel { MouseFilter = MouseFilterEnum.Ignore };
AddChild(bg);
bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
MoveChild(bg, 0);
BuildUI();
StartGeneration();
}
private void BuildUI()
{
var center = new CenterContainer { MouseFilter = MouseFilterEnum.Ignore };
AddChild(center);
center.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var col = new VBoxContainer { CustomMinimumSize = new Vector2(480, 0) };
col.AddThemeConstantOverride("separation", 14);
center.AddChild(col);
var session = GameSession.From(this);
col.AddChild(new Label
{
Text = "FORGING THE WORLD",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
_titleLabel = new Label
{
Text = $"Seed 0x{session.Seed:X}",
ThemeTypeVariation = "H2",
HorizontalAlignment = HorizontalAlignment.Center,
};
col.AddChild(_titleLabel);
_progressBar = new ProgressBar
{
MinValue = 0,
MaxValue = 1,
Step = 0.001,
ShowPercentage = true,
CustomMinimumSize = new Vector2(0, 22),
};
col.AddChild(_progressBar);
_stageLabel = new Label
{
Text = "Starting…",
HorizontalAlignment = HorizontalAlignment.Center,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
col.AddChild(_stageLabel);
col.AddChild(new Label
{
Text = "Esc to cancel · returns to title.",
ThemeTypeVariation = "Eyebrow",
HorizontalAlignment = HorizontalAlignment.Center,
});
}
private void StartGeneration()
{
_cts = new CancellationTokenSource();
var token = _cts.Token;
var session = GameSession.From(this);
ulong seed = session.Seed;
string dataDir = ContentPaths.DataDir;
_genTask = Task.Run(() =>
{
try
{
var ctx = new WorldGenContext(seed, dataDir)
{
ProgressCallback = (name, frac) =>
{
_stageName = name;
_progress = frac;
},
Log = msg => GD.Print($"[worldgen] {msg}"),
};
WorldGenerator.RunAll(ctx);
if (token.IsCancellationRequested) return;
_ctx = ctx;
_complete = true;
}
catch (Exception ex)
{
var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex;
_error = inner.ToString();
}
}, token);
}
public override void _Process(double delta)
{
if (_transitioned) return;
if (_error is not null)
{
ShowError(_error);
return;
}
if (_complete && _ctx is not null)
{
_transitioned = true;
Transition();
return;
}
_progressBar.Value = _progress;
_stageLabel.Text = _stageName;
}
private void Transition()
{
var session = GameSession.From(this);
if (session.PendingHeader is not null)
CompareStageHashes(session.PendingHeader);
session.Ctx = _ctx;
// M7.2 — the real PlayScreen. PlayScreenStub is kept around as
// a fallback for any future code path that hasn't been wired up
// (e.g. mid-development load flows), but the live hand-off lands
// in the play view.
SwapTo(new PlayScreen());
}
private void SwapTo(Node next)
{
var parent = GetParent();
if (parent is null) return;
foreach (Node sibling in parent.GetChildren())
if (sibling != this) sibling.QueueFree();
parent.AddChild(next);
QueueFree();
}
private void ShowError(string error)
{
_stageLabel.Text = "ERROR — press Escape to return to title";
_progressBar.Value = 0;
// Crop to the first line + 100 chars so the title label stays legible.
int newline = error.IndexOf('\n');
string headline = newline > 0 ? error[..newline] : error;
_titleLabel.Text = headline.Length > 100 ? headline[..100] + "…" : headline;
try
{
string logPath = ProjectSettings.GlobalizePath("user://worldgen_error.log");
File.WriteAllText(logPath, $"[{DateTime.Now:u}] WorldGen ERROR\n{error}\n");
GD.PushError($"[worldgen] Wrote {logPath}");
}
catch { /* best-effort */ }
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape })
{
_cts?.Cancel();
BackToTitle();
}
}
public override void _ExitTree()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private void BackToTitle()
{
var session = GameSession.From(this);
session.ClearPending();
session.Ctx = null;
SwapTo(new TitleScreen());
}
private void CompareStageHashes(SaveHeader savedHeader)
{
if (_ctx is null) return;
int mismatches = 0;
foreach (var kv in _ctx.World.StageHashes)
{
if (!savedHeader.StageHashes.TryGetValue(kv.Key, out var sv)) continue;
string current = $"0x{kv.Value:X}";
if (!string.Equals(sv, current, StringComparison.OrdinalIgnoreCase))
{
mismatches++;
GD.PushWarning($"[save-migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}");
}
}
if (mismatches > 0)
GD.PushWarning($"[save-migration] {mismatches} stage(s) drifted; loading anyway (soft).");
}
}