fix(ui): restore retail spell tabs and spellbook
Bind the spellbook and component-book tabs as their authored stateful text controls, propagate Closed/Open state into the retained DAT child tree, and keep authored label colors live across state changes. Match retail UIElement_Text zero-margin construction so the Magic favorite captions I through VIII are no longer clipped. Add a real portal.dat spellbook fixture plus controller, state-propagation, and text-layout regression coverage. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
07be994d97
commit
ad30c37a48
15 changed files with 13492 additions and 38 deletions
|
|
@ -19,7 +19,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
|
||||||
2. **Done (automated):** authored Magic combat page, eight favorite tabs,
|
2. **Done (automated):** authored Magic combat page, eight favorite tabs,
|
||||||
equipped caster endowment, scoped retail keys, spellbook/component book,
|
equipped caster endowment, scoped retail keys, spellbook/component book,
|
||||||
positive/negative active-enchantment panels, Helpful/Harmful indicator
|
positive/negative active-enchantment panels, Helpful/Harmful indicator
|
||||||
buttons, and the retail one-active-main-panel lifecycle.
|
buttons, and the retail one-active-main-panel lifecycle. Connected-gate
|
||||||
|
correction now also pins type-12 spellbook tabs, retail zero text margins,
|
||||||
|
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
|
||||||
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
|
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
|
||||||
complete retained magic UI interaction checklist.
|
complete retained magic UI interaction checklist.
|
||||||
4. **Pending user visual gate:** two-client portal-out/materialization observer
|
4. **Pending user visual gate:** two-client portal-out/materialization observer
|
||||||
|
|
|
||||||
|
|
@ -645,6 +645,10 @@ Helpful/Harmful indicators are mounted in the retained runtime. Main-panel
|
||||||
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
|
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
|
||||||
active only for the connected single-client magic
|
active only for the connected single-client magic
|
||||||
UI/casting visual gate and the final two-client portal observer gate.
|
UI/casting visual gate and the final two-client portal observer gate.
|
||||||
|
The first connected gate exposed and corrected two retained-import defects:
|
||||||
|
spellbook tabs are authored stateful text controls (not buttons), and retail
|
||||||
|
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
|
||||||
|
Closed/Open child-state propagation, and Magic-tab strings I through VIII.
|
||||||
|
|
||||||
**Phases to ship:**
|
**Phases to ship:**
|
||||||
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
|
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,43 @@ component item list 0x10000464
|
||||||
component scrollbar 0x10000465
|
component scrollbar 0x10000465
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The two top-level tabs are not `UIElement_Button` instances. They resolve from
|
||||||
|
base `0x1000043A` as `UIElement_Text` (type 12), with state 11 `Closed` and
|
||||||
|
state 12 `Open`. Both states set `PassToChildren`; the three child chrome
|
||||||
|
elements carry the left/center/right Closed/Open RenderSurface media.
|
||||||
|
|
||||||
|
Retail state and text construction used by these tabs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UIElement_Text::UIElement_Text @ 0x00468570
|
||||||
|
construct UIElement_Scrollable
|
||||||
|
bitField = 0x300 // OneLine bit 0x2 is clear
|
||||||
|
horizontalJustification = 2
|
||||||
|
verticalJustification = 4
|
||||||
|
marginUp/Down/Left/Right = 0
|
||||||
|
|
||||||
|
UIElement_Text::OnSetAttribute @ 0x0046A640
|
||||||
|
property 0x20 only:
|
||||||
|
SetOneLine(value)
|
||||||
|
// absent 0x20 therefore remains multi-line; do not force it true
|
||||||
|
|
||||||
|
UIElement::SetState @ 0x00464E70
|
||||||
|
next = desc.AccessStateDesc(stateId)
|
||||||
|
if next != current:
|
||||||
|
current = next
|
||||||
|
if next.PassToChildren:
|
||||||
|
for each child:
|
||||||
|
child.SetState(stateId)
|
||||||
|
diff old/new effective properties
|
||||||
|
call OnSetAttribute for changed properties
|
||||||
|
```
|
||||||
|
|
||||||
|
The Magic-combat favorite tab captions are authored text (`I` through `VIII`)
|
||||||
|
and intentionally omit property `0x20`. Their small rectangles still render
|
||||||
|
because retail begins with zero text margins. A generic four-pixel inset on
|
||||||
|
all sides leaves less than one DAT-font line and clips the captions completely;
|
||||||
|
that inset is not retail behavior.
|
||||||
|
|
||||||
Spellbook rows come only from the authoritative learned-spell set. Retail
|
Spellbook rows come only from the authoritative learned-spell set. Retail
|
||||||
sorts by `CSpellBase::_display_order`; school and level buttons apply the
|
sorts by `CSpellBase::_display_order`; school and level buttons apply the
|
||||||
server-persisted filter bitfield. Component rows use the installed component
|
server-persisted filter bitfield. Component rows use the installed component
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,7 @@ public static class DatWidgetFactory
|
||||||
// set DatFont afterward STILL override this (backward-compat guarantee).
|
// set DatFont afterward STILL override this (backward-compat guarantee).
|
||||||
DatFont = elementFont,
|
DatFont = elementFont,
|
||||||
};
|
};
|
||||||
|
t.ConfigureDatState(info);
|
||||||
|
|
||||||
// Font color from dat property 0x1B (ColorBaseProperty).
|
// Font color from dat property 0x1B (ColorBaseProperty).
|
||||||
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
||||||
|
|
@ -534,10 +535,7 @@ public static class DatWidgetFactory
|
||||||
t.DefaultColor = info.FontColor.Value;
|
t.DefaultColor = info.FontColor.Value;
|
||||||
|
|
||||||
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
||||||
{
|
t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];
|
||||||
UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)];
|
|
||||||
t.LinesProvider = () => line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,13 @@ public static class LayoutImporter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
|
||||||
|
// child tree exists. Re-applying the imported default here gives retained
|
||||||
|
// PassToChildren tabs their authored Open/Closed child media without turning
|
||||||
|
// spatial construction into controller-specific special cases.
|
||||||
|
if (w is IUiDatStateful stateful)
|
||||||
|
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
|
||||||
|
|
||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ public enum SpellbookWindowPage { Spells, Components }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
|
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
|
||||||
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
|
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
|
||||||
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
|
/// ComponentTracker and exposes the 0..5000 desired purchase level field. The
|
||||||
|
/// two authored tabs are stateful UIElement_Text controls, matching
|
||||||
|
/// <c>gmSpellbookUI::PostInit @ 0x0048B2B0</c> and the resolved retail layout.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SpellbookWindowController : IRetainedPanelController
|
public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
|
|
@ -54,8 +56,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
private readonly Action _close;
|
private readonly Action _close;
|
||||||
private readonly UiElement _spellPage;
|
private readonly UiElement _spellPage;
|
||||||
private readonly UiElement _componentPage;
|
private readonly UiElement _componentPage;
|
||||||
private readonly UiButton _spellTab;
|
private readonly UiElement _spellTab;
|
||||||
private readonly UiButton _componentTab;
|
private readonly UiElement _componentTab;
|
||||||
private readonly UiButton _closeButton;
|
private readonly UiButton _closeButton;
|
||||||
private readonly UiItemList _spellList;
|
private readonly UiItemList _spellList;
|
||||||
private readonly UiItemList _componentList;
|
private readonly UiItemList _componentList;
|
||||||
|
|
@ -82,8 +84,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
Action close,
|
Action close,
|
||||||
UiElement spellPage,
|
UiElement spellPage,
|
||||||
UiElement componentPage,
|
UiElement componentPage,
|
||||||
UiButton spellTab,
|
UiElement spellTab,
|
||||||
UiButton componentTab,
|
UiElement componentTab,
|
||||||
UiButton closeButton,
|
UiButton closeButton,
|
||||||
UiItemList spellList,
|
UiItemList spellList,
|
||||||
UiItemList componentList)
|
UiItemList componentList)
|
||||||
|
|
@ -108,8 +110,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
_spellList = spellList;
|
_spellList = spellList;
|
||||||
_componentList = componentList;
|
_componentList = componentList;
|
||||||
|
|
||||||
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
|
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||||
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
|
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||||
closeButton.OnClick = close;
|
closeButton.OnClick = close;
|
||||||
foreach ((uint id, uint mask) in FilterButtons)
|
foreach ((uint id, uint mask) in FilterButtons)
|
||||||
{
|
{
|
||||||
|
|
@ -151,8 +153,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||||
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|
|| layout.FindElement(SpellTabId) is not { } spellTab
|
||||||
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|
|| layout.FindElement(ComponentTabId) is not { } componentTab
|
||||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -187,8 +189,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
CurrentPage = page;
|
CurrentPage = page;
|
||||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||||
_spellTab.Selected = page == SpellbookWindowPage.Spells;
|
SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
||||||
_componentTab.Selected = page == SpellbookWindowPage.Components;
|
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConfigureSpellList()
|
private void ConfigureSpellList()
|
||||||
|
|
@ -449,9 +451,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||||
_spellTab.OnClick = null;
|
SetClick(_spellTab, null);
|
||||||
_componentTab.OnClick = null;
|
SetClick(_componentTab, null);
|
||||||
_closeButton.OnClick = null;
|
_closeButton.OnClick = null;
|
||||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
foreach ((UiButton button, _) in _filters) button.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 SetTabOpen(UiElement element, bool open)
|
||||||
|
{
|
||||||
|
if (element is IUiDatStateful stateful
|
||||||
|
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||||
|
return;
|
||||||
|
if (element is UiButton button)
|
||||||
|
button.Selected = open;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
|
|
||||||
private static void SetSelected(UiElement element, bool selected)
|
private static void SetSelected(UiElement element, bool selected)
|
||||||
{
|
{
|
||||||
|
if (element is IUiDatStateful stateful
|
||||||
|
&& stateful.TrySetRetailState(selected ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||||
|
return;
|
||||||
if (element is UiButton button)
|
if (element is UiButton button)
|
||||||
button.Selected = selected;
|
button.Selected = selected;
|
||||||
else if (element is UiText text)
|
else if (element is UiText text)
|
||||||
|
|
|
||||||
|
|
@ -70,29 +70,35 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
|
||||||
|
|
||||||
public bool TrySetRetailState(uint stateId)
|
public bool TrySetRetailState(uint stateId)
|
||||||
{
|
{
|
||||||
|
UiStateInfo? selectedState = null;
|
||||||
if (stateId == UiStateInfo.DirectStateId)
|
if (stateId == UiStateInfo.DirectStateId)
|
||||||
{
|
{
|
||||||
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
|
if (!_info.States.TryGetValue(stateId, out selectedState)
|
||||||
|
&& !_info.StateMedia.ContainsKey(""))
|
||||||
return false;
|
return false;
|
||||||
ActiveState = "";
|
ActiveState = "";
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
else if (_info.States.TryGetValue(stateId, out selectedState))
|
||||||
if (_info.States.TryGetValue(stateId, out var state))
|
|
||||||
{
|
{
|
||||||
ActiveState = state.Name;
|
ActiveState = selectedState.Name;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
|
||||||
if (string.IsNullOrEmpty(stateName))
|
|
||||||
stateName = RetailUiStateIds.StateName(stateId);
|
|
||||||
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
|
|
||||||
{
|
{
|
||||||
|
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||||
|
if (string.IsNullOrEmpty(stateName))
|
||||||
|
stateName = RetailUiStateIds.StateName(stateId);
|
||||||
|
if (string.IsNullOrEmpty(stateName) || !_info.StateMedia.ContainsKey(stateName))
|
||||||
|
return false;
|
||||||
ActiveState = stateName;
|
ActiveState = stateName;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
if (selectedState?.PassToChildren == true)
|
||||||
|
{
|
||||||
|
foreach (UiElement child in Children)
|
||||||
|
if (child is IUiDatStateful stateful)
|
||||||
|
stateful.TrySetRetailState(stateId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ namespace AcDream.App.UI;
|
||||||
/// Display-only text remains click-through and cannot steal focus or window drag.
|
/// Display-only text remains click-through and cannot steal focus or window drag.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiText : UiElement
|
public sealed class UiText : UiElement, IUiDatStateful
|
||||||
{
|
{
|
||||||
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
|
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
|
||||||
public Action? OnClick { get; set; }
|
public Action? OnClick { get; set; }
|
||||||
|
|
@ -79,8 +79,12 @@ public sealed class UiText : UiElement
|
||||||
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
||||||
|
|
||||||
/// <summary>Inner text inset from the view edges, px.</summary>
|
/// <summary>
|
||||||
public float Padding { get; set; } = 4f;
|
/// Uniform inner text inset in pixels. The retail default is zero:
|
||||||
|
/// <c>UIElement_Text::UIElement_Text @ 0x00468570</c> clears all four margins
|
||||||
|
/// before applying any authored margin properties.
|
||||||
|
/// </summary>
|
||||||
|
public float Padding { get; set; }
|
||||||
|
|
||||||
/// <summary>Retail property 0x20. Independent of horizontal/vertical
|
/// <summary>Retail property 0x20. Independent of horizontal/vertical
|
||||||
/// justification; false permits the normal multi-line layout path.</summary>
|
/// justification; false permits the normal multi-line layout path.</summary>
|
||||||
|
|
@ -156,7 +160,11 @@ public sealed class UiText : UiElement
|
||||||
private UiDatFont? _lastDatFont;
|
private UiDatFont? _lastDatFont;
|
||||||
private float _lastLineHeight = 16f;
|
private float _lastLineHeight = 16f;
|
||||||
private float _lastBaseY; // top Y of line 0 in local space
|
private float _lastBaseY; // top Y of line 0 in local space
|
||||||
private float _lastPadding = 4f;
|
private float _lastPadding;
|
||||||
|
|
||||||
|
private ElementInfo? _datInfo;
|
||||||
|
private uint _activeRetailStateId = UiStateInfo.DirectStateId;
|
||||||
|
private string _activeDatStateName = "";
|
||||||
|
|
||||||
// ── Selection state ──────────────────────────────────────────────────
|
// ── Selection state ──────────────────────────────────────────────────
|
||||||
private Pos? _selAnchor; // where the drag started
|
private Pos? _selAnchor; // where the drag started
|
||||||
|
|
@ -172,9 +180,105 @@ public sealed class UiText : UiElement
|
||||||
CapturesPointerDrag = false;
|
CapturesPointerDrag = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The text view draws its own lines + background; any dat sub-elements
|
/// <summary>
|
||||||
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
|
/// Text widgets normally reproduce their own scroll/edit chrome. Retail tab text,
|
||||||
public override bool ConsumesDatChildren => true;
|
/// however, uses <c>PassToChildren</c> states whose child elements are the authored
|
||||||
|
/// Open/Closed cap sprites. Those children must remain in the retained tree so
|
||||||
|
/// <see cref="TrySetRetailState"/> can propagate the state exactly as retail does.
|
||||||
|
/// </summary>
|
||||||
|
public override bool ConsumesDatChildren
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_datInfo is null) return true;
|
||||||
|
foreach (UiStateInfo state in _datInfo.States.Values)
|
||||||
|
if (state.PassToChildren)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint ActiveRetailStateId => _activeRetailStateId;
|
||||||
|
|
||||||
|
public override string ActiveCursorStateName => _activeDatStateName;
|
||||||
|
|
||||||
|
internal void ConfigureDatState(ElementInfo info)
|
||||||
|
{
|
||||||
|
_datInfo = info;
|
||||||
|
_activeRetailStateId = info.EffectiveDefaultStateId();
|
||||||
|
ApplyDatState(_activeRetailStateId, propagate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySetRetailState(uint stateId)
|
||||||
|
=> ApplyDatState(stateId, propagate: true);
|
||||||
|
|
||||||
|
private bool ApplyDatState(uint stateId, bool propagate)
|
||||||
|
{
|
||||||
|
if (_datInfo is null) return false;
|
||||||
|
|
||||||
|
UiStateInfo? state = null;
|
||||||
|
string stateName;
|
||||||
|
if (stateId == UiStateInfo.DirectStateId)
|
||||||
|
{
|
||||||
|
if (!_datInfo.States.TryGetValue(stateId, out state)
|
||||||
|
&& !_datInfo.StateMedia.ContainsKey(""))
|
||||||
|
return false;
|
||||||
|
stateName = "";
|
||||||
|
}
|
||||||
|
else if (_datInfo.States.TryGetValue(stateId, out state))
|
||||||
|
{
|
||||||
|
stateName = state.Name;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stateName = UiButtonStateMachine.StateName(stateId);
|
||||||
|
if (string.IsNullOrEmpty(stateName))
|
||||||
|
stateName = RetailUiStateIds.StateName(stateId);
|
||||||
|
if (string.IsNullOrEmpty(stateName)
|
||||||
|
|| !_datInfo.StateMedia.ContainsKey(stateName))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeRetailStateId = stateId;
|
||||||
|
_activeDatStateName = stateName;
|
||||||
|
BackgroundSprite = _datInfo.StateMedia.TryGetValue(stateName, out var media)
|
||||||
|
? media.File
|
||||||
|
: _datInfo.StateMedia.TryGetValue("", out var direct) ? direct.File : 0u;
|
||||||
|
|
||||||
|
if (_datInfo.TryGetEffectiveProperty(0x1Bu, out UiPropertyValue color, stateId)
|
||||||
|
&& TryColor(color, out Vector4 resolvedColor))
|
||||||
|
DefaultColor = resolvedColor;
|
||||||
|
|
||||||
|
if (propagate && state?.PassToChildren == true)
|
||||||
|
{
|
||||||
|
foreach (UiElement child in Children)
|
||||||
|
if (child is IUiDatStateful stateful)
|
||||||
|
stateful.TrySetRetailState(stateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryColor(UiPropertyValue property, out Vector4 color)
|
||||||
|
{
|
||||||
|
UiPropertyValue? value = property.Kind == UiPropertyKind.Color
|
||||||
|
? property
|
||||||
|
: property.Kind == UiPropertyKind.Array
|
||||||
|
&& property.ArrayValue.Count > 0
|
||||||
|
&& property.ArrayValue[0].Kind == UiPropertyKind.Color
|
||||||
|
? property.ArrayValue[0]
|
||||||
|
: null;
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
color = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UiColorValue c = value.ColorValue;
|
||||||
|
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
|
||||||
|
color = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,50 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(gold, t.DefaultColor);
|
Assert.Equal(gold, t.DefaultColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildText_RetailConstructorDefaultsToZeroMargins()
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
|
||||||
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
||||||
|
|
||||||
|
Assert.Equal(0f, text.Padding);
|
||||||
|
Assert.False(text.OneLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildText_AuthoredLineTracksStateFontColor()
|
||||||
|
{
|
||||||
|
var info = new ElementInfo
|
||||||
|
{
|
||||||
|
Type = 12,
|
||||||
|
Width = 100,
|
||||||
|
Height = 20,
|
||||||
|
DefaultStateId = RetailUiStateIds.Closed,
|
||||||
|
DefaultStateName = "Closed",
|
||||||
|
};
|
||||||
|
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||||
|
direct.Properties.Values[0x17u] = new UiPropertyValue
|
||||||
|
{
|
||||||
|
Kind = UiPropertyKind.StringInfo,
|
||||||
|
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
|
||||||
|
};
|
||||||
|
var closed = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed" };
|
||||||
|
closed.Properties.Values[0x1Bu] = Color(127, 127, 127);
|
||||||
|
var open = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open" };
|
||||||
|
open.Properties.Values[0x1Bu] = Color(204, 204, 204);
|
||||||
|
info.States[UiStateInfo.DirectStateId] = direct;
|
||||||
|
info.States[RetailUiStateIds.Closed] = closed;
|
||||||
|
info.States[RetailUiStateIds.Open] = open;
|
||||||
|
|
||||||
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
||||||
|
info, NoTex, null, stringResolve: _ => "Spells"));
|
||||||
|
Assert.Equal(127f / 255f, text.LinesProvider()[0].Color.X, 5);
|
||||||
|
|
||||||
|
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
|
||||||
|
|
||||||
|
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
||||||
{
|
{
|
||||||
|
|
@ -560,6 +604,13 @@ public class DatWidgetFactoryTests
|
||||||
private static UiPropertyValue Integer(int value)
|
private static UiPropertyValue Integer(int value)
|
||||||
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
|
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
|
||||||
|
|
||||||
|
private static UiPropertyValue Color(byte red, byte green, byte blue)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Kind = UiPropertyKind.Color,
|
||||||
|
ColorValue = new UiColorValue(blue, green, red, 255),
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
||||||
/// stay at white (Vector4.One) — the backward-compatible default.
|
/// stay at white (Vector4.One) — the backward-compatible default.
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,12 @@ public static class FixtureLoader
|
||||||
public static ElementInfo LoadCombatInfos()
|
public static ElementInfo LoadCombatInfos()
|
||||||
=> LoadInfos("combat_21000073.json");
|
=> LoadInfos("combat_21000073.json");
|
||||||
|
|
||||||
|
public static ImportedLayout LoadSpellbook()
|
||||||
|
=> LayoutImporter.Build(LoadSpellbookInfos(), _ => (0u, 0, 0), null);
|
||||||
|
|
||||||
|
public static ElementInfo LoadSpellbookInfos()
|
||||||
|
=> LoadInfos("spellbook_21000034.json");
|
||||||
|
|
||||||
public static ImportedLayout LoadPowerbar()
|
public static ImportedLayout LoadPowerbar()
|
||||||
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,54 @@ public class LayoutImporterTests
|
||||||
Assert.NotNull(tree.FindElement(0x20000002));
|
Assert.NotNull(tree.FindElement(0x20000002));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFromInfos_TextPassToChildren_PropagatesDefaultAndRuntimeState()
|
||||||
|
{
|
||||||
|
var tab = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x100002A9u,
|
||||||
|
Type = 12,
|
||||||
|
Width = 100,
|
||||||
|
Height = 25,
|
||||||
|
DefaultStateId = RetailUiStateIds.Closed,
|
||||||
|
DefaultStateName = "Closed",
|
||||||
|
};
|
||||||
|
tab.States[RetailUiStateIds.Closed] = new UiStateInfo
|
||||||
|
{ Id = RetailUiStateIds.Closed, Name = "Closed", PassToChildren = true };
|
||||||
|
tab.States[RetailUiStateIds.Open] = new UiStateInfo
|
||||||
|
{ Id = RetailUiStateIds.Open, Name = "Open", PassToChildren = true };
|
||||||
|
|
||||||
|
var cap = new ElementInfo { Id = 0x10000439u, Type = 3, Width = 17, Height = 25 };
|
||||||
|
cap.States[RetailUiStateIds.Closed] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Closed,
|
||||||
|
Name = "Closed",
|
||||||
|
Image = new UiImageMedia(0x06005D93u, 3),
|
||||||
|
};
|
||||||
|
cap.States[RetailUiStateIds.Open] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Open,
|
||||||
|
Name = "Open",
|
||||||
|
Image = new UiImageMedia(0x06005D92u, 3),
|
||||||
|
};
|
||||||
|
cap.StateMedia["Closed"] = (0x06005D93u, 3);
|
||||||
|
cap.StateMedia["Open"] = (0x06005D92u, 3);
|
||||||
|
tab.Children.Add(cap);
|
||||||
|
|
||||||
|
var root = new ElementInfo { Id = 1u, Type = 3, Width = 100, Height = 25 };
|
||||||
|
ImportedLayout layout = LayoutImporter.BuildFromInfos(root, [tab], NoTex, null);
|
||||||
|
UiText text = Assert.IsType<UiText>(layout.FindElement(tab.Id));
|
||||||
|
UiDatElement child = Assert.IsType<UiDatElement>(layout.FindElement(cap.Id));
|
||||||
|
|
||||||
|
Assert.Single(text.Children);
|
||||||
|
Assert.Equal(RetailUiStateIds.Closed, child.ActiveRetailStateId);
|
||||||
|
Assert.Equal(0x06005D93u, child.ActiveMedia().File);
|
||||||
|
|
||||||
|
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
|
||||||
|
Assert.Equal(RetailUiStateIds.Open, child.ActiveRetailStateId);
|
||||||
|
Assert.Equal(0x06005D92u, child.ActiveMedia().File);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
|
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
(0x21000023u, "inventory_21000023.json"),
|
(0x21000023u, "inventory_21000023.json"),
|
||||||
(0x21000024u, "paperdoll_21000024.json"),
|
(0x21000024u, "paperdoll_21000024.json"),
|
||||||
(0x2100002Eu, "character_2100002E.json"),
|
(0x2100002Eu, "character_2100002E.json"),
|
||||||
|
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
|
||||||
(0x2100006Cu, "vitals_2100006C.json"),
|
(0x2100006Cu, "vitals_2100006C.json"),
|
||||||
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
|
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
|
||||||
(0x21000072u, "powerbar_21000072.json"),
|
(0x21000072u, "powerbar_21000072.json"),
|
||||||
|
|
@ -116,6 +117,21 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
Assert.Equal("High", labels.High);
|
Assert.Equal("High", labels.High);
|
||||||
Assert.Equal("Medium", labels.Medium);
|
Assert.Equal("Medium", labels.Medium);
|
||||||
Assert.Equal("Low", labels.Low);
|
Assert.Equal("Low", labels.Low);
|
||||||
|
var strings = new DatStringResolver(dats);
|
||||||
|
uint[] tabIds =
|
||||||
|
{
|
||||||
|
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
|
||||||
|
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
|
||||||
|
};
|
||||||
|
string[] expected = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"];
|
||||||
|
for (int i = 0; i < tabIds.Length; i++)
|
||||||
|
{
|
||||||
|
uint id = tabIds[i];
|
||||||
|
ElementInfo tab = FindInfo(info, id)!;
|
||||||
|
Assert.NotNull(tab);
|
||||||
|
Assert.True(tab.TryGetEffectiveProperty(0x17u, out UiPropertyValue label));
|
||||||
|
Assert.Equal(expected[i], strings.Resolve(label.StringInfoValue));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
|
|
@ -129,4 +145,12 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
|
|
||||||
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
|
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
|
||||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
|
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
|
||||||
|
|
||||||
|
private static ElementInfo? FindInfo(ElementInfo root, uint id)
|
||||||
|
{
|
||||||
|
if (root.Id == id) return root;
|
||||||
|
foreach (ElementInfo child in root.Children)
|
||||||
|
if (FindInfo(child, id) is { } match) return match;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
using AcDream.App.Spells;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
public sealed class SpellbookWindowControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||||
|
var closed = new List<uint>();
|
||||||
|
using SpellbookWindowController? controller = SpellbookWindowController.Bind(
|
||||||
|
layout,
|
||||||
|
new Spellbook(),
|
||||||
|
new ClientObjectTable(),
|
||||||
|
() => 1u,
|
||||||
|
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||||
|
spellId => spellId,
|
||||||
|
iconId => iconId,
|
||||||
|
_ => 1,
|
||||||
|
_ => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { },
|
||||||
|
(_, _) => { },
|
||||||
|
() => closed.Add(1u));
|
||||||
|
|
||||||
|
Assert.NotNull(controller);
|
||||||
|
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
|
||||||
|
UiText componentTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.ComponentTabId));
|
||||||
|
UiElement spellPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.SpellPageId));
|
||||||
|
UiElement componentPage = Assert.IsAssignableFrom<UiElement>(layout.FindElement(SpellbookWindowController.ComponentPageId));
|
||||||
|
|
||||||
|
Assert.Equal(RetailUiStateIds.Open, spellTab.ActiveRetailStateId);
|
||||||
|
Assert.Equal(RetailUiStateIds.Closed, componentTab.ActiveRetailStateId);
|
||||||
|
Assert.True(spellPage.Visible);
|
||||||
|
Assert.False(componentPage.Visible);
|
||||||
|
Assert.Equal(3, spellTab.Children.Count);
|
||||||
|
Assert.All(spellTab.Children, child =>
|
||||||
|
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||||
|
|
||||||
|
componentTab.OnEvent(new UiEvent(0, componentTab, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(SpellbookWindowPage.Components, controller!.CurrentPage);
|
||||||
|
Assert.Equal(RetailUiStateIds.Closed, spellTab.ActiveRetailStateId);
|
||||||
|
Assert.Equal(RetailUiStateIds.Open, componentTab.ActiveRetailStateId);
|
||||||
|
Assert.False(spellPage.Visible);
|
||||||
|
Assert.True(componentPage.Visible);
|
||||||
|
Assert.All(componentTab.Children, child =>
|
||||||
|
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||||
|
Assert.Empty(closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
13086
tests/AcDream.App.Tests/UI/Layout/fixtures/spellbook_21000034.json
Normal file
13086
tests/AcDream.App.Tests/UI/Layout/fixtures/spellbook_21000034.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue