feat(D.2b): Character Attributes tab — 9-row list (icons + values + vitals) + footer State-A
Pass 1 of the Attributes tab interactive controller. Replaces the placeholder UiText attribute list with 9 real manual-layout rows matching retail's gmAttributeUI::PostInit (0x0049db70) structure: - 6 attribute rows (Strength/Endurance/Coordination/Quickness/Focus/Self) in retail display order (Coord enum 4 before Quick enum 3 — spec §1) - 3 vital rows (Health/Stamina/Mana) with cur/max format per Attribute2ndInfoRegion::Update (0x004f19e0) - Each row: icon (UiText.BackgroundSprite = 0x06xxxxxx RenderSurface via spriteResolve), name (left-justified), value (new RightAligned mode) Icon DataIDs from SubMap 0x25000006/0x25000007 via spec §2: STR 0x060002C8, END 0x060002C4, COORD 0x060002C9, QUICK 0x060002C6, FOCUS 0x060002C5, SELF 0x060002C7, HP 0x06004C3B, SP 0x06004C3C, MP 0x06004C3D Footer State-A (DisplayDefaultFooter 0x0049cde0): 0x1000024e title = "", 0x10000243 line-1-value = "Select an Attribute to Improve", 0x10000245 line-2-value = SkillCredits (InqInt(0x18)) Other changes: - UiText: add RightAligned bool (single-line right-justified, mirrors Centered path) - CharacterSheet: add SkillCredits property (retail InqInt(0x18)) - SampleData: update to spec fixture values (Str/Quick=200, rest=10, SkillCredits=96, Health=5/5, Stamina=10/10, Mana=10/10) - FixtureProvider: pass stack.ResolveChrome as spriteResolve for icon rendering - Tests: 13 targeted tests (9-row count, row order, attr+vital values, icon DataIDs, RightAligned flag, footer State-A all 5 elements) Pass 2 (selection/raise buttons) is separate per spec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
27819514af
commit
902160098a
6 changed files with 552 additions and 74 deletions
|
|
@ -116,12 +116,13 @@ public static class FixtureProvider
|
||||||
|
|
||||||
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
|
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
|
||||||
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
|
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
|
||||||
// total-XP/XP-meter + the attribute list). NOT the text-report sub-panel
|
// total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report
|
||||||
// (that is gmCharacterInfoUI 0x2100001A → CharacterController).
|
// sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController).
|
||||||
CharacterStatController.Bind(
|
CharacterStatController.Bind(
|
||||||
layout,
|
layout,
|
||||||
data: SampleData.SampleCharacter,
|
data: SampleData.SampleCharacter,
|
||||||
datFont: stack.VitalsDatFont);
|
datFont: stack.VitalsDatFont,
|
||||||
|
spriteResolve: stack.ResolveChrome);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -138,20 +138,25 @@ public static class SampleData
|
||||||
XpToNextLevel = 42_000_000,
|
XpToNextLevel = 42_000_000,
|
||||||
XpFraction = 0.63f,
|
XpFraction = 0.63f,
|
||||||
|
|
||||||
HealthCurrent = 80, HealthMax = 100,
|
// Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
|
||||||
StaminaCurrent = 60, StaminaMax = 100,
|
HealthCurrent = 5, HealthMax = 5,
|
||||||
ManaCurrent = 90, ManaMax = 100,
|
StaminaCurrent = 10, StaminaMax = 10,
|
||||||
|
ManaCurrent = 10, ManaMax = 10,
|
||||||
|
|
||||||
Strength = 100,
|
// Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
|
||||||
Endurance = 100,
|
Strength = 200,
|
||||||
Quickness = 100,
|
Endurance = 10,
|
||||||
Coordination = 100,
|
Quickness = 200,
|
||||||
Focus = 100,
|
Coordination = 10,
|
||||||
Self = 100,
|
Focus = 10,
|
||||||
|
Self = 10,
|
||||||
|
|
||||||
UnspentSkillCredits = 12,
|
UnspentSkillCredits = 12,
|
||||||
SpecializedSkillCredits = 4,
|
SpecializedSkillCredits = 4,
|
||||||
|
|
||||||
|
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
||||||
|
SkillCredits = 96,
|
||||||
|
|
||||||
AugmentationName = "Swords",
|
AugmentationName = "Swords",
|
||||||
|
|
||||||
BurdenCurrent = 1200,
|
BurdenCurrent = 1200,
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,18 @@ public sealed class CharacterSheet
|
||||||
|
|
||||||
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
|
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
|
||||||
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
|
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
|
||||||
|
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
|
||||||
|
|
||||||
public int UnspentSkillCredits { get; init; }
|
public int UnspentSkillCredits { get; init; }
|
||||||
public int SpecializedSkillCredits { get; init; }
|
public int SpecializedSkillCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
||||||
|
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
||||||
|
/// Element 0x10000245 (footer line-2 value).
|
||||||
|
/// </summary>
|
||||||
|
public int SkillCredits { get; init; }
|
||||||
|
|
||||||
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
||||||
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,18 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
|
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
|
||||||
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
|
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
|
||||||
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
|
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
|
||||||
/// <c>gmAttributeUI</c> list box (0x1000023D).</para>
|
/// <c>gmAttributeUI</c> list box (0x1000023D), built as 9 manual-layout rows per
|
||||||
|
/// <c>gmAttributeUI::PostInit</c> (0x0049db70) + <c>AttributeInfoRegion</c> / <c>Attribute2ndInfoRegion</c>
|
||||||
|
/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail
|
||||||
|
/// dat template (each icon is a <c>0x06xxxxxx</c> RenderSurface DataID from SubMap
|
||||||
|
/// 0x25000006 / 0x25000007, spec §2).</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Refinement pending:</b> exact label wording comes from the dat StringTable
|
/// <para>Footer State A (nothing selected) bound from
|
||||||
/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical
|
/// <c>DisplayDefaultFooter</c> (0x0049cde0): title empty, line-1 value =
|
||||||
/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row
|
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).</para>
|
||||||
/// template) are a later pass; this binds values into the real element rects first.</para>
|
///
|
||||||
|
/// <para><b>Pass 2 pending:</b> selection highlight (Button SetState(6)), State-B footer,
|
||||||
|
/// raise buttons (CM_Train::Event_TrainAttribute).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CharacterStatController
|
public static class CharacterStatController
|
||||||
{
|
{
|
||||||
|
|
@ -37,16 +43,64 @@ public static class CharacterStatController
|
||||||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||||||
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
||||||
|
|
||||||
|
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
||||||
|
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
||||||
|
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
||||||
|
public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel
|
||||||
|
public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue
|
||||||
|
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
|
||||||
|
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
|
||||||
|
|
||||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
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
|
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||||
|
|
||||||
|
// ── Row layout constants ─────────────────────────────────────────────────
|
||||||
|
// Row height matches retail's UIElement_ListBox template (~20px; spec §1 "each ~20px tall").
|
||||||
|
private const float RowHeight = 20f;
|
||||||
|
private const float IconSize = 16f; // icon element is ~16×16px (spec §Goal)
|
||||||
|
private const float RowPadX = 2f; // left inset before the icon
|
||||||
|
private const float IconGap = 4f; // gap between icon right and name text left
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
("Endurance", 0x060002C4u), // enum 2
|
||||||
|
("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order)
|
||||||
|
("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
|
||||||
|
("Stamina", 0x06004C3Cu), // CurEnum 4
|
||||||
|
("Mana", 0x06004C3Du), // CurEnum 6
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind the Attributes-tab header + list elements in <paramref name="layout"/> to
|
/// Bind the Attributes-tab header + 9-row list + footer State-A elements in
|
||||||
/// <paramref name="data"/>. Each element is the real importer-mounted widget; the
|
/// <paramref name="layout"/> to <paramref name="data"/>.
|
||||||
/// providers are polled each frame so a live session or the Studio can swap the sheet
|
///
|
||||||
/// without re-binding.
|
/// <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.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Bind(ImportedLayout layout, Func<CharacterSheet> data, UiDatFont? datFont = null)
|
public static void Bind(
|
||||||
|
ImportedLayout layout,
|
||||||
|
Func<CharacterSheet> data,
|
||||||
|
UiDatFont? datFont = null,
|
||||||
|
Func<uint, (uint handle, int w, int h)>? spriteResolve = null)
|
||||||
{
|
{
|
||||||
Label(layout, NameId, datFont, Gold, () => data().Name);
|
Label(layout, NameId, datFont, Gold, () => data().Name);
|
||||||
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
|
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
|
||||||
|
|
@ -58,25 +112,20 @@ public static class CharacterStatController
|
||||||
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
if (layout.FindElement(XpMeterId) is UiMeter meter)
|
||||||
meter.Fill = () => data().XpFraction;
|
meter.Fill = () => data().XpFraction;
|
||||||
|
|
||||||
// Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic
|
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||||
// container in the import, so attach a single text view sized to the rows and pinned
|
// gmAttributeUI::PostInit (0x0049db70) calls AddItemFromTemplateList nine times —
|
||||||
// to the top of the list region. (The full per-row AttributeInfoRegion widget is a
|
// in acdream we manually add 9 UiPanel row containers (no dat template machinery).
|
||||||
// later pass; this renders the six innate attributes + values faithfully.)
|
// Each row = icon (left, ~16×16) + name (left-justified) + value (right-aligned).
|
||||||
if (layout.FindElement(ListBoxId) is { } list)
|
if (layout.FindElement(ListBoxId) is { } list)
|
||||||
{
|
{
|
||||||
var rows = new UiText
|
BuildAttributeRows(list, datFont, spriteResolve, data);
|
||||||
{
|
|
||||||
DatFont = datFont,
|
|
||||||
ClickThrough = true,
|
|
||||||
Left = 12f,
|
|
||||||
Top = 10f,
|
|
||||||
Width = MathF.Max(60f, list.Width - 24f),
|
|
||||||
Height = 9f * LineHeight, // sized to the 8 content lines so it top-aligns
|
|
||||||
};
|
|
||||||
rows.LinesProvider = () => AttributeRows(data());
|
|
||||||
list.AddChild(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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") ──────────────────
|
// ── Active-page selection (fixes the "dark overlay") ──────────────────
|
||||||
// 0x2100002E is a tabbed window: all three tab pages (Attributes / Skills / Titles) mount
|
// 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
|
// their content as separate root children that OVERLAP in the same content rect. Retail
|
||||||
|
|
@ -94,6 +143,209 @@ public static class CharacterStatController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 9-row attribute list ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void BuildAttributeRows(
|
||||||
|
UiElement list,
|
||||||
|
UiDatFont? datFont,
|
||||||
|
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||||
|
Func<CharacterSheet> data)
|
||||||
|
{
|
||||||
|
float listW = list.Width;
|
||||||
|
float y = 0f; // row-local Y within the list box; each row stacks top-down
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
left: 0f, top: y, width: listW, height: RowHeight,
|
||||||
|
iconDid: iconDid,
|
||||||
|
nameText: rowName,
|
||||||
|
valueProvider: () =>
|
||||||
|
{
|
||||||
|
var s = data();
|
||||||
|
int v = rowIndex switch
|
||||||
|
{
|
||||||
|
0 => s.Strength,
|
||||||
|
1 => s.Endurance,
|
||||||
|
2 => s.Coordination,
|
||||||
|
3 => s.Quickness,
|
||||||
|
4 => s.Focus,
|
||||||
|
5 => s.Self,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
return v.ToString();
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
AddRow(list, datFont, spriteResolve,
|
||||||
|
left: 0f, top: y, width: listW, height: RowHeight,
|
||||||
|
iconDid: iconDid,
|
||||||
|
nameText: rowName,
|
||||||
|
valueProvider: () =>
|
||||||
|
{
|
||||||
|
var s = data();
|
||||||
|
return rowIndex switch
|
||||||
|
{
|
||||||
|
0 => $"{s.HealthCurrent}/{s.HealthMax}",
|
||||||
|
1 => $"{s.StaminaCurrent}/{s.StaminaMax}",
|
||||||
|
2 => $"{s.ManaCurrent}/{s.ManaMax}",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
y += RowHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// </summary>
|
||||||
|
private static void AddRow(
|
||||||
|
UiElement list,
|
||||||
|
UiDatFont? datFont,
|
||||||
|
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||||||
|
float left, float top, float width, float height,
|
||||||
|
uint iconDid,
|
||||||
|
string nameText,
|
||||||
|
Func<string> valueProvider)
|
||||||
|
{
|
||||||
|
// Transparent container — the list box backdrop draws behind everything.
|
||||||
|
var row = new UiPanel
|
||||||
|
{
|
||||||
|
Left = left,
|
||||||
|
Top = top,
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
BackgroundColor = Vector4.Zero, // transparent
|
||||||
|
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,
|
||||||
|
Top = iconY,
|
||||||
|
Width = IconSize,
|
||||||
|
Height = IconSize,
|
||||||
|
ClickThrough = true,
|
||||||
|
DatFont = null, // icon has no text
|
||||||
|
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
|
||||||
|
SpriteResolve = spriteResolve is not null
|
||||||
|
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
|
||||||
|
: null,
|
||||||
|
LinesProvider = static () => Array.Empty<UiText.Line>(),
|
||||||
|
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
|
||||||
|
|
||||||
|
string capturedName = nameText; // close over the name string directly
|
||||||
|
var nameEl = new UiText
|
||||||
|
{
|
||||||
|
Left = nameX,
|
||||||
|
Top = nameY,
|
||||||
|
Width = nameW,
|
||||||
|
Height = height,
|
||||||
|
DatFont = datFont,
|
||||||
|
ClickThrough = true,
|
||||||
|
Centered = false,
|
||||||
|
RightAligned = false,
|
||||||
|
Padding = 1f,
|
||||||
|
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
|
||||||
|
var valueEl = new UiText
|
||||||
|
{
|
||||||
|
Left = valueX,
|
||||||
|
Top = nameY,
|
||||||
|
Width = valueW > 0f ? valueW : 40f,
|
||||||
|
Height = height,
|
||||||
|
DatFont = datFont,
|
||||||
|
ClickThrough = true,
|
||||||
|
RightAligned = true,
|
||||||
|
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||||
|
};
|
||||||
|
var capturedProvider = valueProvider;
|
||||||
|
valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) };
|
||||||
|
|
||||||
|
row.AddChild(iconEl);
|
||||||
|
row.AddChild(nameEl);
|
||||||
|
row.AddChild(valueEl);
|
||||||
|
list.AddChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer State A binding ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bind footer elements to their State-A (nothing selected) content per
|
||||||
|
/// <c>DisplayDefaultFooter</c> (0x0049cde0). Only the two "value" slots carry
|
||||||
|
/// text; the label slots are left empty (retail sets them to "" for State A).
|
||||||
|
/// </summary>
|
||||||
|
private static void BindFooterStateA(
|
||||||
|
ImportedLayout layout,
|
||||||
|
UiDatFont? datFont,
|
||||||
|
Func<CharacterSheet> data)
|
||||||
|
{
|
||||||
|
// 0x1000024e title → ""
|
||||||
|
Label(layout, FooterTitleId, datFont, Body, static () => string.Empty);
|
||||||
|
|
||||||
|
// 0x10000242 line-1 label → ""
|
||||||
|
Label(layout, FooterLine1Label, datFont, Body, static () => string.Empty);
|
||||||
|
|
||||||
|
// 0x10000243 line-1 value → "Select an Attribute to Improve"
|
||||||
|
// StringId "ID_StatManagement_Footer_SelectAttribute"
|
||||||
|
Label(layout, FooterLine1Value, datFont, Body,
|
||||||
|
static () => "Select an Attribute to Improve");
|
||||||
|
|
||||||
|
// 0x10000244 line-2 label → ""
|
||||||
|
Label(layout, FooterLine2Label, datFont, Body, static () => string.Empty);
|
||||||
|
|
||||||
|
// 0x10000245 line-2 value → InqInt(0x18) = available skill credits
|
||||||
|
Label(layout, FooterLine2Value, datFont, Body,
|
||||||
|
() => data().SkillCredits.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Depth-first reference search: is <paramref name="target"/> somewhere under
|
/// <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>
|
/// <paramref name="node"/>? Used to find which overlapping tab page owns the bound widgets.</summary>
|
||||||
private static bool ContainsWidget(UiElement node, UiElement target)
|
private static bool ContainsWidget(UiElement node, UiElement target)
|
||||||
|
|
@ -104,8 +356,6 @@ public static class CharacterStatController
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private const float LineHeight = 18f;
|
|
||||||
|
|
||||||
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||||||
{
|
{
|
||||||
if (layout.FindElement(id) is UiText t)
|
if (layout.FindElement(id) is UiText t)
|
||||||
|
|
@ -116,24 +366,4 @@ public static class CharacterStatController
|
||||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The six innate attributes in canonical AC panel order
|
|
||||||
/// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<UiText.Line> AttributeRows(CharacterSheet s)
|
|
||||||
=> new List<UiText.Line>
|
|
||||||
{
|
|
||||||
new("Attributes", Gold),
|
|
||||||
new(string.Empty, Body),
|
|
||||||
Row("Strength", s.Strength),
|
|
||||||
Row("Endurance", s.Endurance),
|
|
||||||
Row("Coordination", s.Coordination),
|
|
||||||
Row("Quickness", s.Quickness),
|
|
||||||
Row("Focus", s.Focus),
|
|
||||||
Row("Self", s.Self),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static UiText.Line Row(string name, int value)
|
|
||||||
=> new($"{name,-13}{value,4}", Body);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ public sealed class UiText : UiElement
|
||||||
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||||
public bool Centered { get; set; }
|
public bool Centered { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Static right-aligned single-line mode: draws the FIRST line right-justified
|
||||||
|
/// within the element rect, vertically centered, with NO scroll/selection machinery.
|
||||||
|
/// Used for value labels in attribute/skill rows where the number must hug the right edge.
|
||||||
|
/// Mutually exclusive with <see cref="Centered"/> — if both are true, Centered takes
|
||||||
|
/// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
|
||||||
|
public bool RightAligned { get; set; }
|
||||||
|
|
||||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||||
public UiScrollable Scroll { get; } = new();
|
public UiScrollable Scroll { get; } = new();
|
||||||
|
|
||||||
|
|
@ -153,6 +160,28 @@ public sealed class UiText : UiElement
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static right-aligned single-line mode: draw the first line flush with the right
|
||||||
|
// edge, vertically centered, then skip the scroll/selection machinery.
|
||||||
|
if (RightAligned)
|
||||||
|
{
|
||||||
|
var rLines = LinesProvider();
|
||||||
|
if (rLines.Count == 0) return;
|
||||||
|
var line0 = rLines[0];
|
||||||
|
if (DatFont is { } rdf)
|
||||||
|
{
|
||||||
|
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
|
||||||
|
float ry = (Height - rdf.LineHeight) * 0.5f;
|
||||||
|
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
|
||||||
|
}
|
||||||
|
else if ((Font ?? ctx.DefaultFont) is { } rbf)
|
||||||
|
{
|
||||||
|
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
|
||||||
|
float ry = (Height - rbf.LineHeight) * 0.5f;
|
||||||
|
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer the retail dat font when set; fall back to BitmapFont.
|
// Prefer the retail dat font when set; fall back to BitmapFont.
|
||||||
var datFont = DatFont;
|
var datFont = DatFont;
|
||||||
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
|
/// Unit tests for <see cref="CharacterStatController"/> — the Attributes-tab controller that
|
||||||
/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data
|
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
|
||||||
/// wiring against fake layouts; no dats, no GL.
|
/// Pure data wiring against fake layouts; no dats, no GL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CharacterStatControllerTests
|
public class CharacterStatControllerTests
|
||||||
{
|
{
|
||||||
|
|
@ -64,26 +64,231 @@ public class CharacterStatControllerTests
|
||||||
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
|
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attribute list ───────────────────────────────────────────────────────
|
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
|
||||||
|
|
||||||
|
/// <summary>Bind adds exactly 9 UiPanel row containers to the list box.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_PopulatesAttributeList()
|
public void Bind_AttributeList_Has9Rows()
|
||||||
{
|
{
|
||||||
var list = new UiPanel();
|
var list = new UiPanel();
|
||||||
var layout = Fake((CharacterStatController.ListBoxId, list));
|
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
var rows = list.Children.OfType<UiText>().First();
|
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||||
var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text));
|
Assert.Equal(9, rows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Contains("Strength", text);
|
/// <summary>
|
||||||
Assert.Contains("Endurance", text);
|
/// Every row must have a right-aligned value UiText child (the last UiText in
|
||||||
Assert.Contains("Coordination", text);
|
/// the row), confirming the RightAligned path was wired.
|
||||||
Assert.Contains("Quickness", text);
|
/// </summary>
|
||||||
Assert.Contains("Focus", text);
|
[Fact]
|
||||||
Assert.Contains("Self", text);
|
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
|
||||||
Assert.Contains("100", text);
|
{
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||||
|
Assert.Equal(9, rows.Count);
|
||||||
|
|
||||||
|
string[] expectedNames =
|
||||||
|
{
|
||||||
|
"Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self",
|
||||||
|
"Health", "Stamina", "Mana",
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver()
|
||||||
|
{
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake((CharacterStatController.ListBoxId, list));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
|
||||||
|
|
||||||
|
var rows = list.Children.OfType<UiPanel>().ToList();
|
||||||
|
Assert.Equal(9, rows.Count);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var iconEl = row.Children.OfType<UiText>().First();
|
||||||
|
Assert.Equal(0u, iconEl.BackgroundSprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer State A ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_TitleEmpty()
|
||||||
|
{
|
||||||
|
var title = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, title.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line1LabelEmpty()
|
||||||
|
{
|
||||||
|
var lbl = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line1ValueIsSelectPrompt()
|
||||||
|
{
|
||||||
|
var val = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine1Value, val));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal("Select an Attribute to Improve", val.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line2LabelEmpty()
|
||||||
|
{
|
||||||
|
var lbl = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line2ValueIsSkillCredits()
|
||||||
|
{
|
||||||
|
var val = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine2Value, val));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
// SampleData.SampleCharacter().SkillCredits == 96
|
||||||
|
Assert.Equal("96", val.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SkillCredits propagation through SampleData ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SampleCharacter_SkillCredits_Is96()
|
||||||
|
{
|
||||||
|
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiText_RightAligned_DefaultFalse()
|
||||||
|
{
|
||||||
|
var t = new UiText();
|
||||||
|
Assert.False(t.RightAligned);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiText_RightAligned_CanBeSetTrue()
|
||||||
|
{
|
||||||
|
var t = new UiText { RightAligned = true };
|
||||||
|
Assert.True(t.RightAligned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Robustness: a partial layout (missing ids) must not throw ────────────
|
// ── Robustness: a partial layout (missing ids) must not throw ────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue