feat(ui): centralize retail selection state
This commit is contained in:
parent
c7607f019c
commit
7983309d23
30 changed files with 591 additions and 108 deletions
|
|
@ -102,7 +102,11 @@ retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
||||||
controllers. Neither presentation stack owns independent game-state truth.
|
controllers. Neither presentation stack owns independent game-state truth.
|
||||||
Plugins register retained gameplay markup through the BCL-only
|
Plugins register retained gameplay markup through the BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
||||||
assemblies.
|
assemblies. Core `SelectionState` is the sole selected-object owner for world,
|
||||||
|
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||||
|
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||||
|
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
||||||
|
must never become a competing selection owner.
|
||||||
|
|
||||||
Every retained gameplay window has a typed `RetailWindowHandle` and at most one
|
Every retained gameplay window has a typed `RetailWindowHandle` and at most one
|
||||||
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
|
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
|
||||||
|
|
@ -164,6 +168,7 @@ src/
|
||||||
IPluginHost.cs -> done
|
IPluginHost.cs -> done
|
||||||
IGameState.cs -> done
|
IGameState.cs -> done
|
||||||
IEvents.cs -> done
|
IEvents.cs -> done
|
||||||
|
ISelectionService.cs -> done
|
||||||
|
|
||||||
AcDream.App/ Layer 1 + Layer 4 wiring
|
AcDream.App/ Layer 1 + Layer 4 wiring
|
||||||
Rendering/
|
Rendering/
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,9 @@ What `GameWindow` loses:
|
||||||
`LiveSessionController`.
|
`LiveSessionController`.
|
||||||
- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
|
- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
|
||||||
translation → moved into `LiveEntityRuntime`.
|
translation → moved into `LiveEntityRuntime`.
|
||||||
- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` → moved
|
- `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved
|
||||||
into `SelectionInteractionController`.
|
into `SelectionInteractionController`; Core `SelectionState` remains the
|
||||||
|
already-shipped session owner and is injected into that controller.
|
||||||
- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
|
- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
|
||||||
|
|
||||||
The eventual `GameEntity` aggregation (target state described in
|
The eventual `GameEntity` aggregation (target state described in
|
||||||
|
|
@ -289,7 +290,7 @@ All four M1 demo targets must still work.
|
||||||
|
|
||||||
### Step 4 — `SelectionInteractionController`
|
### Step 4 — `SelectionInteractionController`
|
||||||
|
|
||||||
**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`,
|
**Scope:** Extract `WorldPicker`, the Core `SelectionState` consumers, `SendUse`,
|
||||||
`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
|
`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
|
||||||
`SelectionPickUp` switch cases into one controller. Depends on Step 3
|
`SelectionPickUp` switch cases into one controller. Depends on Step 3
|
||||||
(uses `LiveEntityRuntime`).
|
(uses `LiveEntityRuntime`).
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 99 active rows
|
## 3. Documented approximation (AP) — 98 active rows
|
||||||
|
|
||||||
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
||||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||||
|
|
@ -162,8 +162,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
|
||||||
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
|
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
|
||||||
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
|
||||||
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
|
||||||
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
|
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by `_openContainer` plus Core `SelectionState.SelectedObjectId`, reproducing the retail keying (`item.itemID == openContainerId / selectedID` per `UpdateOpenContainerIndicator 0x004e3070` / `SetSelectedObject 0x0058c2e0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ACCWeenieObject::SetSelectedObject` 0x0058c2e0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
|
||||||
| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` |
|
|
||||||
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
|
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
|
||||||
| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt |
|
| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt |
|
||||||
| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` |
|
| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` |
|
||||||
|
|
|
||||||
|
|
@ -481,7 +481,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
||||||
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
|
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
|
||||||
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
|
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
|
||||||
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan.
|
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, and **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅ are shipped. Build order from here: **finish Wave 3 item policy/router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate.
|
||||||
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
||||||
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
||||||
- **✓ 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 — 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.
|
||||||
|
|
|
||||||
|
|
@ -979,8 +979,9 @@ implementation patch.
|
||||||
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
|
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
|
||||||
the complete `DetermineUseResult` classification, container post-actions,
|
the complete `DetermineUseResult` classification, container post-actions,
|
||||||
`AttemptPlaceIn3D`, and retail busy semantics.
|
`AttemptPlaceIn3D`, and retail busy semantics.
|
||||||
6. Selection remains owned by `GameWindow._selectedGuid`; the previously documented
|
6. **Resolved 2026-07-11:** selection is owned by Core `SelectionState` and shared
|
||||||
`Core.SelectionState` file is absent in this worktree.
|
by world/radar/inventory/paperdoll/toolbar/plugins; the former
|
||||||
|
`GameWindow._selectedGuid` and inventory-local selected item are gone.
|
||||||
7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog
|
7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog
|
||||||
also includes `0x01..0x06` and `0x0A..0x0F`.
|
also includes `0x01..0x06` and `0x0A..0x0F`.
|
||||||
8. Current cursor resolution uses a modern static priority chain. Retail instead
|
8. Current cursor resolution uses a modern static priority chain. Retail instead
|
||||||
|
|
|
||||||
|
|
@ -316,15 +316,24 @@ chrome and content together; full suite and user visual gate.
|
||||||
|
|
||||||
### 3.1 Centralize selection
|
### 3.1 Centralize selection
|
||||||
|
|
||||||
- [ ] Add Core `SelectionState` with selected guid, previous guid, reason/source,
|
- [x] Add Core `SelectionState` with selected guid, previous guid, reason/source,
|
||||||
and one change event.
|
and one change event.
|
||||||
- [ ] Route world pick, radar click, inventory selection, paperdoll selection,
|
- [x] Route world pick, radar click, inventory selection, paperdoll selection,
|
||||||
toolbar selected strip, target indicator, examine/use, and plugins through it.
|
toolbar selected strip, target indicator, examine/use, and plugins through it.
|
||||||
- [ ] Delete panel-local competing selection state except transient hover/row focus.
|
- [x] Delete panel-local competing selection state except transient hover/row focus.
|
||||||
- [ ] Add one App interaction-state owner for `None`, `Use`, `Examine`, and
|
- [x] Add one App interaction-state owner for `None`, `Use`, `Examine`, and
|
||||||
`UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target
|
`UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target
|
||||||
mode remains UI orchestration state.
|
mode remains UI orchestration state.
|
||||||
|
|
||||||
|
**Implementation (2026-07-11):** Core `SelectionState` ports
|
||||||
|
`ACCWeenieObject::SetSelectedObject @ 0x0058C2E0`, including deduplication,
|
||||||
|
previous/previous-valid tracking, object-removal clear, and plugin callbacks.
|
||||||
|
World pick, radar, combat targeting, inventory, paperdoll slots, the selected
|
||||||
|
toolbar strip, target indicator, use/pickup, PreviousSelection, and the new
|
||||||
|
`IPluginHost.Selection` contract share that owner. `InteractionState` separately
|
||||||
|
owns `None`/`Use`/`Examine`/`UseItemOnTarget`; `ItemInteractionController` now
|
||||||
|
projects its pending source from that state. AP-58 retired.
|
||||||
|
|
||||||
### 3.2 Port item-use/drop legality into Core
|
### 3.2 Port item-use/drop legality into Core
|
||||||
|
|
||||||
- [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and
|
- [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,19 @@ window mounts, toolbar digit and inventory-cell asset lookup, plugin mounts,
|
||||||
cursor feedback, persistence, automation, and retained tick/draw/restore/dispose
|
cursor feedback, persistence, automation, and retained tick/draw/restore/dispose
|
||||||
paths live behind the runtime. The live Debug gate passed across two graceful
|
paths live behind the runtime. The live Debug gate passed across two graceful
|
||||||
restart cycles. Do not add panel mount bodies back to `GameWindow`.
|
restart cycles. Do not add panel mount bodies back to `GameWindow`.
|
||||||
|
|
||||||
|
## 2026-07-11 selection and interaction ownership
|
||||||
|
|
||||||
|
`AcDream.Core.Selection.SelectionState` is the only selected-object truth. It
|
||||||
|
ports retail `ACCWeenieObject::SetSelectedObject`: deduplicate the current guid,
|
||||||
|
remember previous and previous-valid guids, commit before firing one typed
|
||||||
|
transition, and clear with the removed object retained as previous. World pick,
|
||||||
|
radar, combat targeting, inventory, paperdoll slots, toolbar selected-object
|
||||||
|
presentation, target indicator, use/pickup, PreviousSelection, and plugins all
|
||||||
|
read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event
|
||||||
|
failures are isolated from the client and other plugins.
|
||||||
|
|
||||||
|
`AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration:
|
||||||
|
`None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a
|
||||||
|
projection of this state, not a second selected-object field. Keep selection in
|
||||||
|
Core and interaction modes in App when completing Wave 3.
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,23 @@ namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
public sealed class AppPluginHost : IPluginHost
|
public sealed class AppPluginHost : IPluginHost
|
||||||
{
|
{
|
||||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
public AppPluginHost(
|
||||||
|
IPluginLogger log,
|
||||||
|
IGameState state,
|
||||||
|
IEvents events,
|
||||||
|
ISelectionService selection,
|
||||||
|
IUiRegistry ui)
|
||||||
{
|
{
|
||||||
Log = log;
|
Log = log;
|
||||||
State = state;
|
State = state;
|
||||||
Events = events;
|
Events = events;
|
||||||
|
Selection = selection;
|
||||||
Ui = ui;
|
Ui = ui;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPluginLogger Log { get; }
|
public IPluginLogger Log { get; }
|
||||||
public IGameState State { get; }
|
public IGameState State { get; }
|
||||||
public IEvents Events { get; }
|
public IEvents Events { get; }
|
||||||
|
public ISelectionService Selection { get; }
|
||||||
public IUiRegistry Ui { get; }
|
public IUiRegistry Ui { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||||
|
|
||||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||||
|
var selection = new AcDream.Core.Selection.SelectionState();
|
||||||
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
var host = new AppPluginHost(
|
||||||
|
new SerilogAdapter(Log.Logger), worldGameState, worldEvents, selection, uiRegistry);
|
||||||
|
|
||||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||||
|
|
@ -65,7 +67,8 @@ try
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
using var window = new GameWindow(
|
||||||
|
runtimeOptions, worldGameState, worldEvents, selection, uiRegistry);
|
||||||
window.Run();
|
window.Run();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly string _datDir;
|
private readonly string _datDir;
|
||||||
private readonly WorldGameState _worldGameState;
|
private readonly WorldGameState _worldGameState;
|
||||||
private readonly WorldEvents _worldEvents;
|
private readonly WorldEvents _worldEvents;
|
||||||
|
private readonly AcDream.Core.Selection.SelectionState _selection;
|
||||||
private IWindow? _window;
|
private IWindow? _window;
|
||||||
private GL? _gl;
|
private GL? _gl;
|
||||||
private IInputContext? _input;
|
private IInputContext? _input;
|
||||||
|
|
@ -1032,24 +1033,6 @@ public sealed class GameWindow : IDisposable
|
||||||
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
||||||
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
|
|
||||||
private uint? _selectedGuid;
|
|
||||||
/// <summary>Fires when the selected world object changes (retail gmToolbarUI selection-change event,
|
|
||||||
/// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes.</summary>
|
|
||||||
private event Action<uint?>? SelectionChanged;
|
|
||||||
/// <summary>Currently-selected world object guid. The setter fires <see cref="SelectionChanged"/> only on
|
|
||||||
/// an actual change (dedup), so all writes go through here; reads may use the field directly.</summary>
|
|
||||||
private uint? SelectedGuid
|
|
||||||
{
|
|
||||||
get => _selectedGuid;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (_selectedGuid == value) return;
|
|
||||||
_selectedGuid = value;
|
|
||||||
SelectionChanged?.Invoke(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
||||||
// once the local auto-walk overlay reports arrival (body has finished
|
// once the local auto-walk overlay reports arrival (body has finished
|
||||||
// rotating to face the target). Only set for close-range Use/PickUp;
|
// rotating to face the target). Only set for close-range Use/PickUp;
|
||||||
|
|
@ -1085,13 +1068,18 @@ public sealed class GameWindow : IDisposable
|
||||||
private int _liveAnimRejectSingleFrame;
|
private int _liveAnimRejectSingleFrame;
|
||||||
private int _liveAnimRejectPartFrames;
|
private int _liveAnimRejectPartFrames;
|
||||||
|
|
||||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
|
public GameWindow(
|
||||||
|
AcDream.App.RuntimeOptions options,
|
||||||
|
WorldGameState worldGameState,
|
||||||
|
WorldEvents worldEvents,
|
||||||
|
AcDream.Core.Selection.SelectionState selection,
|
||||||
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||||
_datDir = options.DatDir;
|
_datDir = options.DatDir;
|
||||||
_worldGameState = worldGameState;
|
_worldGameState = worldGameState;
|
||||||
_worldEvents = worldEvents;
|
_worldEvents = worldEvents;
|
||||||
|
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
|
||||||
_uiRegistry = uiRegistry;
|
_uiRegistry = uiRegistry;
|
||||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||||
|
|
@ -1525,13 +1513,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// B.7 Vivid Target Indicator — corner-triangle highlight
|
// B.7 Vivid Target Indicator — corner-triangle highlight
|
||||||
// around the currently-selected entity. Delegates pull
|
// around the currently-selected entity. Delegates pull
|
||||||
// live state from this GameWindow instance every frame:
|
// live state from this GameWindow instance every frame:
|
||||||
// - selected guid → _selectedGuid (set by PickAndStoreSelection)
|
// - selected guid → shared SelectionState
|
||||||
// - entity resolver → position from _entitiesByServerGuid +
|
// - entity resolver → position from _entitiesByServerGuid +
|
||||||
// itemType from ClientObjectTable (Objects) + last spawn
|
// itemType from ClientObjectTable (Objects) + last spawn
|
||||||
// - camera → _cameraController.Active or (zero) when not
|
// - camera → _cameraController.Active or (zero) when not
|
||||||
// yet ready, in which case the panel bails on viewport==0.
|
// yet ready, in which case the panel bails on viewport==0.
|
||||||
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
|
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
|
||||||
selectedGuidProvider: () => _selectedGuid,
|
selectedGuidProvider: () => _selection.SelectedObjectId,
|
||||||
entityResolver: guid =>
|
entityResolver: guid =>
|
||||||
{
|
{
|
||||||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
|
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
|
||||||
|
|
@ -2078,7 +2066,7 @@ public sealed class GameWindow : IDisposable
|
||||||
playerGuid: () => _playerServerGuid,
|
playerGuid: () => _playerServerGuid,
|
||||||
playerYawRadians: () => _playerController?.Yaw ?? 0f,
|
playerYawRadians: () => _playerController?.Yaw ?? 0f,
|
||||||
playerCellId: () => _playerController?.CellId ?? 0u,
|
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||||
selectedGuid: () => _selectedGuid,
|
selectedGuid: () => _selection.SelectedObjectId,
|
||||||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||||
uiLocked: () => _persistedGameplay.LockUI);
|
uiLocked: () => _persistedGameplay.LockUI);
|
||||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||||
|
|
@ -2103,7 +2091,7 @@ public sealed class GameWindow : IDisposable
|
||||||
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
AcDream.UI.Abstractions.NullCommandBus.Instance),
|
||||||
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
Radar: new AcDream.App.UI.RadarRuntimeBindings(
|
||||||
radarSnapshotProvider.BuildSnapshot,
|
radarSnapshotProvider.BuildSnapshot,
|
||||||
guid => SelectedGuid = guid,
|
_selection,
|
||||||
SetRetailUiLocked),
|
SetRetailUiLocked),
|
||||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||||
Objects,
|
Objects,
|
||||||
|
|
@ -2114,8 +2102,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_itemInteractionController,
|
_itemInteractionController,
|
||||||
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
|
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
|
||||||
index => _liveSession?.SendRemoveShortcut(index),
|
index => _liveSession?.SendRemoveShortcut(index),
|
||||||
handler => SelectionChanged += handler,
|
_selection,
|
||||||
handler => SelectionChanged -= handler,
|
|
||||||
handler => Combat.HealthChanged += handler,
|
handler => Combat.HealthChanged += handler,
|
||||||
handler => Combat.HealthChanged -= handler,
|
handler => Combat.HealthChanged -= handler,
|
||||||
IsHealthBarTarget,
|
IsHealthBarTarget,
|
||||||
|
|
@ -2137,7 +2124,8 @@ public sealed class GameWindow : IDisposable
|
||||||
(item, container, placement) =>
|
(item, container, placement) =>
|
||||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||||
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||||
_itemInteractionController),
|
_itemInteractionController,
|
||||||
|
_selection),
|
||||||
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
||||||
cursorFeedbackController,
|
cursorFeedbackController,
|
||||||
retailCursorManager),
|
retailCursorManager),
|
||||||
|
|
@ -4478,8 +4466,12 @@ public sealed class GameWindow : IDisposable
|
||||||
_remoteLastMove.Remove(serverGuid);
|
_remoteLastMove.Remove(serverGuid);
|
||||||
_entitiesByServerGuid.Remove(serverGuid);
|
_entitiesByServerGuid.Remove(serverGuid);
|
||||||
_lastSpawnByGuid.Remove(serverGuid);
|
_lastSpawnByGuid.Remove(serverGuid);
|
||||||
if (_selectedGuid == serverGuid)
|
if (_selection.SelectedObjectId == serverGuid)
|
||||||
SelectedGuid = null;
|
{
|
||||||
|
_selection.Clear(
|
||||||
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
|
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||||
|
}
|
||||||
|
|
||||||
// A7 indoor lighting: release this entity's owned lights on EVERY
|
// A7 indoor lighting: release this entity's owned lights on EVERY
|
||||||
// removal, including the respawn-dedup path (former logDelete=false).
|
// removal, including the respawn-dedup path (former logDelete=false).
|
||||||
|
|
@ -11874,6 +11866,10 @@ public sealed class GameWindow : IDisposable
|
||||||
SelectClosestCombatTarget(showToast: true);
|
SelectClosestCombatTarget(showToast: true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection:
|
||||||
|
_selection.SelectPrevious();
|
||||||
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||||
ToggleLiveCombatMode();
|
ToggleLiveCombatMode();
|
||||||
break;
|
break;
|
||||||
|
|
@ -11903,7 +11899,7 @@ public sealed class GameWindow : IDisposable
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
||||||
if (_selectedGuid is uint pickupTarget)
|
if (_selection.SelectedObjectId is uint pickupTarget)
|
||||||
SendPickUp(pickupTarget);
|
SendPickUp(pickupTarget);
|
||||||
else
|
else
|
||||||
_debugVm?.AddToast("Nothing selected");
|
_debugVm?.AddToast("Nothing selected");
|
||||||
|
|
@ -11979,7 +11975,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private uint? GetSelectedOrClosestCombatTarget()
|
private uint? GetSelectedOrClosestCombatTarget()
|
||||||
{
|
{
|
||||||
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected))
|
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
|
||||||
return selected;
|
return selected;
|
||||||
|
|
||||||
return SelectClosestCombatTarget(showToast: false);
|
return SelectClosestCombatTarget(showToast: false);
|
||||||
|
|
@ -12066,7 +12062,7 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedGuid = guid;
|
_selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World);
|
||||||
string label = DescribeLiveEntity(guid);
|
string label = DescribeLiveEntity(guid);
|
||||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||||
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can
|
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can
|
||||||
|
|
@ -12107,7 +12103,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void UseCurrentSelection()
|
private void UseCurrentSelection()
|
||||||
{
|
{
|
||||||
if (_selectedGuid is not uint sel)
|
if (_selection.SelectedObjectId is not uint sel)
|
||||||
{
|
{
|
||||||
_debugVm?.AddToast("Nothing selected");
|
_debugVm?.AddToast("Nothing selected");
|
||||||
return;
|
return;
|
||||||
|
|
@ -12503,7 +12499,10 @@ public sealed class GameWindow : IDisposable
|
||||||
bestGuid = guid;
|
bestGuid = guid;
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedGuid = bestGuid;
|
if (bestGuid is { } selection)
|
||||||
|
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||||
|
else
|
||||||
|
_selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||||
if (bestGuid is { } selected)
|
if (bestGuid is { } selected)
|
||||||
{
|
{
|
||||||
string label = DescribeLiveEntity(selected);
|
string label = DescribeLiveEntity(selected);
|
||||||
|
|
@ -13592,6 +13591,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_retailUiRuntime?.Dispose();
|
_retailUiRuntime?.Dispose();
|
||||||
_retailUiRuntime = null;
|
_retailUiRuntime = null;
|
||||||
_uiHost = null;
|
_uiHost = null;
|
||||||
|
_itemInteractionController?.Dispose();
|
||||||
|
_itemInteractionController = null;
|
||||||
|
|
||||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||||
// state. The worker may still be processing a load job that references
|
// state. The worker may still be processing a load job that references
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ public static class FixtureProvider
|
||||||
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
|
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
|
||||||
|
|
||||||
var iconIds = MakeIconIds(stack);
|
var iconIds = MakeIconIds(stack);
|
||||||
|
var selection = new AcDream.Core.Selection.SelectionState();
|
||||||
|
|
||||||
InventoryController.Bind(
|
InventoryController.Bind(
|
||||||
layout,
|
layout,
|
||||||
|
|
@ -120,6 +121,7 @@ public static class FixtureProvider
|
||||||
playerGuid: () => SampleData.PlayerGuid,
|
playerGuid: () => SampleData.PlayerGuid,
|
||||||
iconIds: iconIds,
|
iconIds: iconIds,
|
||||||
strength: () => 100,
|
strength: () => 100,
|
||||||
|
selection: selection,
|
||||||
datFont: stack.VitalsDatFont,
|
datFont: stack.VitalsDatFont,
|
||||||
contentsEmptySprite: contentsEmpty,
|
contentsEmptySprite: contentsEmpty,
|
||||||
sideBagEmptySprite: sideBagEmpty,
|
sideBagEmptySprite: sideBagEmpty,
|
||||||
|
|
@ -133,6 +135,7 @@ public static class FixtureProvider
|
||||||
objects,
|
objects,
|
||||||
playerGuid: () => SampleData.PlayerGuid,
|
playerGuid: () => SampleData.PlayerGuid,
|
||||||
iconIds: iconIds,
|
iconIds: iconIds,
|
||||||
|
selection: selection,
|
||||||
sendWield: null, // no live session in the studio
|
sendWield: null, // no live session in the studio
|
||||||
emptySlotSprite: contentsEmpty,
|
emptySlotSprite: contentsEmpty,
|
||||||
datFont: stack.VitalsDatFont);
|
datFont: stack.VitalsDatFont);
|
||||||
|
|
|
||||||
54
src/AcDream.App/UI/InteractionState.cs
Normal file
54
src/AcDream.App/UI/InteractionState.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
public enum InteractionModeKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Use,
|
||||||
|
Examine,
|
||||||
|
UseItemOnTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct InteractionMode(
|
||||||
|
InteractionModeKind Kind,
|
||||||
|
uint SourceObjectId = 0)
|
||||||
|
{
|
||||||
|
public static InteractionMode None => new(InteractionModeKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct InteractionModeTransition(
|
||||||
|
InteractionMode Previous,
|
||||||
|
InteractionMode Current);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single App-layer owner for retail's pointer interaction mode. Core selection
|
||||||
|
/// remains session truth; this state represents temporary UI orchestration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InteractionState
|
||||||
|
{
|
||||||
|
public InteractionMode Current { get; private set; } = InteractionMode.None;
|
||||||
|
|
||||||
|
public event Action<InteractionModeTransition>? Changed;
|
||||||
|
|
||||||
|
public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use));
|
||||||
|
|
||||||
|
public bool EnterExamine() => Set(new InteractionMode(InteractionModeKind.Examine));
|
||||||
|
|
||||||
|
public bool EnterUseItemOnTarget(uint sourceObjectId)
|
||||||
|
{
|
||||||
|
if (sourceObjectId == 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(sourceObjectId));
|
||||||
|
return Set(new InteractionMode(InteractionModeKind.UseItemOnTarget, sourceObjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Clear() => Set(InteractionMode.None);
|
||||||
|
|
||||||
|
private bool Set(InteractionMode mode)
|
||||||
|
{
|
||||||
|
if (Current == mode)
|
||||||
|
return false;
|
||||||
|
InteractionMode previous = Current;
|
||||||
|
Current = mode;
|
||||||
|
Changed?.Invoke(new InteractionModeTransition(previous, mode));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ namespace AcDream.App.UI;
|
||||||
/// target acquisition, and drag-out drops here instead of duplicating
|
/// target acquisition, and drag-out drops here instead of duplicating
|
||||||
/// ItemHolder::UseObject fragments in each panel.
|
/// ItemHolder::UseObject fragments in each panel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ItemInteractionController
|
public sealed class ItemInteractionController : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly EquipMask[] AutoEquipOrder =
|
private static readonly EquipMask[] AutoEquipOrder =
|
||||||
{
|
{
|
||||||
|
|
@ -55,8 +55,10 @@ public sealed class ItemInteractionController
|
||||||
private readonly Action<uint, uint>? _sendWield;
|
private readonly Action<uint, uint>? _sendWield;
|
||||||
private readonly Action<uint>? _sendDrop;
|
private readonly Action<uint>? _sendDrop;
|
||||||
private readonly Action<string>? _toast;
|
private readonly Action<string>? _toast;
|
||||||
|
private readonly InteractionState _interactionState;
|
||||||
|
|
||||||
private long _lastUseMs = long.MinValue / 2;
|
private long _lastUseMs = long.MinValue / 2;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public ItemInteractionController(
|
public ItemInteractionController(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
|
|
@ -66,7 +68,8 @@ public sealed class ItemInteractionController
|
||||||
Action<uint, uint>? sendWield,
|
Action<uint, uint>? sendWield,
|
||||||
Action<uint>? sendDrop,
|
Action<uint>? sendDrop,
|
||||||
Func<long>? nowMs = null,
|
Func<long>? nowMs = null,
|
||||||
Action<string>? toast = null)
|
Action<string>? toast = null,
|
||||||
|
InteractionState? interactionState = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
|
@ -76,15 +79,23 @@ public sealed class ItemInteractionController
|
||||||
_sendDrop = sendDrop;
|
_sendDrop = sendDrop;
|
||||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||||
_toast = toast;
|
_toast = toast;
|
||||||
|
_interactionState = interactionState ?? new InteractionState();
|
||||||
|
_interactionState.Changed += OnInteractionModeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event Action? StateChanged;
|
public event Action? StateChanged;
|
||||||
|
|
||||||
public uint PlayerGuid => _playerGuid();
|
public uint PlayerGuid => _playerGuid();
|
||||||
|
|
||||||
public uint PendingSourceItem { get; private set; }
|
public InteractionState InteractionState => _interactionState;
|
||||||
|
|
||||||
public bool IsTargetModeActive => PendingSourceItem != 0;
|
public uint PendingSourceItem
|
||||||
|
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
||||||
|
? mode.SourceObjectId
|
||||||
|
: 0u;
|
||||||
|
|
||||||
|
public bool IsTargetModeActive
|
||||||
|
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
|
||||||
|
|
||||||
public bool IsPendingSource(uint itemGuid)
|
public bool IsPendingSource(uint itemGuid)
|
||||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||||
|
|
@ -188,8 +199,7 @@ public sealed class ItemInteractionController
|
||||||
|
|
||||||
private void EnterTargetMode(uint sourceGuid)
|
private void EnterTargetMode(uint sourceGuid)
|
||||||
{
|
{
|
||||||
PendingSourceItem = sourceGuid;
|
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
||||||
StateChanged?.Invoke();
|
|
||||||
var name = _objects.Get(sourceGuid)?.Name;
|
var name = _objects.Get(sourceGuid)?.Name;
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
_toast?.Invoke($"Choose a target for the {name}");
|
_toast?.Invoke($"Choose a target for the {name}");
|
||||||
|
|
@ -197,8 +207,17 @@ public sealed class ItemInteractionController
|
||||||
|
|
||||||
private void ClearTargetMode()
|
private void ClearTargetMode()
|
||||||
{
|
{
|
||||||
PendingSourceItem = 0;
|
_interactionState.Clear();
|
||||||
StateChanged?.Invoke();
|
}
|
||||||
|
|
||||||
|
private void OnInteractionModeChanged(InteractionModeTransition _)
|
||||||
|
=> StateChanged?.Invoke();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_interactionState.Changed -= OnInteractionModeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ConsumeUseThrottle()
|
private bool ConsumeUseThrottle()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
|
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
|
||||||
|
|
||||||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
private readonly SelectionState _selection;
|
||||||
private readonly Action<uint>? _sendUse;
|
private readonly Action<uint>? _sendUse;
|
||||||
private readonly Action<uint>? _sendNoLongerViewing;
|
private readonly Action<uint>? _sendNoLongerViewing;
|
||||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||||
|
|
@ -70,6 +71,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
Func<uint> playerGuid,
|
Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||||
Func<int?> strength,
|
Func<int?> strength,
|
||||||
|
SelectionState selection,
|
||||||
Func<string>? ownerName,
|
Func<string>? ownerName,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
uint contentsEmptySprite,
|
uint contentsEmptySprite,
|
||||||
|
|
@ -90,6 +92,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
_sendNoLongerViewing = sendNoLongerViewing;
|
_sendNoLongerViewing = sendNoLongerViewing;
|
||||||
_sendPutItemInContainer = sendPutItemInContainer;
|
_sendPutItemInContainer = sendPutItemInContainer;
|
||||||
_itemInteraction = itemInteraction;
|
_itemInteraction = itemInteraction;
|
||||||
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||||
|
|
||||||
WindowChromeController.BindCloseButton(layout, onClose);
|
WindowChromeController.BindCloseButton(layout, onClose);
|
||||||
|
|
||||||
|
|
@ -152,8 +155,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
// Rebuild on any change to the player's possessions.
|
// Rebuild on any change to the player's possessions.
|
||||||
_objects.ObjectAdded += OnObjectChanged;
|
_objects.ObjectAdded += OnObjectChanged;
|
||||||
_objects.ObjectMoved += OnObjectMoved;
|
_objects.ObjectMoved += OnObjectMoved;
|
||||||
_objects.ObjectRemoved += OnObjectChanged;
|
_objects.ObjectRemoved += OnObjectRemoved;
|
||||||
_objects.ObjectUpdated += OnObjectChanged;
|
_objects.ObjectUpdated += OnObjectChanged;
|
||||||
|
_selection.Changed += OnSelectionChanged;
|
||||||
if (_itemInteraction is not null)
|
if (_itemInteraction is not null)
|
||||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||||
|
|
||||||
|
|
@ -184,6 +188,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
Func<uint> playerGuid,
|
Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||||
Func<int?> strength,
|
Func<int?> strength,
|
||||||
|
SelectionState selection,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<string>? ownerName = null,
|
Func<string>? ownerName = null,
|
||||||
uint contentsEmptySprite = 0u,
|
uint contentsEmptySprite = 0u,
|
||||||
|
|
@ -194,14 +199,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||||
ItemInteractionController? itemInteraction = null,
|
ItemInteractionController? itemInteraction = null,
|
||||||
Action? onClose = null)
|
Action? onClose = null)
|
||||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
|
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
|
||||||
|
ownerName, datFont,
|
||||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
|
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
|
||||||
|
onClose);
|
||||||
|
|
||||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||||
|
private void OnObjectRemoved(ClientObject o)
|
||||||
|
{
|
||||||
|
if (_selection.SelectedObjectId == o.ObjectId)
|
||||||
|
{
|
||||||
|
_selection.Clear(
|
||||||
|
SelectionChangeSource.System,
|
||||||
|
SelectionChangeReason.SelectedObjectRemoved);
|
||||||
|
}
|
||||||
|
if (Concerns(o)) Populate();
|
||||||
|
}
|
||||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||||
|
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||||
|
|
||||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||||
private bool Concerns(ClientObject o)
|
private bool Concerns(ClientObject o)
|
||||||
|
|
@ -402,8 +420,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
private void SelectItem(uint guid)
|
private void SelectItem(uint guid)
|
||||||
{
|
{
|
||||||
if (guid == 0) return;
|
if (guid == 0) return;
|
||||||
_selectedItem = guid;
|
_selection.Select(guid, SelectionChangeSource.Inventory);
|
||||||
ApplyIndicators();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||||
|
|
@ -413,7 +430,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
private void OpenContainer(uint guid)
|
private void OpenContainer(uint guid)
|
||||||
{
|
{
|
||||||
if (guid == 0) return;
|
if (guid == 0) return;
|
||||||
_selectedItem = guid; // the container cell is also the selected item
|
_selection.Select(guid, SelectionChangeSource.Inventory);
|
||||||
uint open = EffectiveOpen();
|
uint open = EffectiveOpen();
|
||||||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||||
|
|
||||||
|
|
@ -444,7 +461,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
var cell = list.GetItem(i);
|
var cell = list.GetItem(i);
|
||||||
if (cell is null) continue;
|
if (cell is null) continue;
|
||||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
|
cell.Selected = cell.ItemId != 0
|
||||||
|
&& cell.ItemId == _selection.SelectedObjectId
|
||||||
|
&& !pendingTargetSource;
|
||||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -533,8 +552,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_objects.ObjectAdded -= OnObjectChanged;
|
_objects.ObjectAdded -= OnObjectChanged;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||||
_objects.ObjectUpdated -= OnObjectChanged;
|
_objects.ObjectUpdated -= OnObjectChanged;
|
||||||
|
_selection.Changed -= OnSelectionChanged;
|
||||||
if (_itemInteraction is not null)
|
if (_itemInteraction is not null)
|
||||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||||
private readonly ItemInteractionController? _itemInteraction;
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
private readonly SelectionState _selection;
|
||||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||||
|
|
||||||
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -86,11 +88,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
|
|
||||||
private PaperdollController(
|
private PaperdollController(
|
||||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||||
|
Action<uint, uint>? sendWield,
|
||||||
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
|
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
|
||||||
{
|
{
|
||||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||||
_itemInteraction = itemInteraction;
|
_itemInteraction = itemInteraction;
|
||||||
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||||
|
|
||||||
for (int i = 0; i < SlotMap.Length; i++)
|
for (int i = 0; i < SlotMap.Length; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -100,7 +104,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||||
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
||||||
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
||||||
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
list.Cell.Clicked = () =>
|
||||||
|
{
|
||||||
|
if (_itemInteraction?.IsTargetModeActive == true)
|
||||||
|
{
|
||||||
|
_itemInteraction.AcquireSelfTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (list.Cell.ItemId != 0)
|
||||||
|
_selection.Select(list.Cell.ItemId, SelectionChangeSource.Paperdoll);
|
||||||
|
};
|
||||||
list.Cell.DoubleClicked = () =>
|
list.Cell.DoubleClicked = () =>
|
||||||
{
|
{
|
||||||
if (list.Cell.ItemId != 0)
|
if (list.Cell.ItemId != 0)
|
||||||
|
|
@ -119,6 +132,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
_objects.ObjectMoved += OnObjectMoved;
|
_objects.ObjectMoved += OnObjectMoved;
|
||||||
_objects.ObjectRemoved += OnObjectChanged;
|
_objects.ObjectRemoved += OnObjectChanged;
|
||||||
_objects.ObjectUpdated += OnObjectChanged;
|
_objects.ObjectUpdated += OnObjectChanged;
|
||||||
|
_selection.Changed += OnSelectionChanged;
|
||||||
|
|
||||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||||
foreach (var id in ArmorSlotElementIds)
|
foreach (var id in ArmorSlotElementIds)
|
||||||
|
|
@ -154,14 +168,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
|
|
||||||
public static PaperdollController Bind(
|
public static PaperdollController Bind(
|
||||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
|
||||||
|
Action<uint, uint>? sendWield = null,
|
||||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||||
ItemInteractionController? itemInteraction = null)
|
ItemInteractionController? itemInteraction = null)
|
||||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
|
=> new PaperdollController(
|
||||||
|
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
|
||||||
|
datFont, itemInteraction);
|
||||||
|
|
||||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||||
|
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||||
|
|
||||||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||||
|
|
@ -196,6 +214,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
||||||
list.Cell.SetItem(worn.ObjectId, tex);
|
list.Cell.SetItem(worn.ObjectId, tex);
|
||||||
}
|
}
|
||||||
|
ApplySelectionIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySelectionIndicators()
|
||||||
|
{
|
||||||
|
foreach (var (_, list) in _slots)
|
||||||
|
{
|
||||||
|
list.Cell.Selected = list.Cell.ItemId != 0
|
||||||
|
&& list.Cell.ItemId == _selection.SelectedObjectId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private EquipMask MaskFor(UiItemList list)
|
private EquipMask MaskFor(UiItemList list)
|
||||||
|
|
@ -251,5 +279,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectChanged;
|
||||||
_objects.ObjectUpdated -= OnObjectChanged;
|
_objects.ObjectUpdated -= OnObjectChanged;
|
||||||
|
_selection.Changed -= OnSelectionChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -82,7 +83,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
private readonly Func<uint, bool> _hasHealth;
|
private readonly Func<uint, bool> _hasHealth;
|
||||||
private readonly Func<uint, uint> _stackSize;
|
private readonly Func<uint, uint> _stackSize;
|
||||||
private readonly Action<uint> _sendQueryHealth;
|
private readonly Action<uint> _sendQueryHealth;
|
||||||
private readonly Action<Action<uint?>> _unsubscribeSelectionChanged;
|
private readonly SelectionState _selection;
|
||||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||||
|
|
||||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||||
|
|
@ -96,8 +97,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
|
|
||||||
private SelectedObjectController(
|
private SelectedObjectController(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Action<Action<uint?>> subscribeSelectionChanged,
|
SelectionState selection,
|
||||||
Action<Action<uint?>> unsubscribeSelectionChanged,
|
|
||||||
Action<Action<uint, float>> subscribeHealthChanged,
|
Action<Action<uint, float>> subscribeHealthChanged,
|
||||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||||
Func<uint, bool> isHealthTarget,
|
Func<uint, bool> isHealthTarget,
|
||||||
|
|
@ -114,7 +114,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
_hasHealth = hasHealth;
|
_hasHealth = hasHealth;
|
||||||
_stackSize = stackSize;
|
_stackSize = stackSize;
|
||||||
_sendQueryHealth = sendQueryHealth;
|
_sendQueryHealth = sendQueryHealth;
|
||||||
_unsubscribeSelectionChanged = unsubscribeSelectionChanged;
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||||
|
|
||||||
// Find elements — silently skip absent ones (partial/test layouts).
|
// Find elements — silently skip absent ones (partial/test layouts).
|
||||||
|
|
@ -171,8 +171,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the handlers LAST so the initial state is fully set up first.
|
// Register the handlers LAST so the initial state is fully set up first.
|
||||||
subscribeSelectionChanged(OnSelectionChanged);
|
_selection.Changed += OnSelectionTransition;
|
||||||
subscribeHealthChanged(OnHealthChanged);
|
subscribeHealthChanged(OnHealthChanged);
|
||||||
|
if (_selection.SelectedObjectId is { } initial)
|
||||||
|
ApplySelection(initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -180,8 +182,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
|
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||||||
/// <param name="subscribeSelectionChanged">Called once with <see cref="OnSelectionChanged"/>
|
/// <param name="selection">The single Core selected-object owner.</param>
|
||||||
/// (typical host: <c>h => SelectionChanged += h</c>).</param>
|
|
||||||
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
|
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
|
||||||
/// (typical host: <c>h => Combat.HealthChanged += h</c>) — drives meter visibility.</param>
|
/// (typical host: <c>h => Combat.HealthChanged += h</c>) — drives meter visibility.</param>
|
||||||
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
|
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
|
||||||
|
|
@ -195,8 +196,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
||||||
public static SelectedObjectController Bind(
|
public static SelectedObjectController Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Action<Action<uint?>> subscribeSelectionChanged,
|
SelectionState selection,
|
||||||
Action<Action<uint?>> unsubscribeSelectionChanged,
|
|
||||||
Action<Action<uint, float>> subscribeHealthChanged,
|
Action<Action<uint, float>> subscribeHealthChanged,
|
||||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||||
Func<uint, bool> isHealthTarget,
|
Func<uint, bool> isHealthTarget,
|
||||||
|
|
@ -207,7 +207,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
Action<uint> sendQueryHealth,
|
Action<uint> sendQueryHealth,
|
||||||
UiDatFont? datFont)
|
UiDatFont? datFont)
|
||||||
=> new SelectedObjectController(
|
=> new SelectedObjectController(
|
||||||
layout, subscribeSelectionChanged, unsubscribeSelectionChanged,
|
layout, selection,
|
||||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||||
/// clear-then-populate the selected-object strip on any selection change.
|
/// clear-then-populate the selected-object strip on any selection change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void OnSelectionChanged(uint? guid)
|
private void ApplySelection(uint? guid)
|
||||||
{
|
{
|
||||||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||||||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||||||
|
|
@ -279,11 +279,14 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
_overlay?.TrySetRetailState(state);
|
_overlay?.TrySetRetailState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSelectionTransition(SelectionTransition transition)
|
||||||
|
=> ApplySelection(transition.SelectedObjectId);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_unsubscribeSelectionChanged(OnSelectionChanged);
|
_selection.Changed -= OnSelectionTransition;
|
||||||
_unsubscribeHealthChanged(OnHealthChanged);
|
_unsubscribeHealthChanged(OnHealthChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using AcDream.App.UI.Testing;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Settings;
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
@ -31,7 +32,7 @@ public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func<ICommandBus> Com
|
||||||
|
|
||||||
public sealed record RadarRuntimeBindings(
|
public sealed record RadarRuntimeBindings(
|
||||||
Func<UiRadarSnapshot> Snapshot,
|
Func<UiRadarSnapshot> Snapshot,
|
||||||
Action<uint> SelectObject,
|
SelectionState Selection,
|
||||||
Action<bool> SetUiLocked);
|
Action<bool> SetUiLocked);
|
||||||
|
|
||||||
public sealed record ToolbarRuntimeBindings(
|
public sealed record ToolbarRuntimeBindings(
|
||||||
|
|
@ -43,8 +44,7 @@ public sealed record ToolbarRuntimeBindings(
|
||||||
ItemInteractionController ItemInteraction,
|
ItemInteractionController ItemInteraction,
|
||||||
Action<uint, uint>? SendAddShortcut,
|
Action<uint, uint>? SendAddShortcut,
|
||||||
Action<uint>? SendRemoveShortcut,
|
Action<uint>? SendRemoveShortcut,
|
||||||
Action<Action<uint?>> SubscribeSelectionChanged,
|
SelectionState Selection,
|
||||||
Action<Action<uint?>> UnsubscribeSelectionChanged,
|
|
||||||
Action<Action<uint, float>> SubscribeHealthChanged,
|
Action<Action<uint, float>> SubscribeHealthChanged,
|
||||||
Action<Action<uint, float>> UnsubscribeHealthChanged,
|
Action<Action<uint, float>> UnsubscribeHealthChanged,
|
||||||
Func<uint, bool> IsHealthTarget,
|
Func<uint, bool> IsHealthTarget,
|
||||||
|
|
@ -65,7 +65,8 @@ public sealed record InventoryRuntimeBindings(
|
||||||
Action<uint>? SendNoLongerViewing,
|
Action<uint>? SendNoLongerViewing,
|
||||||
Action<uint, uint, int>? SendPutItemInContainer,
|
Action<uint, uint, int>? SendPutItemInContainer,
|
||||||
Action<uint, uint>? SendWield,
|
Action<uint, uint>? SendWield,
|
||||||
ItemInteractionController ItemInteraction);
|
ItemInteractionController ItemInteraction,
|
||||||
|
SelectionState Selection);
|
||||||
|
|
||||||
public sealed record RetailUiPersistenceBindings(
|
public sealed record RetailUiPersistenceBindings(
|
||||||
SettingsStore Store,
|
SettingsStore Store,
|
||||||
|
|
@ -256,7 +257,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
RadarController controller = RadarController.Bind(
|
RadarController controller = RadarController.Bind(
|
||||||
layout,
|
layout,
|
||||||
_bindings.Radar.Snapshot,
|
_bindings.Radar.Snapshot,
|
||||||
_bindings.Radar.SelectObject,
|
guid => _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar),
|
||||||
setUiLocked: _bindings.Radar.SetUiLocked,
|
setUiLocked: _bindings.Radar.SetUiLocked,
|
||||||
datFont: _bindings.Assets.DefaultFont);
|
datFont: _bindings.Assets.DefaultFont);
|
||||||
RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite,
|
RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite,
|
||||||
|
|
@ -354,8 +355,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut);
|
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut);
|
||||||
SelectedObjectController = Layout.SelectedObjectController.Bind(
|
SelectedObjectController = Layout.SelectedObjectController.Bind(
|
||||||
layout,
|
layout,
|
||||||
b.SubscribeSelectionChanged,
|
b.Selection,
|
||||||
b.UnsubscribeSelectionChanged,
|
|
||||||
b.SubscribeHealthChanged,
|
b.SubscribeHealthChanged,
|
||||||
b.UnsubscribeHealthChanged,
|
b.UnsubscribeHealthChanged,
|
||||||
b.IsHealthTarget,
|
b.IsHealthTarget,
|
||||||
|
|
@ -570,13 +570,13 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
InventoryRuntimeBindings b = _bindings.Inventory;
|
InventoryRuntimeBindings b = _bindings.Inventory;
|
||||||
InventoryController inventory = InventoryController.Bind(
|
InventoryController inventory = InventoryController.Bind(
|
||||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength,
|
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||||
b.SendPutItemInContainer, b.ItemInteraction,
|
b.SendPutItemInContainer, b.ItemInteraction,
|
||||||
() => CloseWindow(WindowNames.Inventory));
|
() => CloseWindow(WindowNames.Inventory));
|
||||||
PaperdollController paperdoll = PaperdollController.Bind(
|
PaperdollController paperdoll = PaperdollController.Bind(
|
||||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.SendWield,
|
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
||||||
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
||||||
Host.WindowManager.AttachController(
|
Host.WindowManager.AttachController(
|
||||||
WindowNames.Inventory,
|
WindowNames.Inventory,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ namespace AcDream.App.UI;
|
||||||
/// on internal state types:
|
/// on internal state types:
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>selectedGuidProvider</c> — host's current <c>_selectedGuid</c>.</item>
|
/// <item><c>selectedGuidProvider</c> — Core <c>SelectionState</c>'s current guid.</item>
|
||||||
/// <item><c>entityResolver</c> — returns
|
/// <item><c>entityResolver</c> — returns
|
||||||
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
|
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
|
||||||
/// the entity is no longer in the world (despawned).</item>
|
/// the entity is no longer in the world (despawned).</item>
|
||||||
|
|
|
||||||
106
src/AcDream.Core/Selection/SelectionState.cs
Normal file
106
src/AcDream.Core/Selection/SelectionState.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Selection;
|
||||||
|
|
||||||
|
public enum SelectionChangeSource
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
World,
|
||||||
|
Radar,
|
||||||
|
Inventory,
|
||||||
|
Paperdoll,
|
||||||
|
Toolbar,
|
||||||
|
Keyboard,
|
||||||
|
Plugin,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SelectionChangeReason
|
||||||
|
{
|
||||||
|
Selected,
|
||||||
|
Cleared,
|
||||||
|
SelectedObjectRemoved,
|
||||||
|
PreviousSelection,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct SelectionTransition(
|
||||||
|
uint? PreviousObjectId,
|
||||||
|
uint? SelectedObjectId,
|
||||||
|
SelectionChangeSource Source,
|
||||||
|
SelectionChangeReason Reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single owner for the client's selected object. This ports the state behavior
|
||||||
|
/// of retail <c>ACCWeenieObject::SetSelectedObject @ 0x0058C2E0</c>: changes are
|
||||||
|
/// deduplicated, the old selection becomes the previous selection, and plugin/UI
|
||||||
|
/// listeners receive one transition after state is committed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelectionState : ISelectionService
|
||||||
|
{
|
||||||
|
private event Action<SelectionChangedEvent>? PluginChanged;
|
||||||
|
|
||||||
|
public uint? SelectedObjectId { get; private set; }
|
||||||
|
public uint? PreviousObjectId { get; private set; }
|
||||||
|
public uint? PreviousValidObjectId { get; private set; }
|
||||||
|
|
||||||
|
public event Action<SelectionTransition>? Changed;
|
||||||
|
|
||||||
|
event Action<SelectionChangedEvent> ISelectionService.Changed
|
||||||
|
{
|
||||||
|
add => PluginChanged += value;
|
||||||
|
remove => PluginChanged -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Select(
|
||||||
|
uint objectId,
|
||||||
|
SelectionChangeSource source,
|
||||||
|
SelectionChangeReason reason = SelectionChangeReason.Selected)
|
||||||
|
=> objectId == 0
|
||||||
|
? Clear(source)
|
||||||
|
: Set(objectId, source, reason);
|
||||||
|
|
||||||
|
public bool Clear(
|
||||||
|
SelectionChangeSource source,
|
||||||
|
SelectionChangeReason reason = SelectionChangeReason.Cleared)
|
||||||
|
=> Set(null, source, reason);
|
||||||
|
|
||||||
|
public bool SelectPrevious(SelectionChangeSource source = SelectionChangeSource.Keyboard)
|
||||||
|
=> PreviousObjectId is uint previous
|
||||||
|
&& Set(previous, source, SelectionChangeReason.PreviousSelection);
|
||||||
|
|
||||||
|
bool ISelectionService.Select(uint objectId)
|
||||||
|
=> Select(objectId, SelectionChangeSource.Plugin);
|
||||||
|
|
||||||
|
bool ISelectionService.Clear()
|
||||||
|
=> Clear(SelectionChangeSource.Plugin);
|
||||||
|
|
||||||
|
private bool Set(
|
||||||
|
uint? selectedObjectId,
|
||||||
|
SelectionChangeSource source,
|
||||||
|
SelectionChangeReason reason)
|
||||||
|
{
|
||||||
|
if (SelectedObjectId == selectedObjectId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint? old = SelectedObjectId;
|
||||||
|
SelectedObjectId = selectedObjectId;
|
||||||
|
PreviousObjectId = old;
|
||||||
|
if (old is not null)
|
||||||
|
PreviousValidObjectId = old;
|
||||||
|
|
||||||
|
var transition = new SelectionTransition(old, selectedObjectId, source, reason);
|
||||||
|
Changed?.Invoke(transition);
|
||||||
|
DispatchPluginChanged(new SelectionChangedEvent(old, selectedObjectId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchPluginChanged(SelectionChangedEvent change)
|
||||||
|
{
|
||||||
|
Action<SelectionChangedEvent>? listeners = PluginChanged;
|
||||||
|
if (listeners is null) return;
|
||||||
|
foreach (Action<SelectionChangedEvent> listener in listeners.GetInvocationList())
|
||||||
|
{
|
||||||
|
try { listener(change); }
|
||||||
|
catch { /* plugin exceptions never escape into client state changes */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,5 +10,6 @@ public interface IPluginHost
|
||||||
IPluginLogger Log { get; }
|
IPluginLogger Log { get; }
|
||||||
IGameState State { get; }
|
IGameState State { get; }
|
||||||
IEvents Events { get; }
|
IEvents Events { get; }
|
||||||
|
ISelectionService Selection { get; }
|
||||||
IUiRegistry Ui { get; }
|
IUiRegistry Ui { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/AcDream.Plugin.Abstractions/ISelectionService.cs
Normal file
19
src/AcDream.Plugin.Abstractions/ISelectionService.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>A plugin-safe selection transition matching retail's selected-target callback.</summary>
|
||||||
|
public readonly record struct SelectionChangedEvent(
|
||||||
|
uint? PreviousObjectId,
|
||||||
|
uint? SelectedObjectId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared selected-object service. Plugins read and change the same selection
|
||||||
|
/// used by the world, radar, inventory, paperdoll, and toolbar.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISelectionService
|
||||||
|
{
|
||||||
|
uint? SelectedObjectId { get; }
|
||||||
|
uint? PreviousObjectId { get; }
|
||||||
|
event Action<SelectionChangedEvent> Changed;
|
||||||
|
bool Select(uint objectId);
|
||||||
|
bool Clear();
|
||||||
|
}
|
||||||
32
tests/AcDream.App.Tests/UI/InteractionStateTests.cs
Normal file
32
tests/AcDream.App.Tests/UI/InteractionStateTests.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class InteractionStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Modes_HaveOneOwnerAndOneDeduplicatedTransitionStream()
|
||||||
|
{
|
||||||
|
var state = new InteractionState();
|
||||||
|
var changes = new List<InteractionModeTransition>();
|
||||||
|
state.Changed += changes.Add;
|
||||||
|
|
||||||
|
Assert.True(state.EnterUse());
|
||||||
|
Assert.False(state.EnterUse());
|
||||||
|
Assert.True(state.EnterExamine());
|
||||||
|
Assert.True(state.EnterUseItemOnTarget(0x100u));
|
||||||
|
Assert.True(state.Clear());
|
||||||
|
|
||||||
|
Assert.Equal(4, changes.Count);
|
||||||
|
Assert.Equal(InteractionModeKind.None, state.Current.Kind);
|
||||||
|
Assert.Equal(0x100u, changes[2].Current.SourceObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseItemOnTarget_RejectsMissingSource()
|
||||||
|
{
|
||||||
|
var state = new InteractionState();
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
|
||||||
|
Assert.Equal(InteractionMode.None, state.Current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,10 @@ public sealed class ItemInteractionControllerTests
|
||||||
|
|
||||||
Assert.True(h.Controller.IsTargetModeActive);
|
Assert.True(h.Controller.IsTargetModeActive);
|
||||||
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
|
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
|
||||||
|
Assert.Equal(InteractionModeKind.UseItemOnTarget,
|
||||||
|
h.Controller.InteractionState.Current.Kind);
|
||||||
|
Assert.Equal(0x50000A01u,
|
||||||
|
h.Controller.InteractionState.Current.SourceObjectId);
|
||||||
Assert.Empty(h.UseWithTarget);
|
Assert.Empty(h.UseWithTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
@ -56,7 +57,8 @@ public class InventoryControllerTests
|
||||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||||
List<(uint item, uint container, int placement)>? puts = null,
|
List<(uint item, uint container, int placement)>? puts = null,
|
||||||
string? ownerName = null,
|
string? ownerName = null,
|
||||||
Action? onClose = null)
|
Action? onClose = null,
|
||||||
|
SelectionState? selection = null)
|
||||||
=> InventoryController.Bind(layout, objects, () => Player,
|
=> InventoryController.Bind(layout, objects, () => Player,
|
||||||
iconIds: (_, _, _, _, _) => 0u,
|
iconIds: (_, _, _, _, _) => 0u,
|
||||||
strength: () => strength, datFont: null,
|
strength: () => strength, datFont: null,
|
||||||
|
|
@ -64,7 +66,8 @@ public class InventoryControllerTests
|
||||||
sendUse: uses is null ? null : g => uses.Add(g),
|
sendUse: uses is null ? null : g => uses.Add(g),
|
||||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||||
onClose: onClose);
|
onClose: onClose,
|
||||||
|
selection: selection ?? new SelectionState());
|
||||||
|
|
||||||
private static UiButton MakeButton(uint id)
|
private static UiButton MakeButton(uint id)
|
||||||
{
|
{
|
||||||
|
|
@ -316,7 +319,8 @@ public class InventoryControllerTests
|
||||||
{
|
{
|
||||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||||
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
||||||
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
|
iconIds: (_, _, _, _, _) => 0u, strength: () => 100,
|
||||||
|
selection: new SelectionState(), datFont: null,
|
||||||
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
|
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
|
||||||
|
|
||||||
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
|
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
|
||||||
|
|
@ -414,6 +418,27 @@ public class InventoryControllerTests
|
||||||
Assert.Empty(closes);
|
Assert.Empty(closes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare()
|
||||||
|
{
|
||||||
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
SeedContained(objects, 0xAu, Player, slot: 0);
|
||||||
|
SeedContained(objects, 0xBu, Player, slot: 1);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
Bind(layout, objects, selection: selection);
|
||||||
|
|
||||||
|
grid.GetItem(0)!.Clicked!();
|
||||||
|
|
||||||
|
Assert.Equal(0xAu, selection.SelectedObjectId);
|
||||||
|
Assert.True(grid.GetItem(0)!.Selected);
|
||||||
|
|
||||||
|
selection.Select(0xBu, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
Assert.False(grid.GetItem(0)!.Selected);
|
||||||
|
Assert.True(grid.GetItem(1)!.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TargetMode_suppressesSelectedSquare_onPendingSource()
|
public void TargetMode_suppressesSelectedSquare_onPendingSource()
|
||||||
{
|
{
|
||||||
|
|
@ -439,6 +464,7 @@ public class InventoryControllerTests
|
||||||
InventoryController.Bind(layout, objects, () => Player,
|
InventoryController.Bind(layout, objects, () => Player,
|
||||||
iconIds: (_, _, _, _, _) => 0u,
|
iconIds: (_, _, _, _, _) => 0u,
|
||||||
strength: () => 100,
|
strength: () => 100,
|
||||||
|
selection: new SelectionState(),
|
||||||
datFont: null,
|
datFont: null,
|
||||||
itemInteraction: interaction);
|
itemInteraction: interaction);
|
||||||
grid.GetItem(0)!.Clicked!();
|
grid.GetItem(0)!.Clicked!();
|
||||||
|
|
@ -469,7 +495,7 @@ public class InventoryControllerTests
|
||||||
(ItemType type, uint icon)? mainPackCall = null;
|
(ItemType type, uint icon)? mainPackCall = null;
|
||||||
InventoryController.Bind(layout, objects, () => Player,
|
InventoryController.Bind(layout, objects, () => Player,
|
||||||
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
|
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
|
||||||
strength: () => 100, datFont: null);
|
strength: () => 100, selection: new SelectionState(), datFont: null);
|
||||||
|
|
||||||
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
|
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
|
||||||
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).
|
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ public class InventoryFrameImportProbe
|
||||||
playerGuid: static () => 0u,
|
playerGuid: static () => 0u,
|
||||||
iconIds: static (_, _, _, _, _) => 0u,
|
iconIds: static (_, _, _, _, _) => 0u,
|
||||||
strength: static () => 100,
|
strength: static () => 100,
|
||||||
|
selection: new AcDream.Core.Selection.SelectionState(),
|
||||||
datFont: null,
|
datFont: null,
|
||||||
onClose: () => closes++);
|
onClose: () => closes++);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
@ -34,11 +35,13 @@ public class PaperdollControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
|
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||||
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u)
|
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u,
|
||||||
|
SelectionState? selection = null)
|
||||||
=> PaperdollController.Bind(layout, objects, () => Player,
|
=> PaperdollController.Bind(layout, objects, () => Player,
|
||||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||||
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
|
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
|
||||||
emptySlotSprite: emptySlot);
|
emptySlotSprite: emptySlot,
|
||||||
|
selection: selection ?? new SelectionState());
|
||||||
|
|
||||||
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
|
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +66,21 @@ public class PaperdollControllerTests
|
||||||
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClickEquippedItem_updatesSharedSelection()
|
||||||
|
{
|
||||||
|
var (layout, lists) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
Bind(layout, objects, selection: selection);
|
||||||
|
|
||||||
|
lists[HeadSlot].Cell.Clicked!();
|
||||||
|
|
||||||
|
Assert.Equal(0xA01u, selection.SelectedObjectId);
|
||||||
|
Assert.True(lists[HeadSlot].Cell.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Populate_matches_a_weapon_into_the_composite_slot()
|
public void Populate_matches_a_weapon_into_the_composite_slot()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
@ -65,7 +66,7 @@ public class SelectedObjectControllerTests
|
||||||
|
|
||||||
private sealed class Harness
|
private sealed class Harness
|
||||||
{
|
{
|
||||||
public Action<uint?>? SelectionHandler;
|
public readonly SelectionState Selection = new();
|
||||||
public Action<uint, float>? HealthHandler;
|
public Action<uint, float>? HealthHandler;
|
||||||
public readonly List<uint> QueryHealthCalls = new();
|
public readonly List<uint> QueryHealthCalls = new();
|
||||||
|
|
||||||
|
|
@ -75,17 +76,19 @@ public class SelectedObjectControllerTests
|
||||||
public readonly Dictionary<uint, bool> HasHealthMap = new();
|
public readonly Dictionary<uint, bool> HasHealthMap = new();
|
||||||
public readonly Dictionary<uint, uint> StackMap = new();
|
public readonly Dictionary<uint, uint> StackMap = new();
|
||||||
|
|
||||||
public void FireSelection(uint? g) => SelectionHandler?.Invoke(g);
|
public void FireSelection(uint? g)
|
||||||
|
{
|
||||||
|
if (g is { } guid)
|
||||||
|
Selection.Select(guid, SelectionChangeSource.System);
|
||||||
|
else
|
||||||
|
Selection.Clear(SelectionChangeSource.System);
|
||||||
|
}
|
||||||
public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct);
|
public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct);
|
||||||
|
|
||||||
public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null)
|
public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null)
|
||||||
=> SelectedObjectController.Bind(
|
=> SelectedObjectController.Bind(
|
||||||
layout,
|
layout,
|
||||||
subscribeSelectionChanged: h => SelectionHandler = h,
|
selection: Selection,
|
||||||
unsubscribeSelectionChanged: h =>
|
|
||||||
{
|
|
||||||
if (SelectionHandler == h) SelectionHandler = null;
|
|
||||||
},
|
|
||||||
subscribeHealthChanged: h => HealthHandler = h,
|
subscribeHealthChanged: h => HealthHandler = h,
|
||||||
unsubscribeHealthChanged: h =>
|
unsubscribeHealthChanged: h =>
|
||||||
{
|
{
|
||||||
|
|
@ -362,7 +365,6 @@ public class SelectedObjectControllerTests
|
||||||
h.NameMap[0x12345678u] = "Something";
|
h.NameMap[0x12345678u] = "Something";
|
||||||
var c = h.Bind(layout);
|
var c = h.Bind(layout);
|
||||||
|
|
||||||
Assert.NotNull(h.SelectionHandler);
|
|
||||||
Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u)));
|
Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u)));
|
||||||
Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f)));
|
Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f)));
|
||||||
Assert.Null(Record.Exception(() => c.Tick(0.5)));
|
Assert.Null(Record.Exception(() => c.Tick(0.5)));
|
||||||
|
|
@ -425,7 +427,6 @@ public class SelectedObjectControllerTests
|
||||||
h.FireSelection(0xAA09u);
|
h.FireSelection(0xAA09u);
|
||||||
h.FireHealth(0xAA09u, 0.5f);
|
h.FireHealth(0xAA09u, 0.5f);
|
||||||
|
|
||||||
Assert.Null(h.SelectionHandler);
|
|
||||||
Assert.Null(h.HealthHandler);
|
Assert.Null(h.HealthHandler);
|
||||||
Assert.False(healthMeterEl.Visible);
|
Assert.False(healthMeterEl.Visible);
|
||||||
Assert.Empty(h.QueryHealthCalls);
|
Assert.Empty(h.QueryHealthCalls);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ public sealed class RetailUiInteractionFlowTests
|
||||||
public readonly List<uint> Uses = new();
|
public readonly List<uint> Uses = new();
|
||||||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||||
|
public readonly AcDream.Core.Selection.SelectionState Selection = new();
|
||||||
public readonly List<uint> Drops = new();
|
public readonly List<uint> Drops = new();
|
||||||
public long Now = 10_000;
|
public long Now = 10_000;
|
||||||
|
|
||||||
|
|
@ -149,6 +150,7 @@ public sealed class RetailUiInteractionFlowTests
|
||||||
playerGuid: () => Player,
|
playerGuid: () => Player,
|
||||||
iconIds: static (_, _, _, _, _) => 0x1234u,
|
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||||
strength: () => 100,
|
strength: () => 100,
|
||||||
|
selection: Selection,
|
||||||
datFont: null,
|
datFont: null,
|
||||||
itemInteraction: interaction);
|
itemInteraction: interaction);
|
||||||
|
|
||||||
|
|
@ -167,6 +169,7 @@ public sealed class RetailUiInteractionFlowTests
|
||||||
Objects,
|
Objects,
|
||||||
playerGuid: () => Player,
|
playerGuid: () => Player,
|
||||||
iconIds: static (_, _, _, _, _) => 0x1234u,
|
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||||
|
selection: Selection,
|
||||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||||
emptySlotSprite: 0x06004D20u);
|
emptySlotSprite: 0x06004D20u);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.Core.Plugins;
|
using AcDream.Core.Plugins;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Plugin.Abstractions;
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Plugins;
|
namespace AcDream.Core.Tests.Plugins;
|
||||||
|
|
@ -30,6 +31,7 @@ public class PluginLoaderTests
|
||||||
public IPluginLogger Log { get; } = new StubLogger();
|
public IPluginLogger Log { get; } = new StubLogger();
|
||||||
public IGameState State { get; } = new StubState();
|
public IGameState State { get; } = new StubState();
|
||||||
public IEvents Events { get; } = new StubEvents();
|
public IEvents Events { get; } = new StubEvents();
|
||||||
|
public ISelectionService Selection { get; } = new SelectionState();
|
||||||
public IUiRegistry Ui { get; } = new StubUiRegistry();
|
public IUiRegistry Ui { get; } = new StubUiRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
100
tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs
Normal file
100
tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Selection;
|
||||||
|
|
||||||
|
public sealed class SelectionStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
var transitions = new List<SelectionTransition>();
|
||||||
|
state.Changed += transitions.Add;
|
||||||
|
|
||||||
|
Assert.True(state.Select(0x50000001u, SelectionChangeSource.World));
|
||||||
|
Assert.True(state.Select(0x50000002u, SelectionChangeSource.Radar));
|
||||||
|
|
||||||
|
Assert.Equal(0x50000002u, state.SelectedObjectId);
|
||||||
|
Assert.Equal(0x50000001u, state.PreviousObjectId);
|
||||||
|
Assert.Equal(0x50000001u, state.PreviousValidObjectId);
|
||||||
|
Assert.Equal(2, transitions.Count);
|
||||||
|
Assert.Equal(SelectionChangeSource.Radar, transitions[1].Source);
|
||||||
|
Assert.Equal(SelectionChangeReason.Selected, transitions[1].Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Select_SameObject_IsDeduplicated()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
int changed = 0;
|
||||||
|
state.Changed += _ => changed++;
|
||||||
|
|
||||||
|
Assert.True(state.Select(0x50000001u, SelectionChangeSource.World));
|
||||||
|
Assert.False(state.Select(0x50000001u, SelectionChangeSource.Inventory));
|
||||||
|
|
||||||
|
Assert.Equal(1, changed);
|
||||||
|
Assert.Null(state.PreviousObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectPrevious_SwapsCurrentAndPrevious()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
state.Select(0x50000001u, SelectionChangeSource.World);
|
||||||
|
state.Select(0x50000002u, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
Assert.True(state.SelectPrevious());
|
||||||
|
|
||||||
|
Assert.Equal(0x50000001u, state.SelectedObjectId);
|
||||||
|
Assert.Equal(0x50000002u, state.PreviousObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearRemoved_PreservesRemovedObjectAsPreviousValid()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
SelectionTransition transition = default;
|
||||||
|
state.Changed += value => transition = value;
|
||||||
|
state.Select(0x50000001u, SelectionChangeSource.World);
|
||||||
|
|
||||||
|
Assert.True(state.Clear(
|
||||||
|
SelectionChangeSource.System,
|
||||||
|
SelectionChangeReason.SelectedObjectRemoved));
|
||||||
|
|
||||||
|
Assert.Null(state.SelectedObjectId);
|
||||||
|
Assert.Equal(0x50000001u, state.PreviousObjectId);
|
||||||
|
Assert.Equal(0x50000001u, state.PreviousValidObjectId);
|
||||||
|
Assert.Equal(SelectionChangeReason.SelectedObjectRemoved, transition.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginService_UsesSameStateAndEventStream()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
ISelectionService plugin = state;
|
||||||
|
SelectionChangedEvent change = default;
|
||||||
|
plugin.Changed += value => change = value;
|
||||||
|
|
||||||
|
Assert.True(plugin.Select(0x50000001u));
|
||||||
|
|
||||||
|
Assert.Equal(0x50000001u, state.SelectedObjectId);
|
||||||
|
Assert.Equal(0x50000001u, plugin.SelectedObjectId);
|
||||||
|
Assert.Null(change.PreviousObjectId);
|
||||||
|
Assert.Equal(0x50000001u, change.SelectedObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PluginListenerFailure_DoesNotEscapeOrBlockOtherPlugins()
|
||||||
|
{
|
||||||
|
var state = new SelectionState();
|
||||||
|
ISelectionService plugin = state;
|
||||||
|
int delivered = 0;
|
||||||
|
plugin.Changed += _ => throw new InvalidOperationException("plugin failure");
|
||||||
|
plugin.Changed += _ => delivered++;
|
||||||
|
|
||||||
|
Assert.Null(Record.Exception(() => plugin.Select(0x50000001u)));
|
||||||
|
Assert.Equal(1, delivered);
|
||||||
|
Assert.Equal(0x50000001u, state.SelectedObjectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue