Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+374
@@ -0,0 +1,374 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M2: pre-game character creation. Player picks clade, species,
|
||||
/// class, background, stat method, skills, and name; the screen builds a
|
||||
/// <see cref="Character"/> and threads it through worldgen into the play
|
||||
/// session.
|
||||
///
|
||||
/// This is intentionally minimal-but-complete: every selector is visible at
|
||||
/// once with sensible defaults, so a player can confirm immediately. M3+ may
|
||||
/// expand stat-array manual assignment, multi-step wizard polish, and the
|
||||
/// portrait/preview pane.
|
||||
/// </summary>
|
||||
public sealed class CharacterCreationScreen : IScreen
|
||||
{
|
||||
private readonly ulong _seed;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
|
||||
// Loaded content
|
||||
private ContentResolver _content = null!;
|
||||
private CladeDef[] _clades = null!;
|
||||
private SpeciesDef[] _allSpecies = null!;
|
||||
private ClassDef[] _classes = null!;
|
||||
private BackgroundDef[] _backgrounds = null!;
|
||||
|
||||
// Live selections
|
||||
private CladeDef? _clade;
|
||||
private SpeciesDef? _species;
|
||||
private ClassDef? _class;
|
||||
private BackgroundDef? _background;
|
||||
private string _name = "Wanderer";
|
||||
private bool _useRoll = false;
|
||||
private AbilityScores _baseAbilities = new(15, 14, 13, 12, 10, 8); // Standard Array, default order
|
||||
private readonly HashSet<SkillId> _chosenSkills = new();
|
||||
|
||||
// Stat-roll seeding: ms-since-game-start at the moment the screen opened
|
||||
// (so successive rerolls advance ms). M2 dev override: hold SHIFT while
|
||||
// pressing Reroll to use a fixed override (helpful for screenshots).
|
||||
private readonly long _gameStartMs;
|
||||
private long _msAtScreenOpen;
|
||||
|
||||
public CharacterCreationScreen(ulong seed)
|
||||
{
|
||||
_seed = seed;
|
||||
_gameStartMs = Environment.TickCount64;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_msAtScreenOpen = Environment.TickCount64 - _gameStartMs;
|
||||
|
||||
var loader = new ContentLoader(_game.ContentDataDirectory);
|
||||
_content = new ContentResolver(loader);
|
||||
_clades = _content.Clades.Values.OrderBy(c => c.Id).ToArray();
|
||||
_allSpecies = _content.Species.Values.OrderBy(s => s.Id).ToArray();
|
||||
_classes = _content.Classes.Values.OrderBy(c => c.Id).ToArray();
|
||||
_backgrounds = _content.Backgrounds.Values.OrderBy(b => b.Id).ToArray();
|
||||
|
||||
// Sensible defaults so the player can press Confirm immediately.
|
||||
_clade = _clades.FirstOrDefault(c => c.Id == "canidae") ?? _clades[0];
|
||||
_species = _allSpecies.FirstOrDefault(s => s.CladeId == _clade.Id);
|
||||
_class = _classes.FirstOrDefault(c => c.Id == "fangsworn") ?? _classes[0];
|
||||
_background = _backgrounds.FirstOrDefault(b => b.Id == "pack_raised") ?? _backgrounds[0];
|
||||
|
||||
AssignStandardArrayByClassPriority();
|
||||
AutoPickSkills();
|
||||
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Myra.Graphics2D.Thickness(20),
|
||||
};
|
||||
root.Widgets.Add(new Label { Text = "CHARACTER CREATION", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = $"Seed: 0x{_seed:X}", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Clade row
|
||||
root.Widgets.Add(new Label { Text = "Clade:" });
|
||||
root.Widgets.Add(BuildSelectorRow(
|
||||
_clades,
|
||||
c => c.Name,
|
||||
c => c == _clade,
|
||||
c => { _clade = c; _species = _allSpecies.FirstOrDefault(s => s.CladeId == c.Id); RebuildUI(); }));
|
||||
|
||||
// Species row (filtered to selected clade)
|
||||
root.Widgets.Add(new Label { Text = "Species:" });
|
||||
var filteredSpecies = _allSpecies.Where(s => _clade is null || s.CladeId == _clade.Id).ToArray();
|
||||
root.Widgets.Add(BuildSelectorRow(
|
||||
filteredSpecies,
|
||||
s => s.Name + " (" + s.Size + ")",
|
||||
s => s == _species,
|
||||
s => { _species = s; RebuildUI(); }));
|
||||
|
||||
// Class row
|
||||
root.Widgets.Add(new Label { Text = "Class:" });
|
||||
root.Widgets.Add(BuildSelectorRow(
|
||||
_classes,
|
||||
c => c.Name + " (d" + c.HitDie + ")",
|
||||
c => c == _class,
|
||||
c => { _class = c; AutoPickSkills(); AssignStandardArrayByClassPriority(); RebuildUI(); }));
|
||||
|
||||
// Background row
|
||||
root.Widgets.Add(new Label { Text = "Background:" });
|
||||
root.Widgets.Add(BuildSelectorRow(
|
||||
_backgrounds,
|
||||
b => b.Name,
|
||||
b => b == _background,
|
||||
b => { _background = b; RebuildUI(); }));
|
||||
|
||||
// Stat method
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
var statRow = new HorizontalStackPanel { Spacing = 8 };
|
||||
statRow.Widgets.Add(MakeButton("Standard Array", !_useRoll,
|
||||
() => { _useRoll = false; AssignStandardArrayByClassPriority(); RebuildUI(); }));
|
||||
statRow.Widgets.Add(MakeButton("Roll 4d6 drop lowest", _useRoll,
|
||||
() => { _useRoll = true; RollAndAssign(); RebuildUI(); }));
|
||||
if (_useRoll)
|
||||
statRow.Widgets.Add(MakeButton("Reroll", false, () => { RollAndAssign(); RebuildUI(); }));
|
||||
root.Widgets.Add(statRow);
|
||||
|
||||
// Stat readout (post clade+species mods)
|
||||
root.Widgets.Add(new Label { Text = FormatStats() });
|
||||
|
||||
// Skills (only show if class is picked)
|
||||
if (_class is not null)
|
||||
{
|
||||
root.Widgets.Add(new Label { Text = $"Skills (pick {_class.SkillsChoose}):" });
|
||||
var skillRow = new HorizontalStackPanel { Spacing = 4 };
|
||||
foreach (var raw in _class.SkillOptions)
|
||||
{
|
||||
SkillId s;
|
||||
try { s = SkillIdExtensions.FromJson(raw); } catch { continue; }
|
||||
bool picked = _chosenSkills.Contains(s);
|
||||
skillRow.Widgets.Add(MakeButton(raw + (picked ? " ✓" : ""), picked, () =>
|
||||
{
|
||||
if (picked) _chosenSkills.Remove(s);
|
||||
else _chosenSkills.Add(s);
|
||||
RebuildUI();
|
||||
}));
|
||||
}
|
||||
root.Widgets.Add(skillRow);
|
||||
}
|
||||
|
||||
// Name
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
var nameRow = new HorizontalStackPanel { Spacing = 8 };
|
||||
nameRow.Widgets.Add(new Label { Text = "Name:", VerticalAlignment = VerticalAlignment.Center });
|
||||
var nameInput = new TextBox { Text = _name, Width = 240 };
|
||||
nameInput.TextChanged += (_, _) => _name = nameInput.Text ?? "Wanderer";
|
||||
nameRow.Widgets.Add(nameInput);
|
||||
root.Widgets.Add(nameRow);
|
||||
|
||||
// Validation status
|
||||
var status = new Label { Text = "", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
if (TryBuildPreview(out var error))
|
||||
status.Text = "Ready.";
|
||||
else
|
||||
status.Text = $"Cannot confirm: {error}";
|
||||
root.Widgets.Add(status);
|
||||
|
||||
// Confirm + Back
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
var btnRow = new HorizontalStackPanel { Spacing = 16, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
var backBtn = new TextButton { Text = "Back", Width = 120 };
|
||||
backBtn.Click += (_, _) => _game.Screens.Pop();
|
||||
btnRow.Widgets.Add(backBtn);
|
||||
|
||||
var confirmBtn = new TextButton { Text = "Confirm", Width = 200 };
|
||||
confirmBtn.Click += (_, _) => OnConfirm();
|
||||
confirmBtn.Enabled = TryBuildPreview(out _);
|
||||
btnRow.Widgets.Add(confirmBtn);
|
||||
root.Widgets.Add(btnRow);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void RebuildUI() => BuildUI();
|
||||
|
||||
private static TextButton MakeButton(string text, bool selected, Action onClick)
|
||||
{
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = (selected ? "→ " : " ") + text,
|
||||
Padding = new Myra.Graphics2D.Thickness(6, 2, 6, 2),
|
||||
};
|
||||
btn.Click += (_, _) => onClick();
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static HorizontalStackPanel BuildSelectorRow<T>(
|
||||
IEnumerable<T> items,
|
||||
Func<T, string> label,
|
||||
Func<T, bool> isSelected,
|
||||
Action<T> onSelect)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 4 };
|
||||
foreach (var item in items)
|
||||
row.Widgets.Add(MakeButton(label(item), isSelected(item), () => onSelect(item)));
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>Validates current selections + builds a preview character. Returns false on the first error.</summary>
|
||||
private bool TryBuildPreview(out string error)
|
||||
{
|
||||
error = "";
|
||||
if (_clade is null || _species is null || _class is null || _background is null)
|
||||
{ error = "Pick clade, species, class, and background."; return false; }
|
||||
if (_chosenSkills.Count != _class.SkillsChoose)
|
||||
{ error = $"Pick exactly {_class.SkillsChoose} skill(s)."; return false; }
|
||||
if (string.IsNullOrWhiteSpace(_name))
|
||||
{ error = "Enter a name."; return false; }
|
||||
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _clade,
|
||||
Species = _species,
|
||||
ClassDef = _class,
|
||||
Background = _background,
|
||||
BaseAbilities = _baseAbilities,
|
||||
Name = _name,
|
||||
};
|
||||
foreach (var s in _chosenSkills) b.ChooseSkill(s);
|
||||
return b.Validate(out error);
|
||||
}
|
||||
|
||||
private void OnConfirm()
|
||||
{
|
||||
if (!TryBuildPreview(out var err)) return;
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = _clade,
|
||||
Species = _species,
|
||||
ClassDef = _class,
|
||||
Background = _background,
|
||||
BaseAbilities = _baseAbilities,
|
||||
Name = _name,
|
||||
};
|
||||
foreach (var s in _chosenSkills) b.ChooseSkill(s);
|
||||
// Apply the class's starting kit so the new character is equipped on
|
||||
// arrival — pass the loaded items table from the resolver.
|
||||
var character = b.Build(_content.Items);
|
||||
|
||||
// Pop ourselves and push worldgen with the pending character. PlayScreen
|
||||
// attaches it to the spawned PlayerActor on Initialize.
|
||||
_game.Screens.Pop();
|
||||
_game.Screens.Push(new WorldGenProgressScreen(_seed, pendingCharacter: character, pendingName: _name));
|
||||
}
|
||||
|
||||
// ── Stats helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Standard Array (15/14/13/12/10/8) assigned in class-priority order so
|
||||
/// the default character is functional. M3 may add manual swapping.
|
||||
/// </summary>
|
||||
private void AssignStandardArrayByClassPriority()
|
||||
{
|
||||
var values = (int[])AbilityScores.StandardArray.Clone();
|
||||
AssignByClassPriority(values);
|
||||
}
|
||||
|
||||
private void RollAndAssign()
|
||||
{
|
||||
ulong msNow = (ulong)(Environment.TickCount64 - _gameStartMs);
|
||||
var rng = SeededRng.ForSubsystem(_seed, C.RNG_STAT_ROLL ^ msNow);
|
||||
int[] values = new int[6];
|
||||
for (int i = 0; i < 6; i++) values[i] = CharacterBuilder.Roll4d6DropLowest(rng);
|
||||
AssignByClassPriority(values);
|
||||
}
|
||||
|
||||
private void AssignByClassPriority(int[] values)
|
||||
{
|
||||
// Sort values descending so highest goes to primary ability.
|
||||
Array.Sort(values, (a, b) => b - a);
|
||||
|
||||
var primary = _class?.PrimaryAbility ?? Array.Empty<string>();
|
||||
var allAbilities = new[] { "STR", "DEX", "CON", "INT", "WIS", "CHA" };
|
||||
var order = new List<string>();
|
||||
foreach (var p in primary) order.Add(p.ToUpperInvariant());
|
||||
// After primaries, prefer CON for survivability, then physical/mental in default order.
|
||||
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
|
||||
if (!order.Contains(a)) order.Add(a);
|
||||
|
||||
var assigned = new Dictionary<string, int>();
|
||||
for (int i = 0; i < 6; i++) assigned[order[i]] = values[i];
|
||||
|
||||
_baseAbilities = new AbilityScores(
|
||||
assigned["STR"], assigned["DEX"], assigned["CON"],
|
||||
assigned["INT"], assigned["WIS"], assigned["CHA"]);
|
||||
}
|
||||
|
||||
private void AutoPickSkills()
|
||||
{
|
||||
_chosenSkills.Clear();
|
||||
if (_class is null) return;
|
||||
int n = _class.SkillsChoose;
|
||||
foreach (var raw in _class.SkillOptions)
|
||||
{
|
||||
if (_chosenSkills.Count >= n) break;
|
||||
try { _chosenSkills.Add(SkillIdExtensions.FromJson(raw)); }
|
||||
catch { /* ignore unknown */ }
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatStats()
|
||||
{
|
||||
if (_clade is null || _species is null) return "";
|
||||
// Apply mods to base for display
|
||||
var mods = new Dictionary<AbilityId, int>();
|
||||
void Add(string raw, int v)
|
||||
{
|
||||
if (TryParseAbility(raw, out var id))
|
||||
mods[id] = (mods.TryGetValue(id, out var x) ? x : 0) + v;
|
||||
}
|
||||
foreach (var kv in _clade.AbilityMods) Add(kv.Key, kv.Value);
|
||||
foreach (var kv in _species.AbilityMods) Add(kv.Key, kv.Value);
|
||||
|
||||
var final = _baseAbilities.Plus(mods);
|
||||
return $"STR {final.STR} ({Sign(AbilityScores.Mod(final.STR))}) " +
|
||||
$"DEX {final.DEX} ({Sign(AbilityScores.Mod(final.DEX))}) " +
|
||||
$"CON {final.CON} ({Sign(AbilityScores.Mod(final.CON))})\n" +
|
||||
$"INT {final.INT} ({Sign(AbilityScores.Mod(final.INT))}) " +
|
||||
$"WIS {final.WIS} ({Sign(AbilityScores.Mod(final.WIS))}) " +
|
||||
$"CHA {final.CHA} ({Sign(AbilityScores.Mod(final.CHA))})";
|
||||
}
|
||||
|
||||
private static string Sign(int n) => n >= 0 ? $"+{n}" : n.ToString();
|
||||
|
||||
private static bool TryParseAbility(string raw, out AbilityId id)
|
||||
{
|
||||
switch (raw.ToUpperInvariant())
|
||||
{
|
||||
case "STR": id = AbilityId.STR; return true;
|
||||
case "DEX": id = AbilityId.DEX; return true;
|
||||
case "CON": id = AbilityId.CON; return true;
|
||||
case "INT": id = AbilityId.INT; return true;
|
||||
case "WIS": id = AbilityId.WIS; return true;
|
||||
case "CHA": id = AbilityId.CHA; return true;
|
||||
default: id = AbilityId.STR; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
if (Keyboard.GetState().IsKeyDown(Keys.Escape)) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(15, 15, 25));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// A single game screen (title, world map, tactical, etc.).
|
||||
/// Managed by ScreenManager as a push/pop stack.
|
||||
/// </summary>
|
||||
public interface IScreen
|
||||
{
|
||||
/// <summary>Called once when the screen is first pushed onto the stack.</summary>
|
||||
void Initialize(Game1 game);
|
||||
|
||||
/// <summary>Called every frame while this screen is active (top of stack).</summary>
|
||||
void Update(GameTime gameTime);
|
||||
|
||||
/// <summary>Called every frame to draw the screen.</summary>
|
||||
void Draw(GameTime gameTime, SpriteBatch spriteBatch);
|
||||
|
||||
/// <summary>Called when a screen is popped or another is pushed on top.</summary>
|
||||
void Deactivate();
|
||||
|
||||
/// <summary>Called when this screen comes back to the top of the stack.</summary>
|
||||
void Reactivate();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Myra;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Title screen: game logo text, "New World" button, optional seed input field.
|
||||
/// Uses Myra for all UI widgets.
|
||||
/// </summary>
|
||||
public sealed class TitleScreen : IScreen
|
||||
{
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private TextBox? _seedInput;
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
// Title label
|
||||
var title = new Label
|
||||
{
|
||||
Text = "THERIAPOLIS",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(title);
|
||||
|
||||
// Sub-title
|
||||
var sub = new Label
|
||||
{
|
||||
Text = "Veldara awaits.",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(sub);
|
||||
|
||||
// Spacer
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Seed row
|
||||
var seedRow = new HorizontalStackPanel { Spacing = 8 };
|
||||
seedRow.Widgets.Add(new Label { Text = "Seed:", VerticalAlignment = VerticalAlignment.Center });
|
||||
_seedInput = new TextBox
|
||||
{
|
||||
Text = "",
|
||||
Width = 160,
|
||||
};
|
||||
seedRow.Widgets.Add(_seedInput);
|
||||
root.Widgets.Add(seedRow);
|
||||
|
||||
// New World button
|
||||
var newWorldBtn = new TextButton
|
||||
{
|
||||
Text = "New World",
|
||||
Width = 180,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
newWorldBtn.Click += OnNewWorldClicked;
|
||||
root.Widgets.Add(newWorldBtn);
|
||||
|
||||
var loadBtn = new TextButton
|
||||
{
|
||||
Text = "Load Game",
|
||||
Width = 180,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
loadBtn.Click += (_, _) => _game.Screens.Push(new SaveLoadScreen());
|
||||
root.Widgets.Add(loadBtn);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void OnNewWorldClicked(object? sender, EventArgs e)
|
||||
{
|
||||
ulong seed;
|
||||
string raw = _seedInput?.Text?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
// Random seed from system time
|
||||
seed = (ulong)DateTime.UtcNow.Ticks;
|
||||
}
|
||||
else if (!ulong.TryParse(raw, out seed))
|
||||
{
|
||||
// Hash the string to a seed
|
||||
seed = 0;
|
||||
foreach (char c in raw) seed = seed * 31 + c;
|
||||
}
|
||||
|
||||
_game.Screens.Push(new CharacterCreationScreen(seed));
|
||||
}
|
||||
|
||||
public void Update(GameTime gameTime) { }
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(20, 20, 30));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Myra;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the world-generation pipeline on a background thread and shows per-stage progress.
|
||||
/// Transitions to WorldMapScreen when generation is complete.
|
||||
/// </summary>
|
||||
public sealed class WorldGenProgressScreen : IScreen
|
||||
{
|
||||
private readonly ulong _seed;
|
||||
private readonly SaveBody? _restoreFromSave;
|
||||
private readonly SaveHeader? _savedHeader;
|
||||
private readonly Character? _pendingCharacter;
|
||||
private readonly string? _pendingName;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private Label? _stageLabel;
|
||||
private Label? _progressLabel;
|
||||
|
||||
private WorldGenContext? _ctx;
|
||||
private Task? _genTask;
|
||||
private volatile float _progress;
|
||||
private volatile string _stageName = "Initialising...";
|
||||
private volatile bool _complete;
|
||||
private volatile string? _error;
|
||||
|
||||
public WorldGenProgressScreen(
|
||||
ulong seed,
|
||||
SaveBody? restoreFromSave = null,
|
||||
SaveHeader? savedHeader = null,
|
||||
Character? pendingCharacter = null,
|
||||
string? pendingName = null)
|
||||
{
|
||||
_seed = seed;
|
||||
_restoreFromSave = restoreFromSave;
|
||||
_savedHeader = savedHeader;
|
||||
_pendingCharacter = pendingCharacter;
|
||||
_pendingName = pendingName;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
StartGeneration();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Generating world... (seed: 0x{_seed:X})",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
|
||||
_progressLabel = new Label
|
||||
{
|
||||
Text = "[ ] 0%",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(_progressLabel);
|
||||
|
||||
_stageLabel = new Label
|
||||
{
|
||||
Text = "Starting...",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(_stageLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void StartGeneration()
|
||||
{
|
||||
string dataDir = _game.ContentDataDirectory;
|
||||
_genTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_ctx = new WorldGenContext(_seed, dataDir)
|
||||
{
|
||||
ProgressCallback = (name, frac) =>
|
||||
{
|
||||
_stageName = name;
|
||||
_progress = frac;
|
||||
},
|
||||
Log = msg => System.Diagnostics.Debug.WriteLine(msg),
|
||||
};
|
||||
WorldGenerator.RunAll(_ctx);
|
||||
_complete = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unwrap AggregateException to get the real inner message
|
||||
var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex;
|
||||
_error = inner.ToString(); // full type + message + stack trace
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Update(GameTime gameTime)
|
||||
{
|
||||
if (_error is not null)
|
||||
{
|
||||
// Show error on screen so it is visible; do NOT pop automatically.
|
||||
System.Diagnostics.Debug.WriteLine($"[WorldGen ERROR] {_error}");
|
||||
if (_stageLabel is not null) _stageLabel.Text = "ERROR — press Escape to go back";
|
||||
if (_progressLabel is not null) _progressLabel.Text = _error.Length > 80
|
||||
? _error[..80] + "..."
|
||||
: _error;
|
||||
// Write full error to a log file next to the exe for post-mortem diagnosis
|
||||
try
|
||||
{
|
||||
string logPath = Path.Combine(
|
||||
AppContext.BaseDirectory, "worldgen_error.log");
|
||||
File.WriteAllText(logPath,
|
||||
$"[{DateTime.Now:u}] WorldGen ERROR\n{_error}\n");
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
// Only pop when the user presses Escape
|
||||
if (Microsoft.Xna.Framework.Input.Keyboard.GetState()
|
||||
.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.Escape))
|
||||
_game.Screens.Pop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_complete && _ctx is not null)
|
||||
{
|
||||
// Stage-hash check: a soft warning is fine for Phase 4. We log
|
||||
// mismatches but proceed — saves anchored only by player position
|
||||
// and chunk deltas tolerate small worldgen drift.
|
||||
if (_savedHeader is not null) CompareStageHashes();
|
||||
|
||||
if (_restoreFromSave is not null)
|
||||
_game.Screens.Push(new PlayScreen(_ctx, _restoreFromSave));
|
||||
else if (_pendingCharacter is not null)
|
||||
_game.Screens.Push(new PlayScreen(_ctx, _pendingCharacter, _pendingName ?? "Wanderer"));
|
||||
else
|
||||
_game.Screens.Push(new PlayScreen(_ctx));
|
||||
|
||||
_complete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI progress on game thread
|
||||
int pct = (int)(_progress * 100f);
|
||||
int filled = pct / 10;
|
||||
string bar = new string('#', filled) + new string(' ', 10 - filled);
|
||||
if (_progressLabel is not null) _progressLabel.Text = $"[{bar}] {pct,3}%";
|
||||
if (_stageLabel is not null) _stageLabel.Text = _stageName;
|
||||
}
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(10, 10, 20));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
private void CompareStageHashes()
|
||||
{
|
||||
if (_savedHeader is null || _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++;
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[Save migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}");
|
||||
}
|
||||
}
|
||||
if (mismatches > 0)
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[Save migration] {mismatches} stage(s) drifted; loading anyway (soft).");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user