using System; using System.Collections.Generic; using System.Linq; using AcDream.App.Spells; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Spells; using AcDream.Core.Selection; using AcDream.UI.Abstractions.Input; namespace AcDream.App.UI.Layout; /// /// Retail gmSpellcastingUI binding for the authored magic page inside combat /// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one /// request through . /// public sealed class SpellcastingUiController : IRetainedPanelController { public const uint PageId = 0x10000061u; public const uint SpellNameId = 0x1000048Bu; public const uint EndowmentId = 0x100000B1u; public const uint CastButtonId = 0x100000B2u; public const uint FavoriteListId = 0x100000B6u; private static readonly uint[] TabIds = [ 0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u, 0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u, ]; private static readonly uint[] GroupIds = [ 0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu, 0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u, ]; private readonly Spellbook _spellbook; private readonly SpellCastingController _casting; private readonly SelectionState _selection; private readonly ClientObjectTable _objects; private readonly Func _playerGuid; private readonly Func _resolveSpellIcon; private readonly Func _resolveItemDragIcon; private readonly Action _useItem; private readonly Action? _addFavorite; private readonly Action? _removeFavorite; private readonly UiShortcutDigitGraphics? _shortcutDigits; private readonly uint _emptySlotSprite; private readonly UiElement[] _tabs; private readonly UiElement[] _groups; private readonly UiItemList?[] _lists; private readonly UiButton _cast; private readonly UiText? _spellName; private readonly UiElement _endowmentHost; private readonly UiCatalogSlot _endowmentSlot; private int _activeTab; private readonly uint?[] _selected = new uint?[8]; private readonly bool[] _endowmentSelected = new bool[8]; private uint _endowmentItemId; private uint _endowmentSpellId; private bool _disposed; private bool _favoritesDirty; private bool _endowmentDirty; private SpellcastingUiController( ImportedLayout layout, Spellbook spellbook, SpellCastingController casting, ClientObjectTable objects, Func playerGuid, Func resolveSpellIcon, Func resolveItemDragIcon, Action useItem, SelectionState selection, Action? addFavorite, Action? removeFavorite, UiElement[] tabs, UiElement[] groups, UiItemList?[] lists, UiButton cast, UiElement endowmentHost, UiShortcutDigitGraphics? shortcutDigits, uint emptySlotSprite) { _spellbook = spellbook; _casting = casting; _selection = selection; _objects = objects; _playerGuid = playerGuid; _resolveSpellIcon = resolveSpellIcon; _resolveItemDragIcon = resolveItemDragIcon; _useItem = useItem; _addFavorite = addFavorite; _removeFavorite = removeFavorite; _shortcutDigits = shortcutDigits; _emptySlotSprite = emptySlotSprite; _tabs = tabs; _groups = groups; _lists = lists; _cast = cast; _spellName = layout.FindElement(SpellNameId) as UiText; _endowmentHost = endowmentHost; foreach (UiElement child in _endowmentHost.Children) child.Visible = false; _endowmentSlot = new UiCatalogSlot { Left = 0f, Top = 0f, Width = endowmentHost.Width, Height = endowmentHost.Height, SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve, }; _endowmentSlot.Clicked = SelectEndowment; _endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); }; _endowmentHost.AddChild(_endowmentSlot); for (int i = 0; i < tabs.Length; i++) { int index = i; SetClick(tabs[i], () => SelectTab(index)); } _cast.OnClick = CastSelected; ConfigureSpellName(); _spellbook.SpellbookChanged += OnSpellbookChanged; _selection.Changed += OnSelectionChanged; _objects.ObjectAdded += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged; _objects.ObjectRemoved += OnObjectChanged; _objects.Cleared += OnObjectsCleared; UpdateEndowment(); SelectTab(0); Rebuild(); } public int ActiveTab => _activeTab; public static SpellcastingUiController? Bind( ImportedLayout layout, Spellbook spellbook, SpellCastingController casting, ClientObjectTable objects, Func playerGuid, Func resolveSpellIcon, Func resolveItemDragIcon, Action useItem, SelectionState selection, Action? addFavorite, Action? removeFavorite, UiShortcutDigitGraphics? shortcutDigits = null, uint emptySlotSprite = 0u) { if (layout.FindElement(CastButtonId) is not UiButton cast || layout.FindElement(EndowmentId) is not { } endowmentHost) return null; var tabs = new UiElement[8]; var groups = new UiElement[8]; var lists = new UiItemList?[8]; for (int i = 0; i < 8; i++) { if (layout.FindElement(TabIds[i]) is not { } tab || layout.FindElement(GroupIds[i]) is not { } group) return null; tabs[i] = tab; groups[i] = group; lists[i] = Descendants(group).OfType().FirstOrDefault(); } return new SpellcastingUiController( layout, spellbook, casting, objects, playerGuid, resolveSpellIcon, resolveItemDragIcon, useItem, selection, addFavorite, removeFavorite, tabs, groups, lists, cast, endowmentHost, shortcutDigits, emptySlotSprite); } public void AddFavorite(uint spellId) { IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (spells.Contains(spellId)) { SelectSpell(spellId); return; } int position = spells.Count; _addFavorite?.Invoke(_activeTab, position, spellId); _spellbook.SetFavorite(_activeTab, position, spellId); SelectSpell(spellId); } public bool Handle(InputAction action) { if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9) { int index = (int)action - (int)InputAction.UseSpellSlot_1; IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (index < spells.Count) { SelectSpell(spells[index]); CastSelected(); } return true; } switch (action) { case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true; case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true; case InputAction.CombatFirstSpellTab: SelectTab(0); return true; case InputAction.CombatLastSpellTab: SelectTab(7); return true; case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true; case InputAction.CombatNextSpell: MoveSelection(1, false); return true; case InputAction.CombatFirstSpell: MoveSelection(0, true); return true; case InputAction.CombatLastSpell: MoveSelection(-1, true); return true; case InputAction.CombatCastCurrentSpell: CastSelected(); return true; default: return false; } } private void SelectTab(int tab) { _activeTab = Math.Clamp(tab, 0, 7); for (int i = 0; i < 8; i++) { SetSelected(_tabs[i], i == _activeTab); _groups[i].Visible = i == _activeTab; } IReadOnlyList favorites = _spellbook.GetFavorites(_activeTab); if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) _selected[_activeTab] = null; else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected)) { _selected[_activeTab] = favorites.Count == 0 ? null : favorites[0]; _endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u; } SyncSelection(); UpdateCastAvailability(); } private void MoveSelection(int delta, bool edge) { IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0); if (count == 0) return; int current = _endowmentSelected[_activeTab] ? 0 : (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1) + (_endowmentItemId != 0u ? 1 : 0); int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count; if (_endowmentItemId != 0u && next == 0) SelectEndowment(); else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]); } private void SelectSpell(uint spellId) { _endowmentSelected[_activeTab] = false; _selected[_activeTab] = spellId; SyncSelection(); UpdateCastAvailability(); } private void CastSelected() { if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) _useItem(_endowmentItemId); else if (_selected[_activeTab] is uint spellId) _casting.Cast(spellId); } private void SelectEndowment() { if (_endowmentItemId == 0u) return; _endowmentSelected[_activeTab] = true; _selected[_activeTab] = null; SyncSelection(); UpdateCastAvailability(); } private void Rebuild() { for (int tab = 0; tab < 8; tab++) { UiItemList? list = _lists[tab]; if (list is null) continue; IReadOnlyList favorites = _spellbook.GetFavorites(tab); int targetTab = tab; list.CatalogDropped = (payload, x, _) => { if (payload is SpellbookShortcutDragPayload shortcut) DropSpellbookShortcut( shortcut, targetTab, DropPosition(list, x, favorites.Count)); }; using (list.DeferLayout()) { list.Flush(); list.SingleRow = true; list.CellWidth = 32f; list.CellHeight = 32f; list.CellEmptySprite = _emptySlotSprite; list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab); list.FillVisibleEmptySlots = true; foreach (uint spellId in favorites) { uint id = spellId; int position = list.GetNumUIItems(); _spellbook.TryGetMetadata(id, out SpellMetadata? metadata); var slot = new UiCatalogSlot { EntryId = id, CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id), Label = metadata?.Name ?? $"Spell {id}", SpriteResolve = list.SpriteResolve, CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id), DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload), DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload), Dropped = payload => { if (payload is SpellFavoriteDragPayload favorite) DropFavorite(favorite, targetTab, position); else if (payload is SpellbookShortcutDragPayload shortcut) DropSpellbookShortcut(shortcut, targetTab, position); }, }; slot.Clicked = () => SelectSpell(id); slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); }; list.AddItem(slot); } } StampShortcutOverlays(list); } SelectTab(_activeTab); } private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab) { int shortcutIndex = list.GetNumUIItems(); UiCatalogSlot? slot = null; slot = new UiCatalogSlot { SpriteResolve = list.SpriteResolve, Dropped = payload => { int position = Math.Max(0, list.IndexOf(slot!)); int favoriteCount = _spellbook.GetFavorites(targetTab).Count; position = Math.Min(position, favoriteCount); if (payload is SpellFavoriteDragPayload favorite) DropFavorite(favorite, targetTab, position); else if (payload is SpellbookShortcutDragPayload shortcut) DropSpellbookShortcut(shortcut, targetTab, position); }, }; ConfigureShortcutOverlay(slot, shortcutIndex); return slot; } private void StampShortcutOverlays(UiItemList list) { for (int i = 0; i < list.GetNumUIItems(); i++) { UiItemSlot? slot = list.GetItem(i); if (slot is null) continue; ConfigureShortcutOverlay(slot, i); } } private void ConfigureShortcutOverlay(UiItemSlot slot, int index) { slot.RegularDigits = _shortcutDigits?.RegularDigits; slot.GhostedDigits = _shortcutDigits?.GhostedDigits; slot.EmptyDigits = _shortcutDigits?.EmptyDigits; if (index < 9) slot.SetShortcutNum(index, ghosted: false); else slot.ClearShortcutNum(); } private void BeginFavoriteDrag(SpellFavoriteDragPayload payload) => _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId); private void EndFavoriteDrag(SpellFavoriteDragPayload payload) => _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId); private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition) { _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); _spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId); _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 favoriteCount) { int position = list.CellWidth <= 0f ? favoriteCount : (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth); return Math.Clamp(position, 0, favoriteCount); } private void OnSpellbookChanged() => _favoritesDirty = true; private void OnObjectChanged(ClientObject _) => _endowmentDirty = true; private void OnObjectsCleared() => _endowmentDirty = true; public void Tick() { if (_endowmentDirty) { _endowmentDirty = false; UpdateEndowment(); SelectTab(_activeTab); } if (_favoritesDirty) { _favoritesDirty = false; Rebuild(); } } private void SyncSelection() { for (int tab = 0; tab < 8; tab++) { UiItemList? list = _lists[tab]; if (list is null) continue; for (int i = 0; i < list.GetNumUIItems(); i++) if (list.GetItem(i) is UiCatalogSlot slot) slot.Selected = slot.EntryId == _selected[tab]; } _endowmentSlot.Selected = _endowmentItemId != 0u && _endowmentSelected[_activeTab]; } private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability(); private void UpdateCastAvailability() => _cast.Enabled = _endowmentSelected[_activeTab] ? _endowmentItemId != 0u : _selected[_activeTab] is uint spellId && _casting.IsTargetReady(spellId); private void ConfigureSpellName() { if (_spellName is null) return; _spellName.OneLine = true; _spellName.Centered = true; _spellName.Padding = 0; _spellName.LinesProvider = () => { uint? chosenSpell = _endowmentSelected[_activeTab] ? (_endowmentSpellId == 0u ? null : _endowmentSpellId) : _selected[_activeTab]; string name = chosenSpell is uint spellId && _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata) ? metadata.Name : string.Empty; return [new UiText.Line(name, _spellName.DefaultColor)]; }; } private void UpdateEndowment() { ClientObject? endowment = _objects.GetEquippedBy(_playerGuid()) .FirstOrDefault(item => (item.CurrentlyEquippedLocation & EquipMask.Held) != 0 && (item.Type & ItemType.Caster) != 0 && item.SpellId.GetValueOrDefault() != 0u); _endowmentItemId = endowment?.ObjectId ?? 0u; _endowmentSpellId = endowment?.SpellId ?? 0u; _endowmentHost.Visible = endowment is not null; _endowmentSlot.EntryId = _endowmentItemId; _endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u ? 0u : _resolveSpellIcon(_endowmentSpellId); _endowmentSlot.CatalogOverlayTexture = endowment is null ? 0u : _resolveItemDragIcon(endowment); string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata) ? metadata.Name : $"Spell {_endowmentSpellId}"; _endowmentSlot.Label = endowment is null ? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})"; for (int tab = 0; tab < 8; tab++) { if (_endowmentItemId != 0u && _selected[tab] is null) _endowmentSelected[tab] = true; else if (_endowmentItemId == 0u && _endowmentSelected[tab]) _endowmentSelected[tab] = false; } } private static IEnumerable Descendants(UiElement root) { foreach (UiElement child in root.Children) { yield return child; foreach (UiElement nested in Descendants(child)) yield return nested; } } public void OnShown() => SyncSelection(); public void Dispose() { if (_disposed) return; _disposed = true; _spellbook.SpellbookChanged -= OnSpellbookChanged; _selection.Changed -= OnSelectionChanged; _objects.ObjectAdded -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectRemoved -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; foreach (UiElement tab in _tabs) SetClick(tab, null); _cast.OnClick = null; } private static void SetClick(UiElement element, Action? action) { element.ClickThrough = action is null; switch (element) { case UiButton button: button.OnClick = action; break; case UiText text: text.OnClick = action; break; case UiDatElement dat: dat.OnClick = action; break; } } private static void SetSelected(UiElement element, bool selected) { if (element is IUiDatStateful stateful && stateful.TrySetRetailState(selected ? RetailUiStateIds.Open : RetailUiStateIds.Closed)) return; if (element is UiButton button) button.Selected = selected; else if (element is UiText text) text.DefaultColor = selected ? new System.Numerics.Vector4(1f, 1f, 1f, 1f) : new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f); } } public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId); /// /// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag, /// lifting this payload never removes anything from its source collection. /// public sealed record SpellbookShortcutDragPayload(uint SpellId); internal static class FavoriteListExtensions { public static int IndexOf(this IReadOnlyList values, uint value) { for (int i = 0; i < values.Count; i++) if (values[i] == value) return i; return -1; } }