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,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; the appraisal and retained-item input corrections pass 3,548
|
||||
App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
|
||||
Release builds; the appraisal and retained catalog-input corrections pass
|
||||
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
|
||||
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
|
||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||
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
|
||||
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,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; the appraisal and retained-item input corrections pass 3,548
|
||||
App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
|
||||
Release builds; the appraisal and retained catalog-input corrections pass
|
||||
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
|
||||
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
|
||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||
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
|
||||
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
|
||||
click/double-click actions, so a drag or target-consumed press cannot also
|
||||
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`;
|
||||
`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.
|
||||
Left-pressing one selects it before the button is released; holding and
|
||||
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`,
|
||||
then its description after one retail paragraph break, without the bogus
|
||||
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-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-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-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` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
---
|
||||
|
|
@ -38,7 +38,14 @@ selects and appraises occupied inventory, external-container, paperdoll, and
|
|||
physical-shortcut cells through the same request owner. Its shared physical
|
||||
left-click contract now also matches retail's press-time selection: the
|
||||
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
|
||||
creature bit `0x0100` and dropped monster packets while retaining the busy
|
||||
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 is visible before release or drag promotion, while use/open/equip
|
||||
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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
ordered stat rows and animated private preview. The follow-up item report and
|
||||
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.
|
||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||
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,
|
||||
equipping, looting, and shortcut activation remain completed-click or
|
||||
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
|
||||
|
||||
|
|
@ -371,6 +382,11 @@ Named retail references and executable pseudocode are recorded in
|
|||
refresh; closing the examination window must stop refreshes.
|
||||
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
||||
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
|
||||
clicking elsewhere, then reassess it. An item authored by another player is
|
||||
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)
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
|
||||
|
|
|
|||
|
|
@ -405,6 +405,39 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
canSend: () => late.Session.IsInWorld);
|
||||
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)
|
||||
host.WireMouse(mouse);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
|
||||
|
|
@ -500,6 +533,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
iconComposer.GetSpellComponentIcon,
|
||||
d.Selection,
|
||||
d.MagicCatalog.GetSpellLevel,
|
||||
magic.GetExamineComponents,
|
||||
MagicSkillLevel,
|
||||
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
|
||||
guid => late.Session.TryUseItem(guid, d.Log),
|
||||
(tab, position, spellId) =>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor(
|
|||
uint Category,
|
||||
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>
|
||||
/// 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
|
||||
|
|
@ -52,6 +58,20 @@ public sealed class MagicCatalog
|
|||
public CoreSpellTable SpellTable { 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)
|
||||
=> Components.ContainsKey(weenieClassId);
|
||||
|
||||
|
|
@ -143,15 +163,43 @@ public sealed class MagicCatalog
|
|||
/// </summary>
|
||||
public sealed class MagicRuntime
|
||||
{
|
||||
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
|
||||
private readonly SpellComponentRequirementService _requirements;
|
||||
|
||||
private MagicRuntime(
|
||||
MagicCatalog catalog,
|
||||
SpellCastingController casting,
|
||||
SpellComponentRequirementService requirements)
|
||||
{
|
||||
Catalog = catalog;
|
||||
Casting = casting;
|
||||
_requirements = requirements;
|
||||
}
|
||||
|
||||
public MagicCatalog Catalog { 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(
|
||||
MagicCatalog catalog,
|
||||
Spellbook spellbook,
|
||||
|
|
@ -198,7 +246,7 @@ public sealed class MagicRuntime
|
|||
incrementBusy: incrementBusy,
|
||||
canSend: canSend,
|
||||
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
|
||||
return new MagicRuntime(catalog, casting);
|
||||
return new MagicRuntime(catalog, casting, requirements);
|
||||
}
|
||||
|
||||
public void Reset() => Casting.Reset();
|
||||
|
|
|
|||
|
|
@ -75,6 +75,31 @@ public sealed class SpellComponentRequirementService
|
|||
/// (0x00567D50): an infused school or a directly carried school focus uses
|
||||
/// the scarab-only formula; otherwise the account-customized formula wins.
|
||||
/// </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(
|
||||
SpellFormulaDefinition formula,
|
||||
ClientObject? player,
|
||||
|
|
|
|||
|
|
@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable
|
|||
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>
|
||||
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
|
||||
/// from the live selected object's public description. This is deliberately
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
|
@ -32,6 +34,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
public const uint CreatureDisplayNameId = 0x1000014Eu;
|
||||
public const uint CreatureLevelValueId = 0x1000014Cu;
|
||||
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 CharacterMarkerIntProperty = 0x105u;
|
||||
|
|
@ -51,7 +60,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly Action _closeWindow;
|
||||
private readonly UiElement _itemPanel;
|
||||
private readonly UiElement _creaturePanel;
|
||||
private readonly UiElement? _spellPanel;
|
||||
private readonly UiElement _spellPanel;
|
||||
private readonly UiText _title;
|
||||
private readonly UiText _itemText;
|
||||
private readonly UiText? _inscriptionText;
|
||||
|
|
@ -64,6 +73,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
|
||||
private readonly CreatureDisplayNameResolver _creatureNames;
|
||||
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 uint _itemObjectId;
|
||||
private uint _creatureObjectId;
|
||||
|
|
@ -75,6 +100,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private string _scribeName = string.Empty;
|
||||
private string _oldInscription = string.Empty;
|
||||
private bool _presentationInscribable;
|
||||
private uint _spellId;
|
||||
private bool _windowVisible;
|
||||
private double _refreshElapsed;
|
||||
private bool _disposed;
|
||||
|
|
@ -93,11 +119,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
Action close,
|
||||
UiElement itemPanel,
|
||||
UiElement creaturePanel,
|
||||
UiElement spellPanel,
|
||||
UiText title,
|
||||
UiText itemText,
|
||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
|
||||
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;
|
||||
_objects = objects;
|
||||
|
|
@ -112,7 +144,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_closeWindow = close;
|
||||
_itemPanel = itemPanel;
|
||||
_creaturePanel = creaturePanel;
|
||||
_spellPanel = layout.FindElement(SpellPanelId);
|
||||
_spellPanel = spellPanel;
|
||||
_title = title;
|
||||
_itemText = itemText;
|
||||
_creatureRowTemplates = creatureRowTemplates;
|
||||
|
|
@ -120,6 +152,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
?? new CreatureDisplayNameResolver(
|
||||
new Dictionary<uint, string>());
|
||||
_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;
|
||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||
|
|
@ -164,6 +210,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_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
|
||||
&& layout.FindElement(CreatureStatsListId) is { } statsHost
|
||||
&& layout.FindElement(CreatureExtraListId) is { } extraHost
|
||||
|
|
@ -184,6 +246,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
}
|
||||
|
||||
_selection.Changed += HandleSelectionChanged;
|
||||
_objects.ObjectAdded += HandleSpellComponentObjectChanged;
|
||||
_objects.ObjectMoved += HandleSpellComponentObjectMoved;
|
||||
_objects.ObjectRemoved += HandleSpellComponentObjectChanged;
|
||||
_objects.StackSizeUpdated += HandleSpellComponentObjectChanged;
|
||||
_objects.Cleared += HandleSpellComponentObjectsCleared;
|
||||
SetActiveView(AppraisalView.Item);
|
||||
}
|
||||
|
||||
|
|
@ -204,7 +271,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
Action close,
|
||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = 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(objects);
|
||||
|
|
@ -220,8 +292,16 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
|
||||
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|
||||
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|
||||
|| layout.FindElement(SpellPanelId) is not { } spellPanel
|
||||
|| 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 new AppraisalUiController(
|
||||
|
|
@ -238,11 +318,79 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
close,
|
||||
itemPanel,
|
||||
creaturePanel,
|
||||
spellPanel,
|
||||
title,
|
||||
itemText,
|
||||
creatureRowTemplates,
|
||||
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>
|
||||
|
|
@ -335,7 +483,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_itemObjectId = 0;
|
||||
_creatureObjectId = 0;
|
||||
_characterObjectId = 0;
|
||||
_spellId = 0u;
|
||||
_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);
|
||||
ClearCreatureText();
|
||||
}
|
||||
|
|
@ -579,6 +735,75 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
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)
|
||||
{
|
||||
if (_layout.FindElement(elementId) is not UiText text)
|
||||
|
|
@ -623,8 +848,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_activeView = view;
|
||||
_itemPanel.Visible = view == AppraisalView.Item;
|
||||
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
|
||||
if (_spellPanel is not null)
|
||||
_spellPanel.Visible = false;
|
||||
_spellPanel.Visible = view == AppraisalView.Spell;
|
||||
|
||||
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
|
||||
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
|
||||
|
|
@ -704,12 +928,39 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_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()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_selection.Changed -= HandleSelectionChanged;
|
||||
_objects.ObjectAdded -= HandleSpellComponentObjectChanged;
|
||||
_objects.ObjectMoved -= HandleSpellComponentObjectMoved;
|
||||
_objects.ObjectRemoved -= HandleSpellComponentObjectChanged;
|
||||
_objects.StackSizeUpdated -= HandleSpellComponentObjectChanged;
|
||||
_objects.Cleared -= HandleSpellComponentObjectsCleared;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
if (_inscriptionField is not null)
|
||||
|
|
@ -720,6 +971,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
}
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.OnClick = null;
|
||||
ClearSpellFormula();
|
||||
_spellIconHost.RemoveChild(_spellIcon);
|
||||
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
||||
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
||||
|| (_inscriptionText is not null
|
||||
|
|
@ -735,4 +988,5 @@ public enum AppraisalView
|
|||
Item,
|
||||
Creature,
|
||||
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<ClientObject, uint> _resolveItemDragIcon;
|
||||
private readonly Action<uint> _useItem;
|
||||
private readonly Action<uint>? _examineSpell;
|
||||
private readonly Action<int, int, uint>? _addFavorite;
|
||||
private readonly Action<int, uint>? _removeFavorite;
|
||||
private readonly UiShortcutDigitGraphics? _shortcutDigits;
|
||||
|
|
@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<ClientObject, uint> resolveItemDragIcon,
|
||||
Action<uint> useItem,
|
||||
Action<uint>? examineSpell,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite,
|
||||
|
|
@ -94,6 +96,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveItemDragIcon = resolveItemDragIcon;
|
||||
_useItem = useItem;
|
||||
_examineSpell = examineSpell;
|
||||
_addFavorite = addFavorite;
|
||||
_removeFavorite = removeFavorite;
|
||||
_shortcutDigits = shortcutDigits;
|
||||
|
|
@ -120,7 +123,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
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;
|
||||
list.HorizontalScroll = true;
|
||||
scrollbar.Horizontal = true;
|
||||
|
|
@ -161,7 +168,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite,
|
||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||
uint emptySlotSprite = 0u)
|
||||
uint emptySlotSprite = 0u,
|
||||
Action<uint>? examineSpell = null)
|
||||
{
|
||||
if (layout.FindElement(CastButtonId) is not UiButton cast
|
||||
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
|
||||
|
|
@ -185,7 +193,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
return new SpellcastingUiController(
|
||||
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
||||
resolveItemDragIcon, useItem, selection,
|
||||
resolveItemDragIcon, useItem, examineSpell, selection,
|
||||
addFavorite, removeFavorite,
|
||||
tabs, groups, lists, scrollbars, cast, endowmentHost,
|
||||
shortcutDigits, emptySlotSprite);
|
||||
|
|
@ -357,8 +365,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||
},
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(id);
|
||||
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
|
||||
// gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -557,6 +568,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings(
|
|||
Func<uint, uint> ResolveComponentIcon,
|
||||
SelectionState Selection,
|
||||
Func<uint, int> SpellLevel,
|
||||
Func<uint, IReadOnlyList<SpellExamineComponent>> SpellComponents,
|
||||
Func<MagicSchool, uint> MagicSkill,
|
||||
Action<uint> SelectObject,
|
||||
Action<uint> UseItem,
|
||||
Action<int, int, uint> AddFavorite,
|
||||
|
|
@ -831,7 +833,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Magic.AddFavorite,
|
||||
_bindings.Magic.RemoveFavorite,
|
||||
LoadShortcutDigitGraphics(),
|
||||
favoriteEmptySprite);
|
||||
favoriteEmptySprite,
|
||||
examineSpell: spellId => AppraisalController?.ExamineSpell(spellId));
|
||||
if (spellcasting is null)
|
||||
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
|
||||
|
||||
|
|
@ -965,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
ImportedLayout? layout;
|
||||
CreatureAppraisalRowTemplateFactory? creatureRows;
|
||||
SpellExamineComponentTemplateFactory? spellComponentTemplates;
|
||||
CreatureDisplayNameResolver? creatureNames;
|
||||
RetailAppraisalNameResolver? itemNames;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
|
|
@ -981,6 +985,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
spellComponentTemplates =
|
||||
SpellExamineComponentTemplateFactory.TryLoad(
|
||||
_bindings.Assets.Dats,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
creatureNames = CreatureDisplayNameResolver.Load(
|
||||
_bindings.Assets.Dats);
|
||||
itemNames = RetailAppraisalNameResolver.Load(
|
||||
|
|
@ -1008,7 +1018,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
close: () => CloseWindow(WindowNames.Examination),
|
||||
creatureRowTemplates: creatureRows,
|
||||
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)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
if (EntryId != 0u
|
||||
&& FindList() is { PrimaryCatalogEntryPressed: { } pressed })
|
||||
pressed(EntryId);
|
||||
return true;
|
||||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
|
|
@ -65,6 +68,11 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
case UiEventType.DoubleClick:
|
||||
DoubleClicked?.Invoke();
|
||||
return true;
|
||||
case UiEventType.RightClick:
|
||||
if (EntryId != 0u
|
||||
&& FindList() is { ExamineCatalogEntryRequested: { } examine })
|
||||
examine(EntryId);
|
||||
return true;
|
||||
case UiEventType.DragBegin:
|
||||
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement
|
|||
/// </summary>
|
||||
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;
|
||||
/// <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
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ public class UiItemSlot : UiElement
|
|||
public override bool IsDragSource => ItemId != 0;
|
||||
|
||||
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||
private UiItemList? FindList()
|
||||
protected UiItemList? FindList()
|
||||
{
|
||||
UiElement? 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));
|
||||
}
|
||||
|
||||
[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(
|
||||
ClientObjectTable objects,
|
||||
IReadOnlyList<uint> components,
|
||||
|
|
|
|||
|
|
@ -245,6 +245,30 @@ public class DragDropSpineTests
|
|||
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]
|
||||
public void BeginDrag_arms_whenPayloadNonNull()
|
||||
{
|
||||
|
|
@ -359,6 +383,32 @@ public class DragDropSpineTests
|
|||
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]
|
||||
public void CompletedDrag_doesNotFireUse()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
|
|
@ -688,6 +689,103 @@ public sealed class AppraisalUiControllerTests
|
|||
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(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
|
|
@ -700,14 +798,20 @@ public sealed class AppraisalUiControllerTests
|
|||
CreatureAppraisalRowTemplateFactory? creatureRows = null,
|
||||
CreatureDisplayNameResolver? creatureNames = 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(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
selection ?? new SelectionState(),
|
||||
combat,
|
||||
new Spellbook(),
|
||||
spellbook ?? new Spellbook(),
|
||||
() => "Tester",
|
||||
(objectId, text) => inscriptions.Add((objectId, text)),
|
||||
messages.Add,
|
||||
|
|
@ -715,7 +819,12 @@ public sealed class AppraisalUiControllerTests
|
|||
close,
|
||||
creatureRows,
|
||||
creatureNames,
|
||||
itemNames);
|
||||
itemNames,
|
||||
resolveSpellIcon,
|
||||
resolveComponentIcon,
|
||||
spellComponents,
|
||||
magicSkill,
|
||||
spellComponentTemplates);
|
||||
|
||||
private static ItemInteractionController NewInteraction(
|
||||
ClientObjectTable objects,
|
||||
|
|
@ -749,4 +858,48 @@ public sealed class AppraisalUiControllerTests
|
|||
ArmorEnchantments: null,
|
||||
WeaponEnchantments: 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);
|
||||
}
|
||||
|
||||
[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(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
|
|
@ -287,7 +326,9 @@ public sealed class SpellcastingUiControllerTests
|
|||
Action<uint> useItem,
|
||||
Action<int, int, uint>? addFavorite = null,
|
||||
UiShortcutDigitGraphics? shortcutDigits = null,
|
||||
uint emptySlotSprite = 0u)
|
||||
uint emptySlotSprite = 0u,
|
||||
SelectionState? selection = null,
|
||||
Action<uint>? examineSpell = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
|
|
@ -296,11 +337,12 @@ public sealed class SpellcastingUiControllerTests
|
|||
spellId => spellId,
|
||||
item => item.ObjectId,
|
||||
useItem,
|
||||
new SelectionState(),
|
||||
selection ?? new SelectionState(),
|
||||
addFavorite ?? ((_, _, _) => { }),
|
||||
(_, _) => { },
|
||||
shortcutDigits,
|
||||
emptySlotSprite);
|
||||
emptySlotSprite,
|
||||
examineSpell);
|
||||
}
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue