feat(studio): Attributes tab Pass 2 — click-to-select (highlight + footer B + raise triangles) + tab states
- UiClickablePanel: new UiPanel subclass with OnClick action + HandlesClick=true
so row clicks survive whole-window-Draggable ancestor frames.
- CharacterStatController overhaul (Pass 2):
- Tab bar button states: SetButtonStateRecursive walks each tab group container
(0x10000228/229/538) setting UiButton.ActiveState="Open" (Attributes) or
"Closed" (Skills/Titles) via UIStateId enum string names from DatReaderWriter.
- Row click: 9 rows become UiClickablePanel; sel[] mutable box drives footer/highlight.
- Toggle: clicking the same row deselects (→ footer State A); click a new row
updates title="Attrib: value", line-1 label="Experience To Raise:", line-1
value=cost, line-2="Unassigned Experience:" in both states.
- Row highlight: BackgroundColor=HighlightBg (semi-translucent gold) on selected row.
- Raise buttons (0x10000246 ×1 + 0x100005EB ×10): hidden initially; shown on
selection with ActiveState="Normal" (affordable) or "Ghosted" (cost=0 or unaffordable).
CollectButtonsById tree-walk finds ALL copies of the button across tab-page mounts
(not just the last-registered _byId copy) so all instances are controlled.
- CharacterSheet: AttributeRaiseCosts long[] (Strength…Mana raise costs in retail
display order; cost=0 → max/disabled row demos the Ghosted button state).
- SampleData.SampleCharacter: fills AttributeRaiseCosts[9] — Strength/Quickness=0
(maxed), Focus@10→110 matching the retail screenshot (spec §4).
- 35 new tests (total 673 pass) covering: row click→footer B title/line1/line2,
toggle deselect→footer A, switch row, highlight set/clear, raise button
hidden/Normal/Ghosted/deselect, tab Open/Closed states, GetRaiseCost helper,
GetRowName helper, SampleData fixture sanity.
Console.WriteLine("[CharacterStat] Row click: index=N → selected=N (Name)") fires
on every click for the user's live verification in the studio.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21d8485053
commit
defbde1f86
5 changed files with 817 additions and 145 deletions
|
|
@ -160,6 +160,13 @@ public static class SampleData
|
|||
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
|
||||
UnassignedXp = 87_757_321_741L,
|
||||
|
||||
// Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
|
||||
// Focus, Self, Health, Stamina, Mana).
|
||||
// Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
|
||||
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
|
||||
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
||||
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
||||
|
||||
AugmentationName = "Swords",
|
||||
|
||||
BurdenCurrent = 1200,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -105,6 +107,21 @@ public sealed class CharacterSheet
|
|||
/// </summary>
|
||||
public long UnassignedXp { get; init; }
|
||||
|
||||
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
|
||||
// Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value).
|
||||
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
|
||||
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
|
||||
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
|
||||
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
|
||||
|
||||
/// <summary>
|
||||
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
|
||||
/// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self,
|
||||
/// [6]=Health, [7]=Stamina, [8]=Mana.
|
||||
/// Cost 0 means the attribute is at max (raise button ghosted).
|
||||
/// </summary>
|
||||
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
||||
|
||||
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
||||
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,18 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>DisplayDefaultFooter</c> (0x0049cde0): title empty, line-1 value =
|
||||
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).</para>
|
||||
///
|
||||
/// <para><b>Pass 2 pending:</b> selection highlight (Button SetState(6)), State-B footer,
|
||||
/// raise buttons (CM_Train::Event_TrainAttribute).</para>
|
||||
/// <para>Footer State B (row selected) bound from
|
||||
/// <c>DisplaySelectedAttribute</c> (implicitly): title = "{AttrName}: {value}",
|
||||
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
||||
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
||||
///
|
||||
/// <para>Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
||||
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
|
||||
/// inside each tab group container.</para>
|
||||
///
|
||||
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
||||
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
||||
/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910).</para>
|
||||
/// </summary>
|
||||
public static class CharacterStatController
|
||||
{
|
||||
|
|
@ -43,6 +53,15 @@ public static class CharacterStatController
|
|||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||||
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
||||
|
||||
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
|
||||
// Three group containers, each holding three UiButton children forming the 3-piece
|
||||
// tab visual. State "Open" (UIStateId 0x0C) = active; "Closed" (0x0B) = inactive.
|
||||
// Source: gmTabUI::SetActive(bool) — the tab-bar controller calls SetState(Open/Closed)
|
||||
// on the three child buttons of the active vs inactive tab slots.
|
||||
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
||||
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
||||
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
||||
|
||||
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
||||
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
||||
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
||||
|
|
@ -51,36 +70,44 @@ public static class CharacterStatController
|
|||
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
|
||||
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
|
||||
|
||||
// ── Raise button element ids ──────────────────────────────────────────────
|
||||
// Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
|
||||
// Button state "Normal" (UIStateId 0x01) = affordable (green/active);
|
||||
// "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
|
||||
public const uint RaiseOneId = 0x10000246u; // raise × 1
|
||||
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
||||
|
||||
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
|
||||
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
|
||||
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
|
||||
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
|
||||
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
|
||||
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
|
||||
|
||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||
|
||||
/// <summary>Row highlight color — semi-translucent gold, matches retail
|
||||
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
|
||||
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
|
||||
|
||||
// ── Row layout constants ─────────────────────────────────────────────────
|
||||
// List box 0x1000023D is 398px tall after the anchor pass (dat stores 160px; the
|
||||
// Top+Bottom anchor stretches it when the 575px content slot inflates the sub-layout).
|
||||
// 9 rows spread evenly over 398px ≈ 44px/row — matching retail's generous spacing.
|
||||
// Icons are 24px, vertically centered in the row.
|
||||
private const float RowHeight = 44f;
|
||||
private const float IconSize = 24f; // icon ~24×24px, vertically centered in the 44px row
|
||||
private const float RowPadX = 4f; // left inset before the icon
|
||||
private const float IconGap = 6f; // gap between icon right and name text left
|
||||
private const float IconSize = 24f;
|
||||
private const float RowPadX = 4f;
|
||||
private const float IconGap = 6f;
|
||||
|
||||
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||||
// InfoRegion::InfoRegion row sub-element ids:
|
||||
// 0x10000129 = icon image
|
||||
// 0x1000012a = m_pLabelText (name)
|
||||
// 0x1000012b = m_pValueText (value)
|
||||
// Icon DataIDs from SubMap 0x25000006 (typeId 0x10000002) — spec §2.
|
||||
private static readonly (string name, uint iconDid)[] AttrRows = new[]
|
||||
{
|
||||
("Strength", 0x060002C8u), // enum 1 — spec §1/§2
|
||||
("Strength", 0x060002C8u), // enum 1
|
||||
("Endurance", 0x060002C4u), // enum 2
|
||||
("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order)
|
||||
("Coordination", 0x060002C9u), // enum 4
|
||||
("Quickness", 0x060002C6u), // enum 3
|
||||
("Focus", 0x060002C5u), // enum 5
|
||||
("Self", 0x060002C7u), // enum 6
|
||||
};
|
||||
|
||||
// Vital icon DataIDs from SubMap 0x25000007 (typeId 0x10000003) — spec §2.
|
||||
private static readonly (string name, uint iconDid)[] VitalRows = new[]
|
||||
{
|
||||
("Health", 0x06004C3Bu), // CurEnum 2
|
||||
|
|
@ -89,14 +116,20 @@ public static class CharacterStatController
|
|||
};
|
||||
|
||||
/// <summary>
|
||||
/// Bind the Attributes-tab header + 9-row list + footer State-A elements in
|
||||
/// <paramref name="layout"/> to <paramref name="data"/>.
|
||||
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
|
||||
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Interactive mode (Pass 2):</b> each attribute/vital row is a
|
||||
/// <see cref="UiClickablePanel"/> that fires selection logic on left-click. The
|
||||
/// selected row index is held in a mutable <c>int[]</c> box (single element) so that
|
||||
/// all closures share the same mutable slot without escaping the static method boundary.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="spriteResolve"/> resolves a <c>0x06xxxxxx</c> RenderSurface dat id to
|
||||
/// a (GL tex handle, pixel width, pixel height) triple — the same resolver that
|
||||
/// DatWidgetFactory uses for chrome sprites. Pass <c>null</c> in tests where
|
||||
/// icon rendering is not asserted; icons are skipped when the resolver is absent.
|
||||
/// a (GL tex handle, pixel width, pixel height) triple. Pass <c>null</c> in tests where
|
||||
/// icon rendering is not asserted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void Bind(
|
||||
|
|
@ -115,58 +148,119 @@ public static class CharacterStatController
|
|||
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
||||
meter.Fill = () => data().XpFraction;
|
||||
|
||||
// ── Tab bar button states ─────────────────────────────────────────────
|
||||
// Each tab group (0x10000228/229/538) is a UiDatElement container whose children
|
||||
// are UiButton widgets that form the 3-piece tab visual. Setting ActiveState on
|
||||
// all children to "Open" or "Closed" switches the visible sprite set.
|
||||
// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B).
|
||||
// Only Attributes is active; Skills + Titles controllers don't exist yet.
|
||||
SetTabState(layout, TabAttribId, StateOpen);
|
||||
SetTabState(layout, TabSkillsId, StateClosed);
|
||||
SetTabState(layout, TabTitlesId, StateClosed);
|
||||
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||
// gmAttributeUI::PostInit (0x0049db70) calls AddItemFromTemplateList nine times —
|
||||
// in acdream we manually add 9 UiPanel row containers (no dat template machinery).
|
||||
// Each row = icon (left, ~16×16) + name (left-justified) + value (right-aligned).
|
||||
// Mutable selected-index box: -1 = nothing selected.
|
||||
var sel = new int[] { -1 };
|
||||
|
||||
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
|
||||
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
|
||||
// in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
|
||||
// mounted copy. We collect all copies so we can hide them all initially and
|
||||
// show/hide the correct set when a row is selected.
|
||||
//
|
||||
// At bind time the tree includes all three tab pages (the page-visibility pass
|
||||
// runs AFTER this). Collecting from the full tree is safe: once the page-
|
||||
// visibility pass hides the inactive pages their raise buttons are invisible
|
||||
// regardless of the Visible flag we set here — but the Attributes page's
|
||||
// buttons (which are NOT hidden by the page pass) must be explicitly hidden.
|
||||
var allRaise1 = new List<UiButton>();
|
||||
var allRaise10 = new List<UiButton>();
|
||||
if (layout.Root is { } r)
|
||||
{
|
||||
// Single-pass tree walk to collect all UiButton copies at the two ids.
|
||||
// FindElement only returns the last-registered copy in _byId; we need ALL
|
||||
// copies because duplicated sub-layout mounts each tab page independently.
|
||||
CollectButtonsById(r, RaiseOneId, allRaise1, layout);
|
||||
CollectButtonsById(r, RaiseTenId, allRaise10, layout);
|
||||
}
|
||||
// If tree-walk found nothing, fall back to _byId (covers fake/test layouts).
|
||||
if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1);
|
||||
if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10);
|
||||
|
||||
// Initial state: raise buttons hidden until a row is selected.
|
||||
foreach (var b in allRaise1) b.Visible = false;
|
||||
foreach (var b in allRaise10) b.Visible = false;
|
||||
|
||||
// ── Footer State A initial binding ────────────────────────────────────
|
||||
// All footer providers close over sel[] so they can reflect State A vs B per frame.
|
||||
BindFooterDynamic(layout, datFont, data, sel);
|
||||
|
||||
List<UiClickablePanel>? rowPanels = null;
|
||||
|
||||
if (layout.FindElement(ListBoxId) is { } list)
|
||||
{
|
||||
BuildAttributeRows(list, datFont, spriteResolve, data);
|
||||
rowPanels = BuildAttributeRows(list, datFont, spriteResolve, data, sel,
|
||||
allRaise1, allRaise10);
|
||||
}
|
||||
|
||||
// ── Footer — State A (nothing selected): DisplayDefaultFooter (0x0049cde0) ────
|
||||
// title = "" ; line-1 value = "Select an Attribute to Improve" ;
|
||||
// line-2 value = InqInt(0x18) = available skill credits.
|
||||
BindFooterStateA(layout, datFont, data);
|
||||
|
||||
// ── Active-page selection (fixes the "dark overlay") ──────────────────
|
||||
// 0x2100002E is a tabbed window: all three tab pages (Attributes / Skills / Titles) mount
|
||||
// their content as separate root children that OVERLAP in the same content rect. Retail
|
||||
// shows only the active page; acdream draws them all, so the inactive pages' backdrops paint
|
||||
// over the active content (the dim "almost opaque" cover). The shared gmStatManagement ids
|
||||
// are duplicated across pages and acdream's _byId keeps the LAST-mounted copy, so every widget
|
||||
// we just bound lives in exactly ONE page. Keep that page visible; hide the rest.
|
||||
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||||
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root)
|
||||
{
|
||||
foreach (var page in root.Children)
|
||||
{
|
||||
if (page.Children.Count == 0) continue; // tab-bar buttons are leaves — leave them
|
||||
page.Visible = ContainsWidget(page, anchor); // only the bound page stays visible
|
||||
if (page.Children.Count == 0) continue;
|
||||
page.Visible = ContainsWidget(page, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab bar state ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Set the <see cref="UiButton.ActiveState"/> on ALL <see cref="UiButton"/> descendants
|
||||
/// of the tab group container identified by <paramref name="groupId"/> to
|
||||
/// <paramref name="state"/>. The three-piece tab visual is composed of three UiButton
|
||||
/// children (left-cap, label/center, right-cap) that must all share the same state.
|
||||
///
|
||||
/// <para>Source: retail tab-button assembly with child buttons having UIStateId states
|
||||
/// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from
|
||||
/// <c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</para>
|
||||
/// </summary>
|
||||
private static void SetTabState(ImportedLayout layout, uint groupId, string state)
|
||||
{
|
||||
if (layout.FindElement(groupId) is not { } group) return;
|
||||
SetButtonStateRecursive(group, state);
|
||||
}
|
||||
|
||||
private static void SetButtonStateRecursive(UiElement node, string state)
|
||||
{
|
||||
if (node is UiButton btn)
|
||||
btn.ActiveState = state;
|
||||
foreach (var child in node.Children)
|
||||
SetButtonStateRecursive(child, state);
|
||||
}
|
||||
|
||||
// ── 9-row attribute list ─────────────────────────────────────────────────
|
||||
|
||||
private static void BuildAttributeRows(
|
||||
private static List<UiClickablePanel> BuildAttributeRows(
|
||||
UiElement list,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||
Func<CharacterSheet> data)
|
||||
Func<CharacterSheet> data,
|
||||
int[] sel,
|
||||
List<UiButton> allRaise1,
|
||||
List<UiButton> allRaise10)
|
||||
{
|
||||
float listW = list.Width;
|
||||
float y = 0f; // row-local Y within the list box; each row stacks top-down
|
||||
float y = 0f;
|
||||
var rows = new List<UiClickablePanel>();
|
||||
|
||||
// Six attribute rows (Strength, Endurance, Coordination, Quickness, Focus, Self).
|
||||
// Value format: "%d" (buffed integer) — AttributeInfoRegion::Update (0x004f1910).
|
||||
for (int i = 0; i < AttrRows.Length; i++)
|
||||
{
|
||||
var (rowName, iconDid) = AttrRows[i];
|
||||
int rowIndex = i;
|
||||
|
||||
// Capture for the lambda — locals from the loop are captured by VALUE here.
|
||||
int rowIndex = i; // not used in value yet but keeps the closure honest
|
||||
|
||||
AddRow(list, datFont, spriteResolve,
|
||||
var row = AddRow(list, datFont, spriteResolve,
|
||||
left: 0f, top: y, width: listW, height: RowHeight,
|
||||
iconDid: iconDid,
|
||||
nameText: rowName,
|
||||
|
|
@ -186,17 +280,18 @@ public static class CharacterStatController
|
|||
return v.ToString();
|
||||
});
|
||||
|
||||
row.OnClick = () => HandleRowClick(rowIndex, sel, rows, data, allRaise1, allRaise10);
|
||||
rows.Add(row);
|
||||
y += RowHeight;
|
||||
}
|
||||
|
||||
// Three vital rows (Health, Stamina, Mana).
|
||||
// Value format: "%d/%d" cur/max — Attribute2ndInfoRegion::Update (0x004f19e0).
|
||||
for (int i = 0; i < VitalRows.Length; i++)
|
||||
{
|
||||
var (rowName, iconDid) = VitalRows[i];
|
||||
int rowIndex = i;
|
||||
int absIndex = AttrRows.Length + i;
|
||||
|
||||
AddRow(list, datFont, spriteResolve,
|
||||
var row = AddRow(list, datFont, spriteResolve,
|
||||
left: 0f, top: y, width: listW, height: RowHeight,
|
||||
iconDid: iconDid,
|
||||
nameText: rowName,
|
||||
|
|
@ -212,20 +307,117 @@ public static class CharacterStatController
|
|||
};
|
||||
});
|
||||
|
||||
row.OnClick = () => HandleRowClick(absIndex, sel, rows, data, allRaise1, allRaise10);
|
||||
rows.Add(row);
|
||||
y += RowHeight;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a single row to <paramref name="list"/>: a transparent UiPanel container
|
||||
/// positioned at <paramref name="left"/>,<paramref name="top"/> of the list, holding
|
||||
/// three children: icon (UiText with BackgroundSprite), name (UiText, left-justified),
|
||||
/// value (UiText, right-aligned). All three are ClickThrough non-interactive.
|
||||
///
|
||||
/// <para>Row sub-element ids from the retail dat template (InfoRegion::InfoRegion
|
||||
/// 0x004f1450): 0x10000129 icon, 0x1000012a name, 0x1000012b value.</para>
|
||||
/// Handles a row click: toggle (same row → deselect), else select new row.
|
||||
/// Updates highlight, footer providers, and raise-button state.
|
||||
/// </summary>
|
||||
private static void AddRow(
|
||||
private static void HandleRowClick(
|
||||
int clickedIndex,
|
||||
int[] sel,
|
||||
List<UiClickablePanel> rows,
|
||||
Func<CharacterSheet> data,
|
||||
List<UiButton> allRaise1,
|
||||
List<UiButton> allRaise10)
|
||||
{
|
||||
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
|
||||
sel[0] = newSel;
|
||||
|
||||
// Log for live test confirmation (user tests selection in the studio).
|
||||
string rowName = GetRowName(newSel);
|
||||
Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
|
||||
|
||||
// Update highlight on all rows.
|
||||
for (int i = 0; i < rows.Count; i++)
|
||||
rows[i].BackgroundColor = (i == newSel) ? HighlightBg : Vector4.Zero;
|
||||
|
||||
// Update raise buttons.
|
||||
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
|
||||
}
|
||||
|
||||
/// <summary>Refresh raise button visibility + state based on current selection.
|
||||
/// Applies to ALL collected raise button copies (one per tab page) so the
|
||||
/// Attributes-page buttons are correctly shown/hidden regardless of which
|
||||
/// copy happens to be in ImportedLayout._byId.</summary>
|
||||
private static void RefreshRaiseButtons(
|
||||
int selectedIndex,
|
||||
Func<CharacterSheet> data,
|
||||
List<UiButton> allRaise1,
|
||||
List<UiButton> allRaise10)
|
||||
{
|
||||
if (allRaise1.Count == 0 && allRaise10.Count == 0) return;
|
||||
|
||||
if (selectedIndex < 0)
|
||||
{
|
||||
// Nothing selected: hide all raise buttons.
|
||||
foreach (var b in allRaise1) b.Visible = false;
|
||||
foreach (var b in allRaise10) b.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var sheet = data();
|
||||
long cost = GetRaiseCost(sheet, selectedIndex);
|
||||
bool affordable = cost > 0 && sheet.UnassignedXp >= cost;
|
||||
|
||||
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
|
||||
string btnState = affordable ? StateNormal : StateGhosted;
|
||||
|
||||
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; }
|
||||
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; }
|
||||
}
|
||||
|
||||
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
|
||||
/// Returns 0 if the cost array is shorter than expected.</summary>
|
||||
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
|
||||
{
|
||||
if (sheet.AttributeRaiseCosts is null || rowIndex < 0
|
||||
|| rowIndex >= sheet.AttributeRaiseCosts.Length)
|
||||
return 0L;
|
||||
return sheet.AttributeRaiseCosts[rowIndex];
|
||||
}
|
||||
|
||||
/// <summary>Return the display name for the row at <paramref name="index"/>,
|
||||
/// or an empty string if the index is out of range.</summary>
|
||||
internal static string GetRowName(int index)
|
||||
{
|
||||
if (index < 0) return string.Empty;
|
||||
if (index < AttrRows.Length) return AttrRows[index].name;
|
||||
int vi = index - AttrRows.Length;
|
||||
if (vi < VitalRows.Length) return VitalRows[vi].name;
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Return the numeric value for the row at <paramref name="index"/>.</summary>
|
||||
internal static string GetRowValueString(CharacterSheet sheet, int index)
|
||||
{
|
||||
return index switch
|
||||
{
|
||||
0 => sheet.Strength.ToString(),
|
||||
1 => sheet.Endurance.ToString(),
|
||||
2 => sheet.Coordination.ToString(),
|
||||
3 => sheet.Quickness.ToString(),
|
||||
4 => sheet.Focus.ToString(),
|
||||
5 => sheet.Self.ToString(),
|
||||
6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}",
|
||||
7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}",
|
||||
8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a single attribute/vital row to <paramref name="list"/> as a
|
||||
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
|
||||
/// Returns the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
|
||||
/// </summary>
|
||||
private static UiClickablePanel AddRow(
|
||||
UiElement list,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||
|
|
@ -234,23 +426,19 @@ public static class CharacterStatController
|
|||
string nameText,
|
||||
Func<string> valueProvider)
|
||||
{
|
||||
// Transparent container — the list box backdrop draws behind everything.
|
||||
var row = new UiPanel
|
||||
var row = new UiClickablePanel
|
||||
{
|
||||
Left = left,
|
||||
Top = top,
|
||||
Width = width,
|
||||
Height = height,
|
||||
BackgroundColor = Vector4.Zero, // transparent
|
||||
BackgroundColor = Vector4.Zero, // transparent until selected
|
||||
BorderColor = Vector4.Zero,
|
||||
ClickThrough = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
|
||||
float iconY = (height - IconSize) * 0.5f;
|
||||
|
||||
// Icon element — UiText with BackgroundSprite draws the dat sprite under (empty) text.
|
||||
// Retail: InfoRegion::InfoRegion sub-element 0x10000129 → UIRegion::SetImageByDID.
|
||||
var iconEl = new UiText
|
||||
{
|
||||
Left = RowPadX,
|
||||
|
|
@ -258,7 +446,7 @@ public static class CharacterStatController
|
|||
Width = IconSize,
|
||||
Height = IconSize,
|
||||
ClickThrough = true,
|
||||
DatFont = null, // icon has no text
|
||||
DatFont = null,
|
||||
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
|
||||
SpriteResolve = spriteResolve is not null
|
||||
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
|
||||
|
|
@ -267,15 +455,11 @@ public static class CharacterStatController
|
|||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
|
||||
// Name label — left-justified within its column width.
|
||||
// Retail: InfoRegion sub-element 0x1000012a (m_pLabelText).
|
||||
// UiText has no explicit LeftAligned flag; we use the default non-Centered/non-RightAligned
|
||||
// scroll path with a single-line LinesProvider. Padding=1 keeps the text off the left edge.
|
||||
float nameX = RowPadX + IconSize + IconGap;
|
||||
float nameW = width * 0.60f; // ~60% of the row width for the name column
|
||||
float nameY = 0f; // fill full row height; one line top-pins to the row top
|
||||
float nameW = width * 0.60f;
|
||||
float nameY = 0f;
|
||||
|
||||
string capturedName = nameText; // close over the name string directly
|
||||
string capturedName = nameText;
|
||||
var nameEl = new UiText
|
||||
{
|
||||
Left = nameX,
|
||||
|
|
@ -291,8 +475,6 @@ public static class CharacterStatController
|
|||
};
|
||||
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) };
|
||||
|
||||
// Value label — right-aligned, vertically centered.
|
||||
// Retail: InfoRegion sub-element 0x1000012b (m_pValueText).
|
||||
float valueW = width - nameX - nameW - RowPadX;
|
||||
float valueX = nameX + nameW;
|
||||
|
||||
|
|
@ -314,55 +496,65 @@ public static class CharacterStatController
|
|||
row.AddChild(nameEl);
|
||||
row.AddChild(valueEl);
|
||||
list.AddChild(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Footer State A binding ────────────────────────────────────────────────
|
||||
// ── Footer — dynamic (State A + State B via sel[]) ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Bind footer elements to their State-A (nothing selected) content.
|
||||
/// Bind all 5 footer elements with providers that close over <paramref name="sel"/>:
|
||||
/// when <c>sel[0] == -1</c> (nothing selected) they emit State-A content;
|
||||
/// when a row is selected they emit State-B content.
|
||||
///
|
||||
/// <para>Matching the user's authoritative shot layout (3-line footer at the panel bottom):
|
||||
/// <list type="bullet">
|
||||
/// <item>Title (0x1000024e) → "Select an Attribute to Improve" — centered, full-width.</item>
|
||||
/// <item>Line-1 label (0x10000242) → "Skill Credits Available:"</item>
|
||||
/// <item>Line-1 value (0x10000243) → InqInt(0x18) = available skill credits.</item>
|
||||
/// <item>Line-2 label (0x10000244) → "Unassigned Experience:"</item>
|
||||
/// <item>Line-2 value (0x10000245) → InqInt64(2) = unassigned XP.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para><b>State A</b> (DisplayDefaultFooter 0x0049cde0):
|
||||
/// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:";
|
||||
/// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:";
|
||||
/// line-2 value = UnassignedXp.</para>
|
||||
///
|
||||
/// <para>Source: <c>DisplayDefaultFooter</c> (0x0049cde0) + user's retail screenshot.</para>
|
||||
/// <para><b>State B</b> (attribute selected):
|
||||
/// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
|
||||
/// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
|
||||
/// line-2 value = UnassignedXp.</para>
|
||||
/// </summary>
|
||||
private static void BindFooterStateA(
|
||||
private static void BindFooterDynamic(
|
||||
ImportedLayout layout,
|
||||
UiDatFont? datFont,
|
||||
Func<CharacterSheet> data)
|
||||
Func<CharacterSheet> data,
|
||||
int[] sel)
|
||||
{
|
||||
// 0x1000024e title → "Select an Attribute to Improve"
|
||||
Label(layout, FooterTitleId, datFont, Body,
|
||||
static () => "Select an Attribute to Improve");
|
||||
// Title: State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
|
||||
Label(layout, FooterTitleId, datFont, Body, () =>
|
||||
{
|
||||
if (sel[0] < 0) return "Select an Attribute to Improve";
|
||||
var s = data();
|
||||
string name = GetRowName(sel[0]);
|
||||
string value = GetRowValueString(s, sel[0]);
|
||||
return $"{name}: {value}";
|
||||
});
|
||||
|
||||
// 0x10000242 line-1 label → "Skill Credits Available:"
|
||||
Label(layout, FooterLine1Label, datFont, Body,
|
||||
static () => "Skill Credits Available:");
|
||||
// Line-1 label: State A = "Skill Credits Available:"; State B = "Experience To Raise:"
|
||||
Label(layout, FooterLine1Label, datFont, Body, () =>
|
||||
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
|
||||
|
||||
// 0x10000243 line-1 value → InqInt(0x18) = available skill credits
|
||||
Label(layout, FooterLine1Value, datFont, Body,
|
||||
() => data().SkillCredits.ToString());
|
||||
// Line-1 value: State A = SkillCredits; State B = raise cost
|
||||
Label(layout, FooterLine1Value, datFont, Body, () =>
|
||||
{
|
||||
if (sel[0] < 0) return data().SkillCredits.ToString();
|
||||
long cost = GetRaiseCost(data(), sel[0]);
|
||||
return cost > 0 ? cost.ToString("N0") : "—";
|
||||
});
|
||||
|
||||
// 0x10000244 line-2 label → "Unassigned Experience:"
|
||||
// Line-2 label: "Unassigned Experience:" in both states
|
||||
Label(layout, FooterLine2Label, datFont, Body,
|
||||
static () => "Unassigned Experience:");
|
||||
|
||||
// 0x10000245 line-2 value → InqInt64(2) = unassigned XP
|
||||
// Line-2 value: UnassignedXp in both states
|
||||
Label(layout, FooterLine2Value, datFont, Body,
|
||||
() => data().UnassignedXp.ToString("N0"));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Depth-first reference search: is <paramref name="target"/> somewhere under
|
||||
/// <paramref name="node"/>? Used to find which overlapping tab page owns the bound widgets.</summary>
|
||||
private static bool ContainsWidget(UiElement node, UiElement target)
|
||||
{
|
||||
if (ReferenceEquals(node, target)) return true;
|
||||
|
|
@ -376,9 +568,73 @@ public static class CharacterStatController
|
|||
if (layout.FindElement(id) is UiText t)
|
||||
{
|
||||
t.DatFont = datFont;
|
||||
t.Centered = true; // single-line centered label (retail UIElement_Text)
|
||||
t.Centered = true;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
|
||||
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The standard <see cref="ImportedLayout.FindElement"/> returns only the LAST widget
|
||||
/// registered for a given id; for elements duplicated across tab-page sub-layouts
|
||||
/// (raise buttons, close buttons) we need ALL copies so that visibility changes are
|
||||
/// reflected in every page — not just the last-mounted one.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Implementation: walk the entire <paramref name="root"/> tree calling
|
||||
/// <see cref="ImportedLayout.FindElement"/> is not enough (it uses a dict). Instead we
|
||||
/// exploit the fact that identical element ids produce widgets that all share the SAME
|
||||
/// <see cref="UiButton"/> instance in <c>_byId</c> for THEIR copy; but siblings from
|
||||
/// different inheritance mounts are SEPARATE instances not in the same <c>_byId</c> slot.
|
||||
/// We therefore walk the tree recursively and collect every <see cref="UiButton"/> whose
|
||||
/// <c>ActiveState</c> reflects the dat default (before our code sets it), which is not a
|
||||
/// reliable discriminator. Instead, we gather ALL <see cref="UiButton"/> instances from
|
||||
/// the subtree at the known spatial position (bottom of the panel) — but positions can
|
||||
/// overlap across pages.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The correct approach: since <c>_byId</c> stores only one instance per id, we use the
|
||||
/// <see cref="ImportedLayout.FindElement"/> for the canonical id, then do a FULL tree walk
|
||||
/// to find ADDITIONAL <see cref="UiButton"/> instances that have identical Width×Height to
|
||||
/// the known button. This works because the three page copies share the same dat template
|
||||
/// and thus the same geometry. Collected via reference-equality guard to avoid duplicates.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void CollectButtonsById(
|
||||
UiElement node,
|
||||
uint targetId,
|
||||
List<UiButton> result,
|
||||
ImportedLayout layout)
|
||||
{
|
||||
// Find the canonical copy (last registered in _byId) as the geometry reference.
|
||||
if (layout.FindElement(targetId) is not UiButton canonical) return;
|
||||
|
||||
// Walk the tree and collect ALL UiButton instances matching the canonical geometry.
|
||||
// The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
|
||||
var seen = new HashSet<UiButton>(ReferenceEqualityComparer.Instance);
|
||||
CollectMatchingButtons(node, canonical, seen, result);
|
||||
}
|
||||
|
||||
private static void CollectMatchingButtons(
|
||||
UiElement node,
|
||||
UiButton canonical,
|
||||
HashSet<UiButton> seen,
|
||||
List<UiButton> result)
|
||||
{
|
||||
if (node is UiButton btn
|
||||
&& Math.Abs(btn.Width - canonical.Width) < 0.5f
|
||||
&& Math.Abs(btn.Height - canonical.Height) < 0.5f
|
||||
&& seen.Add(btn))
|
||||
{
|
||||
result.Add(btn);
|
||||
}
|
||||
foreach (var child in node.Children)
|
||||
CollectMatchingButtons(child, canonical, seen, result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
|
@ -94,3 +95,43 @@ public class UiSimpleButton : UiPanel
|
|||
ctx.DrawString(Text, tx, ty, TextColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
|
||||
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
|
||||
/// is a transparent container that needs to respond to pointer hits while its children
|
||||
/// (icon, name, value) are ClickThrough decorations.
|
||||
///
|
||||
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
|
||||
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
|
||||
/// parent window. In acdream we wire the equivalent via this action callback instead of
|
||||
/// the retail message bus.</para>
|
||||
/// </summary>
|
||||
public class UiClickablePanel : UiPanel
|
||||
{
|
||||
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
public UiClickablePanel()
|
||||
{
|
||||
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
||||
// which is the UiElement base default). Explicit for clarity.
|
||||
ClickThrough = false;
|
||||
}
|
||||
|
||||
/// <summary>HandlesClick = true ensures this row receives its own Click even when
|
||||
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
|
||||
/// window with a whole-window drag handle). Without this, the press would be consumed
|
||||
/// by the ancestor's drag logic and the Click would never fire.</summary>
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && Enabled)
|
||||
{
|
||||
OnClick?.Invoke();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
|
||||
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
|
||||
/// Pure data wiring against fake layouts; no dats, no GL.
|
||||
///
|
||||
/// <para>Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
|
||||
/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
|
||||
/// (c) raise-button affordability; (d) tab button states.</para>
|
||||
/// </summary>
|
||||
public class CharacterStatControllerTests
|
||||
{
|
||||
|
|
@ -66,7 +70,6 @@ public class CharacterStatControllerTests
|
|||
|
||||
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
|
||||
|
||||
/// <summary>Bind adds exactly 9 UiPanel row containers to the list box.</summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_Has9Rows()
|
||||
{
|
||||
|
|
@ -75,14 +78,29 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// Rows are UiClickablePanel which inherits UiPanel, so OfType<UiPanel> matches.
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every row must have a right-aligned value UiText child (the last UiText in
|
||||
/// the row), confirming the RightAligned path was wired.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_RowsAreClickablePanels()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
// All rows must have an OnClick wired (not null) and ClickThrough = false.
|
||||
foreach (var row in rows)
|
||||
{
|
||||
Assert.NotNull(row.OnClick);
|
||||
Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
|
||||
{
|
||||
|
|
@ -95,18 +113,13 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(9, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
// Last UiText in each row is the value (right-aligned).
|
||||
var texts = row.Children.OfType<UiText>().ToList();
|
||||
Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
|
||||
var valueEl = texts[^1]; // last = value
|
||||
var valueEl = texts[^1];
|
||||
Assert.True(valueEl.RightAligned, "value label must be RightAligned");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail display order: Strength, Endurance, Coordination, Quickness, Focus, Self,
|
||||
/// Health, Stamina, Mana (spec §1). Name is the SECOND UiText child of each row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_RowNamesInRetailOrder()
|
||||
{
|
||||
|
|
@ -126,19 +139,13 @@ public class CharacterStatControllerTests
|
|||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
// Name UiText = second UiText in the row (after the icon UiText).
|
||||
var texts = rows[i].Children.OfType<UiText>().ToList();
|
||||
Assert.True(texts.Count >= 2, $"row {i} must have name + value");
|
||||
string rowName = texts[1].LinesProvider()[0].Text; // index 1 = name (0 = icon)
|
||||
string rowName = texts[1].LinesProvider()[0].Text;
|
||||
Assert.Equal(expectedNames[i], rowName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attribute row values are plain integers; vital row values are "cur/max".
|
||||
/// Validates against SampleData fixture: Str=200, Quick=200, rest=10;
|
||||
/// Health=5/5, Stamina=10/10, Mana=10/10.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
|
||||
{
|
||||
|
|
@ -149,45 +156,34 @@ public class CharacterStatControllerTests
|
|||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
|
||||
// Helper: value text is the last UiText in the row.
|
||||
string ValueOf(UiPanel row) => row.Children.OfType<UiText>().ToList()[^1].LinesProvider()[0].Text;
|
||||
|
||||
// Attribute rows 0-5.
|
||||
Assert.Equal("200", ValueOf(rows[0])); // Strength
|
||||
Assert.Equal("10", ValueOf(rows[1])); // Endurance
|
||||
Assert.Equal("10", ValueOf(rows[2])); // Coordination
|
||||
Assert.Equal("200", ValueOf(rows[3])); // Quickness
|
||||
Assert.Equal("10", ValueOf(rows[4])); // Focus
|
||||
Assert.Equal("10", ValueOf(rows[5])); // Self
|
||||
|
||||
// Vital rows 6-8: "cur/max" format.
|
||||
Assert.Equal("5/5", ValueOf(rows[6])); // Health
|
||||
Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
|
||||
Assert.Equal("10/10", ValueOf(rows[8])); // Mana
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When spriteResolve is provided the icon UiText must have BackgroundSprite set
|
||||
/// to the correct DataID; when null the BackgroundSprite must be 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
// Non-null resolver — just returns a fake handle for any id.
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16)); // fake: handle == id
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
|
||||
// First icon = Strength row; expected DataID 0x060002C8.
|
||||
var iconEl = rows[0].Children.OfType<UiText>().First();
|
||||
Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
|
||||
|
||||
// Vital (Health) = row 6; expected DataID 0x06004C3B.
|
||||
var healthIcon = rows[6].Children.OfType<UiText>().First();
|
||||
Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
|
||||
}
|
||||
|
|
@ -219,6 +215,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// Initial state (nothing selected): State-A title.
|
||||
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +238,6 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// SampleData.SampleCharacter().SkillCredits == 96
|
||||
Assert.Equal("96", val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
|
|
@ -264,27 +260,375 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// SampleData.SampleCharacter().UnassignedXp == 87_757_321_741
|
||||
// Formatted as "N0" with thousands separators.
|
||||
var expected = (87_757_321_741L).ToString("N0");
|
||||
Assert.Equal(expected, val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
// ── SkillCredits / UnassignedXp propagation through SampleData ───────────
|
||||
// ── Pass 2: Row selection → Footer State B ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
|
||||
{
|
||||
// Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
|
||||
var title = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterTitleId, title),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// Simulate a click on row 4 (Focus).
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
rows[4].OnClick!();
|
||||
|
||||
// Footer title should now be "Focus: 10".
|
||||
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
|
||||
{
|
||||
var lbl = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterLine1Label, lbl),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
|
||||
{
|
||||
var val = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterLine1Value, val),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
// Focus raise cost = 110 (SampleData fixture).
|
||||
Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
|
||||
{
|
||||
var lbl = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterLine2Label, lbl),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
|
||||
{
|
||||
var val = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterLine2Value, val),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
|
||||
// UnassignedXp = 87_757_321_741L
|
||||
var expected = (87_757_321_741L).ToString("N0");
|
||||
Assert.Equal(expected, val.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
// ── Pass 2: Toggle deselects ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
|
||||
{
|
||||
var title = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterTitleId, title),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// Select Focus (row 4).
|
||||
rows[4].OnClick!();
|
||||
Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
|
||||
|
||||
// Click the same row again → deselect.
|
||||
rows[4].OnClick!();
|
||||
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SwitchRow_UpdatesToNewRow()
|
||||
{
|
||||
var title = new UiText();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.FooterTitleId, title),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// Select Endurance (row 1, value=10).
|
||||
rows[1].OnClick!();
|
||||
Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
|
||||
|
||||
// Select Self (row 5, value=10).
|
||||
rows[5].OnClick!();
|
||||
Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
// ── Pass 2: Row highlight ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
|
||||
// All rows start transparent.
|
||||
Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
|
||||
|
||||
// Select row 2 (Coordination).
|
||||
rows[2].OnClick!();
|
||||
Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
|
||||
Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
|
||||
Assert.Equal(0f, rows[1].BackgroundColor.W);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_Deselect_ClearsHighlight()
|
||||
{
|
||||
var list = new UiPanel();
|
||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
rows[2].OnClick!(); // select
|
||||
rows[2].OnClick!(); // deselect
|
||||
|
||||
Assert.Equal(0f, rows[2].BackgroundColor.W);
|
||||
}
|
||||
|
||||
// ── Pass 2: Raise button affordability ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_InitiallyHidden()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.False(btn1.Visible, "raise×1 must start hidden");
|
||||
Assert.False(btn10.Visible, "raise×10 must start hidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_AffordableRow_ShowsNormalState()
|
||||
{
|
||||
// Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // select Focus
|
||||
|
||||
Assert.True(btn1.Visible, "raise×1 visible on selection");
|
||||
Assert.True(btn10.Visible, "raise×10 visible on selection");
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
Assert.Equal("Normal", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_MaxedRow_ShowsGhostedState()
|
||||
{
|
||||
// Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[0].OnClick!(); // select Strength (cost=0)
|
||||
|
||||
Assert.True(btn1.Visible, "raise button visible even when disabled");
|
||||
Assert.Equal("Ghosted", btn1.ActiveState);
|
||||
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaiseButtons_Deselect_HidesButtons()
|
||||
{
|
||||
var btn1 = MakeButton();
|
||||
var list = new UiPanel();
|
||||
var layout = Fake(
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.ListBoxId, list));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||
rows[4].OnClick!(); // select
|
||||
Assert.True(btn1.Visible);
|
||||
rows[4].OnClick!(); // deselect
|
||||
Assert.False(btn1.Visible, "raise button hidden after deselect");
|
||||
}
|
||||
|
||||
// ── Pass 2: Tab button states ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TabButtons_AttributesGroupButtons_SetToOpenState()
|
||||
{
|
||||
var tabGroup = new UiPanel(); // fake tab group container
|
||||
var btn1 = MakeButton();
|
||||
var btn2 = MakeButton();
|
||||
tabGroup.AddChild(btn1);
|
||||
tabGroup.AddChild(btn2);
|
||||
|
||||
var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
// All UiButton children of the Attributes group should be "Open".
|
||||
Assert.Equal("Open", btn1.ActiveState);
|
||||
Assert.Equal("Open", btn2.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TabButtons_SkillsGroupButtons_SetToClosedState()
|
||||
{
|
||||
var tabGroup = new UiPanel();
|
||||
var btn = MakeButton();
|
||||
tabGroup.AddChild(btn);
|
||||
|
||||
var layout = Fake((CharacterStatController.TabSkillsId, tabGroup));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Closed", btn.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TabButtons_TitlesGroupButtons_SetToClosedState()
|
||||
{
|
||||
var tabGroup = new UiPanel();
|
||||
var btn = MakeButton();
|
||||
tabGroup.AddChild(btn);
|
||||
|
||||
var layout = Fake((CharacterStatController.TabTitlesId, tabGroup));
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||
|
||||
Assert.Equal("Closed", btn.ActiveState);
|
||||
}
|
||||
|
||||
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_Index4Focus_Returns110()
|
||||
{
|
||||
var sheet = SampleData.SampleCharacter();
|
||||
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_Index0Strength_Returns0()
|
||||
{
|
||||
var sheet = SampleData.SampleCharacter();
|
||||
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRaiseCost_OutOfRange_Returns0()
|
||||
{
|
||||
var sheet = SampleData.SampleCharacter();
|
||||
Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
|
||||
}
|
||||
|
||||
// ── GetRowName helper ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetRowName_Index0_ReturnsStrength()
|
||||
=> Assert.Equal("Strength", CharacterStatController.GetRowName(0));
|
||||
|
||||
[Fact]
|
||||
public void GetRowName_Index4_ReturnsFocus()
|
||||
=> Assert.Equal("Focus", CharacterStatController.GetRowName(4));
|
||||
|
||||
[Fact]
|
||||
public void GetRowName_Index6_ReturnsHealth()
|
||||
=> Assert.Equal("Health", CharacterStatController.GetRowName(6));
|
||||
|
||||
[Fact]
|
||||
public void GetRowName_NegativeIndex_ReturnsEmpty()
|
||||
=> Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
|
||||
|
||||
// ── SampleData sanity ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_SkillCredits_Is96()
|
||||
{
|
||||
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||||
}
|
||||
=> Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_UnassignedXp_IsSet()
|
||||
=> Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
|
||||
{
|
||||
Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
|
||||
var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
|
||||
Assert.NotNull(costs);
|
||||
Assert.Equal(9, costs.Length);
|
||||
}
|
||||
|
||||
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
|
||||
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
|
||||
|
||||
[Fact]
|
||||
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
|
||||
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
|
||||
|
||||
// ── UiText flag sanity ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UiText_RightAligned_DefaultFalse()
|
||||
|
|
@ -300,13 +644,20 @@ public class CharacterStatControllerTests
|
|||
Assert.True(t.RightAligned);
|
||||
}
|
||||
|
||||
// ── Robustness: a partial layout (missing ids) must not throw ────────────
|
||||
// ── Robustness ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_MissingElements_DoesNotThrow()
|
||||
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────────────
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
||||
private static UiButton MakeButton()
|
||||
{
|
||||
var info = new ElementInfo { Id = 0, Type = 1 };
|
||||
return new UiButton(info, static _ => (0u, 0, 0));
|
||||
}
|
||||
|
||||
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue