Files

252 lines
7.9 KiB
C#
Raw Permalink Normal View History

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).");
}
}