fix(ui): select and examine favorite spells like retail
This commit is contained in:
parent
043ab10b3c
commit
3e31b0ac70
23 changed files with 974 additions and 34 deletions
|
|
@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are
|
||||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||||
Release builds; the appraisal and retained-item input corrections pass 3,548
|
Release builds; the appraisal and retained catalog-input corrections pass
|
||||||
App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
|
3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips.
|
||||||
|
|
||||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
|
|
@ -126,7 +126,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
||||||
user-accepted. Slice 3's first connected gate exposed and corrected the
|
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||||
inscription transaction. The creature and common-item report corrections are
|
inscription transaction. The creature and common-item report corrections are
|
||||||
user-accepted; press-time retained-item selection is the current visual gate.
|
user-accepted; favorite-spell press-time selection and right-click local
|
||||||
|
examination are the current visual gate.
|
||||||
|
|
||||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are
|
||||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||||
Release builds; the appraisal and retained-item input corrections pass 3,548
|
Release builds; the appraisal and retained catalog-input corrections pass
|
||||||
App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
|
3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips.
|
||||||
|
|
||||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
|
|
@ -124,7 +124,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
||||||
user-accepted. Slice 3's first connected gate exposed and corrected the
|
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||||
inscription transaction. The creature and common-item report corrections are
|
inscription transaction. The creature and common-item report corrections are
|
||||||
user-accepted; press-time retained-item selection is the current visual gate.
|
user-accepted; favorite-spell press-time selection and right-click local
|
||||||
|
examination are the current visual gate.
|
||||||
|
|
||||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,14 @@ three-pixel drag threshold. Target mode is intercepted at that same press.
|
||||||
Bag opening, item use/equip, and shortcut activation remain completed
|
Bag opening, item use/equip, and shortcut activation remain completed
|
||||||
click/double-click actions, so a drag or target-consumed press cannot also
|
click/double-click actions, so a drag or target-consumed press cannot also
|
||||||
activate the item.
|
activate the item.
|
||||||
|
The corresponding favorite-spell branch is now ported without conflating a
|
||||||
|
spell ID with a physical object GUID. Favorite selection occurs on left press;
|
||||||
|
right-click opens the authored local Spell examination subview with
|
||||||
|
name, school, mana, duration, range, description, and the current appropriate
|
||||||
|
component formula. It sends no Appraise request and takes no busy reference,
|
||||||
|
so the status-bar magnifier remains object-only. Switching the shared floaty
|
||||||
|
from a pending object appraisal to a spell releases exactly that appraisal
|
||||||
|
transaction before showing the SpellPanel.
|
||||||
|
|
||||||
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
||||||
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||||
|
|
@ -185,6 +193,9 @@ Right-clicking an occupied retained item cell selects and examines it without
|
||||||
using, equipping, looting, or dragging it.
|
using, equipping, looting, or dragging it.
|
||||||
Left-pressing one selects it before the button is released; holding and
|
Left-pressing one selects it before the button is released; holding and
|
||||||
dragging preserves that selection without activating the item.
|
dragging preserves that selection without activating the item.
|
||||||
|
Left-pressing a favorite spell likewise changes its selection immediately.
|
||||||
|
Right-clicking it opens the spell examination subview; selecting it and using
|
||||||
|
the status-bar magnifier does not examine the spell.
|
||||||
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
|
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
|
||||||
then its description after one retail paragraph break, without the bogus
|
then its description after one retail paragraph break, without the bogus
|
||||||
255-item/255-container line.
|
255-item/255-container line.
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||||
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
||||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||||
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# acdream — strategic roadmap
|
# acdream — strategic roadmap
|
||||||
|
|
||||||
**Status:** Living document. Updated 2026-07-23. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
**Status:** Living document. Updated 2026-07-24. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
|
||||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -38,7 +38,14 @@ selects and appraises occupied inventory, external-container, paperdoll, and
|
||||||
physical-shortcut cells through the same request owner. Its shared physical
|
physical-shortcut cells through the same request owner. Its shared physical
|
||||||
left-click contract now also matches retail's press-time selection: the
|
left-click contract now also matches retail's press-time selection: the
|
||||||
selection frame updates on MouseDown before drag promotion, while completed
|
selection frame updates on MouseDown before drag promotion, while completed
|
||||||
click/double-click remains the activation boundary. Slice 3's first connected gate exposed
|
click/double-click remains the activation boundary. The same timing is now
|
||||||
|
ported for favorite spells through a distinct catalog contract: left press
|
||||||
|
selects without waiting for release; right-click opens the examination
|
||||||
|
floaty's authored SpellPanel locally; and spell IDs never enter global object
|
||||||
|
selection or the status-bar magnifier path. The view projects retail's
|
||||||
|
name/school/mana/duration/range/description and authored appropriate-formula
|
||||||
|
component cells, including live missing-component state. Its connected visual
|
||||||
|
gate is pending. Slice 3's first connected gate exposed
|
||||||
three deeper defects, now corrected: the response enum mislabeled ACE's
|
three deeper defects, now corrected: the response enum mislabeled ACE's
|
||||||
creature bit `0x0100` and dropped monster packets while retaining the busy
|
creature bit `0x0100` and dropped monster packets while retaining the busy
|
||||||
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,12 @@ The same shared ItemList contract now ports retail's left-button-down
|
||||||
selection timing across inventory, loot, paperdoll, and physical shortcuts:
|
selection timing across inventory, loot, paperdoll, and physical shortcuts:
|
||||||
selection is visible before release or drag promotion, while use/open/equip
|
selection is visible before release or drag promotion, while use/open/equip
|
||||||
remains release-completed.
|
remains release-completed.
|
||||||
|
Favorite spells now use the parallel retail catalog contract: MouseDown
|
||||||
|
selects immediately, right-click opens the examination floaty's authored
|
||||||
|
SpellPanel locally, and no spell ID enters global object selection or the
|
||||||
|
status-bar magnifier command. Exact spell fields and the current appropriate
|
||||||
|
formula's authored component cells are automated; the connected visual gate
|
||||||
|
is pending.
|
||||||
Slice 3's first connected gate found and corrected the real remaining defects:
|
Slice 3's first connected gate found and corrected the real remaining defects:
|
||||||
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
||||||
appraisal busy reference held, retail's examination UI had been mounted as a
|
appraisal busy reference held, retail's examination UI had been mounted as a
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ shared-panel mount, and its missing inscription transaction. All three are
|
||||||
corrected and live-confirmed. The creature-page follow-up now has retail's
|
corrected and live-confirmed. The creature-page follow-up now has retail's
|
||||||
ordered stat rows and animated private preview. The follow-up item report and
|
ordered stat rows and animated private preview. The follow-up item report and
|
||||||
authored 310 x 400 layout correction are implemented; their connected visual
|
authored 310 x 400 layout correction are implemented; their connected visual
|
||||||
gate is pending.
|
gate is pending. Favorite-spell press-time selection and right-click local
|
||||||
|
SpellPanel examination are implemented and await their connected visual gate.
|
||||||
**Milestone:** M4 prerequisite/preamble.
|
**Milestone:** M4 prerequisite/preamble.
|
||||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||||
interaction state. `GameWindow` remains a composition/callback shell.
|
interaction state. `GameWindow` remains a composition/callback shell.
|
||||||
|
|
@ -249,6 +250,16 @@ and the retained green frame update during left-button down, before the
|
||||||
three-pixel drag threshold. Target mode is offered first. Opening, using,
|
three-pixel drag threshold. Target mode is offered first. Opening, using,
|
||||||
equipping, looting, and shortcut activation remain completed-click or
|
equipping, looting, and shortcut activation remain completed-click or
|
||||||
double-click actions and are suppressed when target mode consumed the press.
|
double-click actions and are suppressed when target mode consumed the press.
|
||||||
|
Favorite spells now use the parallel non-weenie path:
|
||||||
|
`gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0` selects the favorite
|
||||||
|
on left press, while the spell branch of
|
||||||
|
`UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` opens the authored
|
||||||
|
SpellPanel locally on right-click. Spell IDs never enter `SelectionState` or
|
||||||
|
the status-bar magnifier path, and no Appraise/busy transaction is invented.
|
||||||
|
The view projects exact spell fields and the current appropriate formula
|
||||||
|
through authored component template `0x1000032E`; its connected visual gate is
|
||||||
|
pending. The 61-test focused App gate, Release solution build, 3,553 App tests
|
||||||
|
/ 3 skips, and 7,984 complete-solution tests / 5 skips pass.
|
||||||
|
|
||||||
## Slice 1 — spell-bar overflow arrows
|
## Slice 1 — spell-bar overflow arrows
|
||||||
|
|
||||||
|
|
@ -371,6 +382,11 @@ Named retail references and executable pseudocode are recorded in
|
||||||
refresh; closing the examination window must stop refreshes.
|
refresh; closing the examination window must stop refreshes.
|
||||||
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
||||||
both windows coexist with independent rectangles.
|
both windows coexist with independent rectangles.
|
||||||
|
- On the combat spell bar, press and hold a different favorite: its selection
|
||||||
|
and name should update before release. Right-click a favorite: the same
|
||||||
|
examination floaty should show the SpellPanel with name, school, mana,
|
||||||
|
duration, range, description, and component formula. The toolbar magnifier
|
||||||
|
must not treat the selected spell as an object.
|
||||||
- Assess an owned inscribable weapon. Edit and clear its inscription by
|
- Assess an owned inscribable weapon. Edit and clear its inscription by
|
||||||
clicking elsewhere, then reassess it. An item authored by another player is
|
clicking elsewhere, then reassess it. An item authored by another player is
|
||||||
read-only and reports the exact retail permission line when clicked.
|
read-only and reports the exact retail permission line when clicked.
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,85 @@ Outbound character events (`CM_Character`):
|
||||||
0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32)
|
0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Favorite press selection and local spell examination
|
||||||
|
|
||||||
|
Retail:
|
||||||
|
|
||||||
|
- `gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0`
|
||||||
|
- `SpellCastSubMenu::SetSelected @ 0x004C5B00`
|
||||||
|
- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
|
||||||
|
- `ClientUISystem::ExamineSpell @ 0x00564A70`
|
||||||
|
- `gmExaminationUI::ExamineSpell @ 0x004B6900`
|
||||||
|
- `SpellExamineUI::ExamineSpell @ 0x004B6210`
|
||||||
|
- `SpellExamineUI::DetermineSpellRange @ 0x004AD450`
|
||||||
|
|
||||||
|
A favorite spell is selected on the UIItem's left-button-down message, not on
|
||||||
|
button release. Double-click remains a completed gesture and casts the spell
|
||||||
|
that the press already selected.
|
||||||
|
|
||||||
|
```text
|
||||||
|
favorite UIItem message:
|
||||||
|
if message parameter == 7: // left press
|
||||||
|
submenu.SetSelected(spellId)
|
||||||
|
if message parameter == 10: // completed double click
|
||||||
|
Cast()
|
||||||
|
|
||||||
|
UIElement_ItemList right click:
|
||||||
|
if itemId != 0:
|
||||||
|
select itemId as the global weenie selection
|
||||||
|
ExamineObject(itemId)
|
||||||
|
else if spellId != 0:
|
||||||
|
ClientUISystem.ExamineSpell(spellId)
|
||||||
|
```
|
||||||
|
|
||||||
|
The spell branch is deliberately local. It never stores the spell ID in
|
||||||
|
`ACCWeenieObject::selectedID`, never sends an Appraise request, and never takes
|
||||||
|
a new UI-busy reference. The toolbar magnifying glass therefore remains an
|
||||||
|
object-only command; selecting a favorite cannot make it assess that spell.
|
||||||
|
|
||||||
|
The examination floaty is shared between object and spell subviews. Switching
|
||||||
|
to a spell cancels only an in-flight/current object appraisal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gmExaminationUI::ExamineSpell(spellId):
|
||||||
|
if awaiting object appraisal:
|
||||||
|
clear pending object
|
||||||
|
decrement that appraisal's UI-busy reference
|
||||||
|
clear current object
|
||||||
|
send Appraise(0)
|
||||||
|
else if a current object appraisal exists:
|
||||||
|
clear current object
|
||||||
|
send Appraise(0)
|
||||||
|
|
||||||
|
SpellExamineUI.ExamineSpell(spellId)
|
||||||
|
activate SpellPanel
|
||||||
|
show examination window
|
||||||
|
```
|
||||||
|
|
||||||
|
The authored spell subview is part of LayoutDesc `0x2100006B`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SpellPanel 0x10000153
|
||||||
|
school text 0x1000015E
|
||||||
|
spell icon 0x1000015F
|
||||||
|
mana text 0x10000160
|
||||||
|
duration text 0x10000161
|
||||||
|
range text 0x10000162
|
||||||
|
description/components text 0x10000163
|
||||||
|
formula list 0x1000032D
|
||||||
|
formula-cell template 0x1000032E
|
||||||
|
missing-component overlay 0x10000330
|
||||||
|
```
|
||||||
|
|
||||||
|
Presentation uses the current appropriate formula, preserving its order and
|
||||||
|
duplicates. Each component icon keeps the authored missing-component overlay
|
||||||
|
until the player owns that component. Duration is shown as integer seconds
|
||||||
|
below one minute and integer minutes from one minute upward. Range is
|
||||||
|
`min(baseConstant + baseModifier * schoolSkill, 75) / 0.9144`, displayed with
|
||||||
|
one decimal place in yards. School skill IDs are Creature `0x1F`, Item `0x20`,
|
||||||
|
Life `0x21`, War `0x22`, and Void `0x2B`; an unclassified spell uses the
|
||||||
|
highest of those five skills.
|
||||||
|
|
||||||
## Spellbook and component book
|
## Spellbook and component book
|
||||||
|
|
||||||
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
|
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,39 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
canSend: () => late.Session.IsInWorld);
|
canSend: () => late.Session.IsInWorld);
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
|
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
|
||||||
|
|
||||||
|
uint MagicSkillLevel(MagicSchool school)
|
||||||
|
{
|
||||||
|
static uint SkillId(MagicSchool value) => value switch
|
||||||
|
{
|
||||||
|
MagicSchool.CreatureEnchantment => 0x1Fu,
|
||||||
|
MagicSchool.ItemEnchantment => 0x20u,
|
||||||
|
MagicSchool.LifeMagic => 0x21u,
|
||||||
|
MagicSchool.WarMagic => 0x22u,
|
||||||
|
MagicSchool.VoidMagic => 0x2Bu,
|
||||||
|
_ => 0u,
|
||||||
|
};
|
||||||
|
|
||||||
|
uint skillId = SkillId(school);
|
||||||
|
if (skillId != 0u)
|
||||||
|
return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
|
||||||
|
|
||||||
|
uint highest = 0u;
|
||||||
|
foreach (MagicSchool candidate in new[]
|
||||||
|
{
|
||||||
|
MagicSchool.CreatureEnchantment,
|
||||||
|
MagicSchool.ItemEnchantment,
|
||||||
|
MagicSchool.LifeMagic,
|
||||||
|
MagicSchool.WarMagic,
|
||||||
|
MagicSchool.VoidMagic,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
highest = Math.Max(
|
||||||
|
highest,
|
||||||
|
d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u);
|
||||||
|
}
|
||||||
|
return highest;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (IMouse mouse in d.Input.Mice)
|
foreach (IMouse mouse in d.Input.Mice)
|
||||||
host.WireMouse(mouse);
|
host.WireMouse(mouse);
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
|
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
|
||||||
|
|
@ -500,6 +533,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
iconComposer.GetSpellComponentIcon,
|
iconComposer.GetSpellComponentIcon,
|
||||||
d.Selection,
|
d.Selection,
|
||||||
d.MagicCatalog.GetSpellLevel,
|
d.MagicCatalog.GetSpellLevel,
|
||||||
|
magic.GetExamineComponents,
|
||||||
|
MagicSkillLevel,
|
||||||
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
|
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
|
||||||
guid => late.Session.TryUseItem(guid, d.Log),
|
guid => late.Session.TryUseItem(guid, d.Log),
|
||||||
(tab, position, spellId) =>
|
(tab, position, spellId) =>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor(
|
||||||
uint Category,
|
uint Category,
|
||||||
uint IconId);
|
uint IconId);
|
||||||
|
|
||||||
|
/// <summary>One resolved formula cell in retail's spell examination subview.</summary>
|
||||||
|
public readonly record struct SpellExamineComponent(
|
||||||
|
uint SpellComponentId,
|
||||||
|
SpellComponentDescriptor Descriptor,
|
||||||
|
bool Owned);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
|
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
|
||||||
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
|
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
|
||||||
|
|
@ -52,6 +58,20 @@ public sealed class MagicCatalog
|
||||||
public CoreSpellTable SpellTable { get; }
|
public CoreSpellTable SpellTable { get; }
|
||||||
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
|
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
|
||||||
|
|
||||||
|
public bool TryGetComponentBySpellComponentId(
|
||||||
|
uint spellComponentId,
|
||||||
|
out SpellComponentDescriptor descriptor)
|
||||||
|
{
|
||||||
|
if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)
|
||||||
|
&& Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found))
|
||||||
|
{
|
||||||
|
descriptor = found;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
descriptor = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsComponentPack(uint weenieClassId)
|
public bool IsComponentPack(uint weenieClassId)
|
||||||
=> Components.ContainsKey(weenieClassId);
|
=> Components.ContainsKey(weenieClassId);
|
||||||
|
|
||||||
|
|
@ -143,15 +163,43 @@ public sealed class MagicCatalog
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MagicRuntime
|
public sealed class MagicRuntime
|
||||||
{
|
{
|
||||||
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
|
private readonly SpellComponentRequirementService _requirements;
|
||||||
|
|
||||||
|
private MagicRuntime(
|
||||||
|
MagicCatalog catalog,
|
||||||
|
SpellCastingController casting,
|
||||||
|
SpellComponentRequirementService requirements)
|
||||||
{
|
{
|
||||||
Catalog = catalog;
|
Catalog = catalog;
|
||||||
Casting = casting;
|
Casting = casting;
|
||||||
|
_requirements = requirements;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MagicCatalog Catalog { get; }
|
public MagicCatalog Catalog { get; }
|
||||||
public SpellCastingController Casting { get; }
|
public SpellCastingController Casting { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SpellExamineComponent> GetExamineComponents(uint spellId)
|
||||||
|
{
|
||||||
|
IReadOnlyList<uint> formula = _requirements.GetAppropriateFormula(spellId);
|
||||||
|
if (formula.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var result = new List<SpellExamineComponent>(formula.Count);
|
||||||
|
foreach (uint spellComponentId in formula)
|
||||||
|
{
|
||||||
|
if (spellComponentId == 0u
|
||||||
|
|| !Catalog.TryGetComponentBySpellComponentId(
|
||||||
|
spellComponentId,
|
||||||
|
out SpellComponentDescriptor descriptor))
|
||||||
|
continue;
|
||||||
|
result.Add(new SpellExamineComponent(
|
||||||
|
spellComponentId,
|
||||||
|
descriptor,
|
||||||
|
_requirements.IsComponentOwned(spellComponentId)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public static MagicRuntime Create(
|
public static MagicRuntime Create(
|
||||||
MagicCatalog catalog,
|
MagicCatalog catalog,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
|
@ -198,7 +246,7 @@ public sealed class MagicRuntime
|
||||||
incrementBusy: incrementBusy,
|
incrementBusy: incrementBusy,
|
||||||
canSend: canSend,
|
canSend: canSend,
|
||||||
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
|
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
|
||||||
return new MagicRuntime(catalog, casting);
|
return new MagicRuntime(catalog, casting, requirements);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset() => Casting.Reset();
|
public void Reset() => Casting.Reset();
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,31 @@ public sealed class SpellComponentRequirementService
|
||||||
/// (0x00567D50): an infused school or a directly carried school focus uses
|
/// (0x00567D50): an infused school or a directly carried school focus uses
|
||||||
/// the scarab-only formula; otherwise the account-customized formula wins.
|
/// the scarab-only formula; otherwise the account-customized formula wins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
|
||||||
|
{
|
||||||
|
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
|
||||||
|
return [];
|
||||||
|
uint playerGuid = _playerGuid();
|
||||||
|
return GetAppropriateFormula(
|
||||||
|
formula,
|
||||||
|
_objects.Get(playerGuid),
|
||||||
|
playerGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the component tracker would leave a formula icon unghosted.
|
||||||
|
/// The input is a retail spell-component id (SCID), not a weenie class id.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsComponentOwned(uint spellComponentId)
|
||||||
|
{
|
||||||
|
if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId))
|
||||||
|
return false;
|
||||||
|
uint playerGuid = _playerGuid();
|
||||||
|
return _objects.Objects.Any(item =>
|
||||||
|
item.WeenieClassId == weenieClassId
|
||||||
|
&& IsOwnedByPlayer(item, playerGuid));
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<uint> GetAppropriateFormula(
|
private IReadOnlyList<uint> GetAppropriateFormula(
|
||||||
SpellFormulaDefinition formula,
|
SpellFormulaDefinition formula,
|
||||||
ClientObject? player,
|
ClientObject? player,
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases only the object-appraisal transaction before the local spell
|
||||||
|
/// examination subview takes ownership of the shared floaty window.
|
||||||
|
///
|
||||||
|
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
|
||||||
|
/// appraisal busy reference when a response is pending, clears both pending
|
||||||
|
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
|
||||||
|
/// does not clear unrelated use/inventory busy references.
|
||||||
|
/// </summary>
|
||||||
|
public void CancelObjectAppraisalForSpell()
|
||||||
|
{
|
||||||
|
if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_awaitingAppraisalId != 0u)
|
||||||
|
{
|
||||||
|
_awaitingAppraisalId = 0u;
|
||||||
|
if (_busyCount > 0)
|
||||||
|
_busyCount--;
|
||||||
|
}
|
||||||
|
_currentAppraisalId = 0u;
|
||||||
|
_sendExamine?.Invoke(0u);
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
|
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
|
||||||
/// from the live selected object's public description. This is deliberately
|
/// from the live selected object's public description. This is deliberately
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.App.Spells;
|
||||||
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;
|
||||||
|
|
@ -32,6 +34,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
public const uint CreatureDisplayNameId = 0x1000014Eu;
|
public const uint CreatureDisplayNameId = 0x1000014Eu;
|
||||||
public const uint CreatureLevelValueId = 0x1000014Cu;
|
public const uint CreatureLevelValueId = 0x1000014Cu;
|
||||||
public const uint SpellPanelId = 0x10000153u;
|
public const uint SpellPanelId = 0x10000153u;
|
||||||
|
public const uint SpellSchoolTextId = 0x1000015Eu;
|
||||||
|
public const uint SpellIconId = 0x1000015Fu;
|
||||||
|
public const uint SpellManaTextId = 0x10000160u;
|
||||||
|
public const uint SpellDurationTextId = 0x10000161u;
|
||||||
|
public const uint SpellRangeTextId = 0x10000162u;
|
||||||
|
public const uint SpellDisplayTextId = 0x10000163u;
|
||||||
|
public const uint SpellFormulaListId = 0x1000032Du;
|
||||||
|
|
||||||
private const uint TemplateStringProperty = 5u;
|
private const uint TemplateStringProperty = 5u;
|
||||||
private const uint CharacterMarkerIntProperty = 0x105u;
|
private const uint CharacterMarkerIntProperty = 0x105u;
|
||||||
|
|
@ -51,7 +60,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly Action _closeWindow;
|
private readonly Action _closeWindow;
|
||||||
private readonly UiElement _itemPanel;
|
private readonly UiElement _itemPanel;
|
||||||
private readonly UiElement _creaturePanel;
|
private readonly UiElement _creaturePanel;
|
||||||
private readonly UiElement? _spellPanel;
|
private readonly UiElement _spellPanel;
|
||||||
private readonly UiText _title;
|
private readonly UiText _title;
|
||||||
private readonly UiText _itemText;
|
private readonly UiText _itemText;
|
||||||
private readonly UiText? _inscriptionText;
|
private readonly UiText? _inscriptionText;
|
||||||
|
|
@ -64,6 +73,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
|
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
|
||||||
private readonly CreatureDisplayNameResolver _creatureNames;
|
private readonly CreatureDisplayNameResolver _creatureNames;
|
||||||
private readonly RetailAppraisalNameResolver _itemNames;
|
private readonly RetailAppraisalNameResolver _itemNames;
|
||||||
|
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||||
|
private readonly Func<uint, uint> _resolveComponentIcon;
|
||||||
|
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
|
||||||
|
private readonly Func<MagicSchool, uint> _magicSkill;
|
||||||
|
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
||||||
|
private readonly UiText _spellSchool;
|
||||||
|
private readonly UiText _spellMana;
|
||||||
|
private readonly UiText _spellDuration;
|
||||||
|
private readonly UiText _spellRange;
|
||||||
|
private readonly UiText _spellDisplay;
|
||||||
|
private readonly UiElement _spellIconHost;
|
||||||
|
private readonly UiElement _spellFormulaHost;
|
||||||
|
private readonly UiTextureElement _spellIcon;
|
||||||
|
private readonly List<UiElement> _spellFormulaCells = [];
|
||||||
|
private readonly float _spellFormulaOriginX;
|
||||||
|
private readonly float _spellFormulaCellWidth;
|
||||||
private AppraisalView _activeView;
|
private AppraisalView _activeView;
|
||||||
private uint _itemObjectId;
|
private uint _itemObjectId;
|
||||||
private uint _creatureObjectId;
|
private uint _creatureObjectId;
|
||||||
|
|
@ -75,6 +100,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private string _scribeName = string.Empty;
|
private string _scribeName = string.Empty;
|
||||||
private string _oldInscription = string.Empty;
|
private string _oldInscription = string.Empty;
|
||||||
private bool _presentationInscribable;
|
private bool _presentationInscribable;
|
||||||
|
private uint _spellId;
|
||||||
private bool _windowVisible;
|
private bool _windowVisible;
|
||||||
private double _refreshElapsed;
|
private double _refreshElapsed;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -93,11 +119,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Action close,
|
Action close,
|
||||||
UiElement itemPanel,
|
UiElement itemPanel,
|
||||||
UiElement creaturePanel,
|
UiElement creaturePanel,
|
||||||
|
UiElement spellPanel,
|
||||||
UiText title,
|
UiText title,
|
||||||
UiText itemText,
|
UiText itemText,
|
||||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
|
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
|
||||||
CreatureDisplayNameResolver? creatureNames,
|
CreatureDisplayNameResolver? creatureNames,
|
||||||
RetailAppraisalNameResolver? itemNames)
|
RetailAppraisalNameResolver? itemNames,
|
||||||
|
Func<uint, uint>? resolveSpellIcon,
|
||||||
|
Func<uint, uint>? resolveComponentIcon,
|
||||||
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
||||||
|
Func<MagicSchool, uint>? magicSkill,
|
||||||
|
SpellExamineComponentTemplateFactory? spellComponentTemplates)
|
||||||
{
|
{
|
||||||
_layout = layout;
|
_layout = layout;
|
||||||
_objects = objects;
|
_objects = objects;
|
||||||
|
|
@ -112,7 +144,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_closeWindow = close;
|
_closeWindow = close;
|
||||||
_itemPanel = itemPanel;
|
_itemPanel = itemPanel;
|
||||||
_creaturePanel = creaturePanel;
|
_creaturePanel = creaturePanel;
|
||||||
_spellPanel = layout.FindElement(SpellPanelId);
|
_spellPanel = spellPanel;
|
||||||
_title = title;
|
_title = title;
|
||||||
_itemText = itemText;
|
_itemText = itemText;
|
||||||
_creatureRowTemplates = creatureRowTemplates;
|
_creatureRowTemplates = creatureRowTemplates;
|
||||||
|
|
@ -120,6 +152,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
?? new CreatureDisplayNameResolver(
|
?? new CreatureDisplayNameResolver(
|
||||||
new Dictionary<uint, string>());
|
new Dictionary<uint, string>());
|
||||||
_itemNames = itemNames ?? RetailAppraisalNameResolver.Empty;
|
_itemNames = itemNames ?? RetailAppraisalNameResolver.Empty;
|
||||||
|
_resolveSpellIcon = resolveSpellIcon ?? (_ => 0u);
|
||||||
|
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
|
||||||
|
_spellComponents = spellComponents ?? (_ => []);
|
||||||
|
_magicSkill = magicSkill ?? (_ => 0u);
|
||||||
|
_spellComponentTemplates = spellComponentTemplates;
|
||||||
|
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
||||||
|
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
||||||
|
_spellDuration = (UiText)layout.FindElement(SpellDurationTextId)!;
|
||||||
|
_spellRange = (UiText)layout.FindElement(SpellRangeTextId)!;
|
||||||
|
_spellDisplay = (UiText)layout.FindElement(SpellDisplayTextId)!;
|
||||||
|
_spellIconHost = layout.FindElement(SpellIconId)!;
|
||||||
|
_spellFormulaHost = layout.FindElement(SpellFormulaListId)!;
|
||||||
|
_spellFormulaOriginX = _spellFormulaHost.Left;
|
||||||
|
_spellFormulaCellWidth = _spellFormulaHost.Width;
|
||||||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||||
|
|
@ -164,6 +210,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_inscriptionBackground.ClickThrough = false;
|
_inscriptionBackground.ClickThrough = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_spellIconHost is UiDatElement spellIconDat)
|
||||||
|
spellIconDat.MediaVisible = false;
|
||||||
|
_spellIcon = new UiTextureElement
|
||||||
|
{
|
||||||
|
Width = _spellIconHost.Width,
|
||||||
|
Height = _spellIconHost.Height,
|
||||||
|
Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
|
};
|
||||||
|
_spellIconHost.AddChild(_spellIcon);
|
||||||
|
ConfigureSpellText(_spellSchool);
|
||||||
|
ConfigureSpellText(_spellMana);
|
||||||
|
ConfigureSpellText(_spellDuration);
|
||||||
|
ConfigureSpellText(_spellRange);
|
||||||
|
ConfigureSpellText(_spellDisplay, wrap: true);
|
||||||
|
|
||||||
if (creatureRowTemplates is not null
|
if (creatureRowTemplates is not null
|
||||||
&& layout.FindElement(CreatureStatsListId) is { } statsHost
|
&& layout.FindElement(CreatureStatsListId) is { } statsHost
|
||||||
&& layout.FindElement(CreatureExtraListId) is { } extraHost
|
&& layout.FindElement(CreatureExtraListId) is { } extraHost
|
||||||
|
|
@ -184,6 +246,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
}
|
}
|
||||||
|
|
||||||
_selection.Changed += HandleSelectionChanged;
|
_selection.Changed += HandleSelectionChanged;
|
||||||
|
_objects.ObjectAdded += HandleSpellComponentObjectChanged;
|
||||||
|
_objects.ObjectMoved += HandleSpellComponentObjectMoved;
|
||||||
|
_objects.ObjectRemoved += HandleSpellComponentObjectChanged;
|
||||||
|
_objects.StackSizeUpdated += HandleSpellComponentObjectChanged;
|
||||||
|
_objects.Cleared += HandleSpellComponentObjectsCleared;
|
||||||
SetActiveView(AppraisalView.Item);
|
SetActiveView(AppraisalView.Item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +271,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
Action close,
|
Action close,
|
||||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
|
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
|
||||||
CreatureDisplayNameResolver? creatureNames = null,
|
CreatureDisplayNameResolver? creatureNames = null,
|
||||||
RetailAppraisalNameResolver? itemNames = null)
|
RetailAppraisalNameResolver? itemNames = null,
|
||||||
|
Func<uint, uint>? resolveSpellIcon = null,
|
||||||
|
Func<uint, uint>? resolveComponentIcon = null,
|
||||||
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
ArgumentNullException.ThrowIfNull(objects);
|
ArgumentNullException.ThrowIfNull(objects);
|
||||||
|
|
@ -220,8 +292,16 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
|
|
||||||
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|
||||||
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|
||||||
|
|| layout.FindElement(SpellPanelId) is not { } spellPanel
|
||||||
|| layout.FindElement(TitleId) is not UiText title
|
|| layout.FindElement(TitleId) is not UiText title
|
||||||
|| layout.FindElement(ItemTextId) is not UiText itemText)
|
|| layout.FindElement(ItemTextId) is not UiText itemText
|
||||||
|
|| layout.FindElement(SpellSchoolTextId) is not UiText
|
||||||
|
|| layout.FindElement(SpellManaTextId) is not UiText
|
||||||
|
|| layout.FindElement(SpellDurationTextId) is not UiText
|
||||||
|
|| layout.FindElement(SpellRangeTextId) is not UiText
|
||||||
|
|| layout.FindElement(SpellDisplayTextId) is not UiText
|
||||||
|
|| layout.FindElement(SpellIconId) is null
|
||||||
|
|| layout.FindElement(SpellFormulaListId) is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new AppraisalUiController(
|
return new AppraisalUiController(
|
||||||
|
|
@ -238,11 +318,79 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
close,
|
close,
|
||||||
itemPanel,
|
itemPanel,
|
||||||
creaturePanel,
|
creaturePanel,
|
||||||
|
spellPanel,
|
||||||
title,
|
title,
|
||||||
itemText,
|
itemText,
|
||||||
creatureRowTemplates,
|
creatureRowTemplates,
|
||||||
creatureNames,
|
creatureNames,
|
||||||
itemNames);
|
itemNames,
|
||||||
|
resolveSpellIcon,
|
||||||
|
resolveComponentIcon,
|
||||||
|
spellComponents,
|
||||||
|
magicSkill,
|
||||||
|
spellComponentTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens retail's local spell-examination subview. Unlike object appraisal,
|
||||||
|
/// this performs no network request and never changes the globally selected
|
||||||
|
/// object. Source: <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> and
|
||||||
|
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool ExamineSpell(uint spellId)
|
||||||
|
{
|
||||||
|
if (spellId == 0u
|
||||||
|
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_interaction.CancelObjectAppraisalForSpell();
|
||||||
|
_spellId = spellId;
|
||||||
|
_titleValue = metadata.Name;
|
||||||
|
SetSpellText(_spellSchool, $"School: {metadata.School}");
|
||||||
|
|
||||||
|
string mana = metadata.ManaCost > 0
|
||||||
|
? metadata.ManaCost.ToString(CultureInfo.InvariantCulture)
|
||||||
|
: "???";
|
||||||
|
if (metadata.ManaModifier > 0u)
|
||||||
|
mana += $" + {metadata.ManaModifier.ToString(CultureInfo.InvariantCulture)} per target";
|
||||||
|
SetSpellText(_spellMana, $"Mana: {mana}");
|
||||||
|
|
||||||
|
string duration = string.Empty;
|
||||||
|
if (metadata.Duration > 0f && metadata.Duration != -1f)
|
||||||
|
{
|
||||||
|
uint displayed = metadata.Duration >= 60f
|
||||||
|
? (uint)(metadata.Duration / 60f)
|
||||||
|
: (uint)metadata.Duration;
|
||||||
|
duration = metadata.Duration >= 60f
|
||||||
|
? $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} min."
|
||||||
|
: $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} sec.";
|
||||||
|
}
|
||||||
|
SetSpellText(_spellDuration, duration);
|
||||||
|
|
||||||
|
float range = MathF.Min(
|
||||||
|
metadata.BaseRangeConstant
|
||||||
|
+ metadata.BaseRangeModifier * _magicSkill(metadata.SchoolId),
|
||||||
|
75f);
|
||||||
|
SetSpellText(
|
||||||
|
_spellRange,
|
||||||
|
range > 0f
|
||||||
|
? string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"Range: {0:F1} yds.",
|
||||||
|
range / 0.9144f)
|
||||||
|
: string.Empty);
|
||||||
|
|
||||||
|
IReadOnlyList<SpellExamineComponent> components =
|
||||||
|
_spellComponents(spellId);
|
||||||
|
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
|
||||||
|
_spellDisplay.Scroll.SetScrollY(0);
|
||||||
|
_spellIcon.Texture = _resolveSpellIcon(spellId);
|
||||||
|
RebuildSpellFormula(components);
|
||||||
|
|
||||||
|
SetActiveView(AppraisalView.Spell);
|
||||||
|
_refreshElapsed = 0;
|
||||||
|
_show();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -335,7 +483,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_itemObjectId = 0;
|
_itemObjectId = 0;
|
||||||
_creatureObjectId = 0;
|
_creatureObjectId = 0;
|
||||||
_characterObjectId = 0;
|
_characterObjectId = 0;
|
||||||
|
_spellId = 0u;
|
||||||
_refreshElapsed = 0;
|
_refreshElapsed = 0;
|
||||||
|
_spellIcon.Texture = 0u;
|
||||||
|
SetSpellText(_spellSchool, string.Empty);
|
||||||
|
SetSpellText(_spellMana, string.Empty);
|
||||||
|
SetSpellText(_spellDuration, string.Empty);
|
||||||
|
SetSpellText(_spellRange, string.Empty);
|
||||||
|
SetSpellText(_spellDisplay, string.Empty);
|
||||||
|
ClearSpellFormula();
|
||||||
SetActiveView(AppraisalView.Item);
|
SetActiveView(AppraisalView.Item);
|
||||||
ClearCreatureText();
|
ClearCreatureText();
|
||||||
}
|
}
|
||||||
|
|
@ -579,6 +735,75 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
scrollbar.Model = text.Scroll;
|
scrollbar.Model = text.Scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureSpellText(UiText text, bool wrap = false)
|
||||||
|
{
|
||||||
|
text.PreserveEndOnLayout = false;
|
||||||
|
text.ClickThrough = true;
|
||||||
|
if (wrap)
|
||||||
|
{
|
||||||
|
text.OneLine = false;
|
||||||
|
text.VerticalJustify = VJustify.Top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetSpellText(UiText text, string value)
|
||||||
|
=> text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
|
||||||
|
|
||||||
|
private static string BuildSpellDisplay(
|
||||||
|
SpellMetadata metadata,
|
||||||
|
IReadOnlyList<SpellExamineComponent> components)
|
||||||
|
{
|
||||||
|
var display = new StringBuilder(metadata.Description);
|
||||||
|
if (components.Count == 0)
|
||||||
|
return display.ToString();
|
||||||
|
|
||||||
|
display.Append("\nCOMPONENTS:");
|
||||||
|
foreach (SpellExamineComponent component in components)
|
||||||
|
display.Append("\n ").Append(component.Descriptor.Name);
|
||||||
|
return display.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildSpellFormula(
|
||||||
|
IReadOnlyList<SpellExamineComponent> components)
|
||||||
|
{
|
||||||
|
ClearSpellFormula();
|
||||||
|
if (_spellComponentTemplates is null || components.Count == 0)
|
||||||
|
{
|
||||||
|
_spellFormulaHost.Width = _spellFormulaCellWidth;
|
||||||
|
_spellFormulaHost.Left = _spellFormulaOriginX;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float cellWidth = _spellFormulaCellWidth;
|
||||||
|
_spellFormulaHost.Left =
|
||||||
|
_spellFormulaOriginX - cellWidth * 0.5f * (components.Count - 1);
|
||||||
|
_spellFormulaHost.Width = cellWidth * components.Count;
|
||||||
|
_spellFormulaHost.ResetAnchorCapture();
|
||||||
|
for (int i = 0; i < components.Count; i++)
|
||||||
|
{
|
||||||
|
SpellExamineComponent component = components[i];
|
||||||
|
UiElement cell = _spellComponentTemplates.Create(
|
||||||
|
_resolveComponentIcon(component.Descriptor.WeenieClassId),
|
||||||
|
component.Owned);
|
||||||
|
cell.LayoutPolicy = null;
|
||||||
|
cell.Anchors = AnchorEdges.Left | AnchorEdges.Top;
|
||||||
|
cell.Left = i * cellWidth;
|
||||||
|
cell.Top = 0f;
|
||||||
|
_spellFormulaHost.AddChild(cell);
|
||||||
|
_spellFormulaCells.Add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSpellFormula()
|
||||||
|
{
|
||||||
|
foreach (UiElement cell in _spellFormulaCells)
|
||||||
|
_spellFormulaHost.RemoveChild(cell);
|
||||||
|
_spellFormulaCells.Clear();
|
||||||
|
_spellFormulaHost.Left = _spellFormulaOriginX;
|
||||||
|
_spellFormulaHost.Width = _spellFormulaCellWidth;
|
||||||
|
_spellFormulaHost.ResetAnchorCapture();
|
||||||
|
}
|
||||||
|
|
||||||
private void SetText(uint elementId, string value, bool scrollable = false)
|
private void SetText(uint elementId, string value, bool scrollable = false)
|
||||||
{
|
{
|
||||||
if (_layout.FindElement(elementId) is not UiText text)
|
if (_layout.FindElement(elementId) is not UiText text)
|
||||||
|
|
@ -623,8 +848,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_activeView = view;
|
_activeView = view;
|
||||||
_itemPanel.Visible = view == AppraisalView.Item;
|
_itemPanel.Visible = view == AppraisalView.Item;
|
||||||
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
|
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
|
||||||
if (_spellPanel is not null)
|
_spellPanel.Visible = view == AppraisalView.Spell;
|
||||||
_spellPanel.Visible = false;
|
|
||||||
|
|
||||||
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
|
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
|
||||||
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
|
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
|
||||||
|
|
@ -704,12 +928,39 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_closeWindow();
|
_closeWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleSpellComponentObjectChanged(ClientObject _)
|
||||||
|
=> RefreshSpellComponents();
|
||||||
|
|
||||||
|
private void HandleSpellComponentObjectMoved(ClientObjectMove _)
|
||||||
|
=> RefreshSpellComponents();
|
||||||
|
|
||||||
|
private void HandleSpellComponentObjectsCleared()
|
||||||
|
=> RefreshSpellComponents();
|
||||||
|
|
||||||
|
private void RefreshSpellComponents()
|
||||||
|
{
|
||||||
|
if (_activeView != AppraisalView.Spell
|
||||||
|
|| _spellId == 0u
|
||||||
|
|| !_spellbook.TryGetMetadata(_spellId, out SpellMetadata metadata))
|
||||||
|
return;
|
||||||
|
|
||||||
|
IReadOnlyList<SpellExamineComponent> components =
|
||||||
|
_spellComponents(_spellId);
|
||||||
|
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
|
||||||
|
RebuildSpellFormula(components);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_selection.Changed -= HandleSelectionChanged;
|
_selection.Changed -= HandleSelectionChanged;
|
||||||
|
_objects.ObjectAdded -= HandleSpellComponentObjectChanged;
|
||||||
|
_objects.ObjectMoved -= HandleSpellComponentObjectMoved;
|
||||||
|
_objects.ObjectRemoved -= HandleSpellComponentObjectChanged;
|
||||||
|
_objects.StackSizeUpdated -= HandleSpellComponentObjectChanged;
|
||||||
|
_objects.Cleared -= HandleSpellComponentObjectsCleared;
|
||||||
if (_close is not null)
|
if (_close is not null)
|
||||||
_close.OnClick = null;
|
_close.OnClick = null;
|
||||||
if (_inscriptionField is not null)
|
if (_inscriptionField is not null)
|
||||||
|
|
@ -720,6 +971,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
}
|
}
|
||||||
if (_inscriptionBackground is not null)
|
if (_inscriptionBackground is not null)
|
||||||
_inscriptionBackground.OnClick = null;
|
_inscriptionBackground.OnClick = null;
|
||||||
|
ClearSpellFormula();
|
||||||
|
_spellIconHost.RemoveChild(_spellIcon);
|
||||||
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
||||||
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
||||||
|| (_inscriptionText is not null
|
|| (_inscriptionText is not null
|
||||||
|
|
@ -735,4 +988,5 @@ public enum AppraisalView
|
||||||
Item,
|
Item,
|
||||||
Creature,
|
Creature,
|
||||||
Character,
|
Character,
|
||||||
|
Spell,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using AcDream.Content;
|
||||||
|
using DatReaderWriter;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates the authored formula-icon template used by retail
|
||||||
|
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>. The controller replaces
|
||||||
|
/// the template root's placeholder image with the resolved component icon while
|
||||||
|
/// retaining child <c>0x10000330</c> as the missing-component overlay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpellExamineComponentTemplateFactory
|
||||||
|
{
|
||||||
|
public const uint TemplateId = 0x1000032Eu;
|
||||||
|
public const uint MissingOverlayId = 0x10000330u;
|
||||||
|
|
||||||
|
private readonly ElementInfo _template;
|
||||||
|
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
|
||||||
|
private readonly UiDatFont? _defaultFont;
|
||||||
|
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
|
||||||
|
|
||||||
|
public SpellExamineComponentTemplateFactory(
|
||||||
|
ElementInfo template,
|
||||||
|
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||||
|
UiDatFont? defaultFont,
|
||||||
|
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
|
||||||
|
{
|
||||||
|
_template = template ?? throw new ArgumentNullException(nameof(template));
|
||||||
|
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
|
||||||
|
_defaultFont = defaultFont;
|
||||||
|
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SpellExamineComponentTemplateFactory? TryLoad(
|
||||||
|
IDatReaderWriter dats,
|
||||||
|
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||||
|
UiDatFont? defaultFont,
|
||||||
|
Func<uint, UiDatFont?>? resolveFont)
|
||||||
|
{
|
||||||
|
ElementInfo? template = LayoutImporter.ImportInfos(
|
||||||
|
dats,
|
||||||
|
AppraisalUiController.LayoutId,
|
||||||
|
TemplateId);
|
||||||
|
if (template is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var fonts = new Dictionary<uint, UiDatFont?>();
|
||||||
|
CaptureFonts(template, resolveFont, fonts);
|
||||||
|
return new SpellExamineComponentTemplateFactory(
|
||||||
|
template,
|
||||||
|
resolveSprite,
|
||||||
|
defaultFont,
|
||||||
|
fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiElement Create(uint iconTexture, bool owned)
|
||||||
|
{
|
||||||
|
ImportedLayout content = LayoutImporter.Build(
|
||||||
|
_template,
|
||||||
|
_resolveSprite,
|
||||||
|
_defaultFont,
|
||||||
|
did => _fonts.TryGetValue(did, out UiDatFont? font)
|
||||||
|
? font
|
||||||
|
: _defaultFont);
|
||||||
|
UiElement root = content.Root;
|
||||||
|
if (root is UiDatElement datRoot)
|
||||||
|
datRoot.MediaVisible = false;
|
||||||
|
|
||||||
|
var icon = new UiTextureElement
|
||||||
|
{
|
||||||
|
Width = root.Width,
|
||||||
|
Height = root.Height,
|
||||||
|
Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||||
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||||
|
Texture = iconTexture,
|
||||||
|
ZOrder = int.MinValue / 2,
|
||||||
|
};
|
||||||
|
root.AddChild(icon);
|
||||||
|
|
||||||
|
if (content.FindElement(MissingOverlayId) is { } missing)
|
||||||
|
missing.Visible = !owned;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CaptureFonts(
|
||||||
|
ElementInfo info,
|
||||||
|
Func<uint, UiDatFont?>? resolveFont,
|
||||||
|
Dictionary<uint, UiDatFont?> fonts)
|
||||||
|
{
|
||||||
|
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
|
||||||
|
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
|
||||||
|
foreach (ElementInfo child in info.Children)
|
||||||
|
CaptureFonts(child, resolveFont, fonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||||
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
|
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
|
||||||
private readonly Action<uint> _useItem;
|
private readonly Action<uint> _useItem;
|
||||||
|
private readonly Action<uint>? _examineSpell;
|
||||||
private readonly Action<int, int, uint>? _addFavorite;
|
private readonly Action<int, int, uint>? _addFavorite;
|
||||||
private readonly Action<int, uint>? _removeFavorite;
|
private readonly Action<int, uint>? _removeFavorite;
|
||||||
private readonly UiShortcutDigitGraphics? _shortcutDigits;
|
private readonly UiShortcutDigitGraphics? _shortcutDigits;
|
||||||
|
|
@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
Func<uint, uint> resolveSpellIcon,
|
Func<uint, uint> resolveSpellIcon,
|
||||||
Func<ClientObject, uint> resolveItemDragIcon,
|
Func<ClientObject, uint> resolveItemDragIcon,
|
||||||
Action<uint> useItem,
|
Action<uint> useItem,
|
||||||
|
Action<uint>? examineSpell,
|
||||||
SelectionState selection,
|
SelectionState selection,
|
||||||
Action<int, int, uint>? addFavorite,
|
Action<int, int, uint>? addFavorite,
|
||||||
Action<int, uint>? removeFavorite,
|
Action<int, uint>? removeFavorite,
|
||||||
|
|
@ -94,6 +96,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
_resolveSpellIcon = resolveSpellIcon;
|
_resolveSpellIcon = resolveSpellIcon;
|
||||||
_resolveItemDragIcon = resolveItemDragIcon;
|
_resolveItemDragIcon = resolveItemDragIcon;
|
||||||
_useItem = useItem;
|
_useItem = useItem;
|
||||||
|
_examineSpell = examineSpell;
|
||||||
_addFavorite = addFavorite;
|
_addFavorite = addFavorite;
|
||||||
_removeFavorite = removeFavorite;
|
_removeFavorite = removeFavorite;
|
||||||
_shortcutDigits = shortcutDigits;
|
_shortcutDigits = shortcutDigits;
|
||||||
|
|
@ -120,7 +123,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
|
|
||||||
for (int i = 0; i < _lists.Length; i++)
|
for (int i = 0; i < _lists.Length; i++)
|
||||||
{
|
{
|
||||||
if (_lists[i] is not { } list || _scrollbars[i] is not { } scrollbar)
|
if (_lists[i] is not { } list)
|
||||||
|
continue;
|
||||||
|
list.PrimaryCatalogEntryPressed = SelectSpell;
|
||||||
|
list.ExamineCatalogEntryRequested = _examineSpell;
|
||||||
|
if (_scrollbars[i] is not { } scrollbar)
|
||||||
continue;
|
continue;
|
||||||
list.HorizontalScroll = true;
|
list.HorizontalScroll = true;
|
||||||
scrollbar.Horizontal = true;
|
scrollbar.Horizontal = true;
|
||||||
|
|
@ -161,7 +168,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
Action<int, int, uint>? addFavorite,
|
Action<int, int, uint>? addFavorite,
|
||||||
Action<int, uint>? removeFavorite,
|
Action<int, uint>? removeFavorite,
|
||||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||||
uint emptySlotSprite = 0u)
|
uint emptySlotSprite = 0u,
|
||||||
|
Action<uint>? examineSpell = null)
|
||||||
{
|
{
|
||||||
if (layout.FindElement(CastButtonId) is not UiButton cast
|
if (layout.FindElement(CastButtonId) is not UiButton cast
|
||||||
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
|
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
|
||||||
|
|
@ -185,7 +193,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
|
|
||||||
return new SpellcastingUiController(
|
return new SpellcastingUiController(
|
||||||
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
||||||
resolveItemDragIcon, useItem, selection,
|
resolveItemDragIcon, useItem, examineSpell, selection,
|
||||||
addFavorite, removeFavorite,
|
addFavorite, removeFavorite,
|
||||||
tabs, groups, lists, scrollbars, cast, endowmentHost,
|
tabs, groups, lists, scrollbars, cast, endowmentHost,
|
||||||
shortcutDigits, emptySlotSprite);
|
shortcutDigits, emptySlotSprite);
|
||||||
|
|
@ -357,8 +365,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
slot.Clicked = () => SelectSpell(id);
|
// gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0
|
||||||
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
|
// selects on left press (message parameter 7). A completed
|
||||||
|
// double click casts, but release is not required merely to
|
||||||
|
// change the current spell.
|
||||||
|
slot.DoubleClicked = CastSelected;
|
||||||
list.AddItem(slot);
|
list.AddItem(slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,6 +568,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectChanged;
|
||||||
_objects.Cleared -= OnObjectsCleared;
|
_objects.Cleared -= OnObjectsCleared;
|
||||||
foreach (UiElement tab in _tabs) SetClick(tab, null);
|
foreach (UiElement tab in _tabs) SetClick(tab, null);
|
||||||
|
foreach (UiItemList? list in _lists)
|
||||||
|
{
|
||||||
|
if (list is null) continue;
|
||||||
|
list.PrimaryCatalogEntryPressed = null;
|
||||||
|
list.ExamineCatalogEntryRequested = null;
|
||||||
|
}
|
||||||
_cast.OnClick = null;
|
_cast.OnClick = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings(
|
||||||
Func<uint, uint> ResolveComponentIcon,
|
Func<uint, uint> ResolveComponentIcon,
|
||||||
SelectionState Selection,
|
SelectionState Selection,
|
||||||
Func<uint, int> SpellLevel,
|
Func<uint, int> SpellLevel,
|
||||||
|
Func<uint, IReadOnlyList<SpellExamineComponent>> SpellComponents,
|
||||||
|
Func<MagicSchool, uint> MagicSkill,
|
||||||
Action<uint> SelectObject,
|
Action<uint> SelectObject,
|
||||||
Action<uint> UseItem,
|
Action<uint> UseItem,
|
||||||
Action<int, int, uint> AddFavorite,
|
Action<int, int, uint> AddFavorite,
|
||||||
|
|
@ -831,7 +833,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Magic.AddFavorite,
|
_bindings.Magic.AddFavorite,
|
||||||
_bindings.Magic.RemoveFavorite,
|
_bindings.Magic.RemoveFavorite,
|
||||||
LoadShortcutDigitGraphics(),
|
LoadShortcutDigitGraphics(),
|
||||||
favoriteEmptySprite);
|
favoriteEmptySprite,
|
||||||
|
examineSpell: spellId => AppraisalController?.ExamineSpell(spellId));
|
||||||
if (spellcasting is null)
|
if (spellcasting is null)
|
||||||
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
|
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
|
||||||
|
|
||||||
|
|
@ -965,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
CreatureAppraisalRowTemplateFactory? creatureRows;
|
CreatureAppraisalRowTemplateFactory? creatureRows;
|
||||||
|
SpellExamineComponentTemplateFactory? spellComponentTemplates;
|
||||||
CreatureDisplayNameResolver? creatureNames;
|
CreatureDisplayNameResolver? creatureNames;
|
||||||
RetailAppraisalNameResolver? itemNames;
|
RetailAppraisalNameResolver? itemNames;
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
|
|
@ -981,6 +985,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.ResolveFont);
|
_bindings.Assets.ResolveFont);
|
||||||
|
spellComponentTemplates =
|
||||||
|
SpellExamineComponentTemplateFactory.TryLoad(
|
||||||
|
_bindings.Assets.Dats,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.DefaultFont,
|
||||||
|
_bindings.Assets.ResolveFont);
|
||||||
creatureNames = CreatureDisplayNameResolver.Load(
|
creatureNames = CreatureDisplayNameResolver.Load(
|
||||||
_bindings.Assets.Dats);
|
_bindings.Assets.Dats);
|
||||||
itemNames = RetailAppraisalNameResolver.Load(
|
itemNames = RetailAppraisalNameResolver.Load(
|
||||||
|
|
@ -1008,7 +1018,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
close: () => CloseWindow(WindowNames.Examination),
|
close: () => CloseWindow(WindowNames.Examination),
|
||||||
creatureRowTemplates: creatureRows,
|
creatureRowTemplates: creatureRows,
|
||||||
creatureNames: creatureNames,
|
creatureNames: creatureNames,
|
||||||
itemNames: itemNames);
|
itemNames: itemNames,
|
||||||
|
resolveSpellIcon: _bindings.Magic.ResolveSpellIcon,
|
||||||
|
resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
|
||||||
|
spellComponents: _bindings.Magic.SpellComponents,
|
||||||
|
magicSkill: _bindings.Magic.MagicSkill,
|
||||||
|
spellComponentTemplates: spellComponentTemplates);
|
||||||
if (controller is null)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ public sealed class UiCatalogSlot : UiItemSlot
|
||||||
switch (e.Type)
|
switch (e.Type)
|
||||||
{
|
{
|
||||||
case UiEventType.MouseDown:
|
case UiEventType.MouseDown:
|
||||||
|
if (EntryId != 0u
|
||||||
|
&& FindList() is { PrimaryCatalogEntryPressed: { } pressed })
|
||||||
|
pressed(EntryId);
|
||||||
return true;
|
return true;
|
||||||
case UiEventType.Click:
|
case UiEventType.Click:
|
||||||
Clicked?.Invoke();
|
Clicked?.Invoke();
|
||||||
|
|
@ -65,6 +68,11 @@ public sealed class UiCatalogSlot : UiItemSlot
|
||||||
case UiEventType.DoubleClick:
|
case UiEventType.DoubleClick:
|
||||||
DoubleClicked?.Invoke();
|
DoubleClicked?.Invoke();
|
||||||
return true;
|
return true;
|
||||||
|
case UiEventType.RightClick:
|
||||||
|
if (EntryId != 0u
|
||||||
|
&& FindList() is { ExamineCatalogEntryRequested: { } examine })
|
||||||
|
examine(EntryId);
|
||||||
|
return true;
|
||||||
case UiEventType.DragBegin:
|
case UiEventType.DragBegin:
|
||||||
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Action<uint>? ExamineItemRequested { get; set; }
|
public Action<uint>? ExamineItemRequested { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-weenie catalog-entry left-button-down command supplied by the owning
|
||||||
|
/// panel. Spell favorites use this distinct path so a spell id is never
|
||||||
|
/// published as the globally selected object GUID.
|
||||||
|
///
|
||||||
|
/// Retail <c>gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0</c>
|
||||||
|
/// handles UIItem message parameter 7 (left press) by calling
|
||||||
|
/// <c>SpellCastSubMenu::SetSelected</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Action<uint>? PrimaryCatalogEntryPressed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local examination command for non-weenie catalog entries. Retail
|
||||||
|
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c>
|
||||||
|
/// routes right-clicked <c>spellID</c> values to
|
||||||
|
/// <c>ClientUISystem::ExamineSpell</c> without changing the selected object.
|
||||||
|
/// </summary>
|
||||||
|
public Action<uint>? ExamineCatalogEntryRequested { get; set; }
|
||||||
|
|
||||||
private uint _cellEmptySprite;
|
private uint _cellEmptySprite;
|
||||||
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
||||||
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ public class UiItemSlot : UiElement
|
||||||
public override bool IsDragSource => ItemId != 0;
|
public override bool IsDragSource => ItemId != 0;
|
||||||
|
|
||||||
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||||
private UiItemList? FindList()
|
protected UiItemList? FindList()
|
||||||
{
|
{
|
||||||
UiElement? e = Parent;
|
UiElement? e = Parent;
|
||||||
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
|
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,39 @@ public sealed class SpellComponentRequirementServiceTests
|
||||||
Assert.False(service.HasRequiredComponents(50u));
|
Assert.False(service.HasRequiredComponents(50u));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExaminationFormulaPreservesOrderAndReportsNestedOwnership()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 2u,
|
||||||
|
ContainerId = 1u,
|
||||||
|
Type = ItemType.Container,
|
||||||
|
});
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 3u,
|
||||||
|
WeenieClassId = 101u,
|
||||||
|
ContainerId = 2u,
|
||||||
|
});
|
||||||
|
var service = Service(
|
||||||
|
objects,
|
||||||
|
[10u, 11u, 10u],
|
||||||
|
new Dictionary<uint, uint>
|
||||||
|
{
|
||||||
|
[10u] = 101u,
|
||||||
|
[11u] = 102u,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new uint[] { 10u, 11u, 10u },
|
||||||
|
service.GetAppropriateFormula(50u));
|
||||||
|
Assert.True(service.IsComponentOwned(10u));
|
||||||
|
Assert.False(service.IsComponentOwned(11u));
|
||||||
|
}
|
||||||
|
|
||||||
private static SpellComponentRequirementService Service(
|
private static SpellComponentRequirementService Service(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
IReadOnlyList<uint> components,
|
IReadOnlyList<uint> components,
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,30 @@ public class DragDropSpineTests
|
||||||
return (root, list, list.Cell);
|
return (root, list, list.Cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (UiRoot root, UiItemList list, UiCatalogSlot cell)
|
||||||
|
RootWithCatalogSlot(uint entryId)
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var list = new UiItemList(_ => (1u, 1, 1))
|
||||||
|
{
|
||||||
|
Left = 0,
|
||||||
|
Top = 0,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
};
|
||||||
|
list.Flush();
|
||||||
|
var cell = new UiCatalogSlot
|
||||||
|
{
|
||||||
|
EntryId = entryId,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
SpriteResolve = _ => (1u, 1, 1),
|
||||||
|
};
|
||||||
|
list.AddItem(cell);
|
||||||
|
root.AddChild(list);
|
||||||
|
return (root, list, cell);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BeginDrag_arms_whenPayloadNonNull()
|
public void BeginDrag_arms_whenPayloadNonNull()
|
||||||
{
|
{
|
||||||
|
|
@ -359,6 +383,32 @@ public class DragDropSpineTests
|
||||||
Assert.Null(root.DragSource);
|
Assert.Null(root.DragSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CatalogEntryPress_selectsBeforeRelease_withoutForgingItemIdentity()
|
||||||
|
{
|
||||||
|
var (root, list, cell) = RootWithCatalogSlot(42u);
|
||||||
|
var selected = new List<uint>();
|
||||||
|
list.PrimaryCatalogEntryPressed = selected.Add;
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||||
|
|
||||||
|
Assert.Equal(new uint[] { 42u }, selected);
|
||||||
|
Assert.Equal(0u, cell.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CatalogEntryRightClick_requestsLocalCatalogExamination()
|
||||||
|
{
|
||||||
|
var (root, list, _) = RootWithCatalogSlot(42u);
|
||||||
|
var examined = new List<uint>();
|
||||||
|
list.ExamineCatalogEntryRequested = examined.Add;
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Right, 10, 10);
|
||||||
|
root.OnMouseUp(UiMouseButton.Right, 10, 10);
|
||||||
|
|
||||||
|
Assert.Equal(new uint[] { 42u }, examined);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CompletedDrag_doesNotFireUse()
|
public void CompletedDrag_doesNotFireUse()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.App.Spells;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
|
@ -688,6 +689,103 @@ public sealed class AppraisalUiControllerTests
|
||||||
mounted => mounted.WindowName == WindowNames.Examination);
|
mounted => mounted.WindowName == WindowNames.Examination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpellExamination_UsesAuthoredLocalSubviewWithoutSelectingOrAppraisingSpellId()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ObjectId,
|
||||||
|
Name = "Selected Drudge",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
var sent = new List<uint>();
|
||||||
|
using var interaction = NewInteraction(objects, sent);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(ObjectId, SelectionChangeSource.World);
|
||||||
|
SpellMetadata metadata = ExaminedSpell();
|
||||||
|
var spellbook = new Spellbook(SpellTable.Create([metadata]));
|
||||||
|
var components = new[]
|
||||||
|
{
|
||||||
|
new SpellExamineComponent(
|
||||||
|
10u,
|
||||||
|
new SpellComponentDescriptor(100u, "Lead Scarab", 0u, 0x06000010u),
|
||||||
|
Owned: true),
|
||||||
|
new SpellExamineComponent(
|
||||||
|
11u,
|
||||||
|
new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u),
|
||||||
|
Owned: false),
|
||||||
|
};
|
||||||
|
int shown = 0;
|
||||||
|
using AppraisalUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
objects,
|
||||||
|
interaction,
|
||||||
|
new CombatState(),
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
() => shown++,
|
||||||
|
() => { },
|
||||||
|
selection: selection,
|
||||||
|
spellbook: spellbook,
|
||||||
|
resolveSpellIcon: id => id + 1_000u,
|
||||||
|
spellComponents: _ => components,
|
||||||
|
magicSkill: _ => 200u)!;
|
||||||
|
|
||||||
|
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
|
||||||
|
Assert.Equal(1, interaction.BusyCount);
|
||||||
|
|
||||||
|
Assert.True(controller.ExamineSpell(metadata.SpellId));
|
||||||
|
|
||||||
|
Assert.Equal(AppraisalView.Spell, controller.ActiveView);
|
||||||
|
Assert.Equal(1, shown);
|
||||||
|
Assert.Equal(ObjectId, selection.SelectedObjectId);
|
||||||
|
Assert.Equal(new uint[] { ObjectId, 0u }, sent);
|
||||||
|
Assert.Equal(0, interaction.BusyCount);
|
||||||
|
Assert.Equal(0u, controller.CurrentObjectId);
|
||||||
|
Assert.True(layout.FindElement(AppraisalUiController.SpellPanelId)!.Visible);
|
||||||
|
Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
|
||||||
|
Assert.False(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
|
||||||
|
|
||||||
|
AssertSpellText(
|
||||||
|
layout,
|
||||||
|
AppraisalUiController.TitleId,
|
||||||
|
"Incantation of Test");
|
||||||
|
AssertSpellText(
|
||||||
|
layout,
|
||||||
|
AppraisalUiController.SpellSchoolTextId,
|
||||||
|
"School: War Magic");
|
||||||
|
AssertSpellText(
|
||||||
|
layout,
|
||||||
|
AppraisalUiController.SpellManaTextId,
|
||||||
|
"Mana: 50 + 14 per target");
|
||||||
|
AssertSpellText(
|
||||||
|
layout,
|
||||||
|
AppraisalUiController.SpellDurationTextId,
|
||||||
|
"Duration: 1 min.");
|
||||||
|
AssertSpellText(
|
||||||
|
layout,
|
||||||
|
AppraisalUiController.SpellRangeTextId,
|
||||||
|
"Range: 82.0 yds.");
|
||||||
|
string display = string.Join(
|
||||||
|
'\n',
|
||||||
|
((UiText)layout.FindElement(
|
||||||
|
AppraisalUiController.SpellDisplayTextId)!)
|
||||||
|
.LinesProvider()
|
||||||
|
.Select(line => line.Text));
|
||||||
|
Assert.Contains("A projected retail spell.", display);
|
||||||
|
Assert.Contains("COMPONENTS:", display);
|
||||||
|
Assert.Contains("Lead Scarab", display);
|
||||||
|
Assert.Contains("Malar Herb", display);
|
||||||
|
|
||||||
|
UiElement iconHost = layout.FindElement(
|
||||||
|
AppraisalUiController.SpellIconId)!;
|
||||||
|
UiTextureElement icon = Assert.Single(
|
||||||
|
iconHost.Children.OfType<UiTextureElement>());
|
||||||
|
Assert.Equal(metadata.SpellId + 1_000u, icon.Texture);
|
||||||
|
}
|
||||||
|
|
||||||
private static AppraisalUiController? Bind(
|
private static AppraisalUiController? Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
|
|
@ -700,14 +798,20 @@ public sealed class AppraisalUiControllerTests
|
||||||
CreatureAppraisalRowTemplateFactory? creatureRows = null,
|
CreatureAppraisalRowTemplateFactory? creatureRows = null,
|
||||||
CreatureDisplayNameResolver? creatureNames = null,
|
CreatureDisplayNameResolver? creatureNames = null,
|
||||||
SelectionState? selection = null,
|
SelectionState? selection = null,
|
||||||
RetailAppraisalNameResolver? itemNames = null)
|
RetailAppraisalNameResolver? itemNames = null,
|
||||||
|
Spellbook? spellbook = null,
|
||||||
|
Func<uint, uint>? resolveSpellIcon = null,
|
||||||
|
Func<uint, uint>? resolveComponentIcon = null,
|
||||||
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
||||||
|
Func<MagicSchool, uint>? magicSkill = null,
|
||||||
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
|
||||||
=> AppraisalUiController.Bind(
|
=> AppraisalUiController.Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
interaction,
|
interaction,
|
||||||
selection ?? new SelectionState(),
|
selection ?? new SelectionState(),
|
||||||
combat,
|
combat,
|
||||||
new Spellbook(),
|
spellbook ?? new Spellbook(),
|
||||||
() => "Tester",
|
() => "Tester",
|
||||||
(objectId, text) => inscriptions.Add((objectId, text)),
|
(objectId, text) => inscriptions.Add((objectId, text)),
|
||||||
messages.Add,
|
messages.Add,
|
||||||
|
|
@ -715,7 +819,12 @@ public sealed class AppraisalUiControllerTests
|
||||||
close,
|
close,
|
||||||
creatureRows,
|
creatureRows,
|
||||||
creatureNames,
|
creatureNames,
|
||||||
itemNames);
|
itemNames,
|
||||||
|
resolveSpellIcon,
|
||||||
|
resolveComponentIcon,
|
||||||
|
spellComponents,
|
||||||
|
magicSkill,
|
||||||
|
spellComponentTemplates);
|
||||||
|
|
||||||
private static ItemInteractionController NewInteraction(
|
private static ItemInteractionController NewInteraction(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
|
|
@ -749,4 +858,48 @@ public sealed class AppraisalUiControllerTests
|
||||||
ArmorEnchantments: null,
|
ArmorEnchantments: null,
|
||||||
WeaponEnchantments: null,
|
WeaponEnchantments: null,
|
||||||
ResistEnchantments: null);
|
ResistEnchantments: null);
|
||||||
|
|
||||||
|
private static void AssertSpellText(
|
||||||
|
ImportedLayout layout,
|
||||||
|
uint elementId,
|
||||||
|
string expected)
|
||||||
|
{
|
||||||
|
UiText text = Assert.IsType<UiText>(layout.FindElement(elementId));
|
||||||
|
Assert.Equal(
|
||||||
|
expected,
|
||||||
|
string.Join('\n', text.LinesProvider().Select(line => line.Text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpellMetadata ExaminedSpell()
|
||||||
|
=> new(
|
||||||
|
SpellId: 42u,
|
||||||
|
Name: "Incantation of Test",
|
||||||
|
School: "War Magic",
|
||||||
|
Family: 1u,
|
||||||
|
IconId: 0x06001234u,
|
||||||
|
SpellWords: "Malar Aether",
|
||||||
|
Duration: 90f,
|
||||||
|
ManaCost: 50,
|
||||||
|
IsDebuff: false,
|
||||||
|
IsFellowship: false,
|
||||||
|
Description: "A projected retail spell.",
|
||||||
|
SortKey: 0,
|
||||||
|
Difficulty: 0,
|
||||||
|
Flags: 0u,
|
||||||
|
Generation: 6,
|
||||||
|
IsFastWindup: false,
|
||||||
|
IsOffensive: true,
|
||||||
|
IsUntargeted: false,
|
||||||
|
Speed: 0f,
|
||||||
|
CasterEffect: 0u,
|
||||||
|
TargetEffect: 0u,
|
||||||
|
TargetMask: 0u,
|
||||||
|
SpellType: 0)
|
||||||
|
{
|
||||||
|
SchoolId = MagicSchool.WarMagic,
|
||||||
|
BaseRangeConstant = 40f,
|
||||||
|
BaseRangeModifier = 0.25f,
|
||||||
|
ManaModifier = 14u,
|
||||||
|
FormulaComponents = [10u, 11u],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,45 @@ public sealed class SpellcastingUiControllerTests
|
||||||
Assert.Empty(used);
|
Assert.Empty(used);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = LayoutImporter.Build(
|
||||||
|
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||||
|
var spellbook = new Spellbook();
|
||||||
|
spellbook.OnSpellLearned(42u, 1f);
|
||||||
|
spellbook.OnSpellLearned(43u, 1f);
|
||||||
|
spellbook.SetFavorite(0, 0, 42u);
|
||||||
|
spellbook.SetFavorite(0, 1, 43u);
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||||
|
var selection = new SelectionState();
|
||||||
|
var examined = new List<uint>();
|
||||||
|
using SpellcastingUiController controller = Bind(
|
||||||
|
layout,
|
||||||
|
spellbook,
|
||||||
|
objects,
|
||||||
|
_ => { },
|
||||||
|
selection: selection,
|
||||||
|
examineSpell: examined.Add)!;
|
||||||
|
UiElement group = layout.FindElement(0x100000AAu)!;
|
||||||
|
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
||||||
|
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||||
|
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
|
||||||
|
Assert.True(first.Selected);
|
||||||
|
|
||||||
|
second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown));
|
||||||
|
|
||||||
|
Assert.False(first.Selected);
|
||||||
|
Assert.True(second.Selected);
|
||||||
|
Assert.Null(selection.SelectedObjectId);
|
||||||
|
|
||||||
|
second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick));
|
||||||
|
|
||||||
|
Assert.Equal(new uint[] { 43u }, examined);
|
||||||
|
Assert.Null(selection.SelectedObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
private static SpellcastingUiController? Bind(
|
private static SpellcastingUiController? Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
|
@ -287,7 +326,9 @@ public sealed class SpellcastingUiControllerTests
|
||||||
Action<uint> useItem,
|
Action<uint> useItem,
|
||||||
Action<int, int, uint>? addFavorite = null,
|
Action<int, int, uint>? addFavorite = null,
|
||||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||||
uint emptySlotSprite = 0u)
|
uint emptySlotSprite = 0u,
|
||||||
|
SelectionState? selection = null,
|
||||||
|
Action<uint>? examineSpell = null)
|
||||||
{
|
{
|
||||||
var casting = new SpellCastingController(
|
var casting = new SpellCastingController(
|
||||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||||
|
|
@ -296,11 +337,12 @@ public sealed class SpellcastingUiControllerTests
|
||||||
spellId => spellId,
|
spellId => spellId,
|
||||||
item => item.ObjectId,
|
item => item.ObjectId,
|
||||||
useItem,
|
useItem,
|
||||||
new SelectionState(),
|
selection ?? new SelectionState(),
|
||||||
addFavorite ?? ((_, _, _) => { }),
|
addFavorite ?? ((_, _, _) => { }),
|
||||||
(_, _) => { },
|
(_, _) => { },
|
||||||
shortcutDigits,
|
shortcutDigits,
|
||||||
emptySlotSprite);
|
emptySlotSprite,
|
||||||
|
examineSpell);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyAnchors(UiElement parent)
|
private static void ApplyAnchors(UiElement parent)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue