feat: port retail magic lifecycle and retained spell UI
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>
This commit is contained in:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue