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:
Erik 2026-07-11 10:07:25 +02:00
parent dc1649c493
commit a20e5c68c7
19 changed files with 595 additions and 51 deletions

View file

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