feat(ui): complete retail quick-slot input
Port global toolbar use/select/create actions, migrate the collapsed Ctrl-number bindings, and route them through a focused retained-UI controller. Preserve shortcut aliases as aliases across inventory and paperdoll drops with retail's neutral/accept/reject drag states, preventing physical item moves such as unwielding an equipped helmet. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
6fcc510d5d
commit
e65119f0c6
24 changed files with 650 additions and 69 deletions
|
|
@ -11781,6 +11781,13 @@ public sealed class GameWindow : IDisposable
|
|||
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
|
||||
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
|
||||
|
||||
// Wave 4.1: one retained-UI delegation seam. The runtime routes only
|
||||
// toolbar-owned semantic actions; every other action continues through
|
||||
// the game switch below. Keyboard capture is already enforced at the
|
||||
// InputDispatcher source, matching retail's stack-entry focus gate.
|
||||
if (_retailUiRuntime?.HandleInputAction(action) == true)
|
||||
return;
|
||||
|
||||
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
|
||||
// Q to start, press Q again to stop. Pressing Backup / Stop /
|
||||
// StrafeLeft / StrafeRight while autorun is active also cancels it
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Visual result of a drag-over query. Retail handlers can consume a drag-over
|
||||
/// message without setting either accept or reject art; shortcut aliases over a
|
||||
/// physical item list use that neutral path.
|
||||
/// </summary>
|
||||
public enum ItemDragAcceptance
|
||||
{
|
||||
None,
|
||||
Accept,
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A panel controller implements this and registers itself on each of its
|
||||
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
|
||||
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
|
||||
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
|
||||
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
|
||||
/// <para><see cref="OnDragOver"/> decides the neutral/accept/reject overlay only (advisory).
|
||||
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
|
||||
/// no-ops to reject.</para>
|
||||
/// </summary>
|
||||
|
|
@ -17,8 +29,8 @@ public interface IItemListDragHandler
|
|||
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
/// <summary>Returns the advisory target-cell overlay state.</summary>
|
||||
ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
||||
/// toolbar shortcut creation. Ownership includes self, equipped items, and
|
||||
/// arbitrarily nested carried containers.
|
||||
/// </summary>
|
||||
public bool IsOwnedByPlayer(uint itemGuid)
|
||||
=> _objects.Get(itemGuid) is { } item
|
||||
&& (item.ObjectId == _playerGuid()
|
||||
|| IsCarriedByPlayer(item)
|
||||
|| IsEquippedByPlayer(item));
|
||||
|
||||
public bool IsCurrentTargetCompatible(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
|
|
|||
|
|
@ -379,24 +379,41 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
|
||||
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
|
||||
/// drops accept; a side-bag/main-pack drop rejects only when that container is known full.</summary>
|
||||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.ObjId == 0) return false;
|
||||
if (targetList == _contentsGrid) return true;
|
||||
// UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates
|
||||
// physical targets when (DropItemFlags & 0xE) == 0. A toolbar alias
|
||||
// carries bit 4: leave the inventory target neutral while the alias's
|
||||
// remove-on-lift stands.
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return ItemDragAcceptance.None;
|
||||
if (payload.ObjId == 0)
|
||||
return ItemDragAcceptance.Reject;
|
||||
if (targetList == _contentsGrid)
|
||||
return ItemDragAcceptance.Accept;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
|
||||
return !IsContainerFull(targetCell.ItemId);
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return IsContainerFull(targetCell.ItemId)
|
||||
? ItemDragAcceptance.Reject
|
||||
: ItemDragAcceptance.Accept;
|
||||
}
|
||||
return false;
|
||||
return ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
||||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
|
||||
// (flags & 0xE) == 0 gate before AcceptDragObject. A shortcut alias is
|
||||
// not the equipped/contained object it points at.
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return;
|
||||
|
||||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -259,11 +259,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
|
||||
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
|
||||
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// gmPaperDollUI::OnItemListDragOver @ 0x004A4270 ignores shortcut
|
||||
// aliases through its (DropItemFlags & 0xE) == 0 gate.
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return ItemDragAcceptance.None;
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return false;
|
||||
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
|
||||
if (item is null)
|
||||
return ItemDragAcceptance.Reject;
|
||||
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
|
||||
|
|
@ -271,6 +278,10 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// gmPaperDollUI::HandleDropRelease @ 0x004A4D80 applies the same flag
|
||||
// gate before accepting/wielding the physical object.
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return;
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly Action<uint>? _selectItem;
|
||||
private bool _disposed;
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
|
|
@ -98,7 +99,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null)
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_combatState = combatState;
|
||||
|
|
@ -111,6 +113,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_itemInteraction = itemInteraction;
|
||||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
_selectItem = selectItem;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
|
|
@ -233,11 +236,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null)
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits, itemInteraction,
|
||||
sendAddShortcut, sendRemoveShortcut, toggleCombat);
|
||||
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
|
@ -387,6 +391,95 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
|
||||
/// is one-shot and precedes both normal use and selection.
|
||||
/// </summary>
|
||||
public bool UseShortcut(int slot, bool use)
|
||||
{
|
||||
if ((uint)slot >= _slots.Length || _slots[slot]?.Cell is not { } cell)
|
||||
return false;
|
||||
|
||||
uint itemId = cell.ItemId;
|
||||
if (_itemInteraction?.IsTargetModeActive == true)
|
||||
{
|
||||
if (itemId != 0)
|
||||
_itemInteraction.OfferPrimaryClick(itemId);
|
||||
else
|
||||
_itemInteraction.CancelTargetMode();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemId == 0)
|
||||
return false;
|
||||
|
||||
if (use)
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.ActivateItem(itemId);
|
||||
else
|
||||
_useItem(itemId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectItem?.Invoke(itemId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-slot form of retail <c>gmToolbarUI::CreateShortcutToItem @
|
||||
/// 0x004BDAC0</c>, used by the global CreateShortcut action. It rejects
|
||||
/// stuck/non-player objects, non-player creatures, unowned objects,
|
||||
/// duplicates, and a full bar before emitting AddShortcut.
|
||||
/// </summary>
|
||||
public bool CreateShortcutToItem(uint itemId)
|
||||
{
|
||||
if (itemId == 0 || _repo.Get(itemId) is not { } item)
|
||||
return false;
|
||||
|
||||
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
|
||||
bool isPlayer = itemId == _itemInteraction?.PlayerGuid
|
||||
|| (flags & PublicWeenieFlags.Player) != 0;
|
||||
if ((flags & PublicWeenieFlags.Stuck) != 0 && !isPlayer)
|
||||
return false;
|
||||
if ((item.Type & ItemType.Creature) != 0 && !isPlayer)
|
||||
return false;
|
||||
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
|
||||
return false;
|
||||
|
||||
EnsureStoreLoadedForMutation();
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
if (_store.Get(slot) == itemId)
|
||||
return false;
|
||||
|
||||
int empty = -1;
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
{
|
||||
if (_store.IsEmpty(slot))
|
||||
{
|
||||
empty = slot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (empty < 0)
|
||||
return false;
|
||||
|
||||
_store.Set(empty, itemId);
|
||||
_sendAddShortcut?.Invoke((uint)empty, itemId);
|
||||
Populate();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnsureStoreLoadedForMutation()
|
||||
{
|
||||
if (_storeLoaded)
|
||||
return;
|
||||
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
|
||||
_storeLoaded = true;
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
|
||||
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
|
||||
// Retail model (remove-on-lift / place-on-drop / no-restore):
|
||||
|
|
@ -407,8 +500,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.ObjId != 0;
|
||||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.ObjId != 0 ? ItemDragAcceptance.Accept : ItemDragAcceptance.None;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
|
|
|
|||
71
src/AcDream.App/UI/Layout/ToolbarInputController.cs
Normal file
71
src/AcDream.App/UI/Layout/ToolbarInputController.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using AcDream.Core.Selection;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Focused retained-toolbar adapter for retail global input actions. The input
|
||||
/// dispatcher owns chords/scopes; this class only maps semantic actions to the
|
||||
/// exact <c>gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0</c> slot intent.
|
||||
/// </summary>
|
||||
public sealed class ToolbarInputController
|
||||
{
|
||||
private readonly ToolbarController _toolbar;
|
||||
private readonly SelectionState _selection;
|
||||
|
||||
public ToolbarInputController(ToolbarController toolbar, SelectionState selection)
|
||||
{
|
||||
_toolbar = toolbar ?? throw new ArgumentNullException(nameof(toolbar));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
}
|
||||
|
||||
/// <summary>Returns true when <paramref name="action"/> belongs to the toolbar.</summary>
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
if (TryMapShortcut(action, out int slot, out bool use))
|
||||
{
|
||||
_toolbar.UseShortcut(slot, use);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action == InputAction.CreateShortcut)
|
||||
{
|
||||
_toolbar.CreateShortcutToItem(_selection.SelectedObjectId ?? 0u);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryMapShortcut(InputAction action, out int slot, out bool use)
|
||||
{
|
||||
int value = (int)action;
|
||||
if (value >= (int)InputAction.UseQuickSlot_1
|
||||
&& value <= (int)InputAction.UseQuickSlot_9)
|
||||
{
|
||||
slot = value - (int)InputAction.UseQuickSlot_1;
|
||||
use = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value >= (int)InputAction.SelectQuickSlot_1
|
||||
&& value <= (int)InputAction.SelectQuickSlot_9)
|
||||
{
|
||||
slot = value - (int)InputAction.SelectQuickSlot_1;
|
||||
use = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value >= (int)InputAction.UseQuickSlot_14
|
||||
&& value <= (int)InputAction.UseQuickSlot_18)
|
||||
{
|
||||
slot = 13 + value - (int)InputAction.UseQuickSlot_14;
|
||||
use = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
slot = -1;
|
||||
use = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +149,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public ItemInteractionController ItemInteraction => _bindings.Inventory.ItemInteraction;
|
||||
public CharacterSheetProvider CharacterSheetProvider => _bindings.Character.Provider;
|
||||
public ToolbarController? ToolbarController { get; private set; }
|
||||
public ToolbarInputController? ToolbarInputController { get; private set; }
|
||||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
|
|
@ -176,6 +177,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
|
||||
|
||||
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
=> ToolbarInputController?.Handle(action) == true;
|
||||
|
||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||
{
|
||||
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
|
||||
|
|
@ -360,7 +364,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ToolbarController = Layout.ToolbarController.Bind(
|
||||
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
|
||||
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
|
||||
toggleCombat: b.ToggleCombat);
|
||||
toggleCombat: b.ToggleCombat,
|
||||
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar));
|
||||
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
|
||||
SelectedObjectController = Layout.SelectedObjectController.Bind(
|
||||
layout,
|
||||
b.Selection,
|
||||
|
|
|
|||
|
|
@ -199,9 +199,15 @@ public sealed class UiItemSlot : UiElement
|
|||
return true;
|
||||
|
||||
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
|
||||
_dragAccept = (FindList() is { DragHandler: { } h } list
|
||||
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
|
||||
? DragAcceptState.Accept : DragAcceptState.Reject;
|
||||
_dragAccept = FindList() is { DragHandler: { } h } list
|
||||
&& e.Payload is ItemDragPayload p
|
||||
? h.OnDragOver(list, this, p) switch
|
||||
{
|
||||
ItemDragAcceptance.Accept => DragAcceptState.Accept,
|
||||
ItemDragAcceptance.Reject => DragAcceptState.Reject,
|
||||
_ => DragAcceptState.None,
|
||||
}
|
||||
: DragAcceptState.Reject;
|
||||
return true;
|
||||
|
||||
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ namespace AcDream.UI.Abstractions.Input;
|
|||
/// debug bindings that have no retail equivalent.
|
||||
///
|
||||
/// <para>
|
||||
/// K.1a defines the enum but only the existing acdream-current chords
|
||||
/// resolve to actions; K.1c flips the bindings table to the full retail
|
||||
/// preset. Several actions in this enum (<c>UseQuickSlot_*</c>,
|
||||
/// <c>Combat*</c>, <c>UseSpellSlot_*</c>) have NO subscribers in K.1 —
|
||||
/// the chord fires the event but nothing acts on it. Phase L panels
|
||||
/// will subscribe.
|
||||
/// K.1a defined the enum and K.1c flipped the bindings table to the full
|
||||
/// retail preset. Runtime controllers subscribe by subsystem; actions whose
|
||||
/// owning panel has not landed yet (for example <c>UseSpellSlot_*</c>) may
|
||||
/// intentionally remain undispatched.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum InputAction
|
||||
|
|
@ -142,12 +140,21 @@ public enum InputAction
|
|||
UseQuickSlot_7,
|
||||
UseQuickSlot_8,
|
||||
UseQuickSlot_9,
|
||||
SelectQuickSlot_1,
|
||||
SelectQuickSlot_2,
|
||||
SelectQuickSlot_3,
|
||||
SelectQuickSlot_4,
|
||||
SelectQuickSlot_5,
|
||||
SelectQuickSlot_6,
|
||||
SelectQuickSlot_7,
|
||||
SelectQuickSlot_8,
|
||||
SelectQuickSlot_9,
|
||||
UseQuickSlot_14,
|
||||
UseQuickSlot_15,
|
||||
UseQuickSlot_16,
|
||||
UseQuickSlot_17,
|
||||
UseQuickSlot_18,
|
||||
/// <summary>Drop-target shortcut creation (retail 0 / drag-drop).</summary>
|
||||
/// <summary>Create a toolbar shortcut to the selected object (retail 0).</summary>
|
||||
CreateShortcut,
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
|
|||
/// </summary>
|
||||
public sealed class KeyBindings
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
private const int CurrentSchemaVersion = 2;
|
||||
|
||||
private readonly List<Binding> _bindings = new();
|
||||
|
||||
|
|
@ -212,13 +212,18 @@ public sealed class KeyBindings
|
|||
b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT));
|
||||
|
||||
// ── QuickslotCommands ──────────────────────────────────
|
||||
// Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords.
|
||||
// Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives
|
||||
// distinct action-id ranges: bare 1..9 USE slots 0..8, while
|
||||
// Ctrl+1..9 SELECT those slots. The keymap repeats the display name
|
||||
// UseQuickSlot_N for both bindings, so our semantic action layer must
|
||||
// preserve the differing intent explicitly.
|
||||
for (int i = 1; i <= 9; i++)
|
||||
{
|
||||
var k = (Key)((int)Key.Number0 + i); // Number1..Number9
|
||||
var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
|
||||
b.Add(new(new KeyChord(k, ModifierMask.None), action));
|
||||
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action));
|
||||
var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
|
||||
var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1);
|
||||
b.Add(new(new KeyChord(k, ModifierMask.None), useAction));
|
||||
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction));
|
||||
}
|
||||
// Alt+5..9 → UseQuickSlot_14..18.
|
||||
for (int i = 5; i <= 9; i++)
|
||||
|
|
@ -377,9 +382,9 @@ public sealed class KeyBindings
|
|||
using var stream = File.OpenRead(path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
// version is currently advisory — read so future migrations
|
||||
// can branch on it; the field's presence is non-fatal.
|
||||
_ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0;
|
||||
// Missing/older versions are valid and drive narrow migrations;
|
||||
// the field's presence remains non-fatal.
|
||||
int version = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0;
|
||||
|
||||
var defaults = RetailDefaults();
|
||||
var loaded = new KeyBindings();
|
||||
|
|
@ -413,7 +418,9 @@ public sealed class KeyBindings
|
|||
{
|
||||
device = (byte)dEl.GetInt32();
|
||||
}
|
||||
loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation));
|
||||
var chord = new KeyChord(silkKey, mods, device);
|
||||
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
||||
loaded.Add(new(chord, action, activation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -481,6 +488,34 @@ public sealed class KeyBindings
|
|||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema v1 repeated <c>UseQuickSlot_N</c> for bare and Ctrl chords,
|
||||
/// losing retail's use-vs-select distinction. Migrate only the exact old
|
||||
/// default Ctrl+matching-number shape; arbitrary user rebindings remain
|
||||
/// attached to the action the user chose.
|
||||
/// </summary>
|
||||
private static InputAction MigrateLegacyQuickSlotIntent(
|
||||
int version,
|
||||
InputAction action,
|
||||
KeyChord chord,
|
||||
ActivationType activation)
|
||||
{
|
||||
if (version >= 2
|
||||
|| activation != ActivationType.Press
|
||||
|| chord.Device != 0
|
||||
|| chord.Modifiers != ModifierMask.Ctrl)
|
||||
return action;
|
||||
|
||||
int offset = (int)action - (int)InputAction.UseQuickSlot_1;
|
||||
if ((uint)offset >= 9u)
|
||||
return action;
|
||||
|
||||
var expectedKey = (Key)((int)Key.Number1 + offset);
|
||||
return chord.Key == expectedKey
|
||||
? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset)
|
||||
: action;
|
||||
}
|
||||
|
||||
private static ModifierMask ParseModifiers(JsonElement bindingEl)
|
||||
{
|
||||
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ public sealed class SettingsPanel : IPanel
|
|||
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,
|
||||
InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6,
|
||||
InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9,
|
||||
InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3,
|
||||
InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6,
|
||||
InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9,
|
||||
InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16,
|
||||
InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18,
|
||||
InputAction.CreateShortcut,
|
||||
});
|
||||
RenderSection(renderer, "Emotes", new[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue