fix(ui): restore retail magic shortcut bar
Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0527325b25
commit
e3605672bb
12 changed files with 466 additions and 62 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, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #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, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 1–9 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #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.
|
||||
|
|
|
|||
|
|
@ -165,6 +165,43 @@ Selecting a spell updates its name and current spell. Cast invokes
|
|||
retail spell input actions change tab/selection; the cast-current action casts
|
||||
the current selection. Favorite edits are sent to ACE.
|
||||
|
||||
The visible row is still an ordinary `UIElement_ItemList`; it is not a
|
||||
spell-specific strip painted by `gmSpellcastingUI`:
|
||||
|
||||
```text
|
||||
UIElement_ItemList::UpdateEmptySlots @ 0x004E3700
|
||||
if list is visible and FixedSlots (0x10000015) == -1:
|
||||
read row/column constraints 0x5F and 0x60
|
||||
when the horizontal constraint is unbounded:
|
||||
occupiedWidth = itemCount * cellWidth
|
||||
append floor((listWidth - occupiedWidth) / cellWidth) empty UIItems
|
||||
when the list shrinks, remove only trailing UIItems in ItemSlot_Empty state
|
||||
never remove an occupied trailing item merely because it exceeds the viewport
|
||||
|
||||
SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0
|
||||
for each UIItem in list order:
|
||||
if index < 9: SetShortcutNum(item, index, ghosted=false)
|
||||
else: SetShortcutNum(item, -1, ghosted=false)
|
||||
|
||||
UIElement_UIItem::SetShortcutNum @ 0x004E1590
|
||||
occupied spell shortcut -> regular digit array 0x10000042
|
||||
empty padding cell -> empty digit array 0x1000005E
|
||||
```
|
||||
|
||||
Thus the first nine visible cells use the same blue 1–9 UIItem overlays as the
|
||||
main status toolbar, including empty cells, while later cells retain only the
|
||||
authored `ItemSlot_Empty` background. The count is based on the reflowed list
|
||||
width (639 pixels at the 800-pixel design parent, 439 pixels inside the live
|
||||
600-pixel combat page, yielding 13 whole 32-pixel cells).
|
||||
|
||||
The Cast button is likewise fully DAT-authored rather than a synthesized red
|
||||
rectangle. Element `0x100000B2` is 75 by 32 pixels and its face is three stateful
|
||||
children authored against a 79-pixel design parent: left `0..31`, middle
|
||||
`32..46`, and right `47..78`. Apply each child's independent retail edge policy
|
||||
after the button reflows; at 75 pixels they become `0..31`, `32..42`, and
|
||||
`43..74`. Collapsing child-authored button media to the first child renders only
|
||||
the left cap and is not retail behavior.
|
||||
|
||||
Outbound character events (`CM_Character`):
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -552,8 +552,12 @@ public static class DatWidgetFactory
|
|||
// Spellbook school/level filters are Type-1 buttons with no parent media:
|
||||
// their 13x13 child carries Normal/Highlight art. Keep that child as the
|
||||
// face of this retained leaf instead of consuming and losing it.
|
||||
ElementInfo? face = info.StateMedia.Count == 0
|
||||
? FindStatefulFaceChild(info)
|
||||
ElementInfo[] authoredFaces = info.StateMedia.Count == 0
|
||||
? FindStatefulFaceChildren(info)
|
||||
: [];
|
||||
ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null;
|
||||
IReadOnlyList<ElementInfo>? faceSegments = authoredFaces.Length > 1
|
||||
? authoredFaces
|
||||
: null;
|
||||
|
||||
string? label = ResolveAuthoredString(info, stringResolve);
|
||||
|
|
@ -576,7 +580,7 @@ public static class DatWidgetFactory
|
|||
if (labelInfo.FontDid != 0u && fontResolve is not null)
|
||||
labelFont = fontResolve(labelInfo.FontDid) ?? elementFont;
|
||||
|
||||
var button = new UiButton(info, resolve, face)
|
||||
var button = new UiButton(info, resolve, face, faceSegments)
|
||||
{
|
||||
Label = label,
|
||||
LabelFont = labelFont,
|
||||
|
|
@ -638,11 +642,16 @@ public static class DatWidgetFactory
|
|||
}
|
||||
|
||||
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
|
||||
=> info.Children.FirstOrDefault(child =>
|
||||
=> FindStatefulFaceChildren(info).FirstOrDefault();
|
||||
|
||||
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info)
|
||||
=> info.Children.Where(child =>
|
||||
child.StateMedia.Count != 0
|
||||
&& child.StateMedia.Keys.Any(childState =>
|
||||
info.States.Values.Any(parentState =>
|
||||
string.Equals(parentState.Name, childState, StringComparison.Ordinal))));
|
||||
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
|
||||
.OrderBy(child => child.ReadOrder)
|
||||
.ToArray();
|
||||
|
||||
private static string? ResolveAuthoredString(
|
||||
ElementInfo info,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
private readonly Action<uint> _useItem;
|
||||
private readonly Action<int, int, uint>? _addFavorite;
|
||||
private readonly Action<int, uint>? _removeFavorite;
|
||||
private readonly UiShortcutSlotGraphics? _shortcutGraphics;
|
||||
private readonly UiElement[] _tabs;
|
||||
private readonly UiElement[] _groups;
|
||||
private readonly UiItemList?[] _lists;
|
||||
|
|
@ -77,7 +78,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
UiElement[] groups,
|
||||
UiItemList?[] lists,
|
||||
UiButton cast,
|
||||
UiElement endowmentHost)
|
||||
UiElement endowmentHost,
|
||||
UiShortcutSlotGraphics? shortcutGraphics)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_casting = casting;
|
||||
|
|
@ -89,6 +91,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_useItem = useItem;
|
||||
_addFavorite = addFavorite;
|
||||
_removeFavorite = removeFavorite;
|
||||
_shortcutGraphics = shortcutGraphics;
|
||||
_tabs = tabs;
|
||||
_groups = groups;
|
||||
_lists = lists;
|
||||
|
|
@ -139,7 +142,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
Action<uint> useItem,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite)
|
||||
Action<int, uint>? removeFavorite,
|
||||
UiShortcutSlotGraphics? shortcutGraphics = null)
|
||||
{
|
||||
if (layout.FindElement(CastButtonId) is not UiButton cast
|
||||
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
|
||||
|
|
@ -162,7 +166,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
||||
resolveItemDragIcon, useItem, selection,
|
||||
addFavorite, removeFavorite,
|
||||
tabs, groups, lists, cast, endowmentHost);
|
||||
tabs, groups, lists, cast, endowmentHost, shortcutGraphics);
|
||||
}
|
||||
|
||||
public void AddFavorite(uint spellId)
|
||||
|
|
@ -278,14 +282,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
list.CatalogDropped = (payload, x, _) =>
|
||||
{
|
||||
if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
|
||||
DropSpellbookShortcut(
|
||||
shortcut, targetTab, DropPosition(list, x, favorites.Count));
|
||||
};
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
list.Columns = 9;
|
||||
list.SingleRow = true;
|
||||
list.CellWidth = 32f;
|
||||
list.CellHeight = 32f;
|
||||
list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab);
|
||||
list.FillVisibleEmptySlots = true;
|
||||
foreach (uint spellId in favorites)
|
||||
{
|
||||
uint id = spellId;
|
||||
|
|
@ -313,10 +320,59 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
list.AddItem(slot);
|
||||
}
|
||||
}
|
||||
StampShortcutOverlays(list);
|
||||
}
|
||||
SelectTab(_activeTab);
|
||||
}
|
||||
|
||||
private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab)
|
||||
{
|
||||
int shortcutIndex = list.GetNumUIItems();
|
||||
UiCatalogSlot? slot = null;
|
||||
slot = new UiCatalogSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
EmptySprite = _shortcutGraphics is { EmptySprite: > 0u } graphics
|
||||
? graphics.EmptySprite
|
||||
: 0x060074CFu,
|
||||
Dropped = payload =>
|
||||
{
|
||||
int position = Math.Max(0, list.IndexOf(slot!));
|
||||
int favoriteCount = _spellbook.GetFavorites(targetTab).Count;
|
||||
position = Math.Min(position, favoriteCount);
|
||||
if (payload is SpellFavoriteDragPayload favorite)
|
||||
DropFavorite(favorite, targetTab, position);
|
||||
else if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||
},
|
||||
};
|
||||
ConfigureShortcutOverlay(slot, shortcutIndex);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void StampShortcutOverlays(UiItemList list)
|
||||
{
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
{
|
||||
UiItemSlot? slot = list.GetItem(i);
|
||||
if (slot is null) continue;
|
||||
ConfigureShortcutOverlay(slot, i);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureShortcutOverlay(UiItemSlot slot, int index)
|
||||
{
|
||||
slot.RegularDigits = _shortcutGraphics?.RegularDigits;
|
||||
slot.GhostedDigits = _shortcutGraphics?.GhostedDigits;
|
||||
slot.EmptyDigits = _shortcutGraphics?.EmptyDigits;
|
||||
if (_shortcutGraphics is { EmptySprite: > 0u } graphics)
|
||||
slot.EmptySprite = graphics.EmptySprite;
|
||||
if (index < 9)
|
||||
slot.SetShortcutNum(index, ghosted: false);
|
||||
else
|
||||
slot.ClearShortcutNum();
|
||||
}
|
||||
|
||||
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
|
||||
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
|
||||
|
||||
|
|
@ -343,12 +399,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private static int DropPosition(UiItemList list, int localX)
|
||||
private static int DropPosition(UiItemList list, int localX, int favoriteCount)
|
||||
{
|
||||
int position = list.CellWidth <= 0f
|
||||
? list.GetNumUIItems()
|
||||
? favoriteCount
|
||||
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
|
||||
return Math.Clamp(position, 0, list.GetNumUIItems());
|
||||
return Math.Clamp(position, 0, favoriteCount);
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _favoritesDirty = true;
|
||||
|
|
@ -381,9 +437,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
if (list.GetItem(i) is UiCatalogSlot slot)
|
||||
slot.Selected = slot.EntryId == _selected[tab];
|
||||
}
|
||||
_endowmentSlot.Selected = _endowmentItemId != 0u
|
||||
&& _endowmentSelected[_activeTab];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private GameplayConfirmationController? _gameplayConfirmationController;
|
||||
private RetailItemConfirmationController? _itemConfirmationController;
|
||||
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
||||
private UiShortcutSlotGraphics? _shortcutSlotGraphics;
|
||||
private bool _disposed;
|
||||
|
||||
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
|
||||
|
|
@ -558,11 +559,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
var (regular, ghosted, empty) = LoadToolbarDigits();
|
||||
UiShortcutSlotGraphics shortcutGraphics = LoadShortcutSlotGraphics();
|
||||
ToolbarRuntimeBindings b = _bindings.Toolbar;
|
||||
ToolbarController = Layout.ToolbarController.Bind(
|
||||
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
|
||||
regular, ghosted, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
|
||||
shortcutGraphics.RegularDigits, shortcutGraphics.GhostedDigits,
|
||||
shortcutGraphics.EmptyDigits, b.ItemInteraction,
|
||||
b.SendAddShortcut, b.SendRemoveShortcut,
|
||||
toggleCombat: b.ToggleCombat,
|
||||
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
|
||||
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
|
||||
|
|
@ -671,7 +674,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Magic.UseItem,
|
||||
_bindings.Magic.Selection,
|
||||
_bindings.Magic.AddFavorite,
|
||||
_bindings.Magic.RemoveFavorite);
|
||||
_bindings.Magic.RemoveFavorite,
|
||||
LoadShortcutSlotGraphics());
|
||||
if (spellcasting is null)
|
||||
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
|
||||
|
||||
|
|
@ -1012,9 +1016,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
|
||||
}
|
||||
|
||||
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
|
||||
private UiShortcutSlotGraphics LoadShortcutSlotGraphics()
|
||||
{
|
||||
if (_shortcutSlotGraphics is not null)
|
||||
return _shortcutSlotGraphics;
|
||||
|
||||
uint[]? regular = null, ghosted = null, empty = null;
|
||||
uint emptySprite = 0u;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
|
||||
|
|
@ -1031,6 +1039,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyNumber)
|
||||
&& emptyNumber.StateDesc?.Properties is { } emptyProps)
|
||||
empty = ReadDataIds(emptyProps, 0x1000005Eu);
|
||||
emptySprite = ItemListCellTemplate.ResolveEmptySprite(
|
||||
_bindings.Assets.Dats,
|
||||
CombatUiController.LayoutId,
|
||||
SpellcastingUiController.FavoriteListId);
|
||||
}
|
||||
|
||||
regular ??=
|
||||
|
|
@ -1043,8 +1055,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
|
||||
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
|
||||
];
|
||||
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: regular={regular.Length}, ghosted={ghosted.Length}, empty={empty?.Length ?? 0} entries.");
|
||||
return (regular, ghosted, empty);
|
||||
_shortcutSlotGraphics = new UiShortcutSlotGraphics(
|
||||
regular, ghosted, empty, emptySprite);
|
||||
Console.WriteLine(
|
||||
$"[D.5.1] shared shortcut graphics ready: regular={regular.Length}, " +
|
||||
$"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}, " +
|
||||
$"slot=0x{emptySprite:X8}.");
|
||||
return _shortcutSlotGraphics;
|
||||
}
|
||||
|
||||
private static uint[]? ReadDataIds(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly ElementInfo _mediaInfo;
|
||||
private readonly FaceSegment[] _faceSegments;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
private readonly HashSet<uint> _availableStates = new();
|
||||
private bool _pressed;
|
||||
|
|
@ -175,12 +176,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
|
||||
if (stateId == UiStateInfo.DirectStateId)
|
||||
{
|
||||
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
|
||||
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
|
||||
return false;
|
||||
ActiveState = "";
|
||||
return true;
|
||||
}
|
||||
if (_mediaInfo.States.TryGetValue(stateId, out var state))
|
||||
if (TryFindState(stateId, out var state))
|
||||
{
|
||||
ActiveState = state.Name;
|
||||
return true;
|
||||
|
|
@ -188,7 +189,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
stateName = RetailUiStateIds.StateName(stateId);
|
||||
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
|
||||
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
|
||||
{
|
||||
ActiveState = stateName;
|
||||
return true;
|
||||
|
|
@ -202,10 +203,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
public UiButton(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint tex, int w, int h)> resolve,
|
||||
ElementInfo? mediaInfo = null)
|
||||
ElementInfo? mediaInfo = null,
|
||||
IReadOnlyList<ElementInfo>? faceSegments = null)
|
||||
{
|
||||
_info = info;
|
||||
_info = info;
|
||||
_mediaInfo = mediaInfo ?? info;
|
||||
_faceSegments = faceSegments is null
|
||||
? []
|
||||
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
|
|
@ -213,9 +218,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// Retail layouts commonly declare an empty Normal_pressed descriptor while
|
||||
// supplying art only for Normal/Highlight. Treating that property-only state
|
||||
// as drawable briefly blanks the button during mouse-down.
|
||||
foreach (string stateName in _mediaInfo.StateMedia.Keys)
|
||||
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
||||
_availableStates.Add(stateId);
|
||||
if (_faceSegments.Length == 0)
|
||||
AddAvailableStates(_mediaInfo);
|
||||
else
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
AddAvailableStates(segment.Info);
|
||||
|
||||
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
|
||||
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
||||
|
|
@ -233,7 +240,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
|
||||
else if (HasStateMedia("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
|
||||
|
|
@ -258,24 +265,32 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// Returns 0 if neither exists.
|
||||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||
/// </summary>
|
||||
private uint ActiveFile()
|
||||
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
private uint ActiveFile(ElementInfo mediaInfo)
|
||||
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
uint file = ActiveFile();
|
||||
if (file != 0)
|
||||
if (_faceSegments.Length != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
|
||||
}
|
||||
else
|
||||
{
|
||||
uint file = ActiveFile(_mediaInfo);
|
||||
if (file != 0)
|
||||
{
|
||||
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
||||
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
||||
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
||||
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
|
||||
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
if (tex != 0 && tw != 0 && th != 0)
|
||||
{
|
||||
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
||||
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
||||
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
||||
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
|
||||
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +317,79 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
|
||||
{
|
||||
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
|
||||
return;
|
||||
var (texture, textureWidth, textureHeight) = _resolve(file);
|
||||
if (texture == 0 || textureWidth == 0 || textureHeight == 0)
|
||||
return;
|
||||
|
||||
// Same tiled/cropped media path as UiDatElement. Segment geometry is
|
||||
// first reflowed by its own four-edge retail layout policy.
|
||||
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
|
||||
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
|
||||
Vector4.One);
|
||||
}
|
||||
|
||||
private void AddAvailableStates(ElementInfo mediaInfo)
|
||||
{
|
||||
foreach (string stateName in mediaInfo.StateMedia.Keys)
|
||||
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
||||
_availableStates.Add(stateId);
|
||||
}
|
||||
|
||||
private bool HasStateMedia(string stateName)
|
||||
{
|
||||
if (_faceSegments.Length == 0)
|
||||
return _mediaInfo.StateMedia.ContainsKey(stateName);
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
if (segment.Info.StateMedia.ContainsKey(stateName))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryFindState(uint stateId, out UiStateInfo state)
|
||||
{
|
||||
if (_faceSegments.Length == 0)
|
||||
return _mediaInfo.States.TryGetValue(stateId, out state!);
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
if (segment.Info.States.TryGetValue(stateId, out state!))
|
||||
return true;
|
||||
state = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class FaceSegment
|
||||
{
|
||||
private readonly UiPixelRect _original;
|
||||
private readonly UiLayoutPolicy? _layout;
|
||||
|
||||
public FaceSegment(ElementInfo info)
|
||||
{
|
||||
Info = info;
|
||||
_original = UiPixelRect.FromPositionAndSize(
|
||||
(int)info.X, (int)info.Y, (int)info.Width, (int)info.Height);
|
||||
if (info.HasOriginalParentSize)
|
||||
_layout = new UiLayoutPolicy(
|
||||
info.Left, info.Top, info.Right, info.Bottom,
|
||||
_original,
|
||||
UiPixelRect.FromPositionAndSize(
|
||||
0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight));
|
||||
}
|
||||
|
||||
public ElementInfo Info { get; }
|
||||
|
||||
public UiPixelRect Rect(float parentWidth, float parentHeight)
|
||||
{
|
||||
if (_layout is null)
|
||||
return _original;
|
||||
return _layout.Apply(
|
||||
_original,
|
||||
UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight));
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
|
|
@ -393,6 +481,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
UpdateVisualState();
|
||||
}
|
||||
|
||||
internal int FaceSegmentCount => _faceSegments.Length;
|
||||
|
||||
internal IReadOnlyList<UiPixelRect> FaceSegmentRectsForTest()
|
||||
=> _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray();
|
||||
|
||||
public void OnGlobalUiTime(double nowSeconds)
|
||||
{
|
||||
if (!_hotClicking || !HotClickEnabled)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
public Action<object>? DragEnded { get; init; }
|
||||
public Action<object>? Dropped { get; init; }
|
||||
|
||||
protected override bool IsShortcutOccupied => EntryId != 0u;
|
||||
public override bool IsEmptySlot => EntryId == 0u;
|
||||
|
||||
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
|
||||
|
||||
public override bool IsDragSource => CatalogDragPayload is not null;
|
||||
|
|
@ -102,6 +105,11 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
if (CatalogOverlayTexture != 0)
|
||||
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
|
||||
// Spell favorites are UIElement_UIItems too. Retail's
|
||||
// SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same
|
||||
// SetShortcutNum path used by physical toolbar shortcuts.
|
||||
DrawShortcutOverlay(ctx);
|
||||
|
||||
if (!SelectionBehindContent)
|
||||
DrawSelection(ctx);
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public int GetNumUIItems() => _cells.Count;
|
||||
|
||||
/// <summary>Returns this cell's current retained-list position, matching
|
||||
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.</summary>
|
||||
public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell);
|
||||
|
||||
public UiItemSlot? GetItem(int index)
|
||||
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
|
||||
|
||||
|
|
@ -128,6 +132,24 @@ public sealed class UiItemList : UiElement
|
|||
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
|
||||
public float CellHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lay cells left-to-right in one row. Retail favorite spell lists use the
|
||||
/// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so
|
||||
/// the number of visible cells is determined by width rather than the nine
|
||||
/// shortcut-number overlays.
|
||||
/// </summary>
|
||||
public bool SingleRow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
|
||||
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
|
||||
/// </summary>
|
||||
public bool FillVisibleEmptySlots { get; set; }
|
||||
|
||||
/// <summary>Creates an empty cell for <see cref="FillVisibleEmptySlots"/>.
|
||||
/// Null uses a plain physical UiItemSlot.</summary>
|
||||
public Func<UiItemSlot>? EmptySlotFactory { get; set; }
|
||||
|
||||
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
|
||||
/// <paramref name="columns"/> columns.</summary>
|
||||
public static int RowCount(int cellCount, int columns)
|
||||
|
|
@ -177,7 +199,11 @@ public sealed class UiItemList : UiElement
|
|||
return;
|
||||
}
|
||||
|
||||
int cols = Columns < 1 ? 1 : Columns;
|
||||
UpdateEmptySlots();
|
||||
|
||||
int cols = SingleRow
|
||||
? Math.Max(1, _cells.Count)
|
||||
: Columns < 1 ? 1 : Columns;
|
||||
int cellH = (int)MathF.Round(CellHeight);
|
||||
|
||||
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
|
||||
|
|
@ -201,6 +227,35 @@ public sealed class UiItemList : UiElement
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when
|
||||
/// the viewport shrinks removes only empty cells from the tail. Occupied cells
|
||||
/// are never discarded merely because they extend beyond the viewport.
|
||||
/// </summary>
|
||||
private void UpdateEmptySlots()
|
||||
{
|
||||
if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f)
|
||||
return;
|
||||
|
||||
int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth));
|
||||
while (_cells.Count < visibleCellCount)
|
||||
{
|
||||
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
|
||||
cell.Anchors = AnchorEdges.None;
|
||||
_cells.Add(cell);
|
||||
AddChild(cell);
|
||||
}
|
||||
|
||||
while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot)
|
||||
{
|
||||
UiItemSlot cell = _cells[^1];
|
||||
_cells.RemoveAt(_cells.Count - 1);
|
||||
RemoveChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool ClipsChildren => CellWidth > 0f;
|
||||
|
||||
public void Flush()
|
||||
|
|
|
|||
|
|
@ -192,6 +192,17 @@ public class UiItemSlot : UiElement
|
|||
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
|
||||
public uint[]? EmptyDigits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this retained cell represents an occupied UIItem for shortcut-number
|
||||
/// presentation. Physical item cells use their weenie id; non-weenie catalog
|
||||
/// cells override this with their own identity instead of forging an item GUID.
|
||||
/// </summary>
|
||||
protected virtual bool IsShortcutOccupied => ItemId != 0;
|
||||
|
||||
/// <summary>True when this cell is an empty slot that a retail item list may
|
||||
/// add/remove while maintaining its visible empty-cell tail.</summary>
|
||||
public virtual bool IsEmptySlot => ItemId == 0;
|
||||
|
||||
/// <summary>Set the slot's shortcut position and ghosted state so the correct digit
|
||||
/// is drawn. Call with index 0..8 for the top row.</summary>
|
||||
public void SetShortcutNum(int index, bool ghosted)
|
||||
|
|
@ -213,8 +224,9 @@ public class UiItemSlot : UiElement
|
|||
/// </summary>
|
||||
internal uint[]? ActiveDigitArray()
|
||||
{
|
||||
bool occupied = ItemId != 0;
|
||||
return occupied ? (ShortcutGhosted ? GhostedDigits : RegularDigits) : EmptyDigits;
|
||||
return IsShortcutOccupied
|
||||
? (ShortcutGhosted ? GhostedDigits : RegularDigits)
|
||||
: EmptyDigits;
|
||||
}
|
||||
|
||||
// ── Events / draw ─────────────────────────────────────────────────────────
|
||||
|
|
@ -296,20 +308,7 @@ public class UiItemSlot : UiElement
|
|||
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
|
||||
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
|
||||
// full-cell Alphablend so the transparent region is invisible.
|
||||
if (ShortcutNum >= 0 && SpriteResolve is not null)
|
||||
{
|
||||
var arr = ActiveDigitArray();
|
||||
if (arr is not null && ShortcutNum < arr.Length)
|
||||
{
|
||||
uint did = arr[ShortcutNum];
|
||||
if (did != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(did);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawShortcutOverlay(ctx);
|
||||
|
||||
// Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
|
||||
// UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
|
||||
|
|
@ -381,4 +380,25 @@ public class UiItemSlot : UiElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
|
||||
/// call this after their icon layer so magic favorites and physical shortcuts
|
||||
/// use the same UIItem presentation path.</summary>
|
||||
protected void DrawShortcutOverlay(UiRenderContext ctx)
|
||||
{
|
||||
if (ShortcutNum < 0 || SpriteResolve is null)
|
||||
return;
|
||||
|
||||
uint[]? digits = ActiveDigitArray();
|
||||
if (digits is null || ShortcutNum >= digits.Length)
|
||||
return;
|
||||
|
||||
uint did = digits[ShortcutNum];
|
||||
if (did == 0)
|
||||
return;
|
||||
|
||||
var (texture, _, _) = SpriteResolve(did);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/AcDream.App/UI/UiShortcutSlotGraphics.cs
Normal file
12
src/AcDream.App/UI/UiShortcutSlotGraphics.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Shared DAT presentation assets for UIItem shortcut cells. Both gmToolbarUI and
|
||||
/// gmSpellcastingUI use UIElement_UIItem::SetShortcutNum @ 0x004E1590, so their
|
||||
/// digit arrays and empty-cell prototype must come from one resolved asset set.
|
||||
/// </summary>
|
||||
public sealed record UiShortcutSlotGraphics(
|
||||
uint[]? RegularDigits,
|
||||
uint[]? GhostedDigits,
|
||||
uint[]? EmptyDigits,
|
||||
uint EmptySprite);
|
||||
|
|
@ -11,6 +11,60 @@ public sealed class SpellcastingUiControllerTests
|
|||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton()
|
||||
{
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
|
||||
ApplyAnchors(layout.Root);
|
||||
var spellbook = new Spellbook();
|
||||
spellbook.OnSpellLearned(42u, 1f);
|
||||
spellbook.SetFavorite(0, 0, 42u);
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
|
||||
uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u];
|
||||
uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u];
|
||||
uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u];
|
||||
var graphics = new UiShortcutSlotGraphics(regular, ghosted, empty, 0x060074CFu);
|
||||
|
||||
using SpellcastingUiController controller = Bind(
|
||||
layout, spellbook, objects, _ => { }, shortcutGraphics: graphics)!;
|
||||
|
||||
UiElement group = layout.FindElement(0x100000AAu)!;
|
||||
UiItemList list = Descendants(group).OfType<UiItemList>().First();
|
||||
Assert.True(list.SingleRow);
|
||||
Assert.True(list.FillVisibleEmptySlots);
|
||||
Assert.Equal(439f, list.Width);
|
||||
Assert.Equal(13, list.GetNumUIItems());
|
||||
|
||||
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
|
||||
Assert.Equal(42u, favorite.EntryId);
|
||||
Assert.Equal(0, favorite.ShortcutNum);
|
||||
Assert.Same(regular, favorite.ActiveDigitArray());
|
||||
|
||||
for (int i = 1; i < 9; i++)
|
||||
{
|
||||
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(i));
|
||||
Assert.True(slot.IsEmptySlot);
|
||||
Assert.Equal(i, slot.ShortcutNum);
|
||||
Assert.Same(empty, slot.ActiveDigitArray());
|
||||
}
|
||||
for (int i = 9; i < 13; i++)
|
||||
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
|
||||
|
||||
var cast = Assert.IsType<UiButton>(
|
||||
layout.FindElement(SpellcastingUiController.CastButtonId));
|
||||
Assert.Equal((525f, 75f), (cast.Left, cast.Width));
|
||||
Assert.Equal(3, cast.FaceSegmentCount);
|
||||
Assert.Equal(
|
||||
[
|
||||
new UiPixelRect(0, 0, 31, 31),
|
||||
new UiPixelRect(32, 0, 42, 31),
|
||||
new UiPixelRect(43, 0, 74, 31),
|
||||
],
|
||||
cast.FaceSegmentRectsForTest());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
|
||||
{
|
||||
|
|
@ -167,7 +221,8 @@ public sealed class SpellcastingUiControllerTests
|
|||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Action<uint> useItem,
|
||||
Action<int, int, uint>? addFavorite = null)
|
||||
Action<int, int, uint>? addFavorite = null,
|
||||
UiShortcutSlotGraphics? shortcutGraphics = null)
|
||||
{
|
||||
var casting = new SpellCastingController(
|
||||
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
|
||||
|
|
@ -178,7 +233,17 @@ public sealed class SpellcastingUiControllerTests
|
|||
useItem,
|
||||
new SelectionState(),
|
||||
addFavorite ?? ((_, _, _) => { }),
|
||||
(_, _) => { });
|
||||
(_, _) => { },
|
||||
shortcutGraphics);
|
||||
}
|
||||
|
||||
private static void ApplyAnchors(UiElement parent)
|
||||
{
|
||||
foreach (UiElement child in parent.Children)
|
||||
{
|
||||
child.ApplyAnchor(parent.Width, parent.Height);
|
||||
ApplyAnchors(child);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,38 @@ public class UiItemListTests
|
|||
Assert.Equal((17, 9), (received.Value.X, received.Value.Y));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailHorizontalEmptySlots_FillViewport_AndShrinkOnlyEmptyTail()
|
||||
{
|
||||
var list = new UiItemList
|
||||
{
|
||||
Width = 130f,
|
||||
Height = 32f,
|
||||
CellWidth = 32f,
|
||||
CellHeight = 32f,
|
||||
SingleRow = true,
|
||||
FillVisibleEmptySlots = true,
|
||||
EmptySlotFactory = () => new UiCatalogSlot(),
|
||||
};
|
||||
list.Flush();
|
||||
list.AddItem(new UiCatalogSlot { EntryId = 42u });
|
||||
list.LayoutCells();
|
||||
|
||||
Assert.Equal(4, list.GetNumUIItems());
|
||||
Assert.Equal((0f, 32f, 64f, 96f),
|
||||
(list.GetItem(0)!.Left, list.GetItem(1)!.Left,
|
||||
list.GetItem(2)!.Left, list.GetItem(3)!.Left));
|
||||
|
||||
list.Width = 65f;
|
||||
list.LayoutCells();
|
||||
Assert.Equal(2, list.GetNumUIItems());
|
||||
Assert.Equal(42u, Assert.IsType<UiCatalogSlot>(list.GetItem(0)).EntryId);
|
||||
|
||||
list.Width = 16f;
|
||||
list.LayoutCells();
|
||||
Assert.Single(list.Children); // the occupied cell is never discarded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue