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

@ -405,6 +405,39 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
canSend: () => late.Session.IsInWorld);
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
uint MagicSkillLevel(MagicSchool school)
{
static uint SkillId(MagicSchool value) => value switch
{
MagicSchool.CreatureEnchantment => 0x1Fu,
MagicSchool.ItemEnchantment => 0x20u,
MagicSchool.LifeMagic => 0x21u,
MagicSchool.WarMagic => 0x22u,
MagicSchool.VoidMagic => 0x2Bu,
_ => 0u,
};
uint skillId = SkillId(school);
if (skillId != 0u)
return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
uint highest = 0u;
foreach (MagicSchool candidate in new[]
{
MagicSchool.CreatureEnchantment,
MagicSchool.ItemEnchantment,
MagicSchool.LifeMagic,
MagicSchool.WarMagic,
MagicSchool.VoidMagic,
})
{
highest = Math.Max(
highest,
d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u);
}
return highest;
}
foreach (IMouse mouse in d.Input.Mice)
host.WireMouse(mouse);
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
@ -500,6 +533,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
iconComposer.GetSpellComponentIcon,
d.Selection,
d.MagicCatalog.GetSpellLevel,
magic.GetExamineComponents,
MagicSkillLevel,
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) =>

View file

@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor(
uint Category,
uint IconId);
/// <summary>One resolved formula cell in retail's spell examination subview.</summary>
public readonly record struct SpellExamineComponent(
uint SpellComponentId,
SpellComponentDescriptor Descriptor,
bool Owned);
/// <summary>
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
@ -52,6 +58,20 @@ public sealed class MagicCatalog
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool TryGetComponentBySpellComponentId(
uint spellComponentId,
out SpellComponentDescriptor descriptor)
{
if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)
&& Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found))
{
descriptor = found;
return true;
}
descriptor = null!;
return false;
}
public bool IsComponentPack(uint weenieClassId)
=> Components.ContainsKey(weenieClassId);
@ -143,15 +163,43 @@ public sealed class MagicCatalog
/// </summary>
public sealed class MagicRuntime
{
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
private readonly SpellComponentRequirementService _requirements;
private MagicRuntime(
MagicCatalog catalog,
SpellCastingController casting,
SpellComponentRequirementService requirements)
{
Catalog = catalog;
Casting = casting;
_requirements = requirements;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public IReadOnlyList<SpellExamineComponent> GetExamineComponents(uint spellId)
{
IReadOnlyList<uint> formula = _requirements.GetAppropriateFormula(spellId);
if (formula.Count == 0)
return [];
var result = new List<SpellExamineComponent>(formula.Count);
foreach (uint spellComponentId in formula)
{
if (spellComponentId == 0u
|| !Catalog.TryGetComponentBySpellComponentId(
spellComponentId,
out SpellComponentDescriptor descriptor))
continue;
result.Add(new SpellExamineComponent(
spellComponentId,
descriptor,
_requirements.IsComponentOwned(spellComponentId)));
}
return result;
}
public static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
@ -198,7 +246,7 @@ public sealed class MagicRuntime
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting);
return new MagicRuntime(catalog, casting, requirements);
}
public void Reset() => Casting.Reset();

View file

@ -75,6 +75,31 @@ public sealed class SpellComponentRequirementService
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
/// </summary>
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
{
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return [];
uint playerGuid = _playerGuid();
return GetAppropriateFormula(
formula,
_objects.Get(playerGuid),
playerGuid);
}
/// <summary>
/// Whether the component tracker would leave a formula icon unghosted.
/// The input is a retail spell-component id (SCID), not a weenie class id.
/// </summary>
public bool IsComponentOwned(uint spellComponentId)
{
if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId))
return false;
uint playerGuid = _playerGuid();
return _objects.Objects.Any(item =>
item.WeenieClassId == weenieClassId
&& IsOwnedByPlayer(item, playerGuid));
}
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,

View file

@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
/// <summary>
/// Releases only the object-appraisal transaction before the local spell
/// examination subview takes ownership of the shared floaty window.
///
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
/// appraisal busy reference when a response is pending, clears both pending
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
/// does not clear unrelated use/inventory busy references.
/// </summary>
public void CancelObjectAppraisalForSpell()
{
if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u)
return;
if (_awaitingAppraisalId != 0u)
{
_awaitingAppraisalId = 0u;
if (_busyCount > 0)
_busyCount--;
}
_currentAppraisalId = 0u;
_sendExamine?.Invoke(0u);
StateChanged?.Invoke();
}
/// <summary>
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
/// from the live selected object's public description. This is deliberately

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

View file

@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings(
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Func<uint, IReadOnlyList<SpellExamineComponent>> SpellComponents,
Func<MagicSchool, uint> MagicSkill,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> AddFavorite,
@ -831,7 +833,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite,
LoadShortcutDigitGraphics(),
favoriteEmptySprite);
favoriteEmptySprite,
examineSpell: spellId => AppraisalController?.ExamineSpell(spellId));
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
@ -965,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
{
ImportedLayout? layout;
CreatureAppraisalRowTemplateFactory? creatureRows;
SpellExamineComponentTemplateFactory? spellComponentTemplates;
CreatureDisplayNameResolver? creatureNames;
RetailAppraisalNameResolver? itemNames;
lock (_bindings.Assets.DatLock)
@ -981,6 +985,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
spellComponentTemplates =
SpellExamineComponentTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
creatureNames = CreatureDisplayNameResolver.Load(
_bindings.Assets.Dats);
itemNames = RetailAppraisalNameResolver.Load(
@ -1008,7 +1018,12 @@ public sealed class RetailUiRuntime : IDisposable
close: () => CloseWindow(WindowNames.Examination),
creatureRowTemplates: creatureRows,
creatureNames: creatureNames,
itemNames: itemNames);
itemNames: itemNames,
resolveSpellIcon: _bindings.Magic.ResolveSpellIcon,
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
spellComponents: _bindings.Magic.SpellComponents,
magicSkill: _bindings.Magic.MagicSkill,
spellComponentTemplates: spellComponentTemplates);
if (controller is null)
{
Console.WriteLine(

View file

@ -58,6 +58,9 @@ public sealed class UiCatalogSlot : UiItemSlot
switch (e.Type)
{
case UiEventType.MouseDown:
if (EntryId != 0u
&& FindList() is { PrimaryCatalogEntryPressed: { } pressed })
pressed(EntryId);
return true;
case UiEventType.Click:
Clicked?.Invoke();
@ -65,6 +68,11 @@ public sealed class UiCatalogSlot : UiItemSlot
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.RightClick:
if (EntryId != 0u
&& FindList() is { ExamineCatalogEntryRequested: { } examine })
examine(EntryId);
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;

View file

@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement
/// </summary>
public Action<uint>? ExamineItemRequested { get; set; }
/// <summary>
/// Non-weenie catalog-entry left-button-down command supplied by the owning
/// panel. Spell favorites use this distinct path so a spell id is never
/// published as the globally selected object GUID.
///
/// Retail <c>gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0</c>
/// handles UIItem message parameter 7 (left press) by calling
/// <c>SpellCastSubMenu::SetSelected</c>.
/// </summary>
public Action<uint>? PrimaryCatalogEntryPressed { get; set; }
/// <summary>
/// Local examination command for non-weenie catalog entries. Retail
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c>
/// routes right-clicked <c>spellID</c> values to
/// <c>ClientUISystem::ExamineSpell</c> without changing the selected object.
/// </summary>
public Action<uint>? ExamineCatalogEntryRequested { get; set; }
private uint _cellEmptySprite;
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see

View file

@ -180,7 +180,7 @@ public class UiItemSlot : UiElement
public override bool IsDragSource => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList()
protected UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }