feat(ui): finish retail toolbar controls
Discover all seven panel launchers from their DAT panel-id attributes, route mounted panels through event-driven retained window state, and ghost unavailable panels. Port Use and Examine selection/target behavior with exact Appraise dispatch and retail cursor modes. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
21fefce0e0
commit
d3d1c895a0
19 changed files with 478 additions and 72 deletions
|
|
@ -1607,9 +1607,9 @@ R5/R6 touches the action list. **Where:**
|
|||
|
||||
**Description:** `UiRoot`'s window registry flips `Visible` with no lifecycle event, toolbar-button sync is manually pushed at each mutation site, and acdream has no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to `ClientUISystem.groundObject`: replacing or closing an external container sends `0x0195` exactly once. The owned `gmInventoryUI` side-bag path has no corresponding static send.
|
||||
|
||||
**Root cause / status:** Review finding (theme T2), re-framed by the 2026-07-10 named-retail trace. Add typed retained-window lifecycle events and subscriber-driven toolbar state. Port a separate external-container owner whose ground-object replacement/close path emits one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace.
|
||||
**Root cause / status:** PARTIAL 2026-07-11. Typed retained-window visibility events and subscriber-driven toolbar state shipped in Wave 4.4d, replacing manual sync at mutation sites. The issue remains open for the separate external-container owner whose ground-object replacement/close path must emit one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace.
|
||||
|
||||
**Files:** `src/AcDream.App/UI/UiRoot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`, `src/AcDream.App/Rendering/GameWindow.cs` (`CloseRetailWindow`/`SyncToolbarWindowButtons`); future external-container controller.
|
||||
**Files:** `src/AcDream.App/UI/RetailWindowManager.cs`, `src/AcDream.App/UI/RetailUiRuntime.cs`; future external-container controller.
|
||||
|
||||
**Research:** `docs/research/2026-07-02-ui-architecture-review.md`; correction oracle `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
|
||||
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
|
||||
| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
|
||||
| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, selected-item mana with exact query/cancel validity handling, exact stack name/entry/slider, shared split quantity, quick-slot actions, some panel toggles, and all four combat indicators work | Remaining bound retail buttons do nothing | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-101 | Toolbar ammo-number updates are not wired | `src/AcDream.App/UI/Layout/ToolbarController.cs` | All seven DAT-discovered panel launchers, combat, Use, Examine, selected health/mana/name/stack controls, and shortcut actions work; unavailable panels are ghosted and mounted-panel highlights follow window lifecycle events | Missile combat does not display the equipped ammunition count | `gmToolbarUI::UpdateAmmoNumber`; `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
|
||||
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.4b selected-item mana.** Owned non-stack selections send exact `QueryItemMana 0x0263`; `0x0264` parses guid/fraction/trailing-valid into Core `ItemManaState`; valid matching responses reveal authored meter `0x100001A2`, invalid and visible selection-change paths cancel with guid zero. Health uses the same retail visible-meter cancellation rule. Wire/state/controller conformance is green in the 4,712-test Release suite. Live Mana Stone source-destruction → armor-mana-transfer → bar-update gate passed 2026-07-11. AP-101 now covers remaining toolbar controls only.
|
||||
- **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11.
|
||||
- **Wave 4.4d implemented — toolbar launchers + Use/Examine (live gate pending).** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events now own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; AP-101 is narrowed to the remaining ammo-number display.
|
||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||
|
|
|
|||
|
|
@ -1125,8 +1125,10 @@ implementation patch.
|
|||
3. `ToolbarController.HandleDropRelease` uses the shortcut-source restoration
|
||||
branch for every source. Fresh inventory drops need the cyclic-right empty-slot
|
||||
branch.
|
||||
4. Toolbar input actions, seven panel buttons, Use, Examine, mana, and stack
|
||||
controls are not fully wired.
|
||||
4. **Resolved 2026-07-11:** toolbar input actions, all seven DAT-discovered panel
|
||||
buttons, Use, Examine, selected-item mana, and stack controls are wired. Mounted
|
||||
panel state follows typed retained-window visibility events; unavailable panels
|
||||
are ghosted. The separate ammo-number display remains tracked as AP-101.
|
||||
5. `ItemInteractionController.ActivateItem` is a useful orchestration seam, but it
|
||||
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
|
||||
the complete `DetermineUseResult` classification, container post-actions,
|
||||
|
|
|
|||
|
|
@ -515,10 +515,10 @@ their live action gate remains pending. The inventory-button gate remains pendin
|
|||
|
||||
### 4.4 Finish toolbar controls
|
||||
|
||||
- [ ] Bind all seven retail panel buttons by their DAT panel-id attribute plus combat,
|
||||
- [x] Bind all seven retail panel buttons by their DAT panel-id attribute plus combat,
|
||||
Use, and Examine actions; dispatch panel buttons through the window registry,
|
||||
ghosting unavailable panels rather than constructing placeholders.
|
||||
- [ ] Make open/closed states event-driven from `RetailWindowManager`.
|
||||
- [x] Make open/closed states event-driven from `RetailWindowManager`.
|
||||
- [x] Complete selected-object item mana and port `RecvNotice_UpdateItemMana`.
|
||||
- [x] Complete formatted stack count, stack entry and split slider, the applicable
|
||||
`RecvNotice_SplitStack` activation behavior, and `HandleSelectionChanged` stack
|
||||
|
|
@ -569,6 +569,17 @@ friendly NPCs and rejecting attackable non-creatures. AP-46 is retired; automate
|
|||
parser/model/policy conformance is green. The world/radar matrix passed live, and the
|
||||
paperdoll re-gate passed after its separate exact DAT body-map/upper-item route landed.
|
||||
|
||||
**Wave 4.4d implementation (2026-07-11; live gate pending):** `ToolbarController`
|
||||
discovers all seven authored launcher buttons and reads their panel IDs from DAT enum
|
||||
attribute `0x10000029`, matching `gmToolbarUI::PostInit @ 0x004BEA80`. Inventory and
|
||||
Character dispatch through the retained window registry; the five panels not yet mounted
|
||||
are ghosted rather than represented by placeholders. `RetailWindowManager` visibility
|
||||
events now drive state 6/1 (Highlight/Normal), including close-button and persistence
|
||||
transitions, replacing mutation-site synchronization. Use and Examine dispatch the current
|
||||
selection immediately or enter their one-shot retail target modes; Examine sends the exact
|
||||
Appraise request and both modes use the retail global cursor catalog. The warning-free
|
||||
App build and full 4,747-pass / 5-skip Release suite are green.
|
||||
|
||||
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
|
||||
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
|
||||
shortcut to the selected owned/eligible object in the first empty slot. A focused
|
||||
|
|
@ -581,8 +592,8 @@ indicator buttons `0x10000192..0x10000195` now all dispatch one injected combat
|
|||
toggle command. The key binding and retained toolbar therefore converge on
|
||||
`GameWindow.ToggleLiveCombatMode`, matching `gmToolbarUI::ListenToElementMessage
|
||||
@ 0x004BEE90`; the visible indicator continues to update from authoritative
|
||||
`CombatState`. Use, Examine, remaining panel buttons, raw shortcut preservation,
|
||||
drag/drop edge cases, and selected-object residuals remain in this wave.
|
||||
`CombatState`. Raw shortcut preservation, drag/drop edge cases, and the toolbar
|
||||
ammo-number display remain in this wave.
|
||||
|
||||
**Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`,
|
||||
`UIElement_UIItem::SetShortcutNum`, shortcut wire builders.
|
||||
|
|
|
|||
|
|
@ -1995,6 +1995,7 @@ public sealed class GameWindow : IDisposable
|
|||
Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
sendUse: g => _liveSession?.SendUse(g),
|
||||
sendExamine: g => _liveSession?.SendAppraise(g),
|
||||
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||
|
|
@ -11946,7 +11947,7 @@ public sealed class GameWindow : IDisposable
|
|||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
||||
_itemInteractionController.CancelTargetMode();
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
|
|
@ -12092,7 +12093,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Target-use mode picks WITH self (retail: kit-heal yourself by
|
||||
// clicking your own toon); plain selection keeps the self-exclusion.
|
||||
var picked = PickWorldGuidAtCursor(
|
||||
includeSelf: _itemInteractionController?.IsTargetModeActive == true);
|
||||
includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true);
|
||||
|
||||
if (picked is uint guid)
|
||||
{
|
||||
|
|
@ -12133,7 +12134,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else
|
||||
{
|
||||
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||
if (_itemInteractionController?.IsAnyTargetModeActive == true)
|
||||
return;
|
||||
_debugVm?.AddToast("Nothing to select");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,9 +113,7 @@ public sealed class CursorFeedbackController
|
|||
// window occludes the world (no found object → pending). The one
|
||||
// UI-side source retail-style cells contribute is an occupied item
|
||||
// slot's own item.
|
||||
RetailCursorTargetMode targetMode = _itemInteraction?.IsTargetModeActive == true
|
||||
? RetailCursorTargetMode.UseTarget
|
||||
: RetailCursorTargetMode.None;
|
||||
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
|
||||
uint hoverTarget = hover is null
|
||||
? _worldTargetProvider?.Invoke() ?? 0u
|
||||
: targetMode == RetailCursorTargetMode.UseTarget
|
||||
|
|
@ -265,9 +263,18 @@ public sealed class CursorFeedbackController
|
|||
|
||||
private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot)
|
||||
=> snapshot.TargetMode == RetailCursorTargetMode.None
|
||||
&& _itemInteraction?.IsTargetModeActive == true
|
||||
? RetailCursorTargetMode.UseTarget
|
||||
: snapshot.TargetMode;
|
||||
? ModeFromInteraction(_itemInteraction)
|
||||
: snapshot.TargetMode;
|
||||
|
||||
private static RetailCursorTargetMode ModeFromInteraction(
|
||||
ItemInteractionController? interaction)
|
||||
=> interaction?.InteractionState.Current.Kind switch
|
||||
{
|
||||
InteractionModeKind.Use => RetailCursorTargetMode.Use,
|
||||
InteractionModeKind.Examine => RetailCursorTargetMode.Examine,
|
||||
InteractionModeKind.UseItemOnTarget => RetailCursorTargetMode.UseTarget,
|
||||
_ => RetailCursorTargetMode.None,
|
||||
};
|
||||
|
||||
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<long> _nowMs;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendExamine;
|
||||
private readonly Action<uint, uint>? _sendUseWithTarget;
|
||||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
|
|
@ -90,6 +91,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint, uint>? sendUseWithTarget,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint>? sendDrop,
|
||||
Action<uint>? sendExamine = null,
|
||||
Func<long>? nowMs = null,
|
||||
Action<string>? toast = null,
|
||||
InteractionState? interactionState = null,
|
||||
|
|
@ -108,6 +110,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendUse = sendUse;
|
||||
_sendExamine = sendExamine;
|
||||
_sendUseWithTarget = sendUseWithTarget;
|
||||
_sendWield = sendWield;
|
||||
_sendDrop = sendDrop;
|
||||
|
|
@ -152,6 +155,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool IsTargetModeActive
|
||||
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
|
||||
|
||||
public bool IsAnyTargetModeActive
|
||||
=> _interactionState.Current.Kind != InteractionModeKind.None;
|
||||
|
||||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
|
|
@ -182,7 +188,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// </summary>
|
||||
public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive)
|
||||
InteractionModeKind mode = _interactionState.Current.Kind;
|
||||
if (mode == InteractionModeKind.None)
|
||||
{
|
||||
long now = _nowMs();
|
||||
if (targetGuid != 0
|
||||
|
|
@ -193,7 +200,25 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return ItemPrimaryClickResult.NotActive;
|
||||
}
|
||||
|
||||
bool accepted = AcquireTarget(targetGuid);
|
||||
bool accepted;
|
||||
switch (mode)
|
||||
{
|
||||
case InteractionModeKind.Use:
|
||||
ClearTargetMode();
|
||||
accepted = ActivateItem(targetGuid);
|
||||
break;
|
||||
case InteractionModeKind.Examine:
|
||||
ClearTargetMode();
|
||||
accepted = targetGuid != 0 && _sendExamine is not null;
|
||||
if (accepted)
|
||||
_sendExamine!(targetGuid);
|
||||
break;
|
||||
case InteractionModeKind.UseItemOnTarget:
|
||||
accepted = AcquireTarget(targetGuid);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown interaction mode {mode}.");
|
||||
}
|
||||
_consumedPrimaryClickTarget = targetGuid;
|
||||
_consumedPrimaryClickMs = _nowMs();
|
||||
return accepted
|
||||
|
|
@ -204,6 +229,33 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public ItemPrimaryClickResult OfferSelfPrimaryClick()
|
||||
=> OfferPrimaryClick(_playerGuid());
|
||||
|
||||
/// <summary>
|
||||
/// Retail toolbar Use button: use the current selection immediately, or arm
|
||||
/// one-shot <c>TARGET_MODE_USE</c> when no object is selected.
|
||||
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
||||
/// </summary>
|
||||
public bool UseSelectedOrEnterMode(uint selectedObjectId)
|
||||
{
|
||||
if (selectedObjectId != 0)
|
||||
return ActivateItem(selectedObjectId);
|
||||
return _interactionState.EnterUse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail toolbar Examine button: appraise the current selection immediately,
|
||||
/// or arm one-shot <c>TARGET_MODE_EXAMINE</c> when no object is selected.
|
||||
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
||||
/// </summary>
|
||||
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
|
||||
{
|
||||
if (selectedObjectId == 0)
|
||||
return _interactionState.EnterExamine();
|
||||
if (_sendExamine is null)
|
||||
return false;
|
||||
_sendExamine(selectedObjectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ActivateItem(uint itemGuid)
|
||||
{
|
||||
if (itemGuid == 0) return false;
|
||||
|
|
@ -264,7 +316,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public void CancelTargetMode()
|
||||
{
|
||||
if (!IsTargetModeActive) return;
|
||||
if (!IsAnyTargetModeActive) return;
|
||||
ClearTargetMode();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,13 +45,19 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private static readonly uint[] CombatIndicatorIds =
|
||||
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
private const uint PanelIdAttribute = 0x10000029u;
|
||||
private const uint UseButtonId = 0x1000019Du;
|
||||
private const uint ExamineButtonId = 0x100001A5u;
|
||||
private const uint InventoryButtonId = 0x100001B1u;
|
||||
private static readonly uint[] PanelButtonIds =
|
||||
{ 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId };
|
||||
|
||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||
private readonly UiButton? _characterButton;
|
||||
private readonly UiButton? _inventoryButton;
|
||||
private readonly UiButton? _useButton;
|
||||
private readonly UiButton? _examineButton;
|
||||
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
|
||||
private readonly ClientObjectTable _repo;
|
||||
private readonly CombatState? _combatState;
|
||||
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
|
||||
|
|
@ -63,6 +69,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly Action<uint>? _selectItem;
|
||||
private readonly Func<uint> _selectedObjectId;
|
||||
private readonly Func<uint>? _playerGuid;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private bool _disposed;
|
||||
|
|
@ -94,6 +101,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null,
|
||||
Func<uint>? selectedObjectId = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
|
|
@ -109,6 +117,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
_selectItem = selectItem;
|
||||
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
||||
_playerGuid = playerGuid;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
|
||||
|
|
@ -140,7 +149,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
button.OnClick = toggleCombat;
|
||||
}
|
||||
|
||||
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
|
||||
foreach (uint elementId in PanelButtonIds)
|
||||
{
|
||||
if (layout.FindElement(elementId) is UiButton panelButton
|
||||
&& panelButton.TryGetEnumAttribute(PanelIdAttribute, out uint panelId))
|
||||
_panelButtons.Add((panelId, panelButton));
|
||||
}
|
||||
|
||||
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
|
|
@ -152,6 +167,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
|
||||
}
|
||||
|
||||
_useButton = layout.FindElement(UseButtonId) as UiButton;
|
||||
_examineButton = layout.FindElement(ExamineButtonId) as UiButton;
|
||||
if (_useButton is not null)
|
||||
_useButton.OnClick = () => _itemInteraction?.UseSelectedOrEnterMode(_selectedObjectId());
|
||||
if (_examineButton is not null)
|
||||
_examineButton.OnClick = () => _itemInteraction?.ExamineSelectedOrEnterMode(_selectedObjectId());
|
||||
|
||||
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
|
||||
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
|
||||
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
|
||||
|
|
@ -240,42 +262,54 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null,
|
||||
Func<uint>? selectedObjectId = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits, itemInteraction,
|
||||
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
|
||||
playerGuid, sendPutItemInContainer);
|
||||
selectedObjectId, playerGuid, sendPutItemInContainer);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
|
||||
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
|
||||
/// <summary>
|
||||
/// Binds all seven authored panel buttons by DAT panel id. Buttons whose panel
|
||||
/// is not registered are ghosted, matching retail unavailable-panel behavior.
|
||||
/// </summary>
|
||||
public void BindPanelButtons(Func<uint, bool> isAvailable, Action<uint> togglePanel)
|
||||
{
|
||||
if (_inventoryButton is not null)
|
||||
ArgumentNullException.ThrowIfNull(isAvailable);
|
||||
ArgumentNullException.ThrowIfNull(togglePanel);
|
||||
|
||||
foreach (var (panelId, button) in _panelButtons)
|
||||
{
|
||||
_inventoryButton.OnClick = () =>
|
||||
bool available = isAvailable(panelId);
|
||||
button.Enabled = available;
|
||||
button.OnClick = !available ? null : () =>
|
||||
{
|
||||
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||
toggleInventory?.Invoke();
|
||||
if (ReferenceEquals(button, _inventoryButton)
|
||||
&& _itemInteraction?.OfferSelfPrimaryClick()
|
||||
is not null and not ItemPrimaryClickResult.NotActive)
|
||||
return;
|
||||
togglePanel(panelId);
|
||||
};
|
||||
}
|
||||
if (_characterButton is not null)
|
||||
_characterButton.OnClick = toggleCharacter;
|
||||
}
|
||||
|
||||
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
|
||||
|
||||
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
|
||||
|
||||
private static void SetButtonOpen(UiButton? button, bool open)
|
||||
public void SetPanelOpen(uint panelId, bool open)
|
||||
{
|
||||
if (button is not null)
|
||||
button.TrySetRetailState(open
|
||||
// gmToolbarUI::RecvNotice_SetPanelVisibility @ 0x004BD300 maps visible
|
||||
// to state 6 (Highlight) and hidden to state 1 (Normal).
|
||||
foreach (var entry in _panelButtons)
|
||||
{
|
||||
if (entry.PanelId != panelId || !entry.Button.Enabled) continue;
|
||||
entry.Button.TrySetRetailState(open
|
||||
? UiButtonStateMachine.Highlight
|
||||
: UiButtonStateMachine.Normal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -584,6 +618,21 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var (_, button) in _panelButtons)
|
||||
button.OnClick = null;
|
||||
foreach (var indicator in _combatIndicators)
|
||||
if (indicator is UiButton button)
|
||||
button.OnClick = null;
|
||||
if (_useButton is not null)
|
||||
_useButton.OnClick = null;
|
||||
if (_examineButton is not null)
|
||||
_examineButton.OnClick = null;
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
_inventoryButton.OnItemDragOver = null;
|
||||
_inventoryButton.OnItemDrop = null;
|
||||
}
|
||||
|
||||
if (_combatState is not null)
|
||||
_combatState.CombatModeChanged -= SetCombatMode;
|
||||
_repo.ObjectAdded -= OnRepositoryObjectChanged;
|
||||
|
|
|
|||
48
src/AcDream.App/UI/RetailPanelCatalog.cs
Normal file
48
src/AcDream.App/UI/RetailPanelCatalog.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
|
||||
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
|
||||
/// seven buttons and reads this property from each one.
|
||||
/// Only panels mounted in the retained runtime are mapped; other authored
|
||||
/// toolbar buttons remain present but ghosted until their panel ships.
|
||||
/// </summary>
|
||||
public static class RetailPanelCatalog
|
||||
{
|
||||
public const uint Inventory = 7u;
|
||||
public const uint Character = 11u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(Inventory, WindowNames.Inventory),
|
||||
(Character, WindowNames.Character),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||
|
||||
public static bool TryGetWindowName(uint panelId, out string windowName)
|
||||
{
|
||||
foreach (var entry in Mounted)
|
||||
{
|
||||
if (entry.PanelId != panelId) continue;
|
||||
windowName = entry.WindowName;
|
||||
return true;
|
||||
}
|
||||
|
||||
windowName = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryGetPanelId(string windowName, out uint panelId)
|
||||
{
|
||||
foreach (var entry in Mounted)
|
||||
{
|
||||
if (!string.Equals(entry.WindowName, windowName, StringComparison.Ordinal)) continue;
|
||||
panelId = entry.PanelId;
|
||||
return true;
|
||||
}
|
||||
|
||||
panelId = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +128,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
||||
if (bindings.Persistence is { } persistence)
|
||||
|
|
@ -197,22 +199,34 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public void RestoreLayout() => _persistence?.RestoreAll();
|
||||
|
||||
public bool ToggleWindow(string name)
|
||||
{
|
||||
bool visible = Host.ToggleWindow(name);
|
||||
SyncToolbarWindowButtons();
|
||||
return visible;
|
||||
}
|
||||
=> Host.ToggleWindow(name);
|
||||
|
||||
public void CloseWindow(string name)
|
||||
{
|
||||
Host.HideWindow(name);
|
||||
SyncToolbarWindowButtons();
|
||||
}
|
||||
=> Host.HideWindow(name);
|
||||
|
||||
public void SyncToolbarWindowButtons()
|
||||
{
|
||||
ToolbarController?.SetInventoryOpen(Host.IsWindowVisible(WindowNames.Inventory));
|
||||
ToolbarController?.SetCharacterOpen(Host.IsWindowVisible(WindowNames.Character));
|
||||
if (ToolbarController is null) return;
|
||||
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
|
||||
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
|
||||
}
|
||||
|
||||
private void BindToolbarPanelButtons()
|
||||
{
|
||||
ToolbarController?.BindPanelButtons(
|
||||
panelId => RetailPanelCatalog.TryGetWindowName(panelId, out string name)
|
||||
&& Host.WindowManager.TryGet(name, out _),
|
||||
panelId =>
|
||||
{
|
||||
if (RetailPanelCatalog.TryGetWindowName(panelId, out string name))
|
||||
ToggleWindow(name);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnWindowVisibilityChanged(string windowName, bool visible)
|
||||
{
|
||||
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
|
||||
ToolbarController?.SetPanelOpen(panelId, visible);
|
||||
}
|
||||
|
||||
private ImportedLayout? Import(uint layoutId)
|
||||
|
|
@ -374,6 +388,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
|
||||
toggleCombat: b.ToggleCombat,
|
||||
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
|
||||
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
|
||||
playerGuid: b.PlayerGuid,
|
||||
sendPutItemInContainer: b.SendPutItemInContainer);
|
||||
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
|
||||
|
|
@ -420,9 +435,6 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
SelectedObjectController),
|
||||
});
|
||||
ConfigureToolbarCollapse(layout, (UiCollapsibleFrame)handle.OuterFrame, root.Height);
|
||||
ToolbarController.BindWindowToggles(
|
||||
() => ToggleWindow(WindowNames.Inventory),
|
||||
() => ToggleWindow(WindowNames.Character));
|
||||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||
}
|
||||
|
||||
|
|
@ -635,6 +647,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_persistence?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
Host.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public sealed class RetailWindowManager : IDisposable
|
|||
|
||||
public bool IsLocked => _root.UiLocked;
|
||||
public IReadOnlyCollection<RetailWindowHandle> Windows => _byName.Values;
|
||||
public event Action<string, bool>? WindowVisibilityChanged;
|
||||
|
||||
public RetailWindowHandle Register(
|
||||
string name,
|
||||
|
|
@ -233,6 +234,7 @@ public sealed class RetailWindowManager : IDisposable
|
|||
}
|
||||
|
||||
handle.NotifyVisibility(visible);
|
||||
WindowVisibilityChanged?.Invoke(handle.Name, visible);
|
||||
}
|
||||
|
||||
private void OnKeyboardFocusChanged(UiElement? oldFocus, UiElement? newFocus)
|
||||
|
|
|
|||
|
|
@ -123,6 +123,20 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads a resolved enum-valued DAT attribute from this button.</summary>
|
||||
public bool TryGetEnumAttribute(uint propertyId, out uint value)
|
||||
{
|
||||
if (_info.TryGetEffectiveProperty(propertyId, out var property)
|
||||
&& property.Kind == UiPropertyKind.Enum)
|
||||
{
|
||||
value = checked((uint)property.UnsignedValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override string ActiveCursorStateName => ActiveState;
|
||||
|
||||
public bool TrySetRetailState(uint stateId)
|
||||
|
|
|
|||
|
|
@ -1331,6 +1331,13 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
|
||||
}
|
||||
|
||||
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
|
||||
public void SendAppraise(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
|
||||
}
|
||||
|
||||
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
|
||||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
||||
|
|
|
|||
|
|
@ -157,6 +157,33 @@ public sealed class CursorFeedbackControllerTests
|
|||
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericUseAndExamineModes_driveRetailGlobalCursors()
|
||||
{
|
||||
var objects = SeedTargetObjects();
|
||||
var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null);
|
||||
var c = new CursorFeedbackController(interaction);
|
||||
|
||||
Assert.True(interaction.UseSelectedOrEnterMode(0));
|
||||
Assert.Equal(RetailGlobalCursorKind.Use,
|
||||
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
|
||||
Assert.Equal(RetailGlobalCursorKind.UseFound,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
|
||||
|
||||
interaction.CancelTargetMode();
|
||||
Assert.True(interaction.ExamineSelectedOrEnterMode(0));
|
||||
Assert.Equal(RetailGlobalCursorKind.Examine,
|
||||
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
|
||||
Assert.Equal(RetailGlobalCursorKind.ExamineFound,
|
||||
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
|
||||
}
|
||||
|
||||
public static TheoryData<CursorFeedbackSnapshot, RetailGlobalCursorKind> GlobalCursorCases => new()
|
||||
{
|
||||
{ new(), RetailGlobalCursorKind.Default },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public sealed class ItemInteractionControllerTests
|
|||
{
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly List<uint> Uses = new();
|
||||
public readonly List<uint> Examines = new();
|
||||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||
public readonly List<uint> Drops = new();
|
||||
|
|
@ -45,6 +46,7 @@ public sealed class ItemInteractionControllerTests
|
|||
Objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: Uses.Add,
|
||||
sendExamine: Examines.Add,
|
||||
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||
sendDrop: Drops.Add,
|
||||
|
|
@ -116,6 +118,38 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarUse_withoutSelection_armsOneShotUseMode()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A10u, item => item.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.UseSelectedOrEnterMode(0u));
|
||||
Assert.True(h.Controller.IsAnyTargetModeActive);
|
||||
Assert.Equal(InteractionModeKind.Use, h.Controller.InteractionState.Current.Kind);
|
||||
|
||||
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
||||
h.Controller.OfferPrimaryClick(0x50000A10u));
|
||||
Assert.Equal(new[] { 0x50000A10u }, h.Uses);
|
||||
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarExamine_usesSelectionOrArmsOneShotExamineMode()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
|
||||
Assert.Equal(new[] { Player }, h.Examines);
|
||||
|
||||
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
|
||||
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
|
||||
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
||||
h.Controller.OfferPrimaryClick(Pack));
|
||||
Assert.Equal(new[] { Player, Pack }, h.Examines);
|
||||
Assert.False(h.Controller.IsAnyTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,6 +20,18 @@ public class ToolbarControllerTests
|
|||
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
private const uint InventoryButtonId = 0x100001B1u;
|
||||
private const uint UseButtonId = 0x1000019Du;
|
||||
private const uint ExamineButtonId = 0x100001A5u;
|
||||
private static readonly (uint ElementId, uint PanelId)[] PanelButtons =
|
||||
{
|
||||
(0x10000197u, 12u),
|
||||
(0x10000198u, 13u),
|
||||
(CharacterButtonId, RetailPanelCatalog.Character),
|
||||
(0x1000055Au, 25u),
|
||||
(0x1000019Au, 16u),
|
||||
(0x1000019Bu, 10u),
|
||||
(InventoryButtonId, RetailPanelCatalog.Inventory),
|
||||
};
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
|
||||
Dictionary<uint, UiElement> indicators) FakeToolbar()
|
||||
|
|
@ -37,8 +49,10 @@ public class ToolbarControllerTests
|
|||
AddButton(id);
|
||||
indicators[id] = dict[id];
|
||||
}
|
||||
AddButton(CharacterButtonId);
|
||||
AddButton(InventoryButtonId);
|
||||
foreach (var (elementId, panelId) in PanelButtons)
|
||||
AddButton(elementId, panelId);
|
||||
AddButton(UseButtonId);
|
||||
AddButton(ExamineButtonId);
|
||||
return (new ImportedLayout(root, dict), slots, indicators);
|
||||
|
||||
void AddSlot(uint id)
|
||||
|
|
@ -47,7 +61,7 @@ public class ToolbarControllerTests
|
|||
dict[id] = list; slots[id] = list; root.AddChild(list);
|
||||
}
|
||||
|
||||
void AddButton(uint id)
|
||||
void AddButton(uint id, uint? panelId = null)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
|
|
@ -59,6 +73,25 @@ public class ToolbarControllerTests
|
|||
};
|
||||
info.StateMedia["Normal"] = (0x1u, 1);
|
||||
info.StateMedia["Highlight"] = (0x2u, 1);
|
||||
info.StateMedia["Ghosted"] = (0x3u, 1);
|
||||
if (panelId is { } value)
|
||||
{
|
||||
info.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{
|
||||
Id = UiStateInfo.DirectStateId,
|
||||
Properties = new UiPropertyBag
|
||||
{
|
||||
Values =
|
||||
{
|
||||
[0x10000029u] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Enum,
|
||||
UnsignedValue = value,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||
dict[id] = button;
|
||||
root.AddChild(button);
|
||||
|
|
@ -140,25 +173,30 @@ public class ToolbarControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowToggleButtons_clickCallbacks_fire()
|
||||
public void PanelButtons_bindByDatPanelId_andGhostUnavailablePanels()
|
||||
{
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
int inventoryClicks = 0;
|
||||
int characterClicks = 0;
|
||||
var toggles = new List<uint>();
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
ctrl.BindWindowToggles(
|
||||
toggleInventory: () => inventoryClicks++,
|
||||
toggleCharacter: () => characterClicks++);
|
||||
ctrl.BindPanelButtons(
|
||||
panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
|
||||
toggles.Add);
|
||||
|
||||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(1, inventoryClicks);
|
||||
Assert.Equal(1, characterClicks);
|
||||
Assert.Equal(new[] { RetailPanelCatalog.Inventory, RetailPanelCatalog.Character }, toggles);
|
||||
foreach (var (elementId, panelId) in PanelButtons)
|
||||
{
|
||||
var button = (UiButton)layout.FindElement(elementId)!;
|
||||
Assert.Equal(
|
||||
panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
|
||||
button.Enabled);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -195,9 +233,9 @@ public class ToolbarControllerTests
|
|||
iconIds: (_,_,_,_,_) => 0u,
|
||||
useItem: _ => { },
|
||||
itemInteraction: interaction);
|
||||
ctrl.BindWindowToggles(
|
||||
toggleInventory: () => inventoryClicks++,
|
||||
toggleCharacter: () => { });
|
||||
ctrl.BindPanelButtons(
|
||||
panelId => panelId == RetailPanelCatalog.Inventory,
|
||||
_ => inventoryClicks++);
|
||||
interaction.ActivateItem(kit);
|
||||
|
||||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
|
@ -271,19 +309,82 @@ public class ToolbarControllerTests
|
|||
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
|
||||
|
||||
ctrl.SetInventoryOpen(true);
|
||||
ctrl.SetCharacterOpen(true);
|
||||
ctrl.BindPanelButtons(_ => true, _ => { });
|
||||
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, true);
|
||||
ctrl.SetPanelOpen(RetailPanelCatalog.Character, true);
|
||||
|
||||
Assert.Equal("Highlight", inventoryButton.ActiveState);
|
||||
Assert.Equal("Highlight", characterButton.ActiveState);
|
||||
|
||||
ctrl.SetInventoryOpen(false);
|
||||
ctrl.SetCharacterOpen(false);
|
||||
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, false);
|
||||
ctrl.SetPanelOpen(RetailPanelCatalog.Character, false);
|
||||
|
||||
Assert.Equal("Normal", inventoryButton.ActiveState);
|
||||
Assert.Equal("Normal", characterButton.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
|
||||
foreach (var (elementId, panelId) in PanelButtons)
|
||||
{
|
||||
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
|
||||
Assert.True(button.TryGetEnumAttribute(0x10000029u, out uint actual));
|
||||
Assert.Equal(panelId, actual);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseAndExamineButtons_dispatchSelectedObjectPolicy()
|
||||
{
|
||||
const uint player = 0x50000001u;
|
||||
const uint pack = 0x50000002u;
|
||||
const uint item = 0x50000003u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||||
repo.MoveItem(pack, player, 0);
|
||||
repo.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = item,
|
||||
Type = ItemType.Misc,
|
||||
Useability = ItemUseability.Contained,
|
||||
});
|
||||
repo.MoveItem(item, pack, 0);
|
||||
var uses = new List<uint>();
|
||||
var examines = new List<uint>();
|
||||
uint selected = item;
|
||||
var interaction = new ItemInteractionController(
|
||||
repo,
|
||||
() => player,
|
||||
sendUse: uses.Add,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
sendExamine: examines.Add);
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { },
|
||||
itemInteraction: interaction,
|
||||
selectedObjectId: () => selected);
|
||||
|
||||
((UiButton)layout.FindElement(UseButtonId)!).OnClick!.Invoke();
|
||||
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(new[] { item }, uses);
|
||||
Assert.Equal(new[] { item }, examines);
|
||||
|
||||
selected = 0;
|
||||
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
|
||||
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
|
||||
}
|
||||
|
||||
// ── C1: combat-mode indicator tests ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -201,6 +201,31 @@ public sealed class RetailWindowManagerTests
|
|||
Assert.Equal(1, controller.DisposeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibilityEvent_tracksEveryRegisteredWindowTransition()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var inventory = new UiPanel { Width = 100, Height = 100 };
|
||||
root.AddChild(inventory);
|
||||
root.RegisterWindow("inventory", inventory);
|
||||
var transitions = new List<(string Name, bool Visible)>();
|
||||
root.WindowManager.WindowVisibilityChanged +=
|
||||
(name, visible) => transitions.Add((name, visible));
|
||||
|
||||
Assert.True(root.HideWindow("inventory"));
|
||||
Assert.True(root.ShowWindow("inventory"));
|
||||
Assert.False(root.ToggleWindow("inventory"));
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
("inventory", false),
|
||||
("inventory", true),
|
||||
("inventory", false),
|
||||
},
|
||||
transitions);
|
||||
}
|
||||
|
||||
private sealed class RecordingController : IRetainedPanelController
|
||||
{
|
||||
public int ShownCount { get; private set; }
|
||||
|
|
|
|||
|
|
@ -168,4 +168,16 @@ public sealed class WorldSessionCombatTests
|
|||
InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u),
|
||||
captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendAppraise_UsesRetailBuilder()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendAppraise(0x5000000Au);
|
||||
|
||||
Assert.Equal(AppraiseRequest.Build(1u, 0x5000000Au), captured);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue