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;
}
}