feat(ui): port retail spellbook interactions
Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar. Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
ad30c37a48
commit
ac2ca8f965
17 changed files with 727 additions and 47 deletions
|
|
@ -531,7 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
|
||||
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
||||
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
|
||||
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The spellbook now resolves retail UIItem prototype `0x10000343` for its one-column icon/name/separator rows and selected overlay, binds the authored scrollbar, ports exact filters/display ordering, drags learned spells into the open favorite bar without mutating their source, and reuses `RetailDialogFactory` for server-authoritative spell deletion (`CM_Magic::Event_RemoveSpell`, `0x01A8`). `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
|
||||
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
|
||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
|
||||
inventory, and item-giving demo is user-gated. The shared projectile/effect
|
||||
foundation is hardened through Step 9 and its single-client visual gate passes.
|
||||
Next is the F.4/L.1d retail cast-lifecycle gap audit, then F.5 spellbook and
|
||||
active enchantments. Carried: the two-client portal/observer VFX gate,
|
||||
Next is the connected F.5 spellbook/favorite-bar/delete visual gate, followed
|
||||
by cast/enchantment presentation and the two-client portal/observer VFX gate.
|
||||
Carried:
|
||||
#145-residual, #116 slide-response, R6/TS-42, and Track MP0.
|
||||
|
||||
---
|
||||
|
|
@ -649,6 +650,12 @@ The first connected gate exposed and corrected two retained-import defects:
|
|||
spellbook tabs are authored stateful text controls (not buttons), and retail
|
||||
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
|
||||
Closed/Open child-state propagation, and Magic-tab strings I through VIII.
|
||||
The follow-up spellbook slice now clones the DAT-authored one-column
|
||||
spell-shortcut presentation (icon, name, separator, selected overlay), binds
|
||||
its authored scrollbar, ports exact school/level filter masks and stable
|
||||
display-order sorting, carries learned spells into the open favorite bar, and
|
||||
routes Delete through the shared retail confirmation factory plus
|
||||
server-authoritative `CM_Magic::Event_RemoveSpell (0x01A8)` response path.
|
||||
|
||||
**Phases to ship:**
|
||||
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
|
||||
|
|
|
|||
|
|
@ -240,6 +240,105 @@ sorts by `CSpellBase::_display_order`; school and level buttons apply the
|
|||
server-persisted filter bitfield. Component rows use the installed component
|
||||
table and the server-persisted desired purchase amounts.
|
||||
|
||||
### Spellbook row, selection, filtering, dragging, and deletion
|
||||
|
||||
Named-retail anchors:
|
||||
|
||||
- `gmSpellbookUI::IsFilteredOut @ 0x0048AEB0`
|
||||
- `gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440`
|
||||
- `gmSpellbookUI::SetSelected @ 0x0048B540`
|
||||
- `gmSpellbookUI::UpdateFilter @ 0x0048B5E0`
|
||||
- `gmSpellbookUI::AddSpell @ 0x0048B7A0`
|
||||
- `gmSpellbookUI::DeleteSpellDialogCallback @ 0x0048BC10`
|
||||
- `gmSpellbookUI::DeleteSpell @ 0x0048BD50`
|
||||
- `gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0`
|
||||
- `UIElement_UIItem::Init_UIItem_Spell_Shortcut @ 0x004E1140`
|
||||
- `UIElement_ItemList::ItemList_AddSpellShortcut @ 0x004E40A0`
|
||||
- `UIElement_ItemList::ItemList_InsertSpellShortcut @ 0x004E41B0`
|
||||
- `CM_Magic::Event_RemoveSpell @ 0x006A3220`
|
||||
|
||||
DAT structure:
|
||||
|
||||
```text
|
||||
spell list 0x10000295 (280 x 224)
|
||||
spell scrollbar 0x10000296 (16 x 224)
|
||||
spell-row prototype property 0x1000000E -> 0x10000343
|
||||
shared UIItem catalog 0x21000037
|
||||
row prototype 0x10000343 (280 x 32)
|
||||
row background 0x06001396
|
||||
selected overlay child 0x10000342 -> 0x06001397
|
||||
label child 0x10000344 (x=42, width=230)
|
||||
icon child 0x1000033B (x=0, 32 x 32)
|
||||
delete button 0x100002A5
|
||||
```
|
||||
|
||||
The row's authored background contains the narrow horizontal separator visible
|
||||
between entries. It is not an invented procedural line. The selection surface
|
||||
is a separate overlay, so selecting a spell does not replace its icon or label.
|
||||
|
||||
```text
|
||||
AddSpell(spellID):
|
||||
if spell metadata is missing: return
|
||||
if IsFilteredOut(spell): return
|
||||
insertion = first existing row whose displayOrder > spell.displayOrder
|
||||
list.InsertSpellShortcut(spellID, insertion)
|
||||
|
||||
SetSelected(spellID):
|
||||
for each spell shortcut row:
|
||||
row.selectedOverlay = (row.spellID == spellID)
|
||||
if selected: list.ScrollItemIntoView(row)
|
||||
selectedSpellID = spellID
|
||||
|
||||
ListenToElementMessage(message):
|
||||
if message came from a spell shortcut row:
|
||||
if message kind is click or double-click: SetSelected(row.spellID)
|
||||
if message kind is AddSpellShortcut/drag notice:
|
||||
CM_Magic.SendNotice_AddSpellShortcut(row.spellID)
|
||||
if message is click on a school/level filter:
|
||||
UpdateFilter(clickedFilter)
|
||||
if message is click on 0x100002A5:
|
||||
DeleteSpell()
|
||||
|
||||
UpdateFilter(button):
|
||||
mask = exact school/level bit assigned to button
|
||||
if button is currently Highlight: filters |= mask
|
||||
else: filters &= ~mask
|
||||
if filters changed:
|
||||
CM_Character.Event_SpellbookFilterEvent(filters)
|
||||
rebuild list from authoritative learned spells
|
||||
scroll list to first row
|
||||
|
||||
IsFilteredOut(spell):
|
||||
school bits: Creature=0x0001, Item=0x0002, Life=0x0004,
|
||||
War=0x0008, Void=0x2000
|
||||
level bits: I=0x0010 through VIII=0x0800
|
||||
return school bit is disabled OR level bit is disabled
|
||||
|
||||
DeleteSpell():
|
||||
if selectedSpellID == 0: return false
|
||||
name = ClientMagicSystem.GetSpellName(selectedSpellID)
|
||||
message = "Are you sure you want to remove {name} from your spellbook? "
|
||||
"You will no longer be able to cast this spell unless you learn it again!"
|
||||
dialog properties include selectedSpellID under 0x1000003F
|
||||
DialogFactory.MakeCallbackDialogInCurrentUI(message, callback)
|
||||
|
||||
DeleteSpellDialogCallback(dialogProperties):
|
||||
if confirmation result property 0x92 is true:
|
||||
spellID = property 0x1000003F
|
||||
CM_Magic.Event_RemoveSpell(spellID)
|
||||
|
||||
CM_Magic.Event_RemoveSpell(spellID):
|
||||
send GameAction opcode 0x01A8 followed by spellID
|
||||
|
||||
RecvNotice_SpellRemoved(spellID):
|
||||
rebuild from the authoritative player description
|
||||
```
|
||||
|
||||
The client does not optimistically erase the row when Yes is clicked. It waits
|
||||
for the server's `MagicRemoveSpell` notice. Dragging a row similarly carries a
|
||||
spell-shortcut identity into the open spellcasting favorite list; it never
|
||||
removes the spell from the learned-spell set.
|
||||
|
||||
## Active enchantment panels
|
||||
|
||||
Retail: `gmEffectsUI::PostInit` `0x004B7560` and
|
||||
|
|
|
|||
|
|
@ -2272,6 +2272,7 @@ public sealed class GameWindow : IDisposable
|
|||
(tab, spellId) =>
|
||||
_liveSession?.SendRemoveSpellFavorite(spellId, tab),
|
||||
filters => _liveSession?.SendSpellbookFilter(filters),
|
||||
spellId => _liveSession?.SendRemoveSpell(spellId),
|
||||
(componentId, amount) =>
|
||||
_liveSession?.SendSetDesiredComponentLevel(componentId, amount),
|
||||
ClientTimerNow),
|
||||
|
|
|
|||
72
src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
Normal file
72
src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook
|
||||
/// list points at this prototype through ItemList attribute <c>0x1000000E</c>;
|
||||
/// retail clones it for every learned spell.
|
||||
/// </summary>
|
||||
public readonly record struct SpellbookRowStyle(
|
||||
float Width,
|
||||
float Height,
|
||||
float IconLeft,
|
||||
float IconTop,
|
||||
float IconWidth,
|
||||
float IconHeight,
|
||||
float LabelLeft,
|
||||
float LabelWidth,
|
||||
uint FontDid,
|
||||
Vector4 LabelColor,
|
||||
uint BackgroundSprite,
|
||||
uint SelectedSprite)
|
||||
{
|
||||
public const uint CatalogLayoutId = 0x21000037u;
|
||||
public const uint PrototypeId = 0x10000343u;
|
||||
public const uint SelectedOverlayId = 0x10000342u;
|
||||
public const uint LabelId = 0x10000344u;
|
||||
public const uint IconId = 0x1000033Bu;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the complete row geometry and media from the installed retail DAT.
|
||||
/// Returns null if the authored prototype is incomplete; callers then leave the
|
||||
/// panel unbound rather than inventing replacement art.
|
||||
/// </summary>
|
||||
public static SpellbookRowStyle? TryLoad(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId);
|
||||
if (prototype is null
|
||||
|| Find(prototype, SelectedOverlayId) is not { } selected
|
||||
|| Find(prototype, LabelId) is not { } label
|
||||
|| Find(prototype, IconId) is not { } icon
|
||||
|| !prototype.StateMedia.TryGetValue("", out var background)
|
||||
|| !selected.StateMedia.TryGetValue("", out var selection)
|
||||
|| prototype.Width <= 0f
|
||||
|| prototype.Height <= 0f)
|
||||
return null;
|
||||
|
||||
return new SpellbookRowStyle(
|
||||
prototype.Width,
|
||||
prototype.Height,
|
||||
icon.X,
|
||||
icon.Y,
|
||||
icon.Width,
|
||||
icon.Height,
|
||||
label.X,
|
||||
label.Width,
|
||||
label.FontDid,
|
||||
label.FontColor ?? Vector4.One,
|
||||
background.File,
|
||||
selection.File);
|
||||
}
|
||||
|
||||
private static ElementInfo? Find(ElementInfo root, uint id)
|
||||
{
|
||||
if (root.Id == id) return root;
|
||||
foreach (ElementInfo child in root.Children)
|
||||
if (Find(child, id) is { } found) return found;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
public const uint SpellPageId = 0x100002ACu;
|
||||
public const uint ComponentPageId = 0x100002ADu;
|
||||
public const uint SpellListId = 0x10000295u;
|
||||
public const uint SpellScrollbarId = 0x10000296u;
|
||||
public const uint DeleteButtonId = 0x100002A5u;
|
||||
public const uint ComponentListId = 0x10000464u;
|
||||
public const uint ComponentScrollbarId = 0x10000465u;
|
||||
|
||||
|
|
@ -52,6 +54,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly Action<uint> _selectObject;
|
||||
private readonly Action<uint> _addFavorite;
|
||||
private readonly Action<uint> _sendFilter;
|
||||
private readonly Action<uint> _removeSpell;
|
||||
private readonly Action<string, Action<bool>> _showConfirmation;
|
||||
private readonly Action<uint, uint> _setDesiredComponent;
|
||||
private readonly Action _close;
|
||||
private readonly UiElement _spellPage;
|
||||
|
|
@ -59,9 +63,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly UiElement _spellTab;
|
||||
private readonly UiElement _componentTab;
|
||||
private readonly UiButton _closeButton;
|
||||
private readonly UiButton _deleteButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
private readonly List<(UiButton Button, uint Mask)> _filters = new();
|
||||
private readonly SpellbookRowStyle _rowStyle;
|
||||
private readonly UiDatFont? _rowFont;
|
||||
private uint? _selectedSpell;
|
||||
private bool _spellsDirty;
|
||||
private bool _componentsDirty;
|
||||
|
|
@ -80,6 +87,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint> removeSpell,
|
||||
Action<string, Action<bool>> showConfirmation,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close,
|
||||
UiElement spellPage,
|
||||
|
|
@ -87,8 +96,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
UiElement spellTab,
|
||||
UiElement componentTab,
|
||||
UiButton closeButton,
|
||||
UiButton deleteButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList)
|
||||
UiItemList componentList,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_objects = objects;
|
||||
|
|
@ -100,6 +112,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_selectObject = selectObject;
|
||||
_addFavorite = addFavorite;
|
||||
_sendFilter = sendFilter;
|
||||
_removeSpell = removeSpell;
|
||||
_showConfirmation = showConfirmation;
|
||||
_setDesiredComponent = setDesiredComponent;
|
||||
_close = close;
|
||||
_spellPage = spellPage;
|
||||
|
|
@ -107,12 +121,16 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_spellTab = spellTab;
|
||||
_componentTab = componentTab;
|
||||
_closeButton = closeButton;
|
||||
_deleteButton = deleteButton;
|
||||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
_rowStyle = rowStyle;
|
||||
_rowFont = rowFont;
|
||||
|
||||
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||
closeButton.OnClick = close;
|
||||
deleteButton.OnClick = RequestDeleteSelected;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
{
|
||||
if (layout.FindElement(id) is not UiButton button) continue;
|
||||
|
|
@ -121,7 +139,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_filters.Add((button, mask));
|
||||
}
|
||||
|
||||
ConfigureSpellList();
|
||||
ConfigureSpellList(layout);
|
||||
ConfigureComponentList(layout);
|
||||
_spellbook.SpellbookChanged += OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
|
||||
|
|
@ -148,14 +166,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint> removeSpell,
|
||||
Action<string, Action<bool>> showConfirmation,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close)
|
||||
Action close,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||
|| layout.FindElement(SpellTabId) is not { } spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not { } componentTab
|
||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||
|| layout.FindElement(DeleteButtonId) is not UiButton deleteButton
|
||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||
return null;
|
||||
|
||||
|
|
@ -179,9 +202,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
layout, spellbook, objects, playerGuid, components,
|
||||
resolveSpellIcon, resolveComponentIcon,
|
||||
spellLevel, selectObject,
|
||||
addFavorite, sendFilter, setDesiredComponent, close,
|
||||
addFavorite, sendFilter, removeSpell, showConfirmation,
|
||||
setDesiredComponent, close,
|
||||
spellPage, componentPage, spellTab, componentTab, closeButton,
|
||||
spellList, componentList);
|
||||
deleteButton, spellList, componentList, rowStyle, rowFont);
|
||||
}
|
||||
|
||||
public void ShowPage(SpellbookWindowPage page)
|
||||
|
|
@ -193,11 +217,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||
}
|
||||
|
||||
private void ConfigureSpellList()
|
||||
private void ConfigureSpellList(ImportedLayout layout)
|
||||
{
|
||||
_spellList.Columns = 6;
|
||||
_spellList.CellWidth = 32f;
|
||||
_spellList.CellHeight = 32f;
|
||||
_spellList.Columns = 1;
|
||||
_spellList.CellWidth = Math.Max(1f, _spellList.Width);
|
||||
_spellList.CellHeight = _rowStyle.Height;
|
||||
if (layout.FindElement(SpellScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _spellList.Scroll;
|
||||
}
|
||||
|
||||
private void ConfigureComponentList(ImportedLayout layout)
|
||||
|
|
@ -211,9 +237,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
|
||||
private void ToggleFilter(uint mask)
|
||||
{
|
||||
// gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
|
||||
// filter bitfield, rebuilds from learned spells, and returns to row zero.
|
||||
uint filters = _spellbook.SpellbookFilters ^ mask;
|
||||
_spellbook.SetSpellbookFilters(filters);
|
||||
_sendFilter(filters);
|
||||
_spellList.Scroll.SetScrollY(0);
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
|
|
@ -230,11 +259,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
foreach ((UiButton button, uint mask) in _filters)
|
||||
button.Selected = (effective & mask) != 0;
|
||||
|
||||
// gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440 inserts before
|
||||
// the first GREATER display order. Stable OrderBy preserves ties.
|
||||
SpellMetadata[] spells = _spellbook.LearnedSpells
|
||||
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
|
||||
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
|
||||
.OrderBy(metadata => metadata!.SortKey)
|
||||
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Cast<SpellMetadata>()
|
||||
.ToArray();
|
||||
|
||||
|
|
@ -249,7 +279,21 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
EntryId = spellId,
|
||||
CatalogIconTexture = _resolveSpellIcon(spellId),
|
||||
Label = metadata.Name,
|
||||
ShowLabel = true,
|
||||
BackgroundSprite = _rowStyle.BackgroundSprite,
|
||||
SelectedSprite = _rowStyle.SelectedSprite,
|
||||
SelectionBehindContent = true,
|
||||
LabelFont = _rowFont,
|
||||
LabelColor = _rowStyle.LabelColor,
|
||||
IconLeft = _rowStyle.IconLeft,
|
||||
IconTop = _rowStyle.IconTop,
|
||||
IconWidth = _rowStyle.IconWidth,
|
||||
IconHeight = _rowStyle.IconHeight,
|
||||
LabelLeft = _rowStyle.LabelLeft,
|
||||
LabelWidth = _rowStyle.LabelWidth,
|
||||
SpriteResolve = _spellList.SpriteResolve,
|
||||
CatalogDragPayload = new SpellbookShortcutDragPayload(spellId),
|
||||
DragBegan = _ => SelectSpell(spellId),
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(spellId);
|
||||
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
|
||||
|
|
@ -399,8 +443,33 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
|
||||
private void SelectSpell(uint spellId)
|
||||
{
|
||||
// gmSpellbookUI::SetSelected @ 0x0048B540 toggles the separate overlay
|
||||
// for every row and asks the listbox to expose the matching row.
|
||||
_selectedSpell = spellId;
|
||||
SyncSpellSelection();
|
||||
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
|
||||
if (_spellList.GetItem(i) is UiCatalogSlot slot && slot.EntryId == spellId)
|
||||
{
|
||||
_spellList.ScrollItemIntoView(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestDeleteSelected()
|
||||
{
|
||||
// gmSpellbookUI::DeleteSpell @ 0x0048BD50 stores the spell id in the
|
||||
// shared callback dialog; DeleteSpellDialogCallback @ 0x0048BC10 sends
|
||||
// only after an affirmative result. The inbound remove notice owns UI state.
|
||||
if (_selectedSpell is not uint spellId
|
||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
|
||||
return;
|
||||
|
||||
string message = $"Are you sure you want to remove {metadata.Name} from your spellbook? "
|
||||
+ "You will no longer be able to cast this spell unless you learn it again!";
|
||||
_showConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted) _removeSpell(spellId);
|
||||
});
|
||||
}
|
||||
|
||||
private void SyncSpellSelection()
|
||||
|
|
@ -454,6 +523,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
SetClick(_spellTab, null);
|
||||
SetClick(_componentTab, null);
|
||||
_closeButton.OnClick = null;
|
||||
_deleteButton.OnClick = null;
|
||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
UiItemList? list = _lists[tab];
|
||||
if (list is null) continue;
|
||||
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
|
||||
int targetTab = tab;
|
||||
list.CatalogDropped = (payload, x, _) =>
|
||||
{
|
||||
if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
|
||||
};
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
|
|
@ -297,7 +303,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
Dropped = payload =>
|
||||
{
|
||||
if (payload is SpellFavoriteDragPayload favorite)
|
||||
DropFavorite(favorite, tab, position);
|
||||
DropFavorite(favorite, targetTab, position);
|
||||
else if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||
},
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(id);
|
||||
|
|
@ -322,6 +330,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private void DropSpellbookShortcut(
|
||||
SpellbookShortcutDragPayload payload,
|
||||
int targetTab,
|
||||
int targetPosition)
|
||||
{
|
||||
// gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes
|
||||
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
|
||||
// chooses the favorite tab/position and persists it.
|
||||
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
|
||||
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
|
||||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private static int DropPosition(UiItemList list, int localX)
|
||||
{
|
||||
int position = list.CellWidth <= 0f
|
||||
? list.GetNumUIItems()
|
||||
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
|
||||
return Math.Clamp(position, 0, list.GetNumUIItems());
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _favoritesDirty = true;
|
||||
|
||||
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
|
||||
|
|
@ -465,6 +494,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
|
||||
|
||||
/// <summary>
|
||||
/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
|
||||
/// lifting this payload never removes anything from its source collection.
|
||||
/// </summary>
|
||||
public sealed record SpellbookShortcutDragPayload(uint SpellId);
|
||||
|
||||
internal static class FavoriteListExtensions
|
||||
{
|
||||
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ public sealed record MagicRuntimeBindings(
|
|||
Action<int, int, uint> AddFavorite,
|
||||
Action<int, uint> RemoveFavorite,
|
||||
Action<uint> SendSpellbookFilter,
|
||||
Action<uint> RemoveSpell,
|
||||
Action<uint, uint> SetDesiredComponent,
|
||||
Func<double> ServerTime);
|
||||
|
||||
|
|
@ -707,6 +708,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private void MountSpellbook()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
SpellbookRowStyle? rowStyle;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
|
|
@ -716,13 +718,19 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
|
||||
}
|
||||
if (layout is null)
|
||||
if (layout is null || rowStyle is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
|
||||
Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiDatFont? rowFont = rowStyle.Value.FontDid == 0u
|
||||
? _bindings.Assets.DefaultFont
|
||||
: _bindings.Assets.ResolveFont(rowStyle.Value.FontDid)
|
||||
?? _bindings.Assets.DefaultFont;
|
||||
|
||||
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
|
|
@ -735,8 +743,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Magic.SelectObject,
|
||||
spellId => SpellcastingUiController?.AddFavorite(spellId),
|
||||
_bindings.Magic.SendSpellbookFilter,
|
||||
_bindings.Magic.RemoveSpell,
|
||||
(message, completed) => ShowConfirmation(message, completed),
|
||||
_bindings.Magic.SetDesiredComponent,
|
||||
() => CloseWindow(WindowNames.Spellbook));
|
||||
() => CloseWindow(WindowNames.Spellbook),
|
||||
rowStyle.Value,
|
||||
rowFont);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Icon-list cell for non-weenie catalog entries such as spells and components.
|
||||
/// It reuses the retail UIItemList geometry without pretending a spell id is an
|
||||
/// object GUID and without participating in item drag/drop.
|
||||
/// object GUID; catalog drags stay distinct from physical item drag/drop.
|
||||
/// </summary>
|
||||
public sealed class UiCatalogSlot : UiItemSlot
|
||||
{
|
||||
|
|
@ -17,6 +18,16 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
public string Label { get; set; } = string.Empty;
|
||||
public string? Detail { get; set; }
|
||||
public bool ShowLabel { get; init; }
|
||||
public uint BackgroundSprite { get; init; }
|
||||
public UiDatFont? LabelFont { get; init; }
|
||||
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
|
||||
public float IconLeft { get; init; }
|
||||
public float IconTop { get; init; }
|
||||
public float IconWidth { get; init; }
|
||||
public float IconHeight { get; init; }
|
||||
public float LabelLeft { get; init; }
|
||||
public float LabelWidth { get; init; }
|
||||
public bool SelectionBehindContent { get; init; }
|
||||
|
||||
public new Action? Clicked { get; set; }
|
||||
public new Action? DoubleClicked { get; set; }
|
||||
|
|
@ -67,31 +78,78 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
|
||||
if (BackgroundSprite != 0u && SpriteResolve is not null)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(BackgroundSprite);
|
||||
if (texture != 0u)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (SelectionBehindContent)
|
||||
DrawSelection(ctx);
|
||||
|
||||
float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width;
|
||||
float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height;
|
||||
if (CatalogIconTexture != 0)
|
||||
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
else if (SpriteResolve is not null && EmptySprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(EmptySprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (CatalogOverlayTexture != 0)
|
||||
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
|
||||
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
if (!SelectionBehindContent)
|
||||
DrawSelection(ctx);
|
||||
|
||||
if (ShowLabel)
|
||||
{
|
||||
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
|
||||
float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f;
|
||||
float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft);
|
||||
if (LabelFont is not null)
|
||||
{
|
||||
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
|
||||
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
|
||||
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor);
|
||||
}
|
||||
else
|
||||
{
|
||||
BitmapFont? font = ctx.DefaultFont;
|
||||
string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth);
|
||||
float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f);
|
||||
ctx.DrawString(text, labelLeft, y, LabelColor, font);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(Detail))
|
||||
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
|
||||
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelection(UiRenderContext ctx)
|
||||
{
|
||||
if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return;
|
||||
var (texture, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (texture != 0u)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
private static string FitText(string text, float width, Func<string, float> measure)
|
||||
{
|
||||
if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width)
|
||||
return text;
|
||||
|
||||
const string ellipsis = "...";
|
||||
float ellipsisWidth = measure(ellipsis);
|
||||
if (ellipsisWidth >= width) return string.Empty;
|
||||
int low = 0, high = text.Length;
|
||||
while (low < high)
|
||||
{
|
||||
int mid = (low + high + 1) / 2;
|
||||
if (measure(text[..mid]) + ellipsisWidth <= width) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return text[..low] + ellipsis;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
|
||||
/// physical-item drops continue through <see cref="DragHandler"/>.
|
||||
/// Coordinates are local to this list.
|
||||
/// </summary>
|
||||
public Action<object, int, int>? CatalogDropped { 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
|
||||
|
|
@ -204,6 +211,16 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (CatalogDropped is not null && e.Payload is not null)
|
||||
{
|
||||
if (e.Type is UiEventType.DragEnter or UiEventType.DragOver)
|
||||
return true;
|
||||
if (e.Type == UiEventType.DropReleased)
|
||||
{
|
||||
CatalogDropped(e.Payload, e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
|
||||
{
|
||||
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
|
||||
|
|
@ -213,6 +230,24 @@ public sealed class UiItemList : UiElement
|
|||
return base.OnEvent(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of the listbox expose-item behavior called by
|
||||
/// <c>gmSpellbookUI::SetSelected @ 0x0048B540</c>.
|
||||
/// </summary>
|
||||
public void ScrollItemIntoView(int index)
|
||||
{
|
||||
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
|
||||
int columns = Math.Max(1, Columns);
|
||||
int row = Flow == UiItemListFlow.ColumnMajor
|
||||
? index % Math.Max(1, RowCount(_cells.Count, columns))
|
||||
: index / columns;
|
||||
float top = row * CellHeight;
|
||||
float bottom = top + CellHeight;
|
||||
if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top));
|
||||
else if (bottom > Scroll.ScrollY + Height)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height));
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// The factory sets Width/Height AFTER construction, so re-layout each frame:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ public static class ClientCommandRequests
|
|||
public const uint AddSpellFavoriteOpcode = 0x01E3u;
|
||||
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
|
||||
public const uint SpellbookFilterOpcode = 0x0286u;
|
||||
public const uint RemoveSpellOpcode = 0x01A8u;
|
||||
public const uint LegacyFriendsOpcode = 0xF7CDu;
|
||||
|
||||
// Named-retail anchors:
|
||||
|
|
@ -205,6 +206,10 @@ public static class ClientCommandRequests
|
|||
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
|
||||
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
|
||||
|
||||
// CM_Magic::Event_RemoveSpell @ 0x006A3220.
|
||||
public static byte[] BuildRemoveSpell(uint sequence, uint spellId) =>
|
||||
BuildUInt32(sequence, RemoveSpellOpcode, spellId);
|
||||
|
||||
private static byte[] BuildParameterless(uint sequence, uint opcode)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
|
|
|
|||
|
|
@ -1480,6 +1480,12 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
|
||||
}
|
||||
|
||||
public void SendRemoveSpell(uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveSpell(seq, spellId));
|
||||
}
|
||||
|
||||
public void SendCastUntargetedSpell(uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
|
|
|
|||
32
tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs
Normal file
32
tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class SpellbookRowStyleTests
|
||||
{
|
||||
[Fact]
|
||||
public void InstalledDat_ResolvesPinnedRetailSpellShortcutPrototype()
|
||||
{
|
||||
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir)) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
SpellbookRowStyle style = Assert.IsType<SpellbookRowStyle>(
|
||||
SpellbookRowStyle.TryLoad(dats));
|
||||
|
||||
Assert.Equal(280f, style.Width);
|
||||
Assert.Equal(32f, style.Height);
|
||||
Assert.Equal(0f, style.IconLeft);
|
||||
Assert.Equal(32f, style.IconWidth);
|
||||
Assert.Equal(42f, style.LabelLeft);
|
||||
Assert.Equal(230f, style.LabelWidth);
|
||||
Assert.Equal(0x40000001u, style.FontDid);
|
||||
Assert.Equal(System.Numerics.Vector4.One, style.LabelColor);
|
||||
Assert.Equal(0x06001396u, style.BackgroundSprite);
|
||||
Assert.Equal(0x06001397u, style.SelectedSprite);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,30 +3,33 @@ using AcDream.App.UI;
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class SpellbookWindowControllerTests
|
||||
{
|
||||
private static readonly SpellbookRowStyle RowStyle = new(
|
||||
Width: 280f,
|
||||
Height: 32f,
|
||||
IconLeft: 0f,
|
||||
IconTop: 0f,
|
||||
IconWidth: 32f,
|
||||
IconHeight: 32f,
|
||||
LabelLeft: 42f,
|
||||
LabelWidth: 230f,
|
||||
FontDid: 0x40000001u,
|
||||
LabelColor: Vector4.One,
|
||||
BackgroundSprite: 0x06001396u,
|
||||
SelectedSprite: 0x06001397u);
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
var closed = new List<uint>();
|
||||
using SpellbookWindowController? controller = SpellbookWindowController.Bind(
|
||||
layout,
|
||||
new Spellbook(),
|
||||
new ClientObjectTable(),
|
||||
() => 1u,
|
||||
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
spellId => spellId,
|
||||
iconId => iconId,
|
||||
_ => 1,
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
(_, _) => { },
|
||||
() => closed.Add(1u));
|
||||
using SpellbookWindowController? controller = Bind(
|
||||
layout, new Spellbook(), close: () => closed.Add(1u));
|
||||
|
||||
Assert.NotNull(controller);
|
||||
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
|
||||
|
|
@ -53,4 +56,176 @@ public sealed class SpellbookWindowControllerTests
|
|||
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||
Assert.Empty(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u);
|
||||
book.OnSpellLearned(102u);
|
||||
book.OnSpellLearned(103u);
|
||||
var added = new List<uint>();
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout, book, addFavorite: added.Add)!;
|
||||
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SpellbookWindowController.SpellScrollbarId));
|
||||
|
||||
Assert.Equal(1, list.Columns);
|
||||
Assert.Equal(280f, list.CellWidth);
|
||||
Assert.Equal(32f, list.CellHeight);
|
||||
Assert.Same(list.Scroll, scrollbar.Model);
|
||||
Assert.Equal(3, list.GetNumUIItems());
|
||||
|
||||
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
|
||||
UiCatalogSlot third = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
|
||||
Assert.Equal([102u, 101u, 103u], [first.EntryId, second.EntryId, third.EntryId]);
|
||||
Assert.Equal("Creature One", first.Label);
|
||||
Assert.True(first.ShowLabel);
|
||||
Assert.Equal(RowStyle.BackgroundSprite, first.BackgroundSprite);
|
||||
Assert.Equal(RowStyle.SelectedSprite, first.SelectedSprite);
|
||||
Assert.True(first.SelectionBehindContent);
|
||||
Assert.Equal(RowStyle.LabelLeft, first.LabelLeft);
|
||||
|
||||
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
|
||||
Assert.True(second.Selected);
|
||||
Assert.False(first.Selected);
|
||||
|
||||
first.OnEvent(new UiEvent(
|
||||
0, first, UiEventType.DragBegin,
|
||||
Payload: new SpellbookShortcutDragPayload(first.EntryId)));
|
||||
Assert.True(first.Selected);
|
||||
Assert.IsType<SpellbookShortcutDragPayload>(first.GetDragPayload());
|
||||
|
||||
first.OnEvent(new UiEvent(0, first, UiEventType.DoubleClick));
|
||||
Assert.Equal([102u], added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchoolAndLevelFilters_RebuildFromAuthoritativeBitfield()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u); // War I
|
||||
book.OnSpellLearned(102u); // Creature I
|
||||
book.OnSpellLearned(103u); // War VIII
|
||||
var sent = new List<uint>();
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout, book, sendFilter: sent.Add)!;
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiButton war = Assert.IsType<UiButton>(layout.FindElement(0x1000029Bu));
|
||||
UiButton levelOne = Assert.IsType<UiButton>(layout.FindElement(0x1000029Cu));
|
||||
|
||||
Assert.True(war.Selected);
|
||||
Assert.Equal(3, list.GetNumUIItems());
|
||||
|
||||
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
|
||||
controller.Tick();
|
||||
Assert.Equal([0x3FF7u], sent);
|
||||
Assert.Single(VisibleSpellIds(list));
|
||||
Assert.Equal(102u, VisibleSpellIds(list)[0]);
|
||||
|
||||
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
|
||||
levelOne.OnEvent(new UiEvent(0, levelOne, UiEventType.Click));
|
||||
controller.Tick();
|
||||
Assert.Equal([103u], VisibleSpellIds(list));
|
||||
Assert.False(levelOne.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_UsesSharedConfirmation_AndWaitsForServerRemovalNotice()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.OnSpellLearned(101u);
|
||||
var removed = new List<uint>();
|
||||
string? prompt = null;
|
||||
Action<bool>? complete = null;
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout,
|
||||
book,
|
||||
removeSpell: removed.Add,
|
||||
showConfirmation: (message, callback) =>
|
||||
{
|
||||
prompt = message;
|
||||
complete = callback;
|
||||
})!;
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.SpellListId));
|
||||
UiButton delete = Assert.IsType<UiButton>(
|
||||
layout.FindElement(SpellbookWindowController.DeleteButtonId));
|
||||
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
Assert.Null(prompt);
|
||||
|
||||
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
|
||||
Assert.Equal(
|
||||
"Are you sure you want to remove War One from your spellbook? "
|
||||
+ "You will no longer be able to cast this spell unless you learn it again!",
|
||||
prompt);
|
||||
Assert.Empty(removed);
|
||||
complete!(false);
|
||||
Assert.Empty(removed);
|
||||
Assert.True(book.Knows(101u));
|
||||
|
||||
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
|
||||
complete!(true);
|
||||
Assert.Equal([101u], removed);
|
||||
Assert.True(book.Knows(101u));
|
||||
Assert.Single(VisibleSpellIds(list));
|
||||
|
||||
book.OnSpellForgotten(101u);
|
||||
controller.Tick();
|
||||
Assert.False(book.Knows(101u));
|
||||
Assert.Empty(VisibleSpellIds(list));
|
||||
}
|
||||
|
||||
private static SpellbookWindowController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
Action<uint>? addFavorite = null,
|
||||
Action<uint>? sendFilter = null,
|
||||
Action<uint>? removeSpell = null,
|
||||
Action<string, Action<bool>>? showConfirmation = null,
|
||||
Action? close = null)
|
||||
=> SpellbookWindowController.Bind(
|
||||
layout,
|
||||
spellbook,
|
||||
new ClientObjectTable(),
|
||||
() => 1u,
|
||||
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
spellId => spellId + 1000u,
|
||||
iconId => iconId,
|
||||
spellId => spellId == 103u ? 8 : 1,
|
||||
_ => { },
|
||||
addFavorite ?? (_ => { }),
|
||||
sendFilter ?? (_ => { }),
|
||||
removeSpell ?? (_ => { }),
|
||||
showConfirmation ?? ((_, _) => { }),
|
||||
(_, _) => { },
|
||||
close ?? (() => { }),
|
||||
RowStyle,
|
||||
rowFont: null);
|
||||
|
||||
private static Spellbook CreateSpellbook()
|
||||
{
|
||||
const string csv = "Spell ID,Name,School,IconId [Hex],SortKey\n"
|
||||
+ "101,War One,War Magic,0x06000001,2\n"
|
||||
+ "102,Creature One,Creature Enchantment,0x06000002,1\n"
|
||||
+ "103,War Eight,War Magic,0x06000003,3\n";
|
||||
return new Spellbook(SpellTable.LoadFromReader(new StringReader(csv)));
|
||||
}
|
||||
|
||||
private static uint[] VisibleSpellIds(UiItemList list)
|
||||
=> Enumerable.Range(0, list.GetNumUIItems())
|
||||
.Select(index => Assert.IsType<UiCatalogSlot>(list.GetItem(index)).EntryId)
|
||||
.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,35 @@ public sealed class SpellcastingUiControllerTests
|
|||
Assert.Equal([42u], spellbook.GetFavorites(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
|
||||
{
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
var spellbook = new Spellbook();
|
||||
spellbook.OnSpellLearned(42u, 1f);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
var added = new List<(int Tab, int Position, uint Spell)>();
|
||||
using SpellcastingUiController controller = Bind(
|
||||
layout, spellbook, objects, _ => { },
|
||||
(tab, position, spell) => added.Add((tab, position, spell)))!;
|
||||
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
|
||||
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
||||
|
||||
bool handled = list.OnEvent(new UiEvent(
|
||||
0,
|
||||
list,
|
||||
UiEventType.DropReleased,
|
||||
Data1: 5,
|
||||
Payload: new SpellbookShortcutDragPayload(42u)));
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Equal([(0, 0, 42u)], added);
|
||||
Assert.Equal([42u], spellbook.GetFavorites(0));
|
||||
Assert.True(spellbook.Knows(42u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
|
||||
{
|
||||
|
|
@ -137,7 +166,8 @@ public sealed class SpellcastingUiControllerTests
|
|||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Action<uint> useItem)
|
||||
Action<uint> useItem,
|
||||
Action<int, int, uint>? addFavorite = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
|
|
@ -147,7 +177,7 @@ public sealed class SpellcastingUiControllerTests
|
|||
item => item.ObjectId,
|
||||
useItem,
|
||||
new SelectionState(),
|
||||
(_, _, _) => { },
|
||||
addFavorite ?? ((_, _, _) => { }),
|
||||
(_, _) => { });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,4 +43,43 @@ public class UiItemListTests
|
|||
list.CellEmptySprite = 0u;
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogDrop_routes_payload_and_local_coordinates()
|
||||
{
|
||||
var list = new UiItemList();
|
||||
object payload = new();
|
||||
(object Payload, int X, int Y)? received = null;
|
||||
list.CatalogDropped = (value, x, y) => received = (value, x, y);
|
||||
|
||||
bool handled = list.OnEvent(new UiEvent(
|
||||
0, list, UiEventType.DropReleased, Data1: 17, Data2: 9, Payload: payload));
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.NotNull(received);
|
||||
Assert.Same(payload, received.Value.Payload);
|
||||
Assert.Equal((17, 9), (received.Value.X, received.Value.Y));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport()
|
||||
{
|
||||
var list = new UiItemList
|
||||
{
|
||||
Width = 100f,
|
||||
Height = 64f,
|
||||
Columns = 1,
|
||||
CellWidth = 100f,
|
||||
CellHeight = 32f,
|
||||
};
|
||||
list.Flush();
|
||||
for (int i = 0; i < 5; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
list.ScrollItemIntoView(3);
|
||||
Assert.Equal(64, list.Scroll.ScrollY);
|
||||
list.ScrollItemIntoView(2);
|
||||
Assert.Equal(64, list.Scroll.ScrollY);
|
||||
list.ScrollItemIntoView(0);
|
||||
Assert.Equal(0, list.Scroll.ScrollY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,16 +160,20 @@ public sealed class ClientCommandRequestsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFavoriteAndFilter_MatchRetailPayloads()
|
||||
public void RemoveFavoriteFilterAndLearnedSpell_MatchRetailPayloads()
|
||||
{
|
||||
byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6);
|
||||
byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u);
|
||||
byte[] removeLearned = ClientCommandRequests.BuildRemoveSpell(4u, 77u);
|
||||
|
||||
Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8));
|
||||
Assert.Equal(42u, Read(remove, 12));
|
||||
Assert.Equal(6u, Read(remove, 16));
|
||||
Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8));
|
||||
Assert.Equal(0xA5u, Read(filter, 12));
|
||||
Assert.Equal(16, removeLearned.Length);
|
||||
Assert.Equal(ClientCommandRequests.RemoveSpellOpcode, Read(removeLearned, 8));
|
||||
Assert.Equal(77u, Read(removeLearned, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue