Files

323 lines
13 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// Phase 6.5 M0 — the level-up modal. Pushed by <see cref="PauseMenuScreen"/>
/// when the player clicks "Level Up" while
/// <see cref="LevelUpFlow.CanLevelUp"/> 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 <see cref="Character"/>
/// via <see cref="Character.ApplyLevelUp"/> and pops; if the player still
/// has enough XP for another level, the screen offers to re-open.
/// </summary>
public sealed class LevelUpScreen : IScreen
{
private readonly Character _character;
private readonly ulong _baseSeed;
private readonly IReadOnlyDictionary<string, Theriapolis.Core.Data.SubclassDef>? _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<string, Theriapolis.Core.Data.SubclassDef>? 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<AbilityId>())
{
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(); }
}