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; /// /// Phase 5 M2: pre-game character creation. Player picks clade, species, /// class, background, stat method, skills, and name; the screen builds a /// 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. /// 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 _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( IEnumerable items, Func label, Func isSelected, Action onSelect) { var row = new HorizontalStackPanel { Spacing = 4 }; foreach (var item in items) row.Widgets.Add(MakeButton(label(item), isSelected(item), () => onSelect(item))); return row; } /// Validates current selections + builds a preview character. Returns false on the first error. 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 ──────────────────────────────────────────────────── /// /// Standard Array (15/14/13/12/10/8) assigned in class-priority order so /// the default character is functional. M3 may add manual swapping. /// 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(); var allAbilities = new[] { "STR", "DEX", "CON", "INT", "WIS", "CHA" }; var order = new List(); 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(); 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(); 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() { } }