feat(ui): port retail spellbook interactions

Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar.

Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 16:26:30 +02:00
parent ad30c37a48
commit ac2ca8f965
17 changed files with 727 additions and 47 deletions

View file

@ -0,0 +1,72 @@
using System.Numerics;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook
/// list points at this prototype through ItemList attribute <c>0x1000000E</c>;
/// retail clones it for every learned spell.
/// </summary>
public readonly record struct SpellbookRowStyle(
float Width,
float Height,
float IconLeft,
float IconTop,
float IconWidth,
float IconHeight,
float LabelLeft,
float LabelWidth,
uint FontDid,
Vector4 LabelColor,
uint BackgroundSprite,
uint SelectedSprite)
{
public const uint CatalogLayoutId = 0x21000037u;
public const uint PrototypeId = 0x10000343u;
public const uint SelectedOverlayId = 0x10000342u;
public const uint LabelId = 0x10000344u;
public const uint IconId = 0x1000033Bu;
/// <summary>
/// Resolve the complete row geometry and media from the installed retail DAT.
/// Returns null if the authored prototype is incomplete; callers then leave the
/// panel unbound rather than inventing replacement art.
/// </summary>
public static SpellbookRowStyle? TryLoad(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId);
if (prototype is null
|| Find(prototype, SelectedOverlayId) is not { } selected
|| Find(prototype, LabelId) is not { } label
|| Find(prototype, IconId) is not { } icon
|| !prototype.StateMedia.TryGetValue("", out var background)
|| !selected.StateMedia.TryGetValue("", out var selection)
|| prototype.Width <= 0f
|| prototype.Height <= 0f)
return null;
return new SpellbookRowStyle(
prototype.Width,
prototype.Height,
icon.X,
icon.Y,
icon.Width,
icon.Height,
label.X,
label.Width,
label.FontDid,
label.FontColor ?? Vector4.One,
background.File,
selection.File);
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found) return found;
return null;
}
}

View file

@ -28,6 +28,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
public const uint SpellScrollbarId = 0x10000296u;
public const uint DeleteButtonId = 0x100002A5u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
@ -52,6 +54,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly Action<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint> _removeSpell;
private readonly Action<string, Action<bool>> _showConfirmation;
private readonly Action<uint, uint> _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
@ -59,9 +63,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly UiElement _spellTab;
private readonly UiElement _componentTab;
private readonly UiButton _closeButton;
private readonly UiButton _deleteButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private readonly SpellbookRowStyle _rowStyle;
private readonly UiDatFont? _rowFont;
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
@ -80,6 +87,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint> removeSpell,
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> setDesiredComponent,
Action close,
UiElement spellPage,
@ -87,8 +96,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
UiElement spellTab,
UiElement componentTab,
UiButton closeButton,
UiButton deleteButton,
UiItemList spellList,
UiItemList componentList)
UiItemList componentList,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
_spellbook = spellbook;
_objects = objects;
@ -100,6 +112,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
_removeSpell = removeSpell;
_showConfirmation = showConfirmation;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
@ -107,12 +121,16 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
_deleteButton = deleteButton;
_spellList = spellList;
_componentList = componentList;
_rowStyle = rowStyle;
_rowFont = rowFont;
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
closeButton.OnClick = close;
deleteButton.OnClick = RequestDeleteSelected;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
@ -121,7 +139,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_filters.Add((button, mask));
}
ConfigureSpellList();
ConfigureSpellList(layout);
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
@ -148,14 +166,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint> removeSpell,
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> setDesiredComponent,
Action close)
Action close,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not { } spellTab
|| layout.FindElement(ComponentTabId) is not { } componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(DeleteButtonId) is not UiButton deleteButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
@ -179,9 +202,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, setDesiredComponent, close,
addFavorite, sendFilter, removeSpell, showConfirmation,
setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
spellList, componentList);
deleteButton, spellList, componentList, rowStyle, rowFont);
}
public void ShowPage(SpellbookWindowPage page)
@ -193,11 +217,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
}
private void ConfigureSpellList()
private void ConfigureSpellList(ImportedLayout layout)
{
_spellList.Columns = 6;
_spellList.CellWidth = 32f;
_spellList.CellHeight = 32f;
_spellList.Columns = 1;
_spellList.CellWidth = Math.Max(1f, _spellList.Width);
_spellList.CellHeight = _rowStyle.Height;
if (layout.FindElement(SpellScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _spellList.Scroll;
}
private void ConfigureComponentList(ImportedLayout layout)
@ -211,9 +237,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private void ToggleFilter(uint mask)
{
// gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
// filter bitfield, rebuilds from learned spells, and returns to row zero.
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
_spellList.Scroll.SetScrollY(0);
}
private void RebuildAll()
@ -230,11 +259,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
// gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440 inserts before
// the first GREATER display order. Stable OrderBy preserves ties.
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();
@ -249,7 +279,21 @@ public sealed class SpellbookWindowController : IRetainedPanelController
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
ShowLabel = true,
BackgroundSprite = _rowStyle.BackgroundSprite,
SelectedSprite = _rowStyle.SelectedSprite,
SelectionBehindContent = true,
LabelFont = _rowFont,
LabelColor = _rowStyle.LabelColor,
IconLeft = _rowStyle.IconLeft,
IconTop = _rowStyle.IconTop,
IconWidth = _rowStyle.IconWidth,
IconHeight = _rowStyle.IconHeight,
LabelLeft = _rowStyle.LabelLeft,
LabelWidth = _rowStyle.LabelWidth,
SpriteResolve = _spellList.SpriteResolve,
CatalogDragPayload = new SpellbookShortcutDragPayload(spellId),
DragBegan = _ => SelectSpell(spellId),
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
@ -399,8 +443,33 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private void SelectSpell(uint spellId)
{
// gmSpellbookUI::SetSelected @ 0x0048B540 toggles the separate overlay
// for every row and asks the listbox to expose the matching row.
_selectedSpell = spellId;
SyncSpellSelection();
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
if (_spellList.GetItem(i) is UiCatalogSlot slot && slot.EntryId == spellId)
{
_spellList.ScrollItemIntoView(i);
break;
}
}
private void RequestDeleteSelected()
{
// gmSpellbookUI::DeleteSpell @ 0x0048BD50 stores the spell id in the
// shared callback dialog; DeleteSpellDialogCallback @ 0x0048BC10 sends
// only after an affirmative result. The inbound remove notice owns UI state.
if (_selectedSpell is not uint spellId
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
return;
string message = $"Are you sure you want to remove {metadata.Name} from your spellbook? "
+ "You will no longer be able to cast this spell unless you learn it again!";
_showConfirmation(message, accepted =>
{
if (accepted) _removeSpell(spellId);
});
}
private void SyncSpellSelection()
@ -454,6 +523,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
SetClick(_spellTab, null);
SetClick(_componentTab, null);
_closeButton.OnClick = null;
_deleteButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}

View file

@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
int targetTab = tab;
list.CatalogDropped = (payload, x, _) =>
{
if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
};
using (list.DeferLayout())
{
list.Flush();
@ -297,7 +303,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, tab, position);
DropFavorite(favorite, targetTab, position);
else if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, position);
},
};
slot.Clicked = () => SelectSpell(id);
@ -322,6 +330,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_selected[targetTab] = payload.SpellId;
}
private void DropSpellbookShortcut(
SpellbookShortcutDragPayload payload,
int targetTab,
int targetPosition)
{
// gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
// chooses the favorite tab/position and persists it.
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
private static int DropPosition(UiItemList list, int localX)
{
int position = list.CellWidth <= 0f
? list.GetNumUIItems()
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
return Math.Clamp(position, 0, list.GetNumUIItems());
}
private void OnSpellbookChanged() => _favoritesDirty = true;
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
@ -465,6 +494,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
/// <summary>
/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
/// lifting this payload never removes anything from its source collection.
/// </summary>
public sealed record SpellbookShortcutDragPayload(uint SpellId);
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList<uint> values, uint value)