feat(ui): drive toolbar use state from selection

Port the retail selected-object availability predicate into Core and project it through the shared interaction owner. The imported hand now follows canonical selection/object notices, keeps weapon and targeted-tool activation on the existing wield/use cursor paths, and ghosts empty or explicitly unusable selections per the connected UX requirement.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 08:07:47 +02:00
parent 0134122c28
commit 60387668d0
11 changed files with 269 additions and 16 deletions

View file

@ -496,6 +496,23 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
/// <summary>
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
/// from the live selected object's public description. This is deliberately
/// a read-only query; activation still flows through <see cref="ActivateItem"/>.
/// </summary>
public bool IsToolbarUseEnabled(uint selectedObjectId)
{
if (selectedObjectId == 0
|| _objects.Get(selectedObjectId) is not { } item)
return false;
return ItemInteractionPolicy.IsToolbarUseEnabled(
item.Type,
item.CombatUse ?? 0,
item.Useability ?? ItemUseability.Undef);
}
public bool ActivateItem(uint itemGuid)
{
if (itemGuid == 0) return false;

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
@ -73,6 +74,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private readonly Func<uint> _selectedObjectId;
private readonly SelectionState? _selection;
private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private uint _ammoObjectId;
@ -106,6 +108,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null,
@ -125,6 +128,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_selection = selection;
_playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer;
@ -196,6 +200,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Wire live combat-mode changes if a CombatState was provided.
if (_combatState is not null)
_combatState.CombatModeChanged += SetCombatMode;
if (_selection is not null)
_selection.Changed += OnSelectionChanged;
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
// to our 18 shortcut guids — else every creature spawn in a busy zone
@ -206,6 +212,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared;
RefreshAmmo();
RefreshUseButton();
}
private void OnRepositoryObjectChanged(ClientObject obj)
@ -214,6 +221,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId))
Populate();
if (obj.ObjectId == _selectedObjectId())
RefreshUseButton();
}
private void OnRepositoryObjectMoved(ClientObjectMove move)
@ -234,6 +243,23 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{
RefreshAmmo();
Populate();
RefreshUseButton();
}
private void OnSelectionChanged(SelectionTransition _)
=> RefreshUseButton();
private void RefreshUseButton()
{
if (_useButton is null)
return;
// Retail normally leaves state 1 active when selectedID == 0 so the
// click can enter generic TARGET_MODE_USE. acdream intentionally
// ghosts that empty state per the connected UX requirement; every
// selected-object classification below is the exact retail predicate.
_useButton.Enabled =
_itemInteraction?.IsToolbarUseEnabled(_selectedObjectId()) == true;
}
private bool IsAmmoRelated(ClientObject obj)
@ -330,6 +356,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null,
@ -338,7 +365,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
regularDigits, ghostedDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont,
selectedObjectId, selection, playerGuid, sendPutItemInContainer, ammoFont,
dragIconIds);
c.Populate();
return c;
@ -736,6 +763,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_combatState is not null)
_combatState.CombatModeChanged -= SetCombatMode;
if (_selection is not null)
_selection.Changed -= OnSelectionChanged;
_repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged;

View file

@ -689,6 +689,7 @@ public sealed class RetailUiRuntime : IDisposable
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
selection: b.Selection,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer,
ammoFont: _bindings.Assets.DefaultFont,

View file

@ -151,6 +151,22 @@ public static class ItemInteractionPolicy
private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u;
private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu;
private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u;
private const ItemType ToolbarEquipmentTypes =
ItemType.Armor | ItemType.Clothing | ItemType.Jewelry;
/// <summary>
/// Exact selected-object predicate used by retail's toolbar hand.
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c> leaves the
/// button active for combat-use objects, armor/clothing/jewelry, or an
/// <c>ItemUses</c> value whose <c>USEABLE_NO</c> bit is clear.
/// </summary>
public static bool IsToolbarUseEnabled(
ItemType type,
byte combatUse,
uint useability)
=> combatUse != 0
|| (type & ToolbarEquipmentTypes) != 0
|| (useability & ItemUseability.No) == 0;
public static ItemPrimaryUseResult DetermineUseResult(
in ItemPolicyObject item,