Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
201 lines
7.4 KiB
C#
201 lines
7.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using AcDream.Core.Spells;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
|
|
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<double> _serverTime;
|
|
private readonly Func<uint, uint> _resolveSpellIcon;
|
|
private readonly UiItemList _list;
|
|
private readonly UiText? _info;
|
|
private readonly UiButton? _close;
|
|
private readonly Dictionary<uint, UiCatalogSlot> _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<double> serverTime,
|
|
Func<uint, uint> 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<double> serverTime,
|
|
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
|
|
Func<uint, uint> 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<ActiveEnchantmentRecord> 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<UiText.Line>();
|
|
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(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;
|
|
}
|
|
}
|