bf0041605f
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>
252 lines
7.9 KiB
C#
252 lines
7.9 KiB
C#
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).");
|
|
}
|
|
}
|