feat(studio): port character skills tab

This commit is contained in:
Erik 2026-06-26 14:08:06 +02:00
parent 4cc565badc
commit e7e943a580
4 changed files with 634 additions and 45 deletions

View file

@ -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,

View file

@ -122,6 +122,12 @@ public sealed class CharacterSheet
/// </summary>
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
/// <summary>
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
/// advancement class, then sorts each group alphabetically by skill name.
/// </summary>
public IReadOnlyList<CharacterSkill> Skills { get; init; } = Array.Empty<CharacterSkill>();
// ── 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);

View file

@ -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<UiElement>();
var currentAttributeRows = new List<UiClickablePanel>();
var currentSkillRows = new List<SkillRowBinding>();
var tabVisuals = new List<TabVisual>();
// 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<UiClickablePanel>? 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<UiElement> BuildSkillRows(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
int[] sel,
List<UiButton> allRaise1,
List<UiButton> allRaise10,
out List<SkillRowBinding> skillRows)
{
float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
float y = 0f;
var entries = new List<UiElement>();
var bindings = new List<SkillRowBinding>();
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<CharacterSkill> 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<uint, (uint handle, int w, int h)>? 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<CharacterSkill> OrderedSkills(
CharacterSheet sheet,
CharacterSkillAdvancementClass advancement,
bool? usableUntrained)
{
var result = new List<CharacterSkill>();
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;
/// <summary>
/// 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<SkillRowBinding> rows,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> 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);
}
/// <summary>Refresh raise button visibility + state based on current selection.
/// Applies to ALL collected raise button copies (one per tab page) so the
/// Attributes-page buttons are correctly shown/hidden regardless of which
@ -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<UiButton> allRaise1,
List<UiButton> 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;
}
}
}
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
/// Returns 0 if the cost array is shorter than expected.</summary>
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
@ -617,7 +927,10 @@ public static class CharacterStatController
float left, float top, float width, float height,
uint iconDid,
string nameText,
Func<string> valueProvider)
Func<string> valueProvider,
Func<Vector4>? 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<CharacterSheet> 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
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
/// </summary>
private static void AddTabSpritesToRoot(
private static TabVisual AddTabSpritesToRoot(
UiElement root,
Func<uint, (uint handle, int w, int h)> 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=13).
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<TabVisual> 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) };
}
}
/// <summary>Build a single sprite-drawing <see cref="UiText"/> 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);

View file

@ -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<UiText>().First().LinesProvider()[0].Text)
.ToList();
Assert.Equal(new[]
{
"Specialized Skills",
"Trained Skills",
"Useable Untrained Skills",
"Unuseable Untrained Skills",
}, headers);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(12, rows.Count);
var rowNames = rows
.Select(r => r.Children.OfType<UiText>().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<UiClickablePanel>().Count());
ClickTab(layout, left: 0f);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
Assert.Equal(9, rows.Count);
Assert.Equal("Strength", rows[0].Children.OfType<UiText>().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<UiClickablePanel>().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<UiClickablePanel>().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<UiClickablePanel>()
.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!();
}
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
{