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:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
public enum SpellbookWindowPage { Spells, Components }
/// <summary>
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
/// </summary>
public sealed class SpellbookWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x21000034u;
public const uint RootId = 0x100002A8u;
public const uint SpellTabId = 0x100002A9u;
public const uint ComponentTabId = 0x100002AAu;
public const uint CloseId = 0x100002ABu;
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
private static readonly (uint Id, uint Mask)[] FilterButtons =
[
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
(0x100005C0u, 0x2000u),
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
];
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _spellLevel;
private readonly Action<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint, uint> _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
private readonly UiElement _componentPage;
private readonly UiButton _spellTab;
private readonly UiButton _componentTab;
private readonly UiButton _closeButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
private bool _disposed;
private SpellbookWindowController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close,
UiElement spellPage,
UiElement componentPage,
UiButton spellTab,
UiButton componentTab,
UiButton closeButton,
UiItemList spellList,
UiItemList componentList)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_components = components;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
_componentPage = componentPage;
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
_spellList = spellList;
_componentList = componentList;
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
closeButton.OnClick = close;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
uint filterMask = mask;
button.OnClick = () => ToggleFilter(filterMask);
_filters.Add((button, mask));
}
ConfigureSpellList();
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
public SpellbookWindowPage CurrentPage { get; private set; }
public static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
UiElement? componentHost = layout.FindElement(ComponentListId);
if (componentHost is null) return null;
UiItemList componentList;
if (componentHost is UiItemList itemList)
componentList = itemList;
else
{
componentList = new UiItemList(spellList.SpriteResolve)
{
Width = componentHost.Width,
Height = componentHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
componentHost.AddChild(componentList);
}
return new SpellbookWindowController(
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
spellList, componentList);
}
public void ShowPage(SpellbookWindowPage page)
{
CurrentPage = page;
_spellPage.Visible = page == SpellbookWindowPage.Spells;
_componentPage.Visible = page == SpellbookWindowPage.Components;
_spellTab.Selected = page == SpellbookWindowPage.Spells;
_componentTab.Selected = page == SpellbookWindowPage.Components;
}
private void ConfigureSpellList()
{
_spellList.Columns = 6;
_spellList.CellWidth = 32f;
_spellList.CellHeight = 32f;
}
private void ConfigureComponentList(ImportedLayout layout)
{
_componentList.Columns = 1;
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
_componentList.CellHeight = 32f;
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _componentList.Scroll;
}
private void ToggleFilter(uint mask)
{
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
}
private void RebuildAll()
{
RebuildSpells();
RebuildComponents();
_spellsDirty = false;
_componentsDirty = false;
}
private void RebuildSpells()
{
uint effective = _spellbook.SpellbookFilters;
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
SpellMetadata[] spells = _spellbook.LearnedSpells
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
.OrderBy(metadata => metadata!.SortKey)
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
.Cast<SpellMetadata>()
.ToArray();
using (_spellList.DeferLayout())
{
_spellList.Flush();
foreach (SpellMetadata metadata in spells)
{
uint spellId = metadata.SpellId;
var slot = new UiCatalogSlot
{
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
SpriteResolve = _spellList.SpriteResolve,
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
// gmSpellcastingUI tab chooses the server favorite-list destination.
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
_spellList.AddItem(slot);
}
}
SyncSpellSelection();
}
private void RebuildComponents()
{
uint player = _playerGuid();
var packs = _objects.Objects
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
&& IsOwnedByPlayer(item, player))
.GroupBy(item => item.WeenieClassId)
.Select(group => new
{
ComponentId = group.Key,
First = group.First(),
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
})
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
float width = Math.Max(96f, _componentList.Width);
_componentList.CellWidth = width;
using (_componentList.DeferLayout())
{
_componentList.Flush();
uint? category = null;
foreach (var packGroup in packs)
{
ClientObject pack = packGroup.First;
uint componentId = packGroup.ComponentId;
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
uint nextCategory = descriptor?.Category ?? 8u;
if (category != nextCategory)
{
category = nextCategory;
_componentList.AddItem(new UiCatalogSlot
{
EntryId = uint.MaxValue,
Label = ComponentCategoryName(nextCategory),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
});
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
var row = new UiCatalogSlot
{
EntryId = componentId,
CatalogIconTexture = _resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
};
var desiredField = new UiField
{
Left = width - 47f,
Top = 4f,
Width = 43f,
Height = 24f,
MaxCharacters = 4,
ClearOnSubmit = false,
RecordHistory = false,
SelectAllOnFocus = true,
RightAligned = true,
CharacterFilter = char.IsAsciiDigit,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
};
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
{
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|| parsed > 5000u)
{
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
return;
}
if (parsed == committed) return;
committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed);
}
desiredField.OnSubmit = Commit;
desiredField.OnFocusLost = value =>
{
Commit(value);
_componentEditActive = false;
};
row.AddChild(desiredField);
row.Clicked = () => _selectObject(pack.ObjectId);
_componentList.AddItem(row);
}
}
}
private static string ComponentCategoryName(uint category) => category switch
{
0u => "Scarabs",
1u => "Herbs",
2u => "Powdered Gems",
3u => "Alchemical Substances",
4u => "Talismans",
5u => "Tapers",
6u => "Prismatic Components",
_ => "Other Components",
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
current = parent;
}
return false;
}
private bool IsVisible(SpellMetadata metadata, uint filters)
{
uint school = metadata.School switch
{
"Creature Enchantment" => 0x0001u,
"Item Enchantment" => 0x0002u,
"Life Magic" => 0x0004u,
"War Magic" => 0x0008u,
"Void Magic" => 0x2000u,
_ => 0u,
};
int spellLevel = _spellLevel(metadata.SpellId);
if (spellLevel is < 1 or > 8) return false;
uint level = 0x10u << (spellLevel - 1);
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
}
private void SelectSpell(uint spellId)
{
_selectedSpell = spellId;
SyncSpellSelection();
}
private void SyncSpellSelection()
{
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
if (_spellList.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selectedSpell;
}
public void Tick()
{
if (_spellsDirty)
{
_spellsDirty = false;
RebuildSpells();
}
if (_componentsDirty && !_componentEditActive)
{
_componentsDirty = false;
RebuildComponents();
}
}
private void OnSpellbookChanged() => _spellsDirty = true;
private void OnDesiredComponentsChanged() => _componentsDirty = true;
private void OnObjectChanged(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectRemoved(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_spellTab.OnClick = null;
_componentTab.OnClick = null;
_closeButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}
}