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(); }
|
|||
|
|
}
|