feat(ui): port retail component book rows
Instantiate gmSpellComponentUI category and component entries from LayoutDesc 0x21000033 instead of synthesizing catalog rows. Preserve localized category titles, authored textures and geometry, live icon/count/desired bindings, selected-object synchronization, and retail's inclusive 0..5000 desired-level contract. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
15bb61e05d
commit
a1175e6aac
13 changed files with 2395 additions and 60 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. 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`.
|
||||
- **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`.
|
||||
- **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,8 @@
|
|||
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 connected F.5 spellbook/favorite-bar/delete visual gate, followed
|
||||
by cast/enchantment presentation and the two-client portal/observer VFX gate.
|
||||
Next is the connected F.5 component-book/favorite-bar 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.
|
||||
|
||||
|
|
@ -656,6 +656,9 @@ 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.
|
||||
The component-book follow-up now instantiates retail's category and component
|
||||
listbox templates from LayoutDesc `0x21000033`, preserving localized headings,
|
||||
row/selection textures, icon/name/count columns, and desired-value editing.
|
||||
|
||||
**Phases to ship:**
|
||||
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
|
||||
|
|
|
|||
|
|
@ -292,6 +292,102 @@ 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.
|
||||
|
||||
### Component-book listbox templates and synchronization
|
||||
|
||||
Named-retail anchors:
|
||||
|
||||
- `gmSpellComponentUI::PostInit @ 0x0048A190`
|
||||
- `gmSpellComponentUI::ListenToElementMessage @ 0x0048A420`
|
||||
- `gmSpellComponentUI::SyncComponentRegionWithData @ 0x0048A620`
|
||||
- `gmSpellComponentUI::UpdateComponents @ 0x0048A910`
|
||||
- `gmSpellComponentUI::RecvNotice_SelectionChanged @ 0x00489C40`
|
||||
- `gmSpellComponentUI::UpdateBuyRates @ 0x00489FE0`
|
||||
|
||||
The component page is not a generic icon catalog. LayoutDesc `0x21000033`
|
||||
authors a listbox with two entry templates, referenced in order by list
|
||||
property `0x64`:
|
||||
|
||||
```text
|
||||
component list 0x10000464 (284 x 600)
|
||||
component scrollbar 0x10000465 (16 x 600)
|
||||
|
||||
template 0 / category 0x10000466 (284 x 32, UIElement_Text)
|
||||
background 0x06001392
|
||||
centered category font 0x40000004
|
||||
|
||||
template 1 / component 0x10000467 (284 x 32, UIElement_Field)
|
||||
normal background 0x06005E21
|
||||
Highlight background 0x06005E22
|
||||
icon region 0x10000468 (x=0, 32 x 32)
|
||||
name 0x10000469 (x=42, width=180, height=32)
|
||||
owned count 0x1000046A (x=224, y=0, width=60, height=15)
|
||||
desired purchase count 0x1000046B (x=224, y=15, width=60, height=15)
|
||||
```
|
||||
|
||||
The seven localized headings are string hashes in local string table
|
||||
`0x23000001`: `SCARABS`, `HERBS`, `POWDERED GEMS`, `ALCHEMICAL SUBSTANCES`,
|
||||
`TALISMANS`, `TAPERS`, and `PEAS`. They correspond in order to retail
|
||||
`SpellComponentCategory` values 0 through 6. Category 6 is Peas; treating it
|
||||
as an invented "Prismatic Components" group is not retail behavior.
|
||||
|
||||
```text
|
||||
PostInit():
|
||||
componentList = GetChildRecursive(0x10000464) as ListBox
|
||||
register component/player/selection notices
|
||||
if PlayerDescription already arrived: UpdateBuyRates(all components)
|
||||
|
||||
UpdateComponents(change):
|
||||
tracker = PlayerSystem.ComponentTracker
|
||||
insertion = 0
|
||||
for category in 0..6:
|
||||
if tracker.componentLists[category] is empty:
|
||||
remove a stale category entry for this category, if present
|
||||
continue
|
||||
|
||||
if entry[insertion] is not this category header:
|
||||
header = list.AddItemFromTemplateList(templateIndex=0, insertion)
|
||||
header.attribute[0x1000004D] = category
|
||||
header.text = localizedCategoryTitle[category]
|
||||
insertion++
|
||||
|
||||
reconcile component rows against tracker.componentLists[category]
|
||||
each new row = list.AddItemFromTemplateList(templateIndex=1, insertion)
|
||||
row.attribute[0x1000004C] = componentWCID
|
||||
row[0x10000468] = MakeIcon(componentWCID)
|
||||
row[0x10000469] = componentName
|
||||
row[0x1000046A] = totalOwnedCount
|
||||
row[0x1000046B] = desiredPurchaseCount
|
||||
row[0x1000046B].inputFilter = NumberInputFilter
|
||||
insertion++
|
||||
|
||||
ListenToElementMessage(message):
|
||||
if selection changed:
|
||||
if selected entry is a category:
|
||||
clear selected world object
|
||||
else if selected entry is a component:
|
||||
select the first owned object represented by that component region
|
||||
|
||||
if edit completed on 0x1000046B:
|
||||
parse integer
|
||||
if 0 <= value <= 5000:
|
||||
send SetDesiredComponentLevel(componentWCID, value)
|
||||
update local PlayerModule projection
|
||||
else:
|
||||
restore the authoritative desired value in the field
|
||||
|
||||
RecvNotice_SelectionChanged():
|
||||
if the selected world object belongs to a tracked component region:
|
||||
select and expose its matching component row
|
||||
else:
|
||||
clear component-list selection
|
||||
```
|
||||
|
||||
The retained port instantiates these resolved templates through
|
||||
`ComponentBookTemplateFactory` and `UiTemplateListSlot`, the architectural
|
||||
equivalent of `AddItemFromTemplateList`. Controllers bind only the live icon,
|
||||
texts, selection, and edit callbacks. They do not duplicate the row sprites,
|
||||
fonts, or geometry.
|
||||
|
||||
### Spellbook row, selection, filtering, dragging, and deletion
|
||||
|
||||
Named-retail anchors:
|
||||
|
|
|
|||
188
src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs
Normal file
188
src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates retail's category and component listbox templates from LayoutDesc
|
||||
/// 0x21000033. This is the retained equivalent of
|
||||
/// <c>UIElement_ListBox::AddItemFromTemplateList</c> used by
|
||||
/// <c>gmSpellComponentUI::UpdateComponents @ 0x0048A910</c>.
|
||||
/// </summary>
|
||||
public sealed class ComponentBookTemplateFactory
|
||||
{
|
||||
public const uint LayoutId = 0x21000033u;
|
||||
public const uint CategoryTemplateId = 0x10000466u;
|
||||
public const uint ComponentTemplateId = 0x10000467u;
|
||||
public const uint IconId = 0x10000468u;
|
||||
public const uint NameId = 0x10000469u;
|
||||
public const uint OwnedCountId = 0x1000046Au;
|
||||
public const uint DesiredCountId = 0x1000046Bu;
|
||||
|
||||
private const uint LocalStringTableId = 0x23000001u;
|
||||
private const uint HighlightState = UiButtonStateMachine.Highlight;
|
||||
|
||||
private static readonly (string Key, string Fallback)[] CategoryStrings =
|
||||
[
|
||||
("ID_SpellComp_Category_Scarabs", "SCARABS"),
|
||||
("ID_SpellComp_Category_Herbs", "HERBS"),
|
||||
("ID_SpellComp_Category_Gems", "POWDERED GEMS"),
|
||||
("ID_SpellComp_Category_Alchemical", "ALCHEMICAL SUBSTANCES"),
|
||||
("ID_SpellComp_Category_Talismans", "TALISMANS"),
|
||||
("ID_SpellComp_Category_Tapers", "TAPERS"),
|
||||
("ID_SpellComp_Category_Peas", "PEAS"),
|
||||
];
|
||||
|
||||
private readonly ElementInfo _categoryTemplate;
|
||||
private readonly ElementInfo _componentTemplate;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
|
||||
private readonly UiDatFont? _defaultFont;
|
||||
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
|
||||
private readonly string[] _categoryNames;
|
||||
|
||||
public ComponentBookTemplateFactory(
|
||||
ElementInfo categoryTemplate,
|
||||
ElementInfo componentTemplate,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
UiDatFont? defaultFont,
|
||||
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null,
|
||||
IReadOnlyList<string>? categoryNames = null)
|
||||
{
|
||||
_categoryTemplate = categoryTemplate;
|
||||
_componentTemplate = componentTemplate;
|
||||
_resolveSprite = resolveSprite;
|
||||
_defaultFont = defaultFont;
|
||||
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
|
||||
_categoryNames = categoryNames?.ToArray()
|
||||
?? CategoryStrings.Select(value => value.Fallback).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads and resolves the two template definitions while the caller holds the
|
||||
/// shared DAT lock. Fonts and localized category strings are captured eagerly,
|
||||
/// so later inventory refreshes instantiate rows without reading DAT files.
|
||||
/// </summary>
|
||||
public static ComponentBookTemplateFactory? TryLoad(
|
||||
DatCollection dats,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
UiDatFont? defaultFont,
|
||||
Func<uint, UiDatFont?>? resolveFont)
|
||||
{
|
||||
ElementInfo? category = LayoutImporter.ImportInfos(
|
||||
dats, LayoutId, CategoryTemplateId);
|
||||
ElementInfo? component = LayoutImporter.ImportInfos(
|
||||
dats, LayoutId, ComponentTemplateId);
|
||||
if (category is null || component is null)
|
||||
return null;
|
||||
|
||||
var fonts = new Dictionary<uint, UiDatFont?>();
|
||||
CaptureFonts(category, resolveFont, fonts);
|
||||
CaptureFonts(component, resolveFont, fonts);
|
||||
|
||||
var strings = new DatStringResolver(dats);
|
||||
string[] categoryNames = CategoryStrings
|
||||
.Select(value => strings.Resolve(
|
||||
LocalStringTableId,
|
||||
DatStringResolver.ComputeHash(value.Key))
|
||||
?? value.Fallback)
|
||||
.ToArray();
|
||||
|
||||
return new ComponentBookTemplateFactory(
|
||||
category, component, resolveSprite, defaultFont, fonts, categoryNames);
|
||||
}
|
||||
|
||||
public UiTemplateListSlot CreateCategoryRow(uint category)
|
||||
{
|
||||
ImportedLayout content = Build(_categoryTemplate);
|
||||
if (content.Root is not UiText title)
|
||||
throw new InvalidOperationException(
|
||||
"Retail component category template did not resolve to UIElement_Text.");
|
||||
|
||||
string label = category < _categoryNames.Length
|
||||
? _categoryNames[category]
|
||||
: "OTHER COMPONENTS";
|
||||
title.LinesProvider = () => [new UiText.Line(label, title.DefaultColor)];
|
||||
|
||||
return new UiTemplateListSlot(
|
||||
content, uint.MaxValue, content.Root is IUiDatStateful stateful
|
||||
? stateful.ActiveRetailStateId
|
||||
: UiStateInfo.DirectStateId, selectedState: null);
|
||||
}
|
||||
|
||||
public ComponentRow CreateComponentRow(
|
||||
uint componentId,
|
||||
uint iconTexture,
|
||||
string name,
|
||||
int ownedCount,
|
||||
uint desiredCount)
|
||||
{
|
||||
ImportedLayout content = Build(_componentTemplate);
|
||||
UiElement iconHost = Required(content, IconId);
|
||||
UiText nameText = Required<UiText>(content, NameId);
|
||||
UiText ownedText = Required<UiText>(content, OwnedCountId);
|
||||
UiField desiredField = Required<UiField>(content, DesiredCountId);
|
||||
|
||||
var icon = new UiTextureElement
|
||||
{
|
||||
Width = iconHost.Width,
|
||||
Height = iconHost.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Texture = iconTexture,
|
||||
};
|
||||
iconHost.AddChild(icon);
|
||||
|
||||
nameText.LinesProvider = () => [new UiText.Line(name, nameText.DefaultColor)];
|
||||
ownedText.LinesProvider = () =>
|
||||
[new UiText.Line(ownedCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
ownedText.DefaultColor)];
|
||||
|
||||
// NumberInputFilter + the 0..5000 validation are installed by the controller,
|
||||
// matching gmSpellComponentUI::ListenToElementMessage @ 0x0048A420.
|
||||
desiredField.ClearOnSubmit = false;
|
||||
desiredField.RecordHistory = false;
|
||||
desiredField.SelectAllOnFocus = true;
|
||||
desiredField.CharacterFilter = char.IsAsciiDigit;
|
||||
desiredField.SetText(desiredCount.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
uint normalState = content.Root is IUiDatStateful stateful
|
||||
? stateful.ActiveRetailStateId
|
||||
: UiStateInfo.DirectStateId;
|
||||
var slot = new UiTemplateListSlot(
|
||||
content, componentId, normalState, HighlightState);
|
||||
return new ComponentRow(slot, desiredField);
|
||||
}
|
||||
|
||||
private ImportedLayout Build(ElementInfo template)
|
||||
=> LayoutImporter.Build(
|
||||
template,
|
||||
_resolveSprite,
|
||||
_defaultFont,
|
||||
did => _fonts.TryGetValue(did, out UiDatFont? font) ? font : _defaultFont);
|
||||
|
||||
private static T Required<T>(ImportedLayout content, uint id)
|
||||
where T : UiElement
|
||||
=> content.FindElement(id) as T
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail component template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
|
||||
|
||||
private static UiElement Required(ImportedLayout content, uint id)
|
||||
=> content.FindElement(id)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail component template element 0x{id:X8} is missing.");
|
||||
|
||||
private static void CaptureFonts(
|
||||
ElementInfo info,
|
||||
Func<uint, UiDatFont?>? resolveFont,
|
||||
Dictionary<uint, UiDatFont?> fonts)
|
||||
{
|
||||
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
|
||||
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
|
||||
foreach (ElementInfo child in info.Children)
|
||||
CaptureFonts(child, resolveFont, fonts);
|
||||
}
|
||||
|
||||
public readonly record struct ComponentRow(
|
||||
UiTemplateListSlot Slot,
|
||||
UiField DesiredField);
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -48,6 +48,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly Func<uint, uint> _resolveComponentIcon;
|
||||
private readonly Func<uint, int> _spellLevel;
|
||||
|
|
@ -66,10 +67,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly UiButton _deleteButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
private readonly ComponentBookTemplateFactory _componentTemplates;
|
||||
private readonly List<(UiButton Button, uint Mask)> _filters = new();
|
||||
private readonly SpellbookRowStyle _rowStyle;
|
||||
private readonly UiDatFont? _rowFont;
|
||||
private uint? _selectedSpell;
|
||||
private uint? _selectedComponent;
|
||||
private bool _spellsDirty;
|
||||
private bool _componentsDirty;
|
||||
private bool _componentEditActive;
|
||||
|
|
@ -81,6 +84,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
SelectionState selection,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
|
|
@ -99,6 +103,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
UiButton deleteButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList,
|
||||
ComponentBookTemplateFactory componentTemplates,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
|
|
@ -106,6 +111,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_components = components;
|
||||
_selection = selection;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveComponentIcon = resolveComponentIcon;
|
||||
_spellLevel = spellLevel;
|
||||
|
|
@ -124,6 +130,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_deleteButton = deleteButton;
|
||||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
_componentTemplates = componentTemplates;
|
||||
_rowStyle = rowStyle;
|
||||
_rowFont = rowFont;
|
||||
|
||||
|
|
@ -148,6 +155,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
ShowPage(SpellbookWindowPage.Spells);
|
||||
RebuildAll();
|
||||
}
|
||||
|
|
@ -160,6 +168,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
SelectionState selection,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
|
|
@ -170,6 +179,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action<string, Action<bool>> showConfirmation,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close,
|
||||
ComponentBookTemplateFactory componentTemplates,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
|
|
@ -199,13 +209,14 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
}
|
||||
|
||||
return new SpellbookWindowController(
|
||||
layout, spellbook, objects, playerGuid, components,
|
||||
layout, spellbook, objects, playerGuid, components, selection,
|
||||
resolveSpellIcon, resolveComponentIcon,
|
||||
spellLevel, selectObject,
|
||||
addFavorite, sendFilter, removeSpell, showConfirmation,
|
||||
setDesiredComponent, close,
|
||||
spellPage, componentPage, spellTab, componentTab, closeButton,
|
||||
deleteButton, spellList, componentList, rowStyle, rowFont);
|
||||
deleteButton, spellList, componentList, componentTemplates,
|
||||
rowStyle, rowFont);
|
||||
}
|
||||
|
||||
public void ShowPage(SpellbookWindowPage page)
|
||||
|
|
@ -337,40 +348,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
if (category != nextCategory)
|
||||
{
|
||||
category = nextCategory;
|
||||
_componentList.AddItem(new UiCatalogSlot
|
||||
UiTemplateListSlot categoryRow =
|
||||
_componentTemplates.CreateCategoryRow(nextCategory);
|
||||
categoryRow.Clicked = () =>
|
||||
{
|
||||
EntryId = uint.MaxValue,
|
||||
Label = ComponentCategoryName(nextCategory),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
});
|
||||
_selectedComponent = null;
|
||||
SyncComponentSelection();
|
||||
_selectObject(0u);
|
||||
};
|
||||
_componentList.AddItem(categoryRow);
|
||||
}
|
||||
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
|
||||
var row = new UiCatalogSlot
|
||||
{
|
||||
EntryId = componentId,
|
||||
CatalogIconTexture = _resolveComponentIcon(
|
||||
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
|
||||
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
|
||||
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
};
|
||||
var desiredField = new UiField
|
||||
{
|
||||
Left = width - 47f,
|
||||
Top = 4f,
|
||||
Width = 43f,
|
||||
Height = 24f,
|
||||
MaxCharacters = 4,
|
||||
ClearOnSubmit = false,
|
||||
RecordHistory = false,
|
||||
SelectAllOnFocus = true,
|
||||
RightAligned = true,
|
||||
CharacterFilter = char.IsAsciiDigit,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
|
||||
};
|
||||
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
|
||||
string componentName = string.IsNullOrWhiteSpace(pack.Name)
|
||||
? descriptor?.Name ?? $"Component {componentId}"
|
||||
: pack.Name;
|
||||
ComponentBookTemplateFactory.ComponentRow row =
|
||||
_componentTemplates.CreateComponentRow(
|
||||
componentId,
|
||||
_resolveComponentIcon(
|
||||
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
|
||||
componentName,
|
||||
packGroup.Quantity,
|
||||
desired);
|
||||
UiField desiredField = row.DesiredField;
|
||||
desiredField.OnFocusGained = () => _componentEditActive = true;
|
||||
uint committed = desired;
|
||||
void Commit(string value)
|
||||
|
|
@ -392,25 +392,18 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Commit(value);
|
||||
_componentEditActive = false;
|
||||
};
|
||||
row.AddChild(desiredField);
|
||||
row.Clicked = () => _selectObject(pack.ObjectId);
|
||||
_componentList.AddItem(row);
|
||||
row.Slot.Clicked = () =>
|
||||
{
|
||||
_selectedComponent = componentId;
|
||||
SyncComponentSelection();
|
||||
_selectObject(pack.ObjectId);
|
||||
};
|
||||
_componentList.AddItem(row.Slot);
|
||||
}
|
||||
}
|
||||
SyncComponentSelectionFromWorld();
|
||||
}
|
||||
|
||||
private static string ComponentCategoryName(uint category) => category switch
|
||||
{
|
||||
0u => "Scarabs",
|
||||
1u => "Herbs",
|
||||
2u => "Powdered Gems",
|
||||
3u => "Alchemical Substances",
|
||||
4u => "Talismans",
|
||||
5u => "Tapers",
|
||||
6u => "Prismatic Components",
|
||||
_ => "Other Components",
|
||||
};
|
||||
|
||||
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
|
||||
{
|
||||
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
|
||||
|
|
@ -479,6 +472,23 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
slot.Selected = slot.EntryId == _selectedSpell;
|
||||
}
|
||||
|
||||
private void SyncComponentSelection()
|
||||
{
|
||||
for (int i = 0; i < _componentList.GetNumUIItems(); i++)
|
||||
if (_componentList.GetItem(i) is UiTemplateListSlot slot)
|
||||
slot.SetSelected(slot.EntryId == _selectedComponent);
|
||||
}
|
||||
|
||||
private void SyncComponentSelectionFromWorld()
|
||||
{
|
||||
_selectedComponent = _selection.SelectedObjectId is uint selected
|
||||
&& _objects.Get(selected) is { } item
|
||||
&& IsComponent(item)
|
||||
? item.WeenieClassId
|
||||
: null;
|
||||
SyncComponentSelection();
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_spellsDirty)
|
||||
|
|
@ -505,6 +515,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
}
|
||||
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
|
||||
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
|
||||
private void OnSelectionChanged(SelectionTransition _) => SyncComponentSelectionFromWorld();
|
||||
private bool IsComponent(ClientObject item)
|
||||
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
|
||||
public void OnShown() => RebuildAll();
|
||||
|
|
@ -520,6 +531,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
RetailTabBinding.SetClick(_spellTab, null);
|
||||
RetailTabBinding.SetClick(_componentTab, null);
|
||||
_closeButton.OnClick = null;
|
||||
|
|
|
|||
72
src/AcDream.App/UI/Layout/UiTemplateListSlot.cs
Normal file
72
src/AcDream.App/UI/Layout/UiTemplateListSlot.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// One runtime listbox entry instantiated from an authored LayoutDesc template.
|
||||
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList</c> creates exactly this
|
||||
/// ownership shape: the list owns the entry while the template owns its visuals.
|
||||
/// </summary>
|
||||
public sealed class UiTemplateListSlot : UiItemSlot
|
||||
{
|
||||
private readonly IUiDatStateful? _statefulRoot;
|
||||
private readonly uint _normalState;
|
||||
private readonly uint? _selectedState;
|
||||
private bool? _stateWasSelected;
|
||||
|
||||
public UiTemplateListSlot(
|
||||
ImportedLayout content,
|
||||
uint entryId,
|
||||
uint normalState,
|
||||
uint? selectedState)
|
||||
{
|
||||
Content = content;
|
||||
EntryId = entryId;
|
||||
_normalState = normalState;
|
||||
_selectedState = selectedState;
|
||||
_statefulRoot = content.Root as IUiDatStateful;
|
||||
|
||||
Width = content.Root.Width;
|
||||
Height = content.Root.Height;
|
||||
content.Root.Left = 0f;
|
||||
content.Root.Top = 0f;
|
||||
content.Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom;
|
||||
AddChild(content.Root);
|
||||
}
|
||||
|
||||
public ImportedLayout Content { get; }
|
||||
public uint EntryId { get; }
|
||||
public new Action? Clicked { get; set; }
|
||||
|
||||
public override bool HandlesClick => Clicked is not null;
|
||||
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
Selected = selected;
|
||||
ApplySelectionState();
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown)
|
||||
return true;
|
||||
if (e.Type == UiEventType.Click && Clicked is not null)
|
||||
{
|
||||
Clicked();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
=> ApplySelectionState();
|
||||
|
||||
private void ApplySelectionState()
|
||||
{
|
||||
if (_stateWasSelected == Selected)
|
||||
return;
|
||||
|
||||
_stateWasSelected = Selected;
|
||||
_statefulRoot?.TrySetRetailState(
|
||||
Selected && _selectedState.HasValue ? _selectedState.Value : _normalState);
|
||||
}
|
||||
}
|
||||
|
|
@ -709,6 +709,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
ImportedLayout? layout;
|
||||
SpellbookRowStyle? rowStyle;
|
||||
ComponentBookTemplateFactory? componentTemplates;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
|
|
@ -719,10 +720,16 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
|
||||
componentTemplates = ComponentBookTemplateFactory.TryLoad(
|
||||
_bindings.Assets.Dats,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null || rowStyle is null)
|
||||
if (layout is null || rowStyle is null || componentTemplates is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
|
||||
Console.WriteLine(
|
||||
"[M3] spellbook: layout, spell row, or component list templates not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -737,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Magic.Objects,
|
||||
_bindings.Magic.PlayerGuid,
|
||||
_bindings.Magic.Components,
|
||||
_bindings.Magic.Selection,
|
||||
_bindings.Magic.ResolveSpellIcon,
|
||||
_bindings.Magic.ResolveComponentIcon,
|
||||
_bindings.Magic.SpellLevel,
|
||||
|
|
@ -747,6 +755,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
(message, completed) => ShowConfirmation(message, completed),
|
||||
_bindings.Magic.SetDesiredComponent,
|
||||
() => CloseWindow(WindowNames.Spellbook),
|
||||
componentTemplates,
|
||||
rowStyle.Value,
|
||||
rowFont);
|
||||
if (controller is null)
|
||||
|
|
|
|||
23
src/AcDream.App/UI/UiTextureElement.cs
Normal file
23
src/AcDream.App/UI/UiTextureElement.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-owned texture content inside an authored retained-UI region. LayoutDesc
|
||||
/// still owns the region's geometry and chrome; controllers supply only the live
|
||||
/// texture handle (for example a composed item icon).
|
||||
/// </summary>
|
||||
public sealed class UiTextureElement : UiElement
|
||||
{
|
||||
public UiTextureElement() => ClickThrough = true;
|
||||
|
||||
public uint Texture { get; set; }
|
||||
public Vector4 Tint { get; set; } = Vector4.One;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (Texture != 0u)
|
||||
ctx.DrawSprite(Texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadSpellbookInfos()
|
||||
=> LoadInfos("spellbook_21000034.json");
|
||||
|
||||
public static ElementInfo LoadComponentCategoryTemplateInfos()
|
||||
=> LoadInfos("component_category_21000033_10000466.json");
|
||||
|
||||
public static ElementInfo LoadComponentRowTemplateInfos()
|
||||
=> LoadInfos("component_row_21000033_10000467.json");
|
||||
|
||||
public static ImportedLayout LoadPowerbar()
|
||||
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var localStrings = new DatStringResolver(dats);
|
||||
foreach ((string key, string expected) in new[]
|
||||
{
|
||||
("ID_SpellComp_Category_Scarabs", "SCARABS"),
|
||||
("ID_SpellComp_Category_Herbs", "HERBS"),
|
||||
("ID_SpellComp_Category_Gems", "POWDERED GEMS"),
|
||||
("ID_SpellComp_Category_Alchemical", "ALCHEMICAL SUBSTANCES"),
|
||||
("ID_SpellComp_Category_Talismans", "TALISMANS"),
|
||||
("ID_SpellComp_Category_Tapers", "TAPERS"),
|
||||
("ID_SpellComp_Category_Peas", "PEAS"),
|
||||
})
|
||||
{
|
||||
Assert.Equal(expected, localStrings.Resolve(
|
||||
0x23000001u, DatStringResolver.ComputeHash(key)));
|
||||
}
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
|
||||
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
|
||||
|
|
@ -102,6 +118,26 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
|
||||
}
|
||||
|
||||
foreach ((uint rootId, string fileName) in new[]
|
||||
{
|
||||
(0x10000466u,
|
||||
"component_category_21000033_10000466.json"),
|
||||
(0x10000467u,
|
||||
"component_row_21000033_10000467.json"),
|
||||
})
|
||||
{
|
||||
ElementInfo? componentTemplate = LayoutImporter.ImportInfos(
|
||||
dats, 0x21000033u, rootId);
|
||||
Assert.NotNull(componentTemplate);
|
||||
var componentJson = JsonSerializer.Serialize(componentTemplate,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), componentJson);
|
||||
}
|
||||
|
||||
foreach (var (layoutId, fileName) in Layouts)
|
||||
{
|
||||
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Spells;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using System.Numerics;
|
||||
|
||||
|
|
@ -238,6 +239,130 @@ public sealed class SpellbookWindowControllerTests
|
|||
Assert.Empty(VisibleSpellIds(list));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Components_UseAuthoredCategoryAndItemTemplates_WithRetailCountsAndSelection()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadSpellbook();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 10u,
|
||||
WeenieClassId = 100u,
|
||||
Name = "Copper Scarab",
|
||||
Type = ItemType.SpellComponents,
|
||||
IconId = 0x06000100u,
|
||||
StackSize = 2,
|
||||
ContainerId = 1u,
|
||||
IsComponentPack = true,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 11u,
|
||||
WeenieClassId = 101u,
|
||||
Name = "Diamond Scarab",
|
||||
Type = ItemType.SpellComponents,
|
||||
IconId = 0x06000101u,
|
||||
StackSize = 1,
|
||||
ContainerId = 1u,
|
||||
IsComponentPack = true,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 12u,
|
||||
WeenieClassId = 200u,
|
||||
Name = "Amaranth",
|
||||
Type = ItemType.SpellComponents,
|
||||
IconId = 0x06000200u,
|
||||
StackSize = 3,
|
||||
ContainerId = 1u,
|
||||
IsComponentPack = true,
|
||||
});
|
||||
var components = new Dictionary<uint, SpellComponentDescriptor>
|
||||
{
|
||||
[100u] = new(100u, "Copper Scarab", 0u, 0x06000100u),
|
||||
[101u] = new(101u, "Diamond Scarab", 0u, 0x06000101u),
|
||||
[200u] = new(200u, "Amaranth", 1u, 0x06000200u),
|
||||
};
|
||||
Spellbook book = CreateSpellbook();
|
||||
book.SetDesiredComponent(100u, 7u);
|
||||
var selected = new List<uint>();
|
||||
var desired = new List<(uint Component, uint Amount)>();
|
||||
var selection = new SelectionState();
|
||||
|
||||
using SpellbookWindowController controller = Bind(
|
||||
layout,
|
||||
book,
|
||||
objects: objects,
|
||||
components: components,
|
||||
selection: selection,
|
||||
selectObject: objectId =>
|
||||
{
|
||||
selected.Add(objectId);
|
||||
selection.Select(objectId, SelectionChangeSource.Inventory);
|
||||
},
|
||||
setDesiredComponent: (component, amount) => desired.Add((component, amount)),
|
||||
resolveComponentIcon: icon => icon + 0x1000u)!;
|
||||
controller.ShowPage(SpellbookWindowPage.Components);
|
||||
|
||||
UiItemList list = Assert.IsType<UiItemList>(
|
||||
layout.FindElement(SpellbookWindowController.ComponentListId)?.Children.Single());
|
||||
Assert.Equal(5, list.GetNumUIItems());
|
||||
Assert.Same(
|
||||
list.Scroll,
|
||||
Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SpellbookWindowController.ComponentScrollbarId)).Model);
|
||||
|
||||
UiTemplateListSlot scarabs = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
|
||||
UiText scarabTitle = Assert.IsType<UiText>(scarabs.Content.Root);
|
||||
Assert.Equal("SCARABS", Assert.Single(scarabTitle.LinesProvider()).Text);
|
||||
Assert.Equal(0x06001392u, scarabTitle.BackgroundSprite);
|
||||
|
||||
UiTemplateListSlot copper = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
|
||||
UiDatElement copperRoot = Assert.IsType<UiDatElement>(copper.Content.Root);
|
||||
Assert.Equal((0x06005E21u, 1), copperRoot.ActiveMedia());
|
||||
Assert.Equal("Copper Scarab", Assert.Single(
|
||||
Assert.IsType<UiText>(copper.Content.FindElement(
|
||||
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
|
||||
Assert.Equal("2", Assert.Single(
|
||||
Assert.IsType<UiText>(copper.Content.FindElement(
|
||||
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
|
||||
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
|
||||
ComponentBookTemplateFactory.DesiredCountId));
|
||||
Assert.Equal((224f, 15f, 60f, 15f),
|
||||
(desiredField.Left, desiredField.Top, desiredField.Width, desiredField.Height));
|
||||
Assert.True(desiredField.RightAligned);
|
||||
Assert.Equal("7", desiredField.Text);
|
||||
UiElement iconHost = Assert.IsAssignableFrom<UiElement>(copper.Content.FindElement(
|
||||
ComponentBookTemplateFactory.IconId));
|
||||
Assert.Equal(
|
||||
0x06001100u,
|
||||
Assert.IsType<UiTextureElement>(Assert.Single(iconHost.Children)).Texture);
|
||||
|
||||
copper.OnEvent(new UiEvent(0u, copper, UiEventType.Click));
|
||||
Assert.Equal([10u], selected);
|
||||
Assert.True(copper.Selected);
|
||||
Assert.Equal(UiButtonStateMachine.Highlight, copperRoot.ActiveRetailStateId);
|
||||
Assert.Equal((0x06005E22u, 1), copperRoot.ActiveMedia());
|
||||
|
||||
desiredField.SetText("21");
|
||||
desiredField.OnSubmit!(desiredField.Text);
|
||||
Assert.Equal([(100u, 21u)], desired);
|
||||
desiredField.SetText("5001");
|
||||
desiredField.OnSubmit!(desiredField.Text);
|
||||
Assert.Equal("21", desiredField.Text);
|
||||
Assert.Equal([(100u, 21u)], desired);
|
||||
|
||||
UiTemplateListSlot herbs = Assert.IsType<UiTemplateListSlot>(list.GetItem(3));
|
||||
Assert.Equal("HERBS", Assert.Single(
|
||||
Assert.IsType<UiText>(herbs.Content.Root).LinesProvider()).Text);
|
||||
UiTemplateListSlot amaranth = Assert.IsType<UiTemplateListSlot>(list.GetItem(4));
|
||||
selection.Select(12u, SelectionChangeSource.Radar);
|
||||
Assert.True(amaranth.Selected);
|
||||
Assert.False(copper.Selected);
|
||||
selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
|
||||
Assert.False(amaranth.Selected);
|
||||
}
|
||||
|
||||
private static SpellbookWindowController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
|
|
@ -245,23 +370,35 @@ public sealed class SpellbookWindowControllerTests
|
|||
Action<uint>? sendFilter = null,
|
||||
Action<uint>? removeSpell = null,
|
||||
Action<string, Action<bool>>? showConfirmation = null,
|
||||
Action? close = null)
|
||||
Action? close = null,
|
||||
ClientObjectTable? objects = null,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor>? components = null,
|
||||
SelectionState? selection = null,
|
||||
Action<uint>? selectObject = null,
|
||||
Action<uint, uint>? setDesiredComponent = null,
|
||||
Func<uint, uint>? resolveComponentIcon = null)
|
||||
=> SpellbookWindowController.Bind(
|
||||
layout,
|
||||
spellbook,
|
||||
new ClientObjectTable(),
|
||||
objects ?? new ClientObjectTable(),
|
||||
() => 1u,
|
||||
new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
components ?? new Dictionary<uint, SpellComponentDescriptor>(),
|
||||
selection ?? new SelectionState(),
|
||||
spellId => spellId + 1000u,
|
||||
iconId => iconId,
|
||||
resolveComponentIcon ?? (iconId => iconId),
|
||||
spellId => spellId == 103u ? 8 : 1,
|
||||
_ => { },
|
||||
selectObject ?? (_ => { }),
|
||||
addFavorite ?? (_ => { }),
|
||||
sendFilter ?? (_ => { }),
|
||||
removeSpell ?? (_ => { }),
|
||||
showConfirmation ?? ((_, _) => { }),
|
||||
(_, _) => { },
|
||||
setDesiredComponent ?? ((_, _) => { }),
|
||||
close ?? (() => { }),
|
||||
new ComponentBookTemplateFactory(
|
||||
FixtureLoader.LoadComponentCategoryTemplateInfos(),
|
||||
FixtureLoader.LoadComponentRowTemplateInfos(),
|
||||
_ => (0u, 0, 0),
|
||||
defaultFont: null),
|
||||
RowStyle,
|
||||
rowFont: null);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
{
|
||||
"Id": 268436582,
|
||||
"Type": 12,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 284,
|
||||
"Height": 32,
|
||||
"OriginalParentWidth": 0,
|
||||
"OriginalParentHeight": 0,
|
||||
"HasOriginalParentSize": false,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 1,
|
||||
"ZLevel": 0,
|
||||
"States": {
|
||||
"4294967295": {
|
||||
"Id": 4294967295,
|
||||
"Name": "",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 30,
|
||||
"Image": {
|
||||
"File": 100668306,
|
||||
"DrawMode": 1
|
||||
},
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {
|
||||
"35": {
|
||||
"Kind": 4,
|
||||
"MasterPropertyId": 35,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 10,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
},
|
||||
"26": {
|
||||
"Kind": 7,
|
||||
"MasterPropertyId": 26,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [
|
||||
{
|
||||
"Kind": 2,
|
||||
"MasterPropertyId": 24,
|
||||
"UnsignedValue": 1073741828,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
}
|
||||
],
|
||||
"StructValue": {}
|
||||
},
|
||||
"37": {
|
||||
"Kind": 4,
|
||||
"MasterPropertyId": 37,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
},
|
||||
"27": {
|
||||
"Kind": 7,
|
||||
"MasterPropertyId": 27,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [
|
||||
{
|
||||
"Kind": 6,
|
||||
"MasterPropertyId": 25,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 255,
|
||||
"Green": 255,
|
||||
"Red": 255,
|
||||
"Alpha": 255
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
}
|
||||
],
|
||||
"StructValue": {}
|
||||
},
|
||||
"20": {
|
||||
"Kind": 0,
|
||||
"MasterPropertyId": 20,
|
||||
"UnsignedValue": 1,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
},
|
||||
"21": {
|
||||
"Kind": 0,
|
||||
"MasterPropertyId": 21,
|
||||
"UnsignedValue": 1,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"Id": 13,
|
||||
"Name": "Ghosted",
|
||||
"PassToChildren": false,
|
||||
"IncorporationFlags": 0,
|
||||
"Image": null,
|
||||
"Cursor": null,
|
||||
"Properties": {
|
||||
"Values": {
|
||||
"27": {
|
||||
"Kind": 7,
|
||||
"MasterPropertyId": 27,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 0,
|
||||
"Green": 0,
|
||||
"Red": 0,
|
||||
"Alpha": 0
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [
|
||||
{
|
||||
"Kind": 6,
|
||||
"MasterPropertyId": 25,
|
||||
"UnsignedValue": 0,
|
||||
"IntegerValue": 0,
|
||||
"FloatValue": 0,
|
||||
"BoolValue": false,
|
||||
"StringInfoValue": {
|
||||
"Token": 0,
|
||||
"StringId": 0,
|
||||
"TableId": 0,
|
||||
"Override": 0,
|
||||
"English": 0,
|
||||
"Comment": 0
|
||||
},
|
||||
"ColorValue": {
|
||||
"Blue": 76,
|
||||
"Green": 76,
|
||||
"Red": 76,
|
||||
"Alpha": 255
|
||||
},
|
||||
"VectorValue": {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0
|
||||
},
|
||||
"ArrayValue": [],
|
||||
"StructValue": {}
|
||||
}
|
||||
],
|
||||
"StructValue": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DefaultStateId": 0,
|
||||
"FontDid": 1073741828,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": {
|
||||
"X": 1,
|
||||
"Y": 1,
|
||||
"Z": 1,
|
||||
"W": 1
|
||||
},
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100668306,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue