acdream/src/AcDream.App/UI/Layout/VitaeUiController.cs
Erik 82789eea88 fix(ui): restore effect detail scrolling
Reflow selected Helpful/Harmful spell descriptions through the retained retail text shaper, bind their authored 0x10000127 information scrollbar independently from the list scrollbar, and allow display-only text to consume wheel scrolling without becoming selectable.

Format Vitae recovery experience through retail's grouped integer presentation. Release build succeeds and all 5,832 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 13:28:18 +02:00

171 lines
5.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmVitaeUI</c>. It presents the active vitae
/// modifier and the authoritative experience pool needed to regain one percent.
/// </summary>
public sealed class VitaeUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000020u;
public const uint RootId = 0x100001C1u;
public const uint MainTextId = 0x100001C3u;
public const uint CloseId = 0x100000FCu;
internal const uint VitaeCpPoolProperty = 0x81u;
internal const uint DeathLevelProperty = 0x8Bu;
internal const uint LevelProperty = 0x19u;
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly VitaeStrings _strings;
private readonly UiText _mainText;
private readonly UiButton? _close;
private IReadOnlyList<UiText.Line> _lines = Array.Empty<UiText.Line>();
private bool _disposed;
private VitaeUiController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
VitaeStrings strings,
Action? close)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_strings = strings;
_mainText = (UiText)layout.FindElement(MainTextId)!;
_close = layout.FindElement(CloseId) as UiButton;
_mainText.LinesProvider = () => _lines;
if (_close is not null) _close.OnClick = close;
_spellbook.EnchantmentsChanged += Update;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += Update;
Update();
}
public static VitaeUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
VitaeStrings strings,
Action? close = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(playerGuid);
ArgumentNullException.ThrowIfNull(strings);
return layout.FindElement(MainTextId) is UiText
? new VitaeUiController(
layout, spellbook, objects, playerGuid, strings, close)
: null;
}
public void OnShown() => Update();
private void OnObjectChanged(ClientObject item)
{
if (item.ObjectId == _playerGuid()) Update();
}
private void Update()
{
float vitae = CurrentVitae();
int lostPercent = 100 - (int)(vitae * 100f);
string body;
if (lostPercent <= 0)
{
body = _strings.FullStrength;
}
else
{
ClientObject? player = _objects.Get(_playerGuid());
int pool = player?.Properties.GetInt(VitaeCpPoolProperty) ?? 0;
int level = 0;
if (player is not null)
{
level = player.Properties.Ints.TryGetValue(
DeathLevelProperty, out int deathLevel)
? deathLevel
: player.Properties.GetInt(LevelProperty);
}
int remaining = VitaeCpPoolThreshold(vitae, level) - pool;
body = _strings.LostPrefix
+ lostPercent
+ _strings.LostSuffix
+ _strings.SkillsPrefix
+ lostPercent
+ _strings.SkillsSuffix
+ _strings.RecoveryPrefix
// StringInfo::AddVariable_Int -> LInt_StringInfoData::ToString
// @ 0x0042F7E0 always enables localized digit grouping.
+ FormatExperience(remaining)
+ _strings.RecoverySuffix;
}
_lines = IndicatorDetailText.Shape(_mainText, body);
}
private float CurrentVitae()
=> _spellbook.ActiveEnchantmentSnapshot
.Where(record => record.Bucket == 4u)
.Select(record => record.StatModValue)
.OfType<float>()
.Where(float.IsFinite)
.DefaultIfEmpty(1f)
.Last();
/// <summary>
/// Retail <c>VitaeSystem::VitaeCPPoolThreshold @ 0x005C8FD0</c>, with
/// the x87 expression cross-checked against ACE's Player_Xp port.
/// </summary>
internal static int VitaeCpPoolThreshold(float vitae, int level)
=> (int)(((Math.Pow(level, 2.5d) * 2.5d) + 20d)
* Math.Pow(vitae, 5d)
+ 0.5d);
internal static string FormatExperience(int experience)
=> experience.ToString("N0", CultureInfo.InvariantCulture);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= Update;
if (_close is not null) _close.OnClick = null;
}
}
public sealed record VitaeStrings(
string FullStrength,
string LostPrefix,
string LostSuffix,
string SkillsPrefix,
string SkillsSuffix,
string RecoveryPrefix,
string RecoverySuffix)
{
public static VitaeStrings English { get; } = new(
"Your Vitae, or life force, is at full strength.",
"Due to your recent death, you have temporarily lost ",
"% of your Vitae, or life force.",
"\n\nThis means that your health, stamina, mana, and skills are temporarily reduced by ",
"%. A reduction of less than 15% will not hinder you much, but beware losing much more than that.",
"\n\nYou will regain 1% of your Vitae once you earn ",
" more experience.");
}