using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Myra.Graphics2D; using Myra.Graphics2D.Brushes; using Myra.Graphics2D.UI; using Theriapolis.Core; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; namespace Theriapolis.Game.Screens; /// /// Phase 6.5 M0 — the level-up modal. Pushed by /// when the player clicks "Level Up" while /// returns true. /// /// Shows the rolled (or averaged) HP gain, the feature unlocks for this /// level, and — when applicable — the ASI picker and subclass picker. On /// confirm, applies the deltas to the player's /// via and pops; if the player still /// has enough XP for another level, the screen offers to re-open. /// public sealed class LevelUpScreen : IScreen { private readonly Character _character; private readonly ulong _baseSeed; private readonly IReadOnlyDictionary? _subclasses; private Game1 _game = null!; private Desktop _desktop = null!; private LevelUpResult _preview = null!; private LevelUpChoices _choices = null!; private Label? _statusLabel; private bool _escWasDown = true; public LevelUpScreen( Character character, ulong baseSeed, IReadOnlyDictionary? subclasses = null) { _character = character ?? throw new ArgumentNullException(nameof(character)); _baseSeed = baseSeed; _subclasses = subclasses; } public void Initialize(Game1 game) { _game = game; RecomputePreview(takeAverage: true); Build(); } private void RecomputePreview(bool takeAverage) { int targetLevel = _character.Level + 1; ulong seed = _baseSeed ^ C.RNG_LEVELUP ^ (ulong)targetLevel // Mix in level-up history length so each successive level-up // (when the player chains multiple at once) gets a distinct // sub-seed even when targetLevel is reused after re-entry. ^ ((ulong)_character.LevelUpHistory.Count << 16); _preview = LevelUpFlow.Compute(_character, targetLevel, seed, takeAverage: takeAverage, subclasses: _subclasses); _choices = new LevelUpChoices { TakeAverageHp = takeAverage }; } private void Build() { var root = new VerticalStackPanel { Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Background = new SolidBrush(new Color(0, 0, 0, 220)), Padding = new Thickness(40, 24, 40, 24), }; root.Widgets.Add(new Label { Text = $"LEVEL UP — Level {_character.Level} → {_preview.NewLevel}", HorizontalAlignment = HorizontalAlignment.Center, }); root.Widgets.Add(new Label { Text = " " }); // HP section. string hpLine = _preview.HpWasAveraged ? $"HP: +{_preview.HpGained} (took average; rolled would be 1d{_character.ClassDef.HitDie})" : $"HP: +{_preview.HpGained} (rolled {_preview.HpHitDieResult} on 1d{_character.ClassDef.HitDie})"; root.Widgets.Add(new Label { Text = hpLine, HorizontalAlignment = HorizontalAlignment.Center }); var hpToggle = new TextButton { Text = _preview.HpWasAveraged ? "Switch to: Roll HP" : "Switch to: Take average HP", Width = 280, HorizontalAlignment = HorizontalAlignment.Center, }; hpToggle.Click += (_, _) => { RecomputePreview(takeAverage: !_preview.HpWasAveraged); Build(); }; root.Widgets.Add(hpToggle); // Class features. if (_preview.ClassFeaturesUnlocked.Length > 0) { root.Widgets.Add(new Label { Text = " " }); root.Widgets.Add(new Label { Text = "Features unlocked:", HorizontalAlignment = HorizontalAlignment.Center }); foreach (var fid in _preview.ClassFeaturesUnlocked) { string display = fid; if (_character.ClassDef.FeatureDefinitions.TryGetValue(fid, out var def)) display = string.IsNullOrEmpty(def.Name) ? fid : $"{def.Name} — {def.Kind}"; root.Widgets.Add(new Label { Text = " • " + display, HorizontalAlignment = HorizontalAlignment.Center }); } } // Phase 6.5 M2 — subclass features (post-L3, when SubclassId is set). if (_preview.SubclassFeaturesUnlocked.Length > 0 && _subclasses is not null) { var subclass = Theriapolis.Core.Rules.Character.SubclassResolver.TryFindSubclass( _subclasses, _character.SubclassId); string subclassName = subclass?.Name ?? _character.SubclassId; root.Widgets.Add(new Label { Text = " " }); root.Widgets.Add(new Label { Text = $"{subclassName} features:", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(220, 200, 140), }); foreach (var fid in _preview.SubclassFeaturesUnlocked) { string display = fid; var fdef = Theriapolis.Core.Rules.Character.SubclassResolver.ResolveFeatureDef( _character.ClassDef, subclass, fid); if (fdef is not null) display = string.IsNullOrEmpty(fdef.Name) ? fid : $"{fdef.Name} — {fdef.Kind}"; root.Widgets.Add(new Label { Text = " • " + display, HorizontalAlignment = HorizontalAlignment.Center }); } } root.Widgets.Add(new Label { Text = " " }); root.Widgets.Add(new Label { Text = $"Proficiency bonus: +{_preview.NewProficiencyBonus}", HorizontalAlignment = HorizontalAlignment.Center, }); // Subclass picker. if (_preview.GrantsSubclassChoice) { root.Widgets.Add(new Label { Text = " " }); root.Widgets.Add(new Label { Text = "Choose a subclass:", HorizontalAlignment = HorizontalAlignment.Center }); foreach (var sid in _character.ClassDef.SubclassIds) { string sCapture = sid; string label = sid; string? flavor = null; if (_subclasses is not null && _subclasses.TryGetValue(sid, out var subDef)) { label = subDef.Name; flavor = subDef.Flavor; } var btn = new TextButton { Text = $" {label}{(_choices.SubclassId == sCapture ? " ✓" : "")}", Width = 360, HorizontalAlignment = HorizontalAlignment.Center, }; btn.Click += (_, _) => { _choices.SubclassId = sCapture; Build(); }; root.Widgets.Add(btn); if (_choices.SubclassId == sCapture && !string.IsNullOrEmpty(flavor)) { root.Widgets.Add(new Label { Text = " " + flavor, Wrap = true, Width = 600, HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(170, 170, 170), }); } } } // ASI picker. if (_preview.GrantsAsiChoice) { root.Widgets.Add(new Label { Text = " " }); root.Widgets.Add(new Label { Text = "Ability Score Improvement (+2 to one or +1 to two):", HorizontalAlignment = HorizontalAlignment.Center }); int allocated = _choices.AsiAdjustments.Values.Sum(); root.Widgets.Add(new Label { Text = $"Allocated: +{allocated} / +2", HorizontalAlignment = HorizontalAlignment.Center, }); foreach (AbilityId aid in Enum.GetValues()) { var aidCapture = aid; int current = _character.Abilities.Get(aid); int delta = _choices.AsiAdjustments.TryGetValue(aid, out var d) ? d : 0; var row = new HorizontalStackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center, }; row.Widgets.Add(new Label { Text = $" {aid}: {current}{(delta > 0 ? $" → {current + delta}" : "")} ", VerticalAlignment = VerticalAlignment.Center }); var minus = new TextButton { Text = "−", Width = 30 }; minus.Click += (_, _) => { if (_choices.AsiAdjustments.TryGetValue(aidCapture, out var v) && v > 0) { if (v == 1) _choices.AsiAdjustments.Remove(aidCapture); else _choices.AsiAdjustments[aidCapture] = v - 1; Build(); } }; var plus = new TextButton { Text = "+", Width = 30 }; plus.Click += (_, _) => { int totalAllocated = _choices.AsiAdjustments.Values.Sum(); if (totalAllocated >= 2) return; // cap at +2 int currentDelta = _choices.AsiAdjustments.TryGetValue(aidCapture, out var v) ? v : 0; if (currentDelta >= 2) return; int currentScore = _character.Abilities.Get(aidCapture); if (currentScore + currentDelta + 1 > C.ABILITY_SCORE_CAP_PRE_L20) return; _choices.AsiAdjustments[aidCapture] = currentDelta + 1; Build(); }; row.Widgets.Add(minus); row.Widgets.Add(plus); root.Widgets.Add(row); } } root.Widgets.Add(new Label { Text = " " }); // Confirm button. bool valid = ChoicesValid(out string reason); var confirm = new TextButton { Text = valid ? "Confirm" : $"Confirm — {reason}", Width = 280, HorizontalAlignment = HorizontalAlignment.Center, Enabled = valid, }; confirm.Click += (_, _) => { if (!ChoicesValid(out _)) return; _character.ApplyLevelUp(_preview, _choices); // Chain into the next level-up immediately if eligible. if (LevelUpFlow.CanLevelUp(_character)) { RecomputePreview(takeAverage: true); Build(); ShowStatus($"Now level {_character.Level}. Another level available!"); } else { _game.Screens.Pop(); } }; root.Widgets.Add(confirm); var cancel = new TextButton { Text = "Cancel", Width = 280, HorizontalAlignment = HorizontalAlignment.Center }; cancel.Click += (_, _) => _game.Screens.Pop(); root.Widgets.Add(cancel); _statusLabel = new Label { Text = " ", HorizontalAlignment = HorizontalAlignment.Center }; root.Widgets.Add(_statusLabel); _desktop = new Desktop { Root = root }; } private bool ChoicesValid(out string reason) { if (_preview.GrantsSubclassChoice && string.IsNullOrEmpty(_choices.SubclassId)) { reason = "pick a subclass"; return false; } if (_preview.GrantsAsiChoice) { int total = _choices.AsiAdjustments.Values.Sum(); if (total != 2) { reason = $"allocate +{2 - total} more ASI"; return false; } } reason = ""; return true; } private void ShowStatus(string text) { if (_statusLabel is not null) _statusLabel.Text = text; } public void Update(GameTime gt) { bool down = Keyboard.GetState().IsKeyDown(Keys.Escape); bool justPressed = down && !_escWasDown; _escWasDown = down; if (justPressed) _game.Screens.Pop(); } public void Draw(GameTime gt, SpriteBatch sb) { _desktop.Render(); } public void Deactivate() { } public void Reactivate() { Build(); } }