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
|
|
@ -20,7 +20,8 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
{
|
||||
public const uint LayoutId = 0x21000073u;
|
||||
public const uint BasicPanelId = 0x1000005Cu;
|
||||
public const uint AdvancedPanelId = 0x10000061u;
|
||||
public const uint SpellcastingPanelId = 0x10000061u;
|
||||
public const uint AdvancedPanelId = SpellcastingPanelId;
|
||||
public const uint PowerControlId = 0x1000004Fu;
|
||||
public const uint SpeedLabelId = 0x10000051u;
|
||||
public const uint PowerLabelId = 0x10000052u;
|
||||
|
|
@ -36,7 +37,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
|
||||
private readonly UiElement _root;
|
||||
private readonly UiElement _basicPanel;
|
||||
private readonly UiElement _advancedPanel;
|
||||
private readonly UiElement _spellcastingPanel;
|
||||
private readonly UiScrollbar _powerControl;
|
||||
private readonly UiButton _high;
|
||||
private readonly UiButton _medium;
|
||||
|
|
@ -54,7 +55,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
private CombatUiController(
|
||||
ImportedLayout layout,
|
||||
UiElement basicPanel,
|
||||
UiElement advancedPanel,
|
||||
UiElement spellcastingPanel,
|
||||
UiScrollbar powerControl,
|
||||
UiButton high,
|
||||
UiButton medium,
|
||||
|
|
@ -71,7 +72,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
{
|
||||
_root = layout.Root;
|
||||
_basicPanel = basicPanel;
|
||||
_advancedPanel = advancedPanel;
|
||||
_spellcastingPanel = spellcastingPanel;
|
||||
_powerControl = powerControl;
|
||||
_high = high;
|
||||
_medium = medium;
|
||||
|
|
@ -85,11 +86,10 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
_setGameplay = setGameplay;
|
||||
_setWindowVisible = setWindowVisible;
|
||||
|
||||
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
||||
// basic page. The advanced page belongs to the separate advanced-
|
||||
// combat flow and stays hidden in the basic panel.
|
||||
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
|
||||
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
|
||||
_basicPanel.Visible = true;
|
||||
_advancedPanel.Visible = false;
|
||||
_spellcastingPanel.Visible = false;
|
||||
|
||||
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
||||
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
||||
|
|
@ -140,7 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||
|
||||
if (layout.FindElement(BasicPanelId) is not { } basic
|
||||
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|
||||
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|
||||
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|
||||
|| layout.FindElement(HighButtonId) is not UiButton high
|
||||
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
||||
|
|
@ -151,7 +151,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
return null;
|
||||
|
||||
return new CombatUiController(
|
||||
layout, basic, advanced, power, high, medium, low,
|
||||
layout, basic, spellcasting, power, high, medium, low,
|
||||
repeatAttacks, autoTarget, keepInView,
|
||||
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
|
||||
}
|
||||
|
|
@ -166,7 +166,9 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
|
||||
private void OnCombatModeChanged(CombatMode mode)
|
||||
{
|
||||
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
|
||||
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
|
||||
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
|
||||
_spellcastingPanel.Visible = mode == CombatMode.Magic;
|
||||
if (_root is IUiDatStateful stateful)
|
||||
{
|
||||
if (mode == CombatMode.Melee)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ public static class DatWidgetFactory
|
|||
{
|
||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
||||
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
|
||||
EffectsIndicatorController.RetailClassId => BuildButton(
|
||||
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
|
|
|
|||
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal file
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
|
||||
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
|
||||
/// reflect whether matching enchantments exist and send panel visibility notices
|
||||
/// for gmPanelUI slots 4 and 5.
|
||||
/// </summary>
|
||||
public sealed class EffectsIndicatorController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000071u;
|
||||
public const uint RetailClassId = 0x10000002u;
|
||||
public const uint HelpfulButtonId = 0x100000F5u;
|
||||
public const uint HarmfulButtonId = 0x100000F6u;
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly UiButton _helpful;
|
||||
private readonly UiButton _harmful;
|
||||
private readonly Action<uint> _togglePanel;
|
||||
private bool _disposed;
|
||||
|
||||
private EffectsIndicatorController(
|
||||
Spellbook spellbook,
|
||||
UiButton helpful,
|
||||
UiButton harmful,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_helpful = helpful;
|
||||
_harmful = harmful;
|
||||
_togglePanel = togglePanel;
|
||||
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
|
||||
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
|
||||
_spellbook.EnchantmentsChanged += Update;
|
||||
Update();
|
||||
}
|
||||
|
||||
public static EffectsIndicatorController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(togglePanel);
|
||||
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|
||||
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
|
||||
return null;
|
||||
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
bool helpful = HasMatchingEffect(beneficial: true);
|
||||
bool harmful = HasMatchingEffect(beneficial: false);
|
||||
_helpful.TrySetRetailState(helpful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
_harmful.TrySetRetailState(harmful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
private bool HasMatchingEffect(bool beneficial)
|
||||
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
|
||||
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
|
||||
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
|
||||
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
|
||||
// by spell category first.
|
||||
record.Bucket is 1u or 2u
|
||||
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
|
||||
&& metadata.IsBeneficial == beneficial);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.EnchantmentsChanged -= Update;
|
||||
_helpful.OnClick = null;
|
||||
_harmful.OnClick = null;
|
||||
}
|
||||
}
|
||||
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
|
||||
public sealed class EffectsUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100001Bu;
|
||||
// gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc.
|
||||
// The authored positive/negative gmEffectsUI instances inherit template 0x10000122.
|
||||
public const uint PositiveRootId = 0x1000011Fu;
|
||||
public const uint NegativeRootId = 0x10000121u;
|
||||
public const uint CloseId = 0x100000FCu;
|
||||
public const uint ListId = 0x10000123u;
|
||||
public const uint InfoTextId = 0x10000126u;
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly bool _positive;
|
||||
private readonly Func<double> _serverTime;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly UiItemList _list;
|
||||
private readonly UiText? _info;
|
||||
private readonly UiButton? _close;
|
||||
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
|
||||
private uint? _selectedSpellId;
|
||||
private double _lastDurationUpdate = double.NaN;
|
||||
private bool _disposed;
|
||||
|
||||
internal uint? SelectedSpellId => _selectedSpellId;
|
||||
|
||||
private EffectsUiController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
bool positive,
|
||||
Func<double> serverTime,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
UiItemList list,
|
||||
Action? close)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_positive = positive;
|
||||
_serverTime = serverTime;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_list = list;
|
||||
_info = layout.FindElement(InfoTextId) as UiText;
|
||||
_close = layout.FindElement(CloseId) as UiButton;
|
||||
if (_close is not null) _close.OnClick = close;
|
||||
_list.Columns = 1;
|
||||
_list.CellWidth = Math.Max(1f, _list.Width);
|
||||
_list.CellHeight = 32f;
|
||||
ConfigureInfo();
|
||||
_spellbook.EnchantmentsChanged += Rebuild;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public static EffectsUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
bool positive,
|
||||
Func<double> serverTime,
|
||||
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Action? close = null)
|
||||
{
|
||||
UiElement? host = layout.FindElement(ListId);
|
||||
if (host is null) return null;
|
||||
UiItemList list;
|
||||
if (host is UiItemList itemList)
|
||||
list = itemList;
|
||||
else
|
||||
{
|
||||
list = new UiItemList(spriteResolve)
|
||||
{
|
||||
Width = host.Width,
|
||||
Height = host.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
host.AddChild(list);
|
||||
}
|
||||
return new EffectsUiController(
|
||||
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
double now = _serverTime();
|
||||
if (!double.IsFinite(now)) return;
|
||||
if (double.IsFinite(_lastDurationUpdate)
|
||||
&& now >= _lastDurationUpdate
|
||||
&& now - _lastDurationUpdate < 1.0)
|
||||
return;
|
||||
_lastDurationUpdate = now;
|
||||
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
|
||||
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
|
||||
row.Detail = FormatRemaining(enchantment, now);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
_lastDurationUpdate = double.NaN;
|
||||
_rows.Clear();
|
||||
ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray();
|
||||
using (_list.DeferLayout())
|
||||
{
|
||||
_list.Flush();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
{
|
||||
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
|
||||
uint identity = enchantment.Identity;
|
||||
var row = new UiCatalogSlot
|
||||
{
|
||||
EntryId = enchantment.SpellId,
|
||||
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
|
||||
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
|
||||
Detail = FormatRemaining(enchantment, _serverTime()),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _list.SpriteResolve,
|
||||
};
|
||||
row.Clicked = () => Select(enchantment.SpellId);
|
||||
_rows[identity] = row;
|
||||
_list.AddItem(row);
|
||||
}
|
||||
}
|
||||
if (_selectedSpellId is uint selected
|
||||
&& !_rows.Values.Any(row => row.EntryId == selected))
|
||||
_selectedSpellId = null;
|
||||
SyncSelection();
|
||||
}
|
||||
|
||||
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
|
||||
=> _spellbook.EnchantmentsInEffectSnapshot
|
||||
.Where(record =>
|
||||
{
|
||||
if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata))
|
||||
return false;
|
||||
return metadata.IsBeneficial == _positive;
|
||||
})
|
||||
.OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
|
||||
? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
|
||||
{
|
||||
if (enchantment.Duration < 0) return "Permanent";
|
||||
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
|
||||
if (!double.IsFinite(remaining)) return "--:--";
|
||||
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
|
||||
TimeSpan time = TimeSpan.FromSeconds(remaining);
|
||||
return time.TotalHours >= 1
|
||||
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
|
||||
: $"{time.Minutes}:{time.Seconds:00}";
|
||||
}
|
||||
|
||||
private void Select(uint spellId)
|
||||
{
|
||||
// Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the
|
||||
// token's spell stat, not its enchantment layer identity.
|
||||
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
|
||||
SyncSelection();
|
||||
}
|
||||
|
||||
private void SyncSelection()
|
||||
{
|
||||
foreach (UiCatalogSlot row in _rows.Values)
|
||||
row.Selected = row.EntryId == _selectedSpellId;
|
||||
}
|
||||
|
||||
private void ConfigureInfo()
|
||||
{
|
||||
if (_info is null) return;
|
||||
_info.LinesProvider = () =>
|
||||
{
|
||||
if (_selectedSpellId is not uint spellId)
|
||||
return Array.Empty<UiText.Line>();
|
||||
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
|
||||
.Cast<ActiveEnchantmentRecord?>()
|
||||
.FirstOrDefault(record => record?.SpellId == spellId);
|
||||
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
|
||||
return Array.Empty<UiText.Line>();
|
||||
return
|
||||
[
|
||||
new UiText.Line(metadata.Name, _info.DefaultColor),
|
||||
new UiText.Line(metadata.Description, _info.DefaultColor),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public void OnShown() => Rebuild();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.EnchantmentsChanged -= Rebuild;
|
||||
if (_close is not null) _close.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -131,8 +131,9 @@ public sealed class ElementInfo
|
|||
public string DefaultStateName = "";
|
||||
|
||||
/// <summary>
|
||||
/// Resolved child elements (populated by the importer in Task 5).
|
||||
/// Children come from the derived element's own tree, not the base element's.
|
||||
/// Resolved child elements. The importer ports retail child-table incorporation:
|
||||
/// base-only children remain, same-ID children merge recursively, and derived-only
|
||||
/// children append after the retained inherited entries.
|
||||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
|
||||
|
|
@ -264,7 +265,9 @@ public static class ElementReader
|
|||
/// base entries are the default; derived entries override (or add) per state name key.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
|
||||
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
|
||||
/// incorporation.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
|
|
@ -306,10 +309,9 @@ public static class ElementReader
|
|||
FontColor = derived.FontColor ?? base_.FontColor,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
// Defensive copy: prevent a later mutation of either the merged result or the input
|
||||
// from corrupting the other. Safe because derived.Children is fully
|
||||
// populated by the recursive importer BEFORE Merge is called and never mutated after).
|
||||
// This helper merges one element snapshot only. LayoutImporter separately
|
||||
// incorporates the child tables after the scalar/state merge.
|
||||
// Defensive copy prevents later mutation of either input.
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
// Start with base StateMedia as defaults, then let derived entries override.
|
||||
|
|
|
|||
|
|
@ -295,11 +295,9 @@ public static class LayoutImporter
|
|||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
|
||||
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
|
||||
/// (close button / title), elements with their own children, and childless style
|
||||
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
|
||||
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
|
||||
/// correction. Child inheritance itself is unconditional and follows retail
|
||||
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
|
||||
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
|
||||
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
|
||||
|
||||
|
|
@ -316,7 +314,7 @@ public static class LayoutImporter
|
|||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
List<ElementInfo>? baseChildren = null;
|
||||
ElementInfo? baseInfo = null;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
|
|
@ -327,32 +325,23 @@ public static class LayoutImporter
|
|||
if (baseDesc is not null)
|
||||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; children are attached below.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
baseChildren = baseInfo.Children; // capture for the sub-window mount
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
|
||||
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
|
||||
// base-only children remain, same-ID children incorporate recursively, and
|
||||
// derived-only children append after the retained base entries.
|
||||
IncorporateChildren(dats, result, baseInfo?.Children, d);
|
||||
|
||||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||||
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
|
||||
// inert for media-bearing inheritors (close button/title) and childless style prototypes
|
||||
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
|
||||
if (baseChildren is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
|
||||
// A pure-container sub-window mount needs one additional layer correction.
|
||||
// Child-table incorporation has already happened above for every inheritor.
|
||||
if (baseInfo is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
|
||||
{
|
||||
result.Children.AddRange(baseChildren);
|
||||
|
||||
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
|
||||
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
|
||||
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
|
||||
|
|
@ -372,6 +361,57 @@ public static class LayoutImporter
|
|||
return result;
|
||||
}
|
||||
|
||||
private static void IncorporateChildren(
|
||||
DatCollection dats,
|
||||
ElementInfo result,
|
||||
IReadOnlyList<ElementInfo>? baseChildren,
|
||||
ElementDesc derived)
|
||||
{
|
||||
baseChildren ??= Array.Empty<ElementInfo>();
|
||||
var baseById = baseChildren.ToDictionary(child => child.Id);
|
||||
int retainedBaseCount = baseChildren.Count(child =>
|
||||
!derived.Children.ContainsKey(child.Id));
|
||||
|
||||
foreach (ElementInfo baseChild in baseChildren)
|
||||
{
|
||||
bool hasOverlay = derived.Children.TryGetValue(
|
||||
baseChild.Id, out ElementDesc? overlay);
|
||||
ElementInfo child = hasOverlay
|
||||
? IncorporateResolvedChild(dats, baseChild, overlay!)
|
||||
: baseChild;
|
||||
// Base-only descendants retain the base layout's design parent size;
|
||||
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
|
||||
if (hasOverlay)
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
|
||||
// Every new child receives a fresh base-chain set. Retail offsets its read order
|
||||
// by the count of inherited children not replaced by a same-ID overlay.
|
||||
foreach (var pair in derived.Children)
|
||||
{
|
||||
if (baseById.ContainsKey(pair.Key)) continue;
|
||||
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
|
||||
child.ReadOrder += checked((uint)retainedBaseCount);
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo IncorporateResolvedChild(
|
||||
DatCollection dats,
|
||||
ElementInfo baseChild,
|
||||
ElementDesc derivedChild)
|
||||
{
|
||||
// ElementDesc::Incorporate consumes the partial child directly. It does not
|
||||
// independently re-resolve that child's BaseElement when the inherited table
|
||||
// already contains the same identity.
|
||||
ElementInfo self = ToInfo(derivedChild);
|
||||
ElementInfo result = ElementReader.Merge(baseChild, self);
|
||||
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
|
||||
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
|
||||
|
|
|
|||
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal file
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained-window port of retail <c>gmPanelUI::RecvNotice_SetPanelVisibility</c>
|
||||
/// at <c>0x004BC6F0</c>. The original owns one active child panel and an optional
|
||||
/// deferred child; this owner applies the same lifecycle to registered retained
|
||||
/// windows without making the toolbar authoritative for non-toolbar panels.
|
||||
/// </summary>
|
||||
public sealed class RetailPanelUiController
|
||||
{
|
||||
public const uint RestorePreviousPropertyId = 0x10000049u;
|
||||
|
||||
private readonly Func<string, bool> _isVisible;
|
||||
private readonly Func<string, bool> _show;
|
||||
private readonly Func<string, bool> _hide;
|
||||
private readonly Dictionary<uint, PanelEntry> _byPanel = new();
|
||||
private readonly Dictionary<string, uint> _byWindow = new(StringComparer.Ordinal);
|
||||
private uint? _activePanel;
|
||||
private uint? _deferredPanel;
|
||||
private bool _applying;
|
||||
|
||||
public RetailPanelUiController(
|
||||
Func<string, bool> isVisible,
|
||||
Func<string, bool> show,
|
||||
Func<string, bool> hide)
|
||||
{
|
||||
_isVisible = isVisible ?? throw new ArgumentNullException(nameof(isVisible));
|
||||
_show = show ?? throw new ArgumentNullException(nameof(show));
|
||||
_hide = hide ?? throw new ArgumentNullException(nameof(hide));
|
||||
}
|
||||
|
||||
public uint? ActivePanelId => _activePanel;
|
||||
|
||||
/// <param name="restorePrevious">
|
||||
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
|
||||
/// panel only when the newly shown panel has this property and the previous
|
||||
/// panel does not.
|
||||
/// </param>
|
||||
public void Register(uint panelId, string windowName, bool restorePrevious = false)
|
||||
{
|
||||
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
|
||||
throw new InvalidOperationException(
|
||||
$"Panel {panelId} or retained window '{windowName}' is already registered.");
|
||||
|
||||
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
|
||||
_byWindow.Add(windowName, panelId);
|
||||
if (_isVisible(windowName))
|
||||
ObserveWindowVisibility(windowName, visible: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a panel directly from its fully inherited retail root so the
|
||||
/// authored deferred-panel behavior cannot be dropped at the mount seam.
|
||||
/// </summary>
|
||||
public void Register(uint panelId, string windowName, ElementInfo authoredRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authoredRoot);
|
||||
Register(
|
||||
panelId,
|
||||
windowName,
|
||||
authoredRoot.TryGetEffectiveBool(
|
||||
RestorePreviousPropertyId,
|
||||
out bool restorePrevious)
|
||||
&& restorePrevious);
|
||||
}
|
||||
|
||||
public bool IsPanelVisible(uint panelId)
|
||||
=> _byPanel.TryGetValue(panelId, out PanelEntry entry)
|
||||
&& _isVisible(entry.WindowName);
|
||||
|
||||
public bool TogglePanel(uint panelId)
|
||||
{
|
||||
bool visible = !IsPanelVisible(panelId);
|
||||
return SetPanelVisibility(panelId, visible) && visible;
|
||||
}
|
||||
|
||||
public bool SetPanelVisibility(uint panelId, bool visible)
|
||||
{
|
||||
if (!_byPanel.TryGetValue(panelId, out PanelEntry requested)) return false;
|
||||
|
||||
_applying = true;
|
||||
try
|
||||
{
|
||||
if (visible)
|
||||
{
|
||||
if (_activePanel == panelId)
|
||||
return _show(requested.WindowName);
|
||||
|
||||
uint? previousId = _activePanel;
|
||||
PanelEntry? previous = previousId is uint id
|
||||
&& _byPanel.TryGetValue(id, out PanelEntry found)
|
||||
&& _isVisible(found.WindowName)
|
||||
? found
|
||||
: null;
|
||||
|
||||
_deferredPanel = requested.RestorePrevious
|
||||
&& previous is { RestorePrevious: false }
|
||||
? previousId
|
||||
: null;
|
||||
_activePanel = panelId;
|
||||
if (previous is not null)
|
||||
_hide(previous.Value.WindowName);
|
||||
return _show(requested.WindowName);
|
||||
}
|
||||
|
||||
if (_activePanel != panelId)
|
||||
{
|
||||
if (_deferredPanel == panelId) _deferredPanel = null;
|
||||
return _hide(requested.WindowName);
|
||||
}
|
||||
|
||||
bool hidden = _hide(requested.WindowName);
|
||||
_activePanel = null;
|
||||
if (_deferredPanel is not uint deferredId
|
||||
|| !_byPanel.TryGetValue(deferredId, out PanelEntry deferred))
|
||||
return hidden;
|
||||
|
||||
_deferredPanel = null;
|
||||
_activePanel = deferredId;
|
||||
return _show(deferred.WindowName) || hidden;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds visibility changes made by persistence or another retained-window
|
||||
/// owner back through the canonical panel lifecycle.
|
||||
/// </summary>
|
||||
public void ObserveWindowVisibility(string windowName, bool visible)
|
||||
{
|
||||
if (_applying || !_byWindow.TryGetValue(windowName, out uint panelId)) return;
|
||||
SetPanelVisibility(panelId, visible);
|
||||
}
|
||||
|
||||
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
|
||||
}
|
||||
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
public enum SpellbookWindowPage { Spells, Components }
|
||||
|
||||
/// <summary>
|
||||
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
|
||||
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
|
||||
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
|
||||
/// </summary>
|
||||
public sealed class SpellbookWindowController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000034u;
|
||||
public const uint RootId = 0x100002A8u;
|
||||
public const uint SpellTabId = 0x100002A9u;
|
||||
public const uint ComponentTabId = 0x100002AAu;
|
||||
public const uint CloseId = 0x100002ABu;
|
||||
public const uint SpellPageId = 0x100002ACu;
|
||||
public const uint ComponentPageId = 0x100002ADu;
|
||||
public const uint SpellListId = 0x10000295u;
|
||||
public const uint ComponentListId = 0x10000464u;
|
||||
public const uint ComponentScrollbarId = 0x10000465u;
|
||||
|
||||
private static readonly (uint Id, uint Mask)[] FilterButtons =
|
||||
[
|
||||
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
|
||||
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
|
||||
(0x100005C0u, 0x2000u),
|
||||
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
|
||||
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
|
||||
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
|
||||
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
|
||||
];
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly Func<uint, uint> _resolveComponentIcon;
|
||||
private readonly Func<uint, int> _spellLevel;
|
||||
private readonly Action<uint> _selectObject;
|
||||
private readonly Action<uint> _addFavorite;
|
||||
private readonly Action<uint> _sendFilter;
|
||||
private readonly Action<uint, uint> _setDesiredComponent;
|
||||
private readonly Action _close;
|
||||
private readonly UiElement _spellPage;
|
||||
private readonly UiElement _componentPage;
|
||||
private readonly UiButton _spellTab;
|
||||
private readonly UiButton _componentTab;
|
||||
private readonly UiButton _closeButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
private readonly List<(UiButton Button, uint Mask)> _filters = new();
|
||||
private uint? _selectedSpell;
|
||||
private bool _spellsDirty;
|
||||
private bool _componentsDirty;
|
||||
private bool _componentEditActive;
|
||||
private bool _disposed;
|
||||
|
||||
private SpellbookWindowController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close,
|
||||
UiElement spellPage,
|
||||
UiElement componentPage,
|
||||
UiButton spellTab,
|
||||
UiButton componentTab,
|
||||
UiButton closeButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_components = components;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveComponentIcon = resolveComponentIcon;
|
||||
_spellLevel = spellLevel;
|
||||
_selectObject = selectObject;
|
||||
_addFavorite = addFavorite;
|
||||
_sendFilter = sendFilter;
|
||||
_setDesiredComponent = setDesiredComponent;
|
||||
_close = close;
|
||||
_spellPage = spellPage;
|
||||
_componentPage = componentPage;
|
||||
_spellTab = spellTab;
|
||||
_componentTab = componentTab;
|
||||
_closeButton = closeButton;
|
||||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
|
||||
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
|
||||
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
|
||||
closeButton.OnClick = close;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
{
|
||||
if (layout.FindElement(id) is not UiButton button) continue;
|
||||
uint filterMask = mask;
|
||||
button.OnClick = () => ToggleFilter(filterMask);
|
||||
_filters.Add((button, mask));
|
||||
}
|
||||
|
||||
ConfigureSpellList();
|
||||
ConfigureComponentList(layout);
|
||||
_spellbook.SpellbookChanged += OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
|
||||
ShowPage(SpellbookWindowPage.Spells);
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
public SpellbookWindowPage CurrentPage { get; private set; }
|
||||
|
||||
public static SpellbookWindowController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close)
|
||||
{
|
||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|
||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||
return null;
|
||||
|
||||
UiElement? componentHost = layout.FindElement(ComponentListId);
|
||||
if (componentHost is null) return null;
|
||||
UiItemList componentList;
|
||||
if (componentHost is UiItemList itemList)
|
||||
componentList = itemList;
|
||||
else
|
||||
{
|
||||
componentList = new UiItemList(spellList.SpriteResolve)
|
||||
{
|
||||
Width = componentHost.Width,
|
||||
Height = componentHost.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
componentHost.AddChild(componentList);
|
||||
}
|
||||
|
||||
return new SpellbookWindowController(
|
||||
layout, spellbook, objects, playerGuid, components,
|
||||
resolveSpellIcon, resolveComponentIcon,
|
||||
spellLevel, selectObject,
|
||||
addFavorite, sendFilter, setDesiredComponent, close,
|
||||
spellPage, componentPage, spellTab, componentTab, closeButton,
|
||||
spellList, componentList);
|
||||
}
|
||||
|
||||
public void ShowPage(SpellbookWindowPage page)
|
||||
{
|
||||
CurrentPage = page;
|
||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||
_spellTab.Selected = page == SpellbookWindowPage.Spells;
|
||||
_componentTab.Selected = page == SpellbookWindowPage.Components;
|
||||
}
|
||||
|
||||
private void ConfigureSpellList()
|
||||
{
|
||||
_spellList.Columns = 6;
|
||||
_spellList.CellWidth = 32f;
|
||||
_spellList.CellHeight = 32f;
|
||||
}
|
||||
|
||||
private void ConfigureComponentList(ImportedLayout layout)
|
||||
{
|
||||
_componentList.Columns = 1;
|
||||
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
|
||||
_componentList.CellHeight = 32f;
|
||||
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _componentList.Scroll;
|
||||
}
|
||||
|
||||
private void ToggleFilter(uint mask)
|
||||
{
|
||||
uint filters = _spellbook.SpellbookFilters ^ mask;
|
||||
_spellbook.SetSpellbookFilters(filters);
|
||||
_sendFilter(filters);
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
{
|
||||
RebuildSpells();
|
||||
RebuildComponents();
|
||||
_spellsDirty = false;
|
||||
_componentsDirty = false;
|
||||
}
|
||||
|
||||
private void RebuildSpells()
|
||||
{
|
||||
uint effective = _spellbook.SpellbookFilters;
|
||||
foreach ((UiButton button, uint mask) in _filters)
|
||||
button.Selected = (effective & mask) != 0;
|
||||
|
||||
SpellMetadata[] spells = _spellbook.LearnedSpells
|
||||
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
|
||||
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
|
||||
.OrderBy(metadata => metadata!.SortKey)
|
||||
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Cast<SpellMetadata>()
|
||||
.ToArray();
|
||||
|
||||
using (_spellList.DeferLayout())
|
||||
{
|
||||
_spellList.Flush();
|
||||
foreach (SpellMetadata metadata in spells)
|
||||
{
|
||||
uint spellId = metadata.SpellId;
|
||||
var slot = new UiCatalogSlot
|
||||
{
|
||||
EntryId = spellId,
|
||||
CatalogIconTexture = _resolveSpellIcon(spellId),
|
||||
Label = metadata.Name,
|
||||
SpriteResolve = _spellList.SpriteResolve,
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(spellId);
|
||||
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
|
||||
// gmSpellcastingUI tab chooses the server favorite-list destination.
|
||||
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
|
||||
_spellList.AddItem(slot);
|
||||
}
|
||||
}
|
||||
SyncSpellSelection();
|
||||
}
|
||||
|
||||
private void RebuildComponents()
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
var packs = _objects.Objects
|
||||
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
|
||||
&& IsOwnedByPlayer(item, player))
|
||||
.GroupBy(item => item.WeenieClassId)
|
||||
.Select(group => new
|
||||
{
|
||||
ComponentId = group.Key,
|
||||
First = group.First(),
|
||||
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
|
||||
})
|
||||
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
|
||||
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
float width = Math.Max(96f, _componentList.Width);
|
||||
_componentList.CellWidth = width;
|
||||
using (_componentList.DeferLayout())
|
||||
{
|
||||
_componentList.Flush();
|
||||
uint? category = null;
|
||||
foreach (var packGroup in packs)
|
||||
{
|
||||
ClientObject pack = packGroup.First;
|
||||
uint componentId = packGroup.ComponentId;
|
||||
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
|
||||
uint nextCategory = descriptor?.Category ?? 8u;
|
||||
if (category != nextCategory)
|
||||
{
|
||||
category = nextCategory;
|
||||
_componentList.AddItem(new UiCatalogSlot
|
||||
{
|
||||
EntryId = uint.MaxValue,
|
||||
Label = ComponentCategoryName(nextCategory),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
});
|
||||
}
|
||||
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
|
||||
var row = new UiCatalogSlot
|
||||
{
|
||||
EntryId = componentId,
|
||||
CatalogIconTexture = _resolveComponentIcon(
|
||||
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
|
||||
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
|
||||
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
};
|
||||
var desiredField = new UiField
|
||||
{
|
||||
Left = width - 47f,
|
||||
Top = 4f,
|
||||
Width = 43f,
|
||||
Height = 24f,
|
||||
MaxCharacters = 4,
|
||||
ClearOnSubmit = false,
|
||||
RecordHistory = false,
|
||||
SelectAllOnFocus = true,
|
||||
RightAligned = true,
|
||||
CharacterFilter = char.IsAsciiDigit,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
|
||||
};
|
||||
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
|
||||
desiredField.OnFocusGained = () => _componentEditActive = true;
|
||||
uint committed = desired;
|
||||
void Commit(string value)
|
||||
{
|
||||
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|
||||
|| parsed > 5000u)
|
||||
{
|
||||
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
|
||||
return;
|
||||
}
|
||||
if (parsed == committed) return;
|
||||
committed = parsed;
|
||||
_spellbook.SetDesiredComponent(componentId, parsed);
|
||||
_setDesiredComponent(componentId, parsed);
|
||||
}
|
||||
desiredField.OnSubmit = Commit;
|
||||
desiredField.OnFocusLost = value =>
|
||||
{
|
||||
Commit(value);
|
||||
_componentEditActive = false;
|
||||
};
|
||||
row.AddChild(desiredField);
|
||||
row.Clicked = () => _selectObject(pack.ObjectId);
|
||||
_componentList.AddItem(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComponentCategoryName(uint category) => category switch
|
||||
{
|
||||
0u => "Scarabs",
|
||||
1u => "Herbs",
|
||||
2u => "Powdered Gems",
|
||||
3u => "Alchemical Substances",
|
||||
4u => "Talismans",
|
||||
5u => "Tapers",
|
||||
6u => "Prismatic Components",
|
||||
_ => "Other Components",
|
||||
};
|
||||
|
||||
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
|
||||
{
|
||||
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
|
||||
ClientObject current = item;
|
||||
for (int depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
|
||||
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsVisible(SpellMetadata metadata, uint filters)
|
||||
{
|
||||
uint school = metadata.School switch
|
||||
{
|
||||
"Creature Enchantment" => 0x0001u,
|
||||
"Item Enchantment" => 0x0002u,
|
||||
"Life Magic" => 0x0004u,
|
||||
"War Magic" => 0x0008u,
|
||||
"Void Magic" => 0x2000u,
|
||||
_ => 0u,
|
||||
};
|
||||
int spellLevel = _spellLevel(metadata.SpellId);
|
||||
if (spellLevel is < 1 or > 8) return false;
|
||||
uint level = 0x10u << (spellLevel - 1);
|
||||
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
|
||||
}
|
||||
|
||||
private void SelectSpell(uint spellId)
|
||||
{
|
||||
_selectedSpell = spellId;
|
||||
SyncSpellSelection();
|
||||
}
|
||||
|
||||
private void SyncSpellSelection()
|
||||
{
|
||||
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
|
||||
if (_spellList.GetItem(i) is UiCatalogSlot slot)
|
||||
slot.Selected = slot.EntryId == _selectedSpell;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_spellsDirty)
|
||||
{
|
||||
_spellsDirty = false;
|
||||
RebuildSpells();
|
||||
}
|
||||
if (_componentsDirty && !_componentEditActive)
|
||||
{
|
||||
_componentsDirty = false;
|
||||
RebuildComponents();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _spellsDirty = true;
|
||||
private void OnDesiredComponentsChanged() => _componentsDirty = true;
|
||||
private void OnObjectChanged(ClientObject item)
|
||||
{
|
||||
if (IsComponent(item)) _componentsDirty = true;
|
||||
}
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (IsComponent(item)) _componentsDirty = true;
|
||||
}
|
||||
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
|
||||
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
|
||||
private bool IsComponent(ClientObject item)
|
||||
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
|
||||
public void OnShown() => RebuildAll();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.SpellbookChanged -= OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_spellTab.OnClick = null;
|
||||
_componentTab.OnClick = null;
|
||||
_closeButton.OnClick = null;
|
||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||
}
|
||||
}
|
||||
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