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
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal file
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
|
||||
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
|
||||
/// request through <see cref="SpellCastingController"/>.
|
||||
/// </summary>
|
||||
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<uint> _playerGuid;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
|
||||
private readonly Action<uint> _useItem;
|
||||
private readonly Action<int, int, uint>? _addFavorite;
|
||||
private readonly Action<int, uint>? _removeFavorite;
|
||||
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<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<ClientObject, uint> resolveItemDragIcon,
|
||||
Action<uint> useItem,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite,
|
||||
UiElement[] tabs,
|
||||
UiElement[] groups,
|
||||
UiItemList?[] lists,
|
||||
UiButton cast,
|
||||
UiElement endowmentHost)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_casting = casting;
|
||||
_selection = selection;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveItemDragIcon = resolveItemDragIcon;
|
||||
_useItem = useItem;
|
||||
_addFavorite = addFavorite;
|
||||
_removeFavorite = removeFavorite;
|
||||
_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<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<ClientObject, uint> resolveItemDragIcon,
|
||||
Action<uint> useItem,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite)
|
||||
{
|
||||
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<UiItemList>().FirstOrDefault();
|
||||
}
|
||||
|
||||
return new SpellcastingUiController(
|
||||
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
||||
resolveItemDragIcon, useItem, selection,
|
||||
addFavorite, removeFavorite,
|
||||
tabs, groups, lists, cast, endowmentHost);
|
||||
}
|
||||
|
||||
public void AddFavorite(uint spellId)
|
||||
{
|
||||
IReadOnlyList<uint> 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<uint> 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<uint> 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<uint> 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<uint> favorites = _spellbook.GetFavorites(tab);
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
list.Columns = 9;
|
||||
list.CellWidth = 32f;
|
||||
list.CellHeight = 32f;
|
||||
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, tab, position);
|
||||
},
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(id);
|
||||
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
|
||||
list.AddItem(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectTab(_activeTab);
|
||||
}
|
||||
|
||||
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 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<UiElement> 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 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);
|
||||
|
||||
internal static class FavoriteListExtensions
|
||||
{
|
||||
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
|
||||
{
|
||||
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue