fix(ui): match retail spell bar controls
Place favorite-bar arrows by their authored sides, import rollover and pressed media through the shared scrollbar, and preserve manual offsets across passive refreshes. Carry the mixed-parent DAT anchor chain to a fixed 18-cell favorite viewport so overflow controls and the Cast button remain inside the retail-sized combat frame. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
02c29e67c8
commit
0134122c28
17 changed files with 424 additions and 59 deletions
|
|
@ -126,8 +126,11 @@ public static class DatWidgetFactory
|
|||
|
||||
/// <summary>
|
||||
/// Bind inherited scrollbar media structurally. Property 0x77 names the
|
||||
/// increment button and 0x78 the decrement button; the remaining Type-1
|
||||
/// child is the thumb with ordered top/middle/bottom image slices.
|
||||
/// increment button and 0x78 the decrement button; retail
|
||||
/// <c>UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0</c> then places
|
||||
/// those referenced child buttons by their authored leading/trailing
|
||||
/// positions. The remaining Type-1 child is the thumb with ordered
|
||||
/// top/middle/bottom image slices.
|
||||
/// </summary>
|
||||
private static UiScrollbar BuildScrollbar(
|
||||
ElementInfo info,
|
||||
|
|
@ -144,17 +147,31 @@ public static class DatWidgetFactory
|
|||
uint decrementId = ReferencedElementId(info, 0x78u);
|
||||
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
||||
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
|
||||
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
|
||||
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
|
||||
ElementInfo? leadingButton = new[] { increment, decrement }
|
||||
.Where(child => child is not null)
|
||||
.OrderBy(child => bar.Horizontal ? child!.X : child!.Y)
|
||||
.ThenBy(child => child!.ReadOrder)
|
||||
.FirstOrDefault();
|
||||
ElementInfo? trailingButton = new[] { increment, decrement }
|
||||
.Where(child => child is not null)
|
||||
.OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y)
|
||||
.ThenByDescending(child => child!.ReadOrder)
|
||||
.FirstOrDefault();
|
||||
bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
|
||||
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
|
||||
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
|
||||
bar.DownSprite = ButtonStateImage(trailingButton, "Normal");
|
||||
bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover");
|
||||
bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed");
|
||||
if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled))
|
||||
bar.HideWhenDisabled = hideDisabled;
|
||||
|
||||
if (bar.Horizontal)
|
||||
{
|
||||
if (decrement is { Width: > 0f })
|
||||
bar.DecrementButtonExtent = decrement.Width;
|
||||
if (increment is { Width: > 0f })
|
||||
bar.IncrementButtonExtent = increment.Width;
|
||||
if (leadingButton is { Width: > 0f })
|
||||
bar.DecrementButtonExtent = leadingButton.Width;
|
||||
if (trailingButton is { Width: > 0f })
|
||||
bar.IncrementButtonExtent = trailingButton.Width;
|
||||
|
||||
// Retail horizontal scrollbars use structural child ids: element 1 is
|
||||
// the thumb and element 4 is the optional child-authored track.
|
||||
|
|
@ -207,10 +224,10 @@ public static class DatWidgetFactory
|
|||
return bar;
|
||||
}
|
||||
|
||||
if (decrement is { Height: > 0f })
|
||||
bar.DecrementButtonExtent = decrement.Height;
|
||||
if (increment is { Height: > 0f })
|
||||
bar.IncrementButtonExtent = increment.Height;
|
||||
if (leadingButton is { Height: > 0f })
|
||||
bar.DecrementButtonExtent = leadingButton.Height;
|
||||
if (trailingButton is { Height: > 0f })
|
||||
bar.IncrementButtonExtent = trailingButton.Height;
|
||||
|
||||
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
|
||||
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);
|
||||
|
|
@ -272,6 +289,19 @@ public static class DatWidgetFactory
|
|||
return 0u;
|
||||
}
|
||||
|
||||
private static uint ButtonStateImage(ElementInfo? info, string stateName)
|
||||
{
|
||||
if (info is null)
|
||||
return 0u;
|
||||
if (info.StateMedia.TryGetValue(stateName, out var media))
|
||||
return media.File;
|
||||
UiStateInfo? state = info.States.Values.FirstOrDefault(
|
||||
candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal));
|
||||
if (state?.Image is { } image)
|
||||
return image.File;
|
||||
return stateName == "Normal" ? DefaultImage(info) : 0u;
|
||||
}
|
||||
|
||||
// ── Meter ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
49
src/AcDream.App/UI/Layout/RetailCombatLayout.cs
Normal file
49
src/AcDream.App/UI/Layout/RetailCombatLayout.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Retail geometry policy for the shared melee/missile/magic combat root.</summary>
|
||||
internal static class RetailCombatLayout
|
||||
{
|
||||
internal const int VisibleFavoriteSlots = 18;
|
||||
internal const float FavoriteCellWidth = 32f;
|
||||
|
||||
/// <summary>
|
||||
/// Resize the imported combat tree so its favorite list exposes exactly the
|
||||
/// requested number of cells. The DAT deliberately authors the outer combat
|
||||
/// root at 610 pixels while the magic page's descendants use an 800-pixel
|
||||
/// design parent. Retail <c>UIElement::UpdateForParentSizeChange @
|
||||
/// 0x00462640</c> carries that width delta through every raw-edge policy.
|
||||
/// Applying the same chain and solving for the list viewport keeps the
|
||||
/// endowment, arrows, list, Cast button, and outer frame in one coherent
|
||||
/// retained layout instead of resizing individual controls ad hoc.
|
||||
/// </summary>
|
||||
internal static float FitFavoriteSlots(
|
||||
ImportedLayout layout,
|
||||
int visibleSlots = VisibleFavoriteSlots,
|
||||
float cellWidth = FavoriteCellWidth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
if (visibleSlots < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(visibleSlots));
|
||||
if (!(cellWidth > 0f) || !float.IsFinite(cellWidth))
|
||||
throw new ArgumentOutOfRangeException(nameof(cellWidth));
|
||||
|
||||
UiElement root = layout.Root;
|
||||
ApplyDescendantLayout(root);
|
||||
if (layout.FindElement(SpellcastingUiController.FavoriteListId) is not UiItemList list)
|
||||
throw new InvalidOperationException("Retail combat layout has no favorite spell list.");
|
||||
|
||||
float targetViewport = visibleSlots * cellWidth;
|
||||
root.Width = MathF.Max(0f, root.Width + targetViewport - list.Width);
|
||||
ApplyDescendantLayout(root);
|
||||
return root.Width;
|
||||
}
|
||||
|
||||
private static void ApplyDescendantLayout(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
{
|
||||
child.ApplyAnchor(parent.Width, parent.Height);
|
||||
ApplyDescendantLayout(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,12 @@ public static class RetailWindowFrame
|
|||
public bool DrawChromeCenter { get; init; } = true;
|
||||
public bool Visible { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level desktop anchors. Most movable windows are unanchored; fixed
|
||||
/// retail HUD roots can preserve their authored screen-edge margins.
|
||||
/// </summary>
|
||||
public AnchorEdges OuterAnchors { get; init; } = AnchorEdges.None;
|
||||
|
||||
public AnchorEdges ContentAnchors { get; init; } =
|
||||
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||||
public bool? ContentClickThrough { get; init; }
|
||||
|
|
@ -133,7 +139,7 @@ public static class RetailWindowFrame
|
|||
outerFrame.Top = options.Top;
|
||||
outerFrame.Width = outerWidth;
|
||||
outerFrame.Height = outerHeight;
|
||||
outerFrame.Anchors = AnchorEdges.None;
|
||||
outerFrame.Anchors = options.OuterAnchors;
|
||||
outerFrame.Draggable = options.Draggable;
|
||||
outerFrame.Resizable = options.Resizable;
|
||||
outerFrame.ResizeX = options.ResizeX;
|
||||
|
|
|
|||
|
|
@ -251,8 +251,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
|
||||
}
|
||||
SyncSelection();
|
||||
if (_selected[_activeTab] is uint visibleSpell)
|
||||
ScrollSelectedSpellIntoView(_activeTab, visibleSpell);
|
||||
UpdateCastAvailability();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -764,11 +764,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
strings.Resolve);
|
||||
labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
|
||||
}
|
||||
if (layout is null || labels is null)
|
||||
if (info is null || layout is null || labels is null)
|
||||
{
|
||||
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
|
||||
return;
|
||||
}
|
||||
float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout);
|
||||
|
||||
CombatUiController? controller = Layout.CombatUiController.Bind(
|
||||
layout,
|
||||
|
|
@ -821,9 +822,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
WindowName = WindowNames.Combat,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
|
||||
Left = 0f,
|
||||
Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
|
||||
ContentWidth = combatWidth,
|
||||
DatConstraintSource = info,
|
||||
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,14 @@ public abstract class UiElement
|
|||
/// self-driven interior drag such as text selection). Default false.</summary>
|
||||
public virtual bool HandlesClick => false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether pointer movement should continue reaching this element while it remains
|
||||
/// hovered and no mouse capture is active. Composite controls use this to update
|
||||
/// procedural sub-region hover state without broadening mouse-move delivery to every
|
||||
/// retained widget.
|
||||
/// </summary>
|
||||
public virtual bool ReceivesHoverMouseMove => false;
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
|
|
|||
|
|
@ -851,7 +851,12 @@ public sealed class UiRoot : UiElement
|
|||
private void UpdateHover(int x, int y)
|
||||
{
|
||||
var (w, _, _) = HitTestTopDown(x, y);
|
||||
if (ReferenceEquals(w, _hoverWidget)) return;
|
||||
if (ReferenceEquals(w, _hoverWidget))
|
||||
{
|
||||
if (w?.ReceivesHoverMouseMove == true)
|
||||
DispatchMouseMove(w, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hoverWidget is not null)
|
||||
{
|
||||
|
|
@ -863,7 +868,13 @@ public sealed class UiRoot : UiElement
|
|||
_tooltipFired = false;
|
||||
if (w is not null)
|
||||
{
|
||||
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
|
||||
var screen = w.ScreenPosition;
|
||||
var enter = new UiEvent(
|
||||
w.EventId,
|
||||
w,
|
||||
UiEventType.HoverEnter,
|
||||
Data1: (int)(x - screen.X),
|
||||
Data2: (int)(y - screen.Y));
|
||||
w.OnEvent(in enter);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ namespace AcDream.App.UI;
|
|||
/// </remarks>
|
||||
public sealed class UiScrollbar : UiElement
|
||||
{
|
||||
public override bool ReceivesHoverMouseMove => true;
|
||||
|
||||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||||
public UiScrollable? Model { get; set; }
|
||||
|
||||
|
|
@ -83,6 +85,14 @@ public sealed class UiScrollbar : UiElement
|
|||
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
|
||||
public uint DownSprite { get; set; }
|
||||
|
||||
/// <summary>Rollover and pressed media for the start/decrement button.</summary>
|
||||
public uint UpRolloverSprite { get; set; }
|
||||
public uint UpPressedSprite { get; set; }
|
||||
|
||||
/// <summary>Rollover and pressed media for the end/increment button.</summary>
|
||||
public uint DownRolloverSprite { get; set; }
|
||||
public uint DownPressedSprite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authored extent of the decrement button along the scrollbar axis. Retail
|
||||
/// positions and subtracts the referenced button's real size; chat uses
|
||||
|
|
@ -108,6 +118,15 @@ public sealed class UiScrollbar : UiElement
|
|||
private bool _draggingThumb;
|
||||
private float _dragOffsetY;
|
||||
private float _dragOffsetX;
|
||||
private EndButton _hoveredButton;
|
||||
private EndButton _pressedButton;
|
||||
|
||||
private enum EndButton
|
||||
{
|
||||
None,
|
||||
Decrement,
|
||||
Increment,
|
||||
}
|
||||
|
||||
public UiScrollbar() { CapturesPointerDrag = true; }
|
||||
|
||||
|
|
@ -201,8 +220,8 @@ public sealed class UiScrollbar : UiElement
|
|||
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
|
||||
|
||||
// Decrement/up and increment/down use their authored button heights.
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent);
|
||||
DrawSprite(ctx, resolve, DownSprite,
|
||||
DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, Width, decrementExtent);
|
||||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||
0f, Height - incrementExtent, Width, incrementExtent);
|
||||
|
||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
||||
|
|
@ -235,8 +254,8 @@ public sealed class UiScrollbar : UiElement
|
|||
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
|
||||
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height);
|
||||
DrawSprite(ctx, resolve, DownSprite,
|
||||
DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
|
||||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||
Width - incrementExtent, 0f, incrementExtent, Height);
|
||||
if (!model.HasOverflow) return;
|
||||
|
||||
|
|
@ -322,9 +341,24 @@ public sealed class UiScrollbar : UiElement
|
|||
if (IsModelDisabled)
|
||||
{
|
||||
_draggingThumb = false;
|
||||
_hoveredButton = EndButton.None;
|
||||
_pressedButton = EndButton.None;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.Type == UiEventType.HoverEnter)
|
||||
{
|
||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
if (e.Type == UiEventType.HoverLeave)
|
||||
{
|
||||
_hoveredButton = EndButton.None;
|
||||
return true;
|
||||
}
|
||||
if (e.Type == UiEventType.MouseMove)
|
||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||
|
||||
if (Horizontal && ScalarChanged is not null)
|
||||
return OnScalarEvent(e);
|
||||
|
||||
|
|
@ -339,6 +373,7 @@ public sealed class UiScrollbar : UiElement
|
|||
{
|
||||
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
|
||||
float ly = e.Data2;
|
||||
_pressedButton = ButtonAt(e.Data1, e.Data2);
|
||||
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
|
||||
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
|
||||
|
||||
|
|
@ -386,6 +421,7 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
_pressedButton = EndButton.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +436,7 @@ public sealed class UiScrollbar : UiElement
|
|||
case UiEventType.MouseDown:
|
||||
{
|
||||
float x = e.Data1;
|
||||
_pressedButton = ButtonAt(e.Data1, e.Data2);
|
||||
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
|
||||
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
|
||||
if (x < decrementExtent) { m.ScrollByLines(-1); return true; }
|
||||
|
|
@ -437,6 +474,7 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
_pressedButton = EndButton.None;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -475,6 +513,7 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
_pressedButton = EndButton.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -497,6 +536,48 @@ public sealed class UiScrollbar : UiElement
|
|||
ScalarChanged?.Invoke(ScalarPosition);
|
||||
}
|
||||
|
||||
private EndButton ButtonAt(float x, float y)
|
||||
{
|
||||
if (x < 0f || x >= Width || y < 0f || y >= Height)
|
||||
return EndButton.None;
|
||||
|
||||
if (Horizontal)
|
||||
{
|
||||
if (x < AxisExtent(DecrementButtonExtent, Width))
|
||||
return EndButton.Decrement;
|
||||
if (x >= Width - AxisExtent(IncrementButtonExtent, Width))
|
||||
return EndButton.Increment;
|
||||
return EndButton.None;
|
||||
}
|
||||
|
||||
if (y < AxisExtent(DecrementButtonExtent, Height))
|
||||
return EndButton.Decrement;
|
||||
if (y >= Height - AxisExtent(IncrementButtonExtent, Height))
|
||||
return EndButton.Increment;
|
||||
return EndButton.None;
|
||||
}
|
||||
|
||||
private uint ActiveStartSprite
|
||||
=> _pressedButton == EndButton.Decrement
|
||||
&& _hoveredButton == EndButton.Decrement
|
||||
&& UpPressedSprite != 0u
|
||||
? UpPressedSprite
|
||||
: _hoveredButton == EndButton.Decrement && UpRolloverSprite != 0u
|
||||
? UpRolloverSprite
|
||||
: UpSprite;
|
||||
|
||||
private uint ActiveEndSprite
|
||||
=> _pressedButton == EndButton.Increment
|
||||
&& _hoveredButton == EndButton.Increment
|
||||
&& DownPressedSprite != 0u
|
||||
? DownPressedSprite
|
||||
: _hoveredButton == EndButton.Increment && DownRolloverSprite != 0u
|
||||
? DownRolloverSprite
|
||||
: DownSprite;
|
||||
|
||||
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
|
||||
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
|
||||
|
||||
private static float AxisExtent(float authoredExtent, float axisLength)
|
||||
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue