using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using AcDream.Core.Spells; namespace AcDream.App.UI.Layout; /// Retail gmEffectsUI positive/negative instance binding. public sealed class EffectsUiController : IRetainedPanelController { public const uint LayoutId = 0x2100001Bu; // gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc. // The authored positive/negative gmEffectsUI instances inherit template 0x10000122. public const uint PositiveRootId = 0x1000011Fu; public const uint NegativeRootId = 0x10000121u; public const uint CloseId = 0x100000FCu; public const uint ListId = 0x10000123u; public const uint InfoTextId = 0x10000126u; private readonly Spellbook _spellbook; private readonly bool _positive; private readonly Func _serverTime; private readonly Func _resolveSpellIcon; private readonly UiItemList _list; private readonly UiText? _info; private readonly UiButton? _close; private readonly Dictionary _rows = new(); private uint? _selectedSpellId; private double _lastDurationUpdate = double.NaN; private bool _disposed; internal uint? SelectedSpellId => _selectedSpellId; private EffectsUiController( ImportedLayout layout, Spellbook spellbook, bool positive, Func serverTime, Func resolveSpellIcon, UiItemList list, Action? close) { _spellbook = spellbook; _positive = positive; _serverTime = serverTime; _resolveSpellIcon = resolveSpellIcon; _list = list; _info = layout.FindElement(InfoTextId) as UiText; _close = layout.FindElement(CloseId) as UiButton; if (_close is not null) _close.OnClick = close; _list.Columns = 1; _list.CellWidth = Math.Max(1f, _list.Width); _list.CellHeight = 32f; ConfigureInfo(); _spellbook.EnchantmentsChanged += Rebuild; Rebuild(); } public static EffectsUiController? Bind( ImportedLayout layout, Spellbook spellbook, bool positive, Func serverTime, Func spriteResolve, Func resolveSpellIcon, Action? close = null) { UiElement? host = layout.FindElement(ListId); if (host is null) return null; UiItemList list; if (host is UiItemList itemList) list = itemList; else { list = new UiItemList(spriteResolve) { Width = host.Width, Height = host.Height, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, }; host.AddChild(list); } return new EffectsUiController( layout, spellbook, positive, serverTime, resolveSpellIcon, list, close); } public void Tick() { double now = _serverTime(); if (!double.IsFinite(now)) return; if (double.IsFinite(_lastDurationUpdate) && now >= _lastDurationUpdate && now - _lastDurationUpdate < 1.0) return; _lastDurationUpdate = now; foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments()) if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row)) row.Detail = FormatRemaining(enchantment, now); } private void Rebuild() { _lastDurationUpdate = double.NaN; _rows.Clear(); ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray(); using (_list.DeferLayout()) { _list.Flush(); foreach (ActiveEnchantmentRecord enchantment in enchantments) { _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); _rows[identity] = row; _list.AddItem(row); } } if (_selectedSpellId is uint selected && !_rows.Values.Any(row => row.EntryId == selected)) _selectedSpellId = null; SyncSelection(); } private IEnumerable VisibleEnchantments() => _spellbook.EnchantmentsInEffectSnapshot .Where(record => { if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)) return false; return metadata.IsBeneficial == _positive; }) .OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata) ? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase); private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now) { if (enchantment.Duration < 0) return "Permanent"; double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now); if (!double.IsFinite(remaining)) return "--:--"; remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds); TimeSpan time = TimeSpan.FromSeconds(remaining); return time.TotalHours >= 1 ? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}" : $"{time.Minutes}:{time.Seconds:00}"; } private void Select(uint spellId) { // Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the // token's spell stat, not its enchantment layer identity. _selectedSpellId = _selectedSpellId == spellId ? null : spellId; SyncSelection(); } private void SyncSelection() { foreach (UiCatalogSlot row in _rows.Values) row.Selected = row.EntryId == _selectedSpellId; } private void ConfigureInfo() { if (_info is null) return; _info.LinesProvider = () => { if (_selectedSpellId is not uint spellId) return Array.Empty(); ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot .Cast() .FirstOrDefault(record => record?.SpellId == spellId); if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata)) return Array.Empty(); return [ new UiText.Line(metadata.Name, _info.DefaultColor), new UiText.Line(metadata.Description, _info.DefaultColor), ]; }; } public void OnShown() => Rebuild(); public void Dispose() { if (_disposed) return; _disposed = true; _spellbook.EnchantmentsChanged -= Rebuild; if (_close is not null) _close.OnClick = null; } }