diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 7fdb4c37..e35be76c 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -114,8 +114,11 @@ public static class FixtureProvider break; } - case 0x2100002Eu: // gmCharacterInfoUI — character report (LayoutDesc 0x2100002E) - CharacterController.Bind( + case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E) + // 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 + // (that is gmCharacterInfoUI 0x2100001A → CharacterController). + CharacterStatController.Bind( layout, data: SampleData.SampleCharacter, datFont: stack.VitalsDatFont); diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index 7678b603..969af65c 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -133,6 +133,11 @@ public static class SampleData PlayTime = "2 years, 114 days, 4 hours", Deaths = 42, + PkStatus = "Non-Player Killer", + TotalXp = 1_250_000_000, + XpToNextLevel = 42_000_000, + XpFraction = 0.63f, + HealthCurrent = 80, HealthMax = 100, StaminaCurrent = 60, StaminaMax = 100, ManaCurrent = 90, ManaMax = 100, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index f251f325..aeeec885 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -33,6 +33,25 @@ public sealed class CharacterSheet /// Title string, e.g. "the Adventurer". Null = omit. public string? Title { get; init; } + // ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70, + // UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ────────── + + /// Total accrued experience (retail PropertyInt64 1). Header value + /// element 0x10000235 (m_pTotalXPText). + public long TotalXp { get; init; } + + /// Experience remaining to the next level. Header value element + /// 0x10000238 (m_pXPToLevelText). + public long XpToNextLevel { get; init; } + + /// XP-to-next-level meter fill, 0..1 (retail (cur−base)/(cap−base)). + /// Drives the header meter 0x10000236 (m_pXPToLevelMeter). + public float XpFraction { get; init; } + + /// PK status display string, e.g. "Non-Player Killer". Header element + /// 0x10000233 (m_pPKStatusText). Null = omit. + public string? PkStatus { get; init; } + // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── /// Formatted birth date string (retail InqInt(0x62) → strftime). diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs new file mode 100644 index 00000000..fa79cd85 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// +/// Controller for the Character window's Attributes tab — LayoutDesc 0x2100002E, +/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root +/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content. +/// +/// Unlike (which targets the SEPARATE text-report +/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element), +/// this controller binds the real, statically-mounted header + list elements that the +/// importer already produces — every id below is confirmed present via +/// . +/// +/// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) + +/// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage, +/// PK status, level, total XP and the XP-to-level meter. The attribute list is the +/// gmAttributeUI list box (0x1000023D). +/// +/// Refinement pending: exact label wording comes from the dat StringTable +/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical +/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row +/// template) are a later pass; this binds values into the real element rects first. +/// +public static class CharacterStatController +{ + // ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ── + public const uint NameId = 0x10000231u; // m_pNameText + public const uint HeritageId = 0x10000232u; // m_pHeritageText + public const uint PkStatusId = 0x10000233u; // m_pPKStatusText + public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area) + public const uint TotalXpId = 0x10000235u; // m_pTotalXPText + public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter) + public const uint ListBoxId = 0x1000023Du; // m_pListBox container + + 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 + + /// + /// Bind the Attributes-tab header + list elements in to + /// . Each element is the real importer-mounted widget; the + /// providers are polled each frame so a live session or the Studio can swap the sheet + /// without re-binding. + /// + public static void Bind(ImportedLayout layout, Func data, UiDatFont? datFont = null) + { + Label(layout, NameId, datFont, Gold, () => data().Name); + Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty); + Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty); + Label(layout, LevelId, datFont, Gold, () => data().Level.ToString()); + Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0")); + + // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). + if (layout.FindElement(XpMeterId) is UiMeter meter) + meter.Fill = () => data().XpFraction; + + // Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic + // container in the import, so attach a single text view sized to the rows and pinned + // to the top of the list region. (The full per-row AttributeInfoRegion widget is a + // later pass; this renders the six innate attributes + values faithfully.) + if (layout.FindElement(ListBoxId) is { } list) + { + var rows = new UiText + { + 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); + } + } + + private const float LineHeight = 18f; + + private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) + { + if (layout.FindElement(id) is UiText t) + { + t.DatFont = datFont; + t.Centered = true; // single-line centered label (retail UIElement_Text) + t.ClickThrough = true; + t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; + } + } + + /// + /// The six innate attributes in canonical AC panel order + /// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header. + /// + private static IReadOnlyList AttributeRows(CharacterSheet s) + => new List + { + 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); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs new file mode 100644 index 00000000..70faab1a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -0,0 +1,108 @@ +using AcDream.App.Studio; +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Unit tests for — the Attributes-tab controller that +/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data +/// wiring against fake layouts; no dats, no GL. +/// +public class CharacterStatControllerTests +{ + // ── Header labels bind to the sheet ────────────────────────────────────── + + [Fact] + public void Bind_SetsNameLabel() + { + var name = new UiText(); + var layout = Fake((CharacterStatController.NameId, name)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Studio Player", name.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_SetsLevelLabel() + { + var level = new UiText(); + var layout = Fake((CharacterStatController.LevelId, level)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("126", level.LinesProvider()[0].Text); + } + + [Fact] + public void Bind_SetsHeritageAndPkLabels() + { + var heritage = new UiText(); + var pk = new UiText(); + var layout = Fake((CharacterStatController.HeritageId, heritage), + (CharacterStatController.PkStatusId, pk)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text); + Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text); + } + + // ── XP meter fill ──────────────────────────────────────────────────────── + + [Fact] + public void Bind_SetsXpMeterFill() + { + var meter = new UiMeter(); + var layout = Fake((CharacterStatController.XpMeterId, meter)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var fill = meter.Fill(); + Assert.NotNull(fill); + Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}"); + } + + // ── Attribute list ─────────────────────────────────────────────────────── + + [Fact] + public void Bind_PopulatesAttributeList() + { + var list = new UiPanel(); + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var rows = list.Children.OfType().First(); + var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text)); + + Assert.Contains("Strength", text); + Assert.Contains("Endurance", text); + Assert.Contains("Coordination", text); + Assert.Contains("Quickness", text); + Assert.Contains("Focus", text); + Assert.Contains("Self", text); + Assert.Contains("100", text); + } + + // ── Robustness: a partial layout (missing ids) must not throw ──────────── + + [Fact] + public void Bind_MissingElements_DoesNotThrow() + => CharacterStatController.Bind(Fake(), SampleData.SampleCharacter); + + // ── Helper ───────────────────────────────────────────────────────────── + + private static ImportedLayout Fake(params (uint id, UiElement e)[] items) + { + var dict = new Dictionary(); + var root = new UiPanel(); + foreach (var (id, e) in items) + { + root.AddChild(e); + dict[id] = e; + } + return new ImportedLayout(root, dict); + } +}