diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 6be5fc4f..a6763740 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -174,14 +174,16 @@ public static class CharacterStatController UiDatFont? datFont = null, Func? spriteResolve = null) { - Label(layout, NameId, datFont, Gold, () => data().Name); + // Name: retail "Horan" is WHITE, not gold. Heritage and PK status are also white. + Label(layout, NameId, datFont, Vector4.One, () => data().Name); Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty); Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // 0x1000023A = "Character Level" caption area above the level value (235,0,65×35). - // Source: dump idx=29/30; spec §Header element map. - LabelLeft(layout, LevelCaptionId, datFont, Body, static () => "Character Level"); + // Retail shows 2 lines: "Character" (top) / "Level" (bottom) so neither truncates. + // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). + LabelTwoLine(layout, LevelCaptionId, datFont, Body, "Character", "Level"); Label(layout, LevelId, datFont, Gold, () => data().Level.ToString()); @@ -194,9 +196,58 @@ public static class CharacterStatController // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). // NOTE: child elements 0x10000237 (label) and 0x10000238 (value) are consumed by // the UiMeter and cannot be bound — FindElement returns NULL for both. + // We inject a UiText caption + value ABOVE the meter's parent container instead, + // positioned to the left and right of a thin strip above the red bar. if (layout.FindElement(XpMeterId) is UiMeter meter) + { meter.Fill = () => data().XpFraction; + // Inject "XP for next level:" label and value ON the meter as children. + // The retail layout puts this caption + value ON TOP of the red bar — the bar + // is behind the text (ref 2026-06-26: "value … with the red fill bar behind it"). + // UiMeter.ConsumesDatChildren=true consumed the original 0x10000237/0x10000238 + // children at import; we re-inject them as runtime UiText overlays at LOCAL (0,0) + // filling the meter's full rect. The caption is left-aligned; the value is right-aligned. + // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). + { + float mW = meter.Width; + float mH = meter.Height; + + var xpNextLabel = new UiText + { + Left = 0f, + Top = 0f, + Width = mW * 0.60f, + Height = mH, + DatFont = datFont, + ClickThrough = true, + Centered = false, + RightAligned = false, + Padding = 0f, // avoid scroll clip — meter bar is ~13px tall + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + xpNextLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) }; + + var xpNextValue = new UiText + { + Left = 0f, + Top = 0f, + Width = mW, + Height = mH, + DatFont = datFont, + ClickThrough = true, + Centered = false, + RightAligned = true, + Padding = 0f, // avoid scroll clip + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + xpNextValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; + + meter.AddChild(xpNextLabel); + meter.AddChild(xpNextValue); + } + } + // ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ─── // The tab groups (0x10000228/229/538) are UiText (Type 12) with // ConsumesDatChildren=true, so their 3 button children were consumed at import. @@ -665,14 +716,25 @@ public static class CharacterStatController titleEl.Height = 18f; titleEl.Anchors = AnchorEdges.None; } - LabelProvider(titleEl, datFont, Body, () => + // Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref). + if (titleEl is not null) { - 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}"; - }); + titleEl.DatFont = datFont; + titleEl.Centered = false; + titleEl.RightAligned = false; + titleEl.ClickThrough = true; + titleEl.Padding = 0f; + titleEl.LinesProvider = () => + { + if (sel[0] < 0) + return new[] { new UiText.Line("Select an Attribute to Improve", Body) }; + var s = data(); + string name = GetRowName(sel[0]); + string value = GetRowValueString(s, sel[0]); + // State B title is WHITE (retail confirmed). + return new[] { new UiText.Line($"{name}: {value}", Vector4.One) }; + }; + } // Line-1 label (Top=20, Left=5): State A = "Skill Credits Available:"; State B = "Experience To Raise:" var l1L = ByPos(20f, 5f, FooterLine1Label); @@ -680,12 +742,13 @@ public static class CharacterStatController sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); // Line-1 value (Top=20, Left=200): State A = SkillCredits; State B = raise cost + // When attribute is maxed (cost == 0), retail shows "Infinity!" (confirmed 2026-06-26 ref). var l1V = ByPos(20f, 200f, FooterLine1Value); LabelProvider(l1V, datFont, Body, () => { if (sel[0] < 0) return data().SkillCredits.ToString(); long cost = GetRaiseCost(data(), sel[0]); - return cost > 0 ? cost.ToString("N0") : "—"; + return cost > 0 ? cost.ToString("N0") : "Infinity!"; }); // Line-2 label (Top=37, Left=5): "Unassigned Experience:" in both states @@ -745,7 +808,33 @@ public static class CharacterStatController } } - /// Left-justified label (for captions that should be left-aligned, not centered). + /// Two-line centered label. Provides TWO lines from LinesProvider so both + /// fit side-by-side in a narrow element without truncation. The scroll path in + /// renders multiple lines oldest-first (top-to-bottom), so + /// line 0 = (top) and line 1 = (bottom). + /// This replaces the single-line "Character Level" caption which truncated in the 65px element. + /// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption). + private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, + string line1, string line2) + { + if (layout.FindElement(id) is UiText t) + { + t.DatFont = datFont; + t.Centered = false; // non-Centered → scroll/multi-line path + t.RightAligned = false; + t.ClickThrough = true; + t.Padding = 1f; + t.LinesProvider = () => new[] + { + new UiText.Line(line1, color), + new UiText.Line(line2, color), + }; + } + } + + /// Left-justified label (for captions that should be left-aligned, not centered). + /// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without + /// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding). private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (layout.FindElement(id) is UiText t) @@ -754,6 +843,7 @@ public static class CharacterStatController t.Centered = false; t.RightAligned = false; t.ClickThrough = true; + t.Padding = 0f; // avoid scroll clip in small-height header elements t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 3feb03aa..a875878c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -675,7 +675,10 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - Assert.Equal("Character Level", caption.LinesProvider()[0].Text); + // 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element. + var lines = caption.LinesProvider(); + Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line"); + Assert.Equal("Character", lines[0].Text); Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)"); Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)"); } @@ -693,6 +696,90 @@ public class CharacterStatControllerTests Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)"); } + // ── Polish Commit 1: name white, Infinity!, white footer title ───────────── + + [Fact] + public void Bind_NameColor_IsWhite() + { + // Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref) + var name = new UiText(); + var layout = Fake((CharacterStatController.NameId, name)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var color = name.LinesProvider()[0].Color; + Assert.Equal(1f, color.X, precision: 3); + Assert.Equal(1f, color.Y, precision: 3); + Assert.Equal(1f, color.Z, precision: 3); + Assert.Equal(1f, color.W, precision: 3); + } + + [Fact] + public void RowClick_MaxedRow_FooterLine1ValueIsInfinity() + { + // Strength (index 0) cost=0 → "Infinity!" per retail spec. + 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().ToList()[0].OnClick!(); // Strength + + Assert.Equal("Infinity!", val.LinesProvider()[0].Text); + } + + [Fact] + public void RowClick_SelectedFooterTitle_IsWhite() + { + // State B footer title must be WHITE (retail 2026-06-26 ref). + var title = new UiText(); + var list = new UiPanel(); + var layout = Fake( + (CharacterStatController.FooterTitleId, title), + (CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + list.Children.OfType().ToList()[4].OnClick!(); // Focus + + var color = title.LinesProvider()[0].Color; + Assert.Equal(1f, color.X, precision: 3); + Assert.Equal(1f, color.Y, precision: 3); + Assert.Equal(1f, color.Z, precision: 3); + Assert.Equal(1f, color.W, precision: 3); + } + + [Fact] + public void Bind_FooterStateA_TitleColor_IsBodyNotWhite() + { + // State A title is body (parchment), not white. + var title = new UiText(); + var layout = Fake((CharacterStatController.FooterTitleId, title)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + // Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white. + var color = title.LinesProvider()[0].Color; + Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f, + "State-A title should be body/parchment color, not pure white"); + } + + [Fact] + public void Bind_LevelCaptionId_SetsTwoLines() + { + // Retail: level caption is "Character" / "Level" on two lines (not truncated). + var caption = new UiText(); + var layout = Fake((CharacterStatController.LevelCaptionId, caption)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var lines = caption.LinesProvider(); + Assert.Equal(2, lines.Count); + Assert.Equal("Character", lines[0].Text); + Assert.Equal("Level", lines[1].Text); + } + // ── Robustness ──────────────────────────────────────────────────────────── [Fact]