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
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue