b451f83174
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>
323 lines
13 KiB
C#
323 lines
13 KiB
C#
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(); }
|
||
}
|