fix(ui): select and examine favorite spells like retail

This commit is contained in:
Erik 2026-07-24 06:47:54 +02:00
parent 043ab10b3c
commit 3e31b0ac70
23 changed files with 974 additions and 34 deletions

View file

@ -1,4 +1,6 @@
using System.Globalization;
using System.Text;
using AcDream.App.Spells;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
@ -32,6 +34,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint CreatureDisplayNameId = 0x1000014Eu;
public const uint CreatureLevelValueId = 0x1000014Cu;
public const uint SpellPanelId = 0x10000153u;
public const uint SpellSchoolTextId = 0x1000015Eu;
public const uint SpellIconId = 0x1000015Fu;
public const uint SpellManaTextId = 0x10000160u;
public const uint SpellDurationTextId = 0x10000161u;
public const uint SpellRangeTextId = 0x10000162u;
public const uint SpellDisplayTextId = 0x10000163u;
public const uint SpellFormulaListId = 0x1000032Du;
private const uint TemplateStringProperty = 5u;
private const uint CharacterMarkerIntProperty = 0x105u;
@ -51,7 +60,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly Action _closeWindow;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
private readonly UiElement? _spellPanel;
private readonly UiElement _spellPanel;
private readonly UiText _title;
private readonly UiText _itemText;
private readonly UiText? _inscriptionText;
@ -64,6 +73,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
private readonly CreatureDisplayNameResolver _creatureNames;
private readonly RetailAppraisalNameResolver _itemNames;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
private readonly Func<MagicSchool, uint> _magicSkill;
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
private readonly UiText _spellSchool;
private readonly UiText _spellMana;
private readonly UiText _spellDuration;
private readonly UiText _spellRange;
private readonly UiText _spellDisplay;
private readonly UiElement _spellIconHost;
private readonly UiElement _spellFormulaHost;
private readonly UiTextureElement _spellIcon;
private readonly List<UiElement> _spellFormulaCells = [];
private readonly float _spellFormulaOriginX;
private readonly float _spellFormulaCellWidth;
private AppraisalView _activeView;
private uint _itemObjectId;
private uint _creatureObjectId;
@ -75,6 +100,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private string _scribeName = string.Empty;
private string _oldInscription = string.Empty;
private bool _presentationInscribable;
private uint _spellId;
private bool _windowVisible;
private double _refreshElapsed;
private bool _disposed;
@ -93,11 +119,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action close,
UiElement itemPanel,
UiElement creaturePanel,
UiElement spellPanel,
UiText title,
UiText itemText,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
CreatureDisplayNameResolver? creatureNames,
RetailAppraisalNameResolver? itemNames)
RetailAppraisalNameResolver? itemNames,
Func<uint, uint>? resolveSpellIcon,
Func<uint, uint>? resolveComponentIcon,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
Func<MagicSchool, uint>? magicSkill,
SpellExamineComponentTemplateFactory? spellComponentTemplates)
{
_layout = layout;
_objects = objects;
@ -112,7 +144,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_closeWindow = close;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
_spellPanel = layout.FindElement(SpellPanelId);
_spellPanel = spellPanel;
_title = title;
_itemText = itemText;
_creatureRowTemplates = creatureRowTemplates;
@ -120,6 +152,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
?? new CreatureDisplayNameResolver(
new Dictionary<uint, string>());
_itemNames = itemNames ?? RetailAppraisalNameResolver.Empty;
_resolveSpellIcon = resolveSpellIcon ?? (_ => 0u);
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
_spellComponents = spellComponents ?? (_ => []);
_magicSkill = magicSkill ?? (_ => 0u);
_spellComponentTemplates = spellComponentTemplates;
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
_spellDuration = (UiText)layout.FindElement(SpellDurationTextId)!;
_spellRange = (UiText)layout.FindElement(SpellRangeTextId)!;
_spellDisplay = (UiText)layout.FindElement(SpellDisplayTextId)!;
_spellIconHost = layout.FindElement(SpellIconId)!;
_spellFormulaHost = layout.FindElement(SpellFormulaListId)!;
_spellFormulaOriginX = _spellFormulaHost.Left;
_spellFormulaCellWidth = _spellFormulaHost.Width;
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
@ -164,6 +210,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
_inscriptionBackground.ClickThrough = false;
}
if (_spellIconHost is UiDatElement spellIconDat)
spellIconDat.MediaVisible = false;
_spellIcon = new UiTextureElement
{
Width = _spellIconHost.Width,
Height = _spellIconHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
};
_spellIconHost.AddChild(_spellIcon);
ConfigureSpellText(_spellSchool);
ConfigureSpellText(_spellMana);
ConfigureSpellText(_spellDuration);
ConfigureSpellText(_spellRange);
ConfigureSpellText(_spellDisplay, wrap: true);
if (creatureRowTemplates is not null
&& layout.FindElement(CreatureStatsListId) is { } statsHost
&& layout.FindElement(CreatureExtraListId) is { } extraHost
@ -184,6 +246,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
_selection.Changed += HandleSelectionChanged;
_objects.ObjectAdded += HandleSpellComponentObjectChanged;
_objects.ObjectMoved += HandleSpellComponentObjectMoved;
_objects.ObjectRemoved += HandleSpellComponentObjectChanged;
_objects.StackSizeUpdated += HandleSpellComponentObjectChanged;
_objects.Cleared += HandleSpellComponentObjectsCleared;
SetActiveView(AppraisalView.Item);
}
@ -204,7 +271,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action close,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
CreatureDisplayNameResolver? creatureNames = null,
RetailAppraisalNameResolver? itemNames = null)
RetailAppraisalNameResolver? itemNames = null,
Func<uint, uint>? resolveSpellIcon = null,
Func<uint, uint>? resolveComponentIcon = null,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
Func<MagicSchool, uint>? magicSkill = null,
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
@ -220,8 +292,16 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|| layout.FindElement(SpellPanelId) is not { } spellPanel
|| layout.FindElement(TitleId) is not UiText title
|| layout.FindElement(ItemTextId) is not UiText itemText)
|| layout.FindElement(ItemTextId) is not UiText itemText
|| layout.FindElement(SpellSchoolTextId) is not UiText
|| layout.FindElement(SpellManaTextId) is not UiText
|| layout.FindElement(SpellDurationTextId) is not UiText
|| layout.FindElement(SpellRangeTextId) is not UiText
|| layout.FindElement(SpellDisplayTextId) is not UiText
|| layout.FindElement(SpellIconId) is null
|| layout.FindElement(SpellFormulaListId) is null)
return null;
return new AppraisalUiController(
@ -238,11 +318,79 @@ public sealed class AppraisalUiController : IRetainedPanelController
close,
itemPanel,
creaturePanel,
spellPanel,
title,
itemText,
creatureRowTemplates,
creatureNames,
itemNames);
itemNames,
resolveSpellIcon,
resolveComponentIcon,
spellComponents,
magicSkill,
spellComponentTemplates);
}
/// <summary>
/// Opens retail's local spell-examination subview. Unlike object appraisal,
/// this performs no network request and never changes the globally selected
/// object. Source: <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> and
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>.
/// </summary>
public bool ExamineSpell(uint spellId)
{
if (spellId == 0u
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
return false;
_interaction.CancelObjectAppraisalForSpell();
_spellId = spellId;
_titleValue = metadata.Name;
SetSpellText(_spellSchool, $"School: {metadata.School}");
string mana = metadata.ManaCost > 0
? metadata.ManaCost.ToString(CultureInfo.InvariantCulture)
: "???";
if (metadata.ManaModifier > 0u)
mana += $" + {metadata.ManaModifier.ToString(CultureInfo.InvariantCulture)} per target";
SetSpellText(_spellMana, $"Mana: {mana}");
string duration = string.Empty;
if (metadata.Duration > 0f && metadata.Duration != -1f)
{
uint displayed = metadata.Duration >= 60f
? (uint)(metadata.Duration / 60f)
: (uint)metadata.Duration;
duration = metadata.Duration >= 60f
? $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} min."
: $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} sec.";
}
SetSpellText(_spellDuration, duration);
float range = MathF.Min(
metadata.BaseRangeConstant
+ metadata.BaseRangeModifier * _magicSkill(metadata.SchoolId),
75f);
SetSpellText(
_spellRange,
range > 0f
? string.Format(
CultureInfo.InvariantCulture,
"Range: {0:F1} yds.",
range / 0.9144f)
: string.Empty);
IReadOnlyList<SpellExamineComponent> components =
_spellComponents(spellId);
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
_spellDisplay.Scroll.SetScrollY(0);
_spellIcon.Texture = _resolveSpellIcon(spellId);
RebuildSpellFormula(components);
SetActiveView(AppraisalView.Spell);
_refreshElapsed = 0;
_show();
return true;
}
/// <summary>
@ -335,7 +483,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
_itemObjectId = 0;
_creatureObjectId = 0;
_characterObjectId = 0;
_spellId = 0u;
_refreshElapsed = 0;
_spellIcon.Texture = 0u;
SetSpellText(_spellSchool, string.Empty);
SetSpellText(_spellMana, string.Empty);
SetSpellText(_spellDuration, string.Empty);
SetSpellText(_spellRange, string.Empty);
SetSpellText(_spellDisplay, string.Empty);
ClearSpellFormula();
SetActiveView(AppraisalView.Item);
ClearCreatureText();
}
@ -579,6 +735,75 @@ public sealed class AppraisalUiController : IRetainedPanelController
scrollbar.Model = text.Scroll;
}
private static void ConfigureSpellText(UiText text, bool wrap = false)
{
text.PreserveEndOnLayout = false;
text.ClickThrough = true;
if (wrap)
{
text.OneLine = false;
text.VerticalJustify = VJustify.Top;
}
}
private static void SetSpellText(UiText text, string value)
=> text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
private static string BuildSpellDisplay(
SpellMetadata metadata,
IReadOnlyList<SpellExamineComponent> components)
{
var display = new StringBuilder(metadata.Description);
if (components.Count == 0)
return display.ToString();
display.Append("\nCOMPONENTS:");
foreach (SpellExamineComponent component in components)
display.Append("\n ").Append(component.Descriptor.Name);
return display.ToString();
}
private void RebuildSpellFormula(
IReadOnlyList<SpellExamineComponent> components)
{
ClearSpellFormula();
if (_spellComponentTemplates is null || components.Count == 0)
{
_spellFormulaHost.Width = _spellFormulaCellWidth;
_spellFormulaHost.Left = _spellFormulaOriginX;
return;
}
float cellWidth = _spellFormulaCellWidth;
_spellFormulaHost.Left =
_spellFormulaOriginX - cellWidth * 0.5f * (components.Count - 1);
_spellFormulaHost.Width = cellWidth * components.Count;
_spellFormulaHost.ResetAnchorCapture();
for (int i = 0; i < components.Count; i++)
{
SpellExamineComponent component = components[i];
UiElement cell = _spellComponentTemplates.Create(
_resolveComponentIcon(component.Descriptor.WeenieClassId),
component.Owned);
cell.LayoutPolicy = null;
cell.Anchors = AnchorEdges.Left | AnchorEdges.Top;
cell.Left = i * cellWidth;
cell.Top = 0f;
_spellFormulaHost.AddChild(cell);
_spellFormulaCells.Add(cell);
}
}
private void ClearSpellFormula()
{
foreach (UiElement cell in _spellFormulaCells)
_spellFormulaHost.RemoveChild(cell);
_spellFormulaCells.Clear();
_spellFormulaHost.Left = _spellFormulaOriginX;
_spellFormulaHost.Width = _spellFormulaCellWidth;
_spellFormulaHost.ResetAnchorCapture();
}
private void SetText(uint elementId, string value, bool scrollable = false)
{
if (_layout.FindElement(elementId) is not UiText text)
@ -623,8 +848,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_activeView = view;
_itemPanel.Visible = view == AppraisalView.Item;
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
if (_spellPanel is not null)
_spellPanel.Visible = false;
_spellPanel.Visible = view == AppraisalView.Spell;
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
@ -704,12 +928,39 @@ public sealed class AppraisalUiController : IRetainedPanelController
_closeWindow();
}
private void HandleSpellComponentObjectChanged(ClientObject _)
=> RefreshSpellComponents();
private void HandleSpellComponentObjectMoved(ClientObjectMove _)
=> RefreshSpellComponents();
private void HandleSpellComponentObjectsCleared()
=> RefreshSpellComponents();
private void RefreshSpellComponents()
{
if (_activeView != AppraisalView.Spell
|| _spellId == 0u
|| !_spellbook.TryGetMetadata(_spellId, out SpellMetadata metadata))
return;
IReadOnlyList<SpellExamineComponent> components =
_spellComponents(_spellId);
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
RebuildSpellFormula(components);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_selection.Changed -= HandleSelectionChanged;
_objects.ObjectAdded -= HandleSpellComponentObjectChanged;
_objects.ObjectMoved -= HandleSpellComponentObjectMoved;
_objects.ObjectRemoved -= HandleSpellComponentObjectChanged;
_objects.StackSizeUpdated -= HandleSpellComponentObjectChanged;
_objects.Cleared -= HandleSpellComponentObjectsCleared;
if (_close is not null)
_close.OnClick = null;
if (_inscriptionField is not null)
@ -720,6 +971,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
if (_inscriptionBackground is not null)
_inscriptionBackground.OnClick = null;
ClearSpellFormula();
_spellIconHost.RemoveChild(_spellIcon);
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
@ -735,4 +988,5 @@ public enum AppraisalView
Item,
Creature,
Character,
Spell,
}

View file

@ -0,0 +1,95 @@
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates the authored formula-icon template used by retail
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>. The controller replaces
/// the template root's placeholder image with the resolved component icon while
/// retaining child <c>0x10000330</c> as the missing-component overlay.
/// </summary>
public sealed class SpellExamineComponentTemplateFactory
{
public const uint TemplateId = 0x1000032Eu;
public const uint MissingOverlayId = 0x10000330u;
private readonly ElementInfo _template;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public SpellExamineComponentTemplateFactory(
ElementInfo template,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public static SpellExamineComponentTemplateFactory? TryLoad(
IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
AppraisalUiController.LayoutId,
TemplateId);
if (template is null)
return null;
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(template, resolveFont, fonts);
return new SpellExamineComponentTemplateFactory(
template,
resolveSprite,
defaultFont,
fonts);
}
public UiElement Create(uint iconTexture, bool owned)
{
ImportedLayout content = LayoutImporter.Build(
_template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
UiElement root = content.Root;
if (root is UiDatElement datRoot)
datRoot.MediaVisible = false;
var icon = new UiTextureElement
{
Width = root.Width,
Height = root.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
Texture = iconTexture,
ZOrder = int.MinValue / 2,
};
root.AddChild(icon);
if (content.FindElement(MissingOverlayId) is { } missing)
missing.Visible = !owned;
return root;
}
private static void CaptureFonts(
ElementInfo info,
Func<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> fonts)
{
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
foreach (ElementInfo child in info.Children)
CaptureFonts(child, resolveFont, fonts);
}
}

View file

@ -44,6 +44,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
private readonly Action<uint> _useItem;
private readonly Action<uint>? _examineSpell;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiShortcutDigitGraphics? _shortcutDigits;
@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
Action<uint>? examineSpell,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
@ -94,6 +96,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_resolveSpellIcon = resolveSpellIcon;
_resolveItemDragIcon = resolveItemDragIcon;
_useItem = useItem;
_examineSpell = examineSpell;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_shortcutDigits = shortcutDigits;
@ -120,7 +123,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
for (int i = 0; i < _lists.Length; i++)
{
if (_lists[i] is not { } list || _scrollbars[i] is not { } scrollbar)
if (_lists[i] is not { } list)
continue;
list.PrimaryCatalogEntryPressed = SelectSpell;
list.ExamineCatalogEntryRequested = _examineSpell;
if (_scrollbars[i] is not { } scrollbar)
continue;
list.HorizontalScroll = true;
scrollbar.Horizontal = true;
@ -161,7 +168,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u)
uint emptySlotSprite = 0u,
Action<uint>? examineSpell = null)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
@ -185,7 +193,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
resolveItemDragIcon, useItem, examineSpell, selection,
addFavorite, removeFavorite,
tabs, groups, lists, scrollbars, cast, endowmentHost,
shortcutDigits, emptySlotSprite);
@ -357,8 +365,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
DropSpellbookShortcut(shortcut, targetTab, position);
},
};
slot.Clicked = () => SelectSpell(id);
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
// gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0
// selects on left press (message parameter 7). A completed
// double click casts, but release is not required merely to
// change the current spell.
slot.DoubleClicked = CastSelected;
list.AddItem(slot);
}
}
@ -557,6 +568,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_objects.ObjectRemoved -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
foreach (UiElement tab in _tabs) SetClick(tab, null);
foreach (UiItemList? list in _lists)
{
if (list is null) continue;
list.PrimaryCatalogEntryPressed = null;
list.ExamineCatalogEntryRequested = null;
}
_cast.OnClick = null;
}