fix(ui): complete retail indicator detail panels

Port the authored effect row template, remaining-time and selection details, synchronize the full gmPanelUI child geometry, and route the burden indicator to Character Information panel 3.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 11:36:43 +02:00
parent a96767ba6d
commit d1d603105f
24 changed files with 3696 additions and 147 deletions

View file

@ -16,16 +16,24 @@ public sealed class EffectsUiController : IRetainedPanelController
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint ScrollbarId = 0x10000124u;
public const uint InfoTextId = 0x10000126u;
public const uint RowTemplateId = 0x10000128u;
public const uint RowIconId = 0x10000129u;
public const uint RowLabelId = 0x1000012Au;
public const uint RowDurationId = 0x1000012Bu;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly EffectRowTemplateFactory _templates;
private readonly string _selectPrompt;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private readonly UiScrollbar? _scrollbar;
private readonly Dictionary<uint, EffectRowTemplateFactory.EffectRow> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
@ -38,6 +46,8 @@ public sealed class EffectsUiController : IRetainedPanelController
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
UiItemList list,
Action? close)
{
@ -45,13 +55,18 @@ public sealed class EffectsUiController : IRetainedPanelController
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_templates = templates;
_selectPrompt = selectPrompt;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
_list.CellWidth = templates.Width;
_list.CellHeight = templates.Height;
if (_scrollbar is not null)
_scrollbar.Model = _list.Scroll;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
@ -64,6 +79,8 @@ public sealed class EffectsUiController : IRetainedPanelController
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
@ -82,7 +99,15 @@ public sealed class EffectsUiController : IRetainedPanelController
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
layout,
spellbook,
positive,
serverTime,
resolveSpellIcon,
templates,
selectPrompt,
list,
close);
}
public void Tick()
@ -95,8 +120,10 @@ public sealed class EffectsUiController : IRetainedPanelController
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
if (_rows.TryGetValue(
enchantment.Identity,
out EffectRowTemplateFactory.EffectRow? row))
row.Remaining = FormatRemaining(enchantment, now);
}
private void Rebuild()
@ -111,22 +138,18 @@ public sealed class EffectsUiController : IRetainedPanelController
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
EffectRowTemplateFactory.EffectRow row = _templates.Create(
enchantment.SpellId,
metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
metadata?.Name ?? $"Spell {enchantment.SpellId}",
FormatRemaining(enchantment, _serverTime()));
row.Slot.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
_list.AddItem(row.Slot);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
@ -145,7 +168,9 @@ public sealed class EffectsUiController : IRetainedPanelController
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
// EffectInfoRegion::Update @ 0x004F1C00 starts from retail's empty
// PString and only formats it when the remaining duration is >= 0.
if (enchantment.Duration < 0) return string.Empty;
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
@ -165,25 +190,27 @@ public sealed class EffectsUiController : IRetainedPanelController
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
foreach (EffectRowTemplateFactory.EffectRow row in _rows.Values)
row.Slot.SetSelected(row.Slot.EntryId == _selectedSpellId);
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.PreserveEndOnLayout = false;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(string.Empty, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
@ -197,5 +224,6 @@ public sealed class EffectsUiController : IRetainedPanelController
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
if (_scrollbar is not null) _scrollbar.Model = null;
}
}