From e7e943a5809f2ce4f9e1021624e2b0998865c949 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 26 Jun 2026 14:08:06 +0200 Subject: [PATCH] feat(studio): port character skills tab --- src/AcDream.App/Studio/SampleData.cs | 21 + src/AcDream.App/UI/Layout/CharacterSheet.cs | 27 + .../UI/Layout/CharacterStatController.cs | 503 ++++++++++++++++-- .../UI/Layout/CharacterStatControllerTests.cs | 128 +++++ 4 files changed, 634 insertions(+), 45 deletions(-) diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index da094467..1a53a6ea 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -167,6 +167,27 @@ public static class SampleData // Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10). AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L }, + // Real SkillTable icon IDs and train/specialize costs from client_portal.dat + // SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time. + Skills = new CharacterSkill[] + { + new( 6, "Melee Defense", 0x06000165u, CharacterSkillAdvancementClass.Specialized, 350, 354, false, 10, 20, 18_250_000L, 182_500_000L), + new(34, "War Magic", 0x06001365u, CharacterSkillAdvancementClass.Specialized, 280, 285, false, 16, 28, 11_100_000L, 111_000_000L), + + new(14, "Arcane Lore", 0x0600016Eu, CharacterSkillAdvancementClass.Trained, 260, 269, false, 4, 6, 7_500_000L, 75_000_000L), + new(33, "Life Magic", 0x06001364u, CharacterSkillAdvancementClass.Trained, 250, 252, false, 12, 20, 6_800_000L, 68_000_000L), + new(47, "Missile Weapons", 0x0600015Fu, CharacterSkillAdvancementClass.Trained, 220, 221, false, 6, 12, 5_250_000L, 52_500_000L), + + new(21, "Healing", 0x06000133u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 6, 10, 0L), + new(22, "Jump", 0x0600016Bu, CharacterSkillAdvancementClass.Untrained, 210, 210, true, 0, 4, 0L), + new(36, "Loyalty", 0x06001367u, CharacterSkillAdvancementClass.Untrained, 10, 10, true, 0, 2, 0L), + new(24, "Run", 0x06000173u, CharacterSkillAdvancementClass.Untrained, 390, 390, true, 0, 4, 0L), + + new(38, "Alchemy", 0x060019E4u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 6, 12, 0L), + new(39, "Cooking", 0x06001A54u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L), + new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L), + }, + AugmentationName = "Swords", BurdenCurrent = 1200, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index b64c64eb..7009ff91 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -122,6 +122,12 @@ public sealed class CharacterSheet /// public long[] AttributeRaiseCosts { get; init; } = Array.Empty(); + /// + /// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by + /// advancement class, then sorts each group alphabetically by skill name. + /// + public IReadOnlyList Skills { get; init; } = Array.Empty(); + // ── Augmentations (UpdateAugmentations 0x004b9000) ───────────────────── // Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb. @@ -135,3 +141,24 @@ public sealed class CharacterSheet public int BurdenCurrent { get; init; } public int BurdenMax { get; init; } } + +public enum CharacterSkillAdvancementClass +{ + Inactive = 0, + Untrained = 1, + Trained = 2, + Specialized = 3, +} + +public sealed record CharacterSkill( + uint Id, + string Name, + uint IconDid, + CharacterSkillAdvancementClass AdvancementClass, + int BaseLevel, + int CurrentLevel, + bool UsableUntrained, + int TrainedCost, + int SpecializedCost, + long RaiseCost, + long Raise10Cost = 0L); diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index e686c481..09d14335 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -149,6 +149,34 @@ public static class CharacterStatController private const float RowPadX = 4f; private const float IconGap = 6f; + private const float SkillRowHeight = 20f; + private const float SkillHeaderHeight = 20f; + private const float SkillContentWidth = 282f; + + private const uint SkillHeaderSpecializedSprite = 0x06000F90u; + private const uint SkillHeaderTrainedSprite = 0x06000F86u; + private const uint SkillHeaderUntrainedSprite = 0x06000F89u; + private const uint SkillRowBgSprite = 0x06000F98u; + private const uint RowHighlightSprite = 0x06001397u; + + private enum CharacterStatTab + { + Attributes, + Skills, + } + + private sealed class TabVisual + { + public required CharacterStatTab Tab { get; init; } + public required UiText Left { get; init; } + public required UiText Center { get; init; } + public required UiText Right { get; init; } + public required UiText Label { get; init; } + public required string Text { get; init; } + } + + private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); + // ── Attribute row descriptors — retail display order per spec §1 ───────── private static readonly (string name, uint iconDid)[] AttrRows = new[] { @@ -195,6 +223,14 @@ public static class CharacterStatController // Falls back to datFont when null (tests, or dat missing). rowDatFont ??= datFont; + var activeTab = new[] { CharacterStatTab.Attributes }; + var attrSel = new[] { -1 }; + var skillSel = new[] { -1 }; + var activeListEntries = new List(); + var currentAttributeRows = new List(); + var currentSkillRows = new List(); + var tabVisuals = new List(); + // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. // Controllers still own the text color and the LinesProvider. @@ -284,27 +320,10 @@ public static class CharacterStatController // by "if (page.Children.Count == 0) continue" — they stay visible always. // Source: retail dump tab rects confirmed (2026-06-25): // Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25). - if (layout.Root is { } tabRoot && spriteResolve is not null) - { - // Hide the original tab group elements (0x10000228/229/538): they are UiText - // nodes that render their own state sprite at ZOrder=1/2/3. Our replacement - // sprites go to root at ZOrder=8 (above them), so the originals would paint - // underneath. Hiding them avoids the double-draw and any opaque overlap. - if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false; - if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false; - if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false; - - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 0f, label: "Attributes", isOpen: true); - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 92f, label: "Skills", isOpen: false); - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 184f, label: "Titles", isOpen: false); - } + // Interactive tab sprites are injected after the list/footer closures are wired. // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── // 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) @@ -378,14 +397,27 @@ public static class CharacterStatController // ── Footer State A initial binding ──────────────────────────────────── // Walk to the State-A container directly (rather than _byId which returns the // last duplicate) so we get the wider-label copies (195px) for the unselected state. - BindFooterDynamic(layout, datFont, data, sel); + BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel); - List? rowPanels = null; + UiElement? statList = layout.FindElement(ListBoxId); + RebuildActiveList(); - if (layout.FindElement(ListBoxId) is { } list) + if (layout.Root is { } tabRoot2 && spriteResolve is not null) { - rowPanels = BuildAttributeRows(list, rowDatFont, spriteResolve, data, sel, - allRaise1, allRaise10); + if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false; + if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false; + if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false; + + tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, + tabX: 0f, label: "Attributes", tab: CharacterStatTab.Attributes, + onClick: () => SwitchTab(CharacterStatTab.Attributes))); + tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, + tabX: 92f, label: "Skills", tab: CharacterStatTab.Skills, + onClick: () => SwitchTab(CharacterStatTab.Skills))); + AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, + tabX: 184f, label: "Titles", tab: CharacterStatTab.Attributes, + onClick: static () => { }); + UpdateTabVisuals(tabVisuals, activeTab[0]); } // ── Active-page selection (fixes the dark-overlay) ───────────────────── @@ -403,6 +435,56 @@ public static class CharacterStatController page.Visible = ContainsWidget(page, anchor); } } + + void SwitchTab(CharacterStatTab tab) + { + if (activeTab[0] == tab) return; + activeTab[0] = tab; + attrSel[0] = -1; + skillSel[0] = -1; + RebuildActiveList(); + RefreshActiveRaiseButtons(); + UpdateTabVisuals(tabVisuals, tab); + Console.WriteLine($"[CharacterStat] Tab click: {tab}"); + } + + void RebuildActiveList() + { + if (statList is null) return; + + foreach (var entry in activeListEntries) + statList.RemoveChild(entry); + activeListEntries.Clear(); + currentAttributeRows.Clear(); + currentSkillRows.Clear(); + + if (activeTab[0] == CharacterStatTab.Attributes) + { + currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, + allRaise1, allRaise10); + activeListEntries.AddRange(currentAttributeRows); + } + else + { + activeListEntries.AddRange(BuildSkillRows(statList, rowDatFont, spriteResolve, data, skillSel, + allRaise1, allRaise10, out currentSkillRows)); + } + } + + void RefreshActiveRaiseButtons() + { + if (activeTab[0] == CharacterStatTab.Attributes) + { + RefreshRaiseButtons(attrSel[0], data, allRaise1, allRaise10); + return; + } + + CharacterSkill? selectedSkill = + skillSel[0] >= 0 && skillSel[0] < currentSkillRows.Count + ? currentSkillRows[skillSel[0]].Skill + : null; + RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); + } } // ── 9-row attribute list ───────────────────────────────────────────────── @@ -480,6 +562,146 @@ public static class CharacterStatController return rows; } + private static List BuildSkillRows( + UiElement list, + UiDatFont? datFont, + Func? spriteResolve, + Func data, + int[] sel, + List allRaise1, + List allRaise10, + out List skillRows) + { + float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; + float y = 0f; + var entries = new List(); + var bindings = new List(); + + AddBucket("Specialized Skills", SkillHeaderSpecializedSprite, + OrderedSkills(data(), CharacterSkillAdvancementClass.Specialized, usableUntrained: null)); + AddBucket("Trained Skills", SkillHeaderTrainedSprite, + OrderedSkills(data(), CharacterSkillAdvancementClass.Trained, usableUntrained: null)); + AddBucket("Useable Untrained Skills", SkillHeaderUntrainedSprite, + OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: true)); + AddBucket("Unuseable Untrained Skills", SkillHeaderUntrainedSprite, + OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: false)); + + skillRows = bindings; + return entries; + + void AddBucket(string title, uint spriteId, IReadOnlyList skills) + { + var header = AddSkillHeader(list, datFont, spriteResolve, 0f, y, listW, title, spriteId); + entries.Add(header); + y += SkillHeaderHeight; + + foreach (var skill in skills) + { + int rowIndex = bindings.Count; + var row = AddRow(list, datFont, spriteResolve, + left: 0f, top: y, width: listW, height: SkillRowHeight, + iconDid: skill.IconDid, + nameText: skill.Name, + valueProvider: () => skill.CurrentLevel.ToString(), + valueColorProvider: () => SkillValueColor(skill), + backgroundSprite: SkillRowBgSprite, + useSelectionBars: false); + row.OnClick = () => HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); + bindings.Add(new SkillRowBinding(row, skill)); + entries.Add(row); + y += SkillRowHeight; + } + } + } + + private static UiPanel AddSkillHeader( + UiElement list, + UiDatFont? datFont, + Func? spriteResolve, + float left, + float top, + float width, + string title, + uint spriteId) + { + var header = new UiPanel + { + Left = left, + Top = top, + Width = width, + Height = SkillHeaderHeight, + BackgroundColor = spriteResolve is null ? new Vector4(0.12f, 0.12f, 0.14f, 0.65f) : Vector4.Zero, + BackgroundSprite = spriteResolve is not null ? spriteId : 0u, + SpriteResolve = spriteResolve is not null + ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } + : null, + BorderColor = Vector4.Zero, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + ClickThrough = true, + }; + + var label = new UiText + { + Left = RowPadX, + Top = 0f, + Width = MathF.Max(1f, width - RowPadX * 2f), + Height = SkillHeaderHeight, + DatFont = datFont, + ClickThrough = true, + Centered = false, + RightAligned = false, + Padding = 1f, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + string captured = title; + label.LinesProvider = () => new[] { new UiText.Line(captured, Gold) }; + header.AddChild(label); + list.AddChild(header); + return header; + } + + private static IReadOnlyList OrderedSkills( + CharacterSheet sheet, + CharacterSkillAdvancementClass advancement, + bool? usableUntrained) + { + var result = new List(); + foreach (var skill in sheet.Skills) + { + if (skill.AdvancementClass != advancement) continue; + if (usableUntrained is not null && skill.UsableUntrained != usableUntrained.Value) continue; + result.Add(skill); + } + result.Sort(static (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + return result; + } + + private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index) + { + if (index < 0) return null; + int n = 0; + foreach (var bucket in new[] + { + OrderedSkills(sheet, CharacterSkillAdvancementClass.Specialized, usableUntrained: null), + OrderedSkills(sheet, CharacterSkillAdvancementClass.Trained, usableUntrained: null), + OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: true), + OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: false), + }) + { + foreach (var skill in bucket) + { + if (n == index) return skill; + n++; + } + } + return null; + } + + private static Vector4 SkillValueColor(CharacterSkill skill) + => skill.CurrentLevel > skill.BaseLevel ? new Vector4(0.55f, 1f, 0.55f, 1f) + : skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f) + : Body; + /// /// Handles a row click: toggle (same row → deselect), else select new row. /// Updates highlight, footer providers, and raise-button state. @@ -535,6 +757,56 @@ public static class CharacterStatController RefreshRaiseButtons(newSel, data, allRaise1, allRaise10); } + private static void HandleSkillRowClick( + int clickedIndex, + int[] sel, + List rows, + Func? spriteResolve, + Func data, + List allRaise1, + List allRaise10) + { + int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex; + sel[0] = newSel; + + string rowName = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill.Name : string.Empty; + Console.WriteLine($"[CharacterStat] Skill row click: index={clickedIndex} -> selected={newSel} ({rowName})"); + + for (int i = 0; i < rows.Count; i++) + { + var row = rows[i].Panel; + if (i == newSel) + { + if (spriteResolve is not null) + { + row.BackgroundColor = Vector4.Zero; + row.BackgroundSprite = RowHighlightSprite; + row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; + row.UseSelectionBars = true; + } + else + { + row.BackgroundColor = HighlightBg; + row.BackgroundSprite = 0u; + row.SpriteResolve = null; + row.UseSelectionBars = false; + } + } + else + { + row.BackgroundColor = Vector4.Zero; + row.BackgroundSprite = spriteResolve is not null ? SkillRowBgSprite : 0u; + row.SpriteResolve = spriteResolve is not null + ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } + : null; + row.UseSelectionBars = false; + } + } + + CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null; + RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); + } + /// 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 @@ -566,6 +838,44 @@ public static class CharacterStatController foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; } } + private static void RefreshSkillRaiseButtons( + CharacterSkill? selectedSkill, + CharacterSheet sheet, + List allRaise1, + List allRaise10) + { + if (selectedSkill is null) + { + foreach (var b in allRaise1) b.Visible = false; + foreach (var b in allRaise10) b.Visible = false; + return; + } + + bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; + long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost; + bool affordable = trained + ? cost > 0 && sheet.UnassignedXp >= cost + : cost > 0 && sheet.SkillCredits >= cost; + string state = affordable ? StateNormal : StateGhosted; + + foreach (var b in allRaise1) + { + b.Visible = true; + b.ActiveState = state; + } + + foreach (var b in allRaise10) + { + b.Visible = trained; + if (trained) + { + long cost10 = selectedSkill.Raise10Cost; + bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; + b.ActiveState = affordable10 ? StateNormal : StateGhosted; + } + } + } + /// Return the raise cost for row from the sheet. /// Returns 0 if the cost array is shorter than expected. internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex) @@ -617,7 +927,10 @@ public static class CharacterStatController float left, float top, float width, float height, uint iconDid, string nameText, - Func valueProvider) + Func valueProvider, + Func? valueColorProvider = null, + uint backgroundSprite = 0u, + bool useSelectionBars = true) { var row = new UiClickablePanel { @@ -626,8 +939,12 @@ public static class CharacterStatController Width = width, Height = height, BackgroundColor = Vector4.Zero, // transparent until selected + BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u, + SpriteResolve = spriteResolve is not null && backgroundSprite != 0u + ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } + : null, BorderColor = Vector4.Zero, - UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch + UseSelectionBars = useSelectionBars, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; @@ -684,7 +1001,7 @@ public static class CharacterStatController Anchors = AnchorEdges.Left | AnchorEdges.Top, }; var capturedProvider = valueProvider; - valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) }; + valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), valueColorProvider?.Invoke() ?? Body) }; row.AddChild(iconEl); row.AddChild(nameEl); @@ -720,7 +1037,9 @@ public static class CharacterStatController ImportedLayout layout, UiDatFont? datFont, Func data, - int[] sel) + CharacterStatTab[] activeTab, + int[] attrSel, + int[] skillSel) { // Walk the State A container (0x10000240) to find the wide-label copies of the // footer child elements. The 5 children of 0x10000240 at their LOCAL coords: @@ -798,11 +1117,23 @@ public static class CharacterStatController titleEl.ClickThrough = true; titleEl.LinesProvider = () => { - if (sel[0] < 0) + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is null) + return new[] { new UiText.Line("Select a Skill to Improve", Body) }; + + string title = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? $"{skill.Name}: {skill.CurrentLevel}" + : skill.Name; + return new[] { new UiText.Line(title, Vector4.One) }; + } + + if (attrSel[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]); + string name = GetRowName(attrSel[0]); + string value = GetRowValueString(s, attrSel[0]); // State B title is WHITE (retail confirmed). return new[] { new UiText.Line($"{name}: {value}", Vector4.One) }; }; @@ -811,24 +1142,63 @@ public static class CharacterStatController // Footer lines (all dat-origin with their own font sizes): pass null → keep dat font. var l1L = ByPos(20f, 5f, FooterLine1Label); LabelProvider(l1L, null, Body, () => - sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); + { + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is null) return "Skill Credits Available:"; + return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? "Experience To Raise:" + : "Skill Credits To Raise:"; + } + + return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"; + }); var l1V = ByPos(20f, 200f, FooterLine1Value); LabelProvider(l1V, null, Body, () => { - if (sel[0] < 0) return data().SkillCredits.ToString(); - long cost = GetRaiseCost(data(), sel[0]); + var sheet = data(); + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(sheet, skillSel[0]); + if (skill is null) return sheet.SkillCredits.ToString(); + long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained + ? skill.RaiseCost + : skill.TrainedCost; + return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; + } + + if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); + long cost = GetRaiseCost(sheet, attrSel[0]); return cost > 0 ? cost.ToString("N0") : "Infinity!"; }); // Line-2 elements: pass null → keep dat font. var l2L = ByPos(37f, 5f, FooterLine2Label); - LabelProvider(l2L, null, Body, - static () => "Unassigned Experience:"); + LabelProvider(l2L, null, Body, () => + { + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(data(), skillSel[0]); + if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) + return "Skill Credits Available:"; + } + return "Unassigned Experience:"; + }); var l2V = ByPos(37f, 200f, FooterLine2Value); - LabelProvider(l2V, null, Body, - () => data().UnassignedXp.ToString("N0")); + LabelProvider(l2V, null, Body, () => + { + var sheet = data(); + if (activeTab[0] == CharacterStatTab.Skills) + { + var skill = SkillAtDisplayIndex(sheet, skillSel[0]); + if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) + return sheet.SkillCredits.ToString(); + } + return sheet.UnassignedXp.ToString("N0"); + }); } // ── Helpers ────────────────────────────────────────────────────────────── @@ -952,30 +1322,33 @@ public static class CharacterStatController /// Tab geometry confirmed from dump: each tab = 92px wide, 25px tall. /// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px). /// - private static void AddTabSpritesToRoot( + private static TabVisual AddTabSpritesToRoot( UiElement root, Func spriteResolve, UiDatFont? datFont, float tabX, string label, - bool isOpen) + CharacterStatTab tab, + Action onClick) { - uint leftId = isOpen ? TabOpenLeft : TabClosedLeft; - uint centerId = isOpen ? TabOpenCenter : TabClosedCenter; - uint rightId = isOpen ? TabOpenRight : TabClosedRight; + uint leftId = TabClosedLeft; + uint centerId = TabClosedCenter; + uint rightId = TabClosedRight; // Left cap: 17×25 at (tabX, 0) - root.AddChild(MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId)); + var left = MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId); + root.AddChild(left); // Center: 58×25 at (tabX+17, 0) — carries the label text on top var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId); root.AddChild(center); // Right cap: 17×25 at (tabX+75, 0) - root.AddChild(MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId)); + var right = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId); + root.AddChild(right); // Label text centered on the tab center piece. // Active (Open) tab: gold; inactive (Closed) tabs: parchment body color. // ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=1–3). - Vector4 labelColor = isOpen ? Gold : Body; + Vector4 labelColor = Body; string capturedLabel = label; var labelEl = new UiText { @@ -991,6 +1364,44 @@ public static class CharacterStatController }; labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) }; root.AddChild(labelEl); + + var hit = new UiClickablePanel + { + Left = tabX, + Top = 0f, + Width = TabLeftCapW + TabCenterW + TabRightCapW, + Height = TabH, + ZOrder = 10, + BackgroundColor = Vector4.Zero, + BorderColor = Vector4.Zero, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + OnClick = onClick, + }; + root.AddChild(hit); + + return new TabVisual + { + Tab = tab, + Left = left, + Center = center, + Right = right, + Label = labelEl, + Text = label, + }; + } + + private static void UpdateTabVisuals(IReadOnlyList tabs, CharacterStatTab activeTab) + { + foreach (var tab in tabs) + { + bool isOpen = tab.Tab == activeTab; + tab.Left.BackgroundSprite = isOpen ? TabOpenLeft : TabClosedLeft; + tab.Center.BackgroundSprite = isOpen ? TabOpenCenter : TabClosedCenter; + tab.Right.BackgroundSprite = isOpen ? TabOpenRight : TabClosedRight; + Vector4 color = isOpen ? Gold : Body; + string text = tab.Text; + tab.Label.LinesProvider = () => new[] { new UiText.Line(text, color) }; + } } /// Build a single sprite-drawing leaf at the given position. @@ -1070,6 +1481,8 @@ public static class CharacterStatController if (node is UiButton btn && Math.Abs(btn.Width - canonical.Width) < 0.5f && Math.Abs(btn.Height - canonical.Height) < 0.5f + && Math.Abs(btn.Left - canonical.Left) < 0.5f + && Math.Abs(btn.Top - canonical.Top) < 0.5f && seen.Add(btn)) { result.Add(btn); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 18338f8d..403041a2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -623,6 +623,125 @@ public class CharacterStatControllerTests // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── + [Fact] + public void SkillsTab_Click_RebuildsListWithRetailBucketsAndRows() + { + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + + var headers = list.Children + .Where(c => c is UiPanel and not UiClickablePanel) + .Select(c => c.Children.OfType().First().LinesProvider()[0].Text) + .ToList(); + Assert.Equal(new[] + { + "Specialized Skills", + "Trained Skills", + "Useable Untrained Skills", + "Unuseable Untrained Skills", + }, headers); + + var rows = list.Children.OfType().ToList(); + Assert.Equal(12, rows.Count); + var rowNames = rows + .Select(r => r.Children.OfType().ToList()[1].LinesProvider()[0].Text) + .ToList(); + Assert.Equal(new[] + { + "Melee Defense", "War Magic", + "Arcane Lore", "Life Magic", "Missile Weapons", + "Healing", "Jump", "Loyalty", "Run", + "Alchemy", "Cooking", "Fletching", + }, rowNames); + } + + [Fact] + public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows() + { + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + Assert.Equal(12, list.Children.OfType().Count()); + + ClickTab(layout, left: 0f); + var rows = list.Children.OfType().ToList(); + Assert.Equal(9, rows.Count); + Assert.Equal("Strength", rows[0].Children.OfType().ToList()[1].LinesProvider()[0].Text); + } + + [Fact] + public void SkillsTab_SelectWarMagic_ShowsTrainedSkillFooter() + { + var list = new UiPanel { Width = 300 }; + var title = new UiText(); + var l1Label = new UiText(); + var l1Value = new UiText(); + var l2Label = new UiText(); + var l2Value = new UiText(); + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.FooterTitleId, title), + (CharacterStatController.FooterLine1Label, l1Label), + (CharacterStatController.FooterLine1Value, l1Value), + (CharacterStatController.FooterLine2Label, l2Label), + (CharacterStatController.FooterLine2Value, l2Value)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + list.Children.OfType().ToList()[1].OnClick!(); + + Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text); + Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text); + Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text); + Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text); + Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text); + } + + [Fact] + public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter() + { + var list = new UiPanel { Width = 300 }; + var title = new UiText(); + var l1Label = new UiText(); + var l1Value = new UiText(); + var l2Label = new UiText(); + var l2Value = new UiText(); + var btn1 = MakeButton(); + var layout = Fake( + (CharacterStatController.ListBoxId, list), + (CharacterStatController.FooterTitleId, title), + (CharacterStatController.FooterLine1Label, l1Label), + (CharacterStatController.FooterLine1Value, l1Value), + (CharacterStatController.FooterLine2Label, l2Label), + (CharacterStatController.FooterLine2Value, l2Value), + (CharacterStatController.RaiseOneId, btn1)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + list.Children.OfType().ToList()[5].OnClick!(); + + Assert.Equal("Healing", title.LinesProvider()[0].Text); + Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text); + Assert.Equal("6", l1Value.LinesProvider()[0].Text); + Assert.Equal("Skill Credits Available:", l2Label.LinesProvider()[0].Text); + Assert.Equal("96", l2Value.LinesProvider()[0].Text); + Assert.True(btn1.Visible); + Assert.Equal("Normal", btn1.ActiveState); + } + [Fact] public void GetRaiseCost_Index4Focus_Returns110() { @@ -899,6 +1018,15 @@ public class CharacterStatControllerTests // ── Helpers ────────────────────────────────────────────────────────────── + private static void ClickTab(ImportedLayout layout, float left) + { + var tab = layout.Root.Children.OfType() + .Single(p => System.Math.Abs(p.Left - left) < 0.5f + && System.Math.Abs(p.Top) < 0.5f + && System.Math.Abs(p.Height - 25f) < 0.5f); + tab.OnClick!(); + } + /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites). private static UiButton MakeButton() {