Files
TheriapolisV3/Theriapolis.Game/Screens/LevelUpScreen.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

323 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
}