feat(ui): port retail selected-stack quantity
Bind the authored stack count entry and horizontal slider to one Core split-quantity owner, preserve retail count-first naming and exact 1000-step rounding, refresh on stack changes, and consume the selected amount during merges. Conformance covers the production DAT fixture and retained pointer/focus paths. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
dc1649c493
commit
a20e5c68c7
19 changed files with 595 additions and 51 deletions
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -53,6 +54,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
public const uint OverlayId = 0x100001A0;
|
||||
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
|
||||
public const uint HealthMeterId = 0x100001A1;
|
||||
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
|
||||
public const uint StackSizeEntryId = 0x100001A3;
|
||||
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
|
||||
public const uint StackSizeSliderId = 0x100001A4;
|
||||
|
||||
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
|
||||
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
|
||||
|
|
@ -75,6 +80,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly UiElement? _name;
|
||||
private readonly UiDatElement? _overlay;
|
||||
private readonly UiMeter? _healthMeter;
|
||||
private readonly UiField? _stackSizeEntry;
|
||||
private readonly UiScrollbar? _stackSizeSlider;
|
||||
|
||||
// ── Captured delegates ───────────────────────────────────────────────────
|
||||
private readonly Func<uint, bool> _isHealthTarget;
|
||||
|
|
@ -83,8 +90,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly Func<uint, bool> _hasHealth;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
private uint? _current;
|
||||
|
|
@ -106,7 +115,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_resolveName = name;
|
||||
|
|
@ -114,13 +126,17 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_hasHealth = hasHealth;
|
||||
_stackSize = stackSize;
|
||||
_sendQueryHealth = sendQueryHealth;
|
||||
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
|
||||
|
||||
// Find elements — silently skip absent ones (partial/test layouts).
|
||||
_name = layout.FindElement(NameId);
|
||||
_overlay = layout.FindElement(OverlayId) as UiDatElement;
|
||||
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
|
||||
_stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField;
|
||||
_stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar;
|
||||
|
||||
// The selection-flash overlay must draw OVER the health meter (which spans the whole
|
||||
// strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e.
|
||||
|
|
@ -135,6 +151,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
|
||||
}
|
||||
|
||||
if (_stackSizeEntry is not null)
|
||||
{
|
||||
_stackSizeEntry.Visible = false;
|
||||
_stackSizeEntry.Selectable = true;
|
||||
_stackSizeEntry.ClearOnSubmit = false;
|
||||
_stackSizeEntry.RecordHistory = false;
|
||||
_stackSizeEntry.CharacterFilter = static c => c is >= '0' and <= '9';
|
||||
_stackSizeEntry.SelectAllOnFocus = true;
|
||||
_stackSizeEntry.OnSubmit = CommitStackEntry;
|
||||
_stackSizeEntry.OnFocusLost = CommitStackEntry;
|
||||
}
|
||||
if (_stackSizeSlider is not null)
|
||||
{
|
||||
_stackSizeSlider.Visible = false;
|
||||
_stackSizeSlider.Horizontal = true;
|
||||
_stackSizeSlider.ScalarValue = () => _splitQuantity.Ratio;
|
||||
_stackSizeSlider.ScalarChanged = _splitQuantity.SetFromSliderRatio;
|
||||
}
|
||||
|
||||
// Attach a centered UiText child to the name element for the object name display.
|
||||
// Mirrors VitalsController.BindMeter's number attach. The name is floated to the
|
||||
// top of the strip's z-order so it draws OVER the overlay frame and the health bar
|
||||
|
|
@ -172,7 +207,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
|
||||
// Register the handlers LAST so the initial state is fully set up first.
|
||||
_selection.Changed += OnSelectionTransition;
|
||||
_splitQuantity.Changed += OnSplitQuantityChanged;
|
||||
subscribeHealthChanged(OnHealthChanged);
|
||||
subscribeObjectUpdated(OnObjectUpdated);
|
||||
if (_selection.SelectedObjectId is { } initial)
|
||||
ApplySelection(initial);
|
||||
}
|
||||
|
|
@ -205,11 +242,15 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
=> new SelectedObjectController(
|
||||
layout, selection,
|
||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
|
||||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
|
|
@ -220,6 +261,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||||
if (_healthMeter is not null) _healthMeter.Visible = false;
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false;
|
||||
_splitQuantity.Reset(1u);
|
||||
_currentName = null;
|
||||
_current = guid;
|
||||
|
||||
|
|
@ -234,15 +278,30 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
uint g = guid.Value;
|
||||
|
||||
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
|
||||
_currentName = _resolveName(g);
|
||||
uint stackSize = _stackSize(g);
|
||||
string? objectName = _resolveName(g);
|
||||
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
|
||||
? $"{stackSize} {objectName}"
|
||||
: objectName;
|
||||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(_stackSize(g) > 1u
|
||||
SetOverlayState(stackSize > 1u
|
||||
? RetailUiStateIds.StackedItemSelected
|
||||
: RetailUiStateIds.ObjectSelected);
|
||||
_flashRemaining = FlashSeconds;
|
||||
|
||||
// gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666:
|
||||
// stacks initialize to the full stack, show the numeric entry + horizontal
|
||||
// slider, and set the stacked selection state. Vendor-owned stack precedence
|
||||
// is intentionally absent until the vendor panel owns an active vendor id.
|
||||
if (stackSize > 1u)
|
||||
{
|
||||
_splitQuantity.Reset(stackSize);
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
|
||||
}
|
||||
|
||||
// ── 4. Health: query, and show the meter only if real health is already known.
|
||||
// Otherwise the meter appears when OnHealthChanged fires for this guid
|
||||
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
|
||||
|
|
@ -279,6 +338,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_overlay?.TrySetRetailState(state);
|
||||
}
|
||||
|
||||
private void CommitStackEntry(string text)
|
||||
=> _splitQuantity.SetFromText(text);
|
||||
|
||||
private void OnSplitQuantityChanged()
|
||||
=> _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
private void OnObjectUpdated(ClientObject updated)
|
||||
{
|
||||
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)
|
||||
ApplySelection(updated.ObjectId);
|
||||
}
|
||||
|
||||
private void OnSelectionTransition(SelectionTransition transition)
|
||||
=> ApplySelection(transition.SelectedObjectId);
|
||||
|
||||
|
|
@ -287,6 +358,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_unsubscribeHealthChanged(OnHealthChanged);
|
||||
_unsubscribeObjectUpdated(OnObjectUpdated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue