feat(studio): port character skills tab
This commit is contained in:
parent
4cc565badc
commit
e7e943a580
4 changed files with 634 additions and 45 deletions
|
|
@ -167,6 +167,27 @@ public static class SampleData
|
||||||
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
||||||
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
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",
|
AugmentationName = "Swords",
|
||||||
|
|
||||||
BurdenCurrent = 1200,
|
BurdenCurrent = 1200,
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,12 @@ public sealed class CharacterSheet
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
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) ─────────────────────
|
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
||||||
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
||||||
|
|
||||||
|
|
@ -135,3 +141,24 @@ public sealed class CharacterSheet
|
||||||
public int BurdenCurrent { get; init; }
|
public int BurdenCurrent { get; init; }
|
||||||
public int BurdenMax { 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);
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,34 @@ public static class CharacterStatController
|
||||||
private const float RowPadX = 4f;
|
private const float RowPadX = 4f;
|
||||||
private const float IconGap = 6f;
|
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 ─────────
|
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||||||
private static readonly (string name, uint iconDid)[] AttrRows = new[]
|
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).
|
// Falls back to datFont when null (tests, or dat missing).
|
||||||
rowDatFont ??= datFont;
|
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):
|
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||||||
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
|
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
|
||||||
// Controllers still own the text color and the LinesProvider.
|
// 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.
|
// by "if (page.Children.Count == 0) continue" — they stay visible always.
|
||||||
// Source: retail dump tab rects confirmed (2026-06-25):
|
// Source: retail dump tab rects confirmed (2026-06-25):
|
||||||
// Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,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)
|
// Interactive tab sprites are injected after the list/footer closures are wired.
|
||||||
{
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||||
// Mutable selected-index box: -1 = nothing selected.
|
// 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
|
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
|
||||||
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
|
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
|
||||||
|
|
@ -378,14 +397,27 @@ public static class CharacterStatController
|
||||||
// ── Footer State A initial binding ────────────────────────────────────
|
// ── Footer State A initial binding ────────────────────────────────────
|
||||||
// Walk to the State-A container directly (rather than _byId which returns the
|
// 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.
|
// 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,
|
if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false;
|
||||||
allRaise1, allRaise10);
|
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) ─────────────────────
|
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||||||
|
|
@ -403,6 +435,56 @@ public static class CharacterStatController
|
||||||
page.Visible = ContainsWidget(page, anchor);
|
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 ─────────────────────────────────────────────────
|
// ── 9-row attribute list ─────────────────────────────────────────────────
|
||||||
|
|
@ -480,6 +562,146 @@ public static class CharacterStatController
|
||||||
return rows;
|
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>
|
/// <summary>
|
||||||
/// Handles a row click: toggle (same row → deselect), else select new row.
|
/// Handles a row click: toggle (same row → deselect), else select new row.
|
||||||
/// Updates highlight, footer providers, and raise-button state.
|
/// Updates highlight, footer providers, and raise-button state.
|
||||||
|
|
@ -535,6 +757,56 @@ public static class CharacterStatController
|
||||||
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
|
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.
|
/// <summary>Refresh raise button visibility + state based on current selection.
|
||||||
/// Applies to ALL collected raise button copies (one per tab page) so the
|
/// Applies to ALL collected raise button copies (one per tab page) so the
|
||||||
/// Attributes-page buttons are correctly shown/hidden regardless of which
|
/// 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; }
|
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.
|
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
|
||||||
/// Returns 0 if the cost array is shorter than expected.</summary>
|
/// Returns 0 if the cost array is shorter than expected.</summary>
|
||||||
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
|
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
|
||||||
|
|
@ -617,7 +927,10 @@ public static class CharacterStatController
|
||||||
float left, float top, float width, float height,
|
float left, float top, float width, float height,
|
||||||
uint iconDid,
|
uint iconDid,
|
||||||
string nameText,
|
string nameText,
|
||||||
Func<string> valueProvider)
|
Func<string> valueProvider,
|
||||||
|
Func<Vector4>? valueColorProvider = null,
|
||||||
|
uint backgroundSprite = 0u,
|
||||||
|
bool useSelectionBars = true)
|
||||||
{
|
{
|
||||||
var row = new UiClickablePanel
|
var row = new UiClickablePanel
|
||||||
{
|
{
|
||||||
|
|
@ -626,8 +939,12 @@ public static class CharacterStatController
|
||||||
Width = width,
|
Width = width,
|
||||||
Height = height,
|
Height = height,
|
||||||
BackgroundColor = Vector4.Zero, // transparent until selected
|
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,
|
BorderColor = Vector4.Zero,
|
||||||
UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch
|
UseSelectionBars = useSelectionBars,
|
||||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -684,7 +1001,7 @@ public static class CharacterStatController
|
||||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||||
};
|
};
|
||||||
var capturedProvider = valueProvider;
|
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(iconEl);
|
||||||
row.AddChild(nameEl);
|
row.AddChild(nameEl);
|
||||||
|
|
@ -720,7 +1037,9 @@ public static class CharacterStatController
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<CharacterSheet> data,
|
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
|
// 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:
|
// footer child elements. The 5 children of 0x10000240 at their LOCAL coords:
|
||||||
|
|
@ -798,11 +1117,23 @@ public static class CharacterStatController
|
||||||
titleEl.ClickThrough = true;
|
titleEl.ClickThrough = true;
|
||||||
titleEl.LinesProvider = () =>
|
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) };
|
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
|
||||||
var s = data();
|
var s = data();
|
||||||
string name = GetRowName(sel[0]);
|
string name = GetRowName(attrSel[0]);
|
||||||
string value = GetRowValueString(s, sel[0]);
|
string value = GetRowValueString(s, attrSel[0]);
|
||||||
// State B title is WHITE (retail confirmed).
|
// State B title is WHITE (retail confirmed).
|
||||||
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
|
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.
|
// Footer lines (all dat-origin with their own font sizes): pass null → keep dat font.
|
||||||
var l1L = ByPos(20f, 5f, FooterLine1Label);
|
var l1L = ByPos(20f, 5f, FooterLine1Label);
|
||||||
LabelProvider(l1L, null, Body, () =>
|
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);
|
var l1V = ByPos(20f, 200f, FooterLine1Value);
|
||||||
LabelProvider(l1V, null, Body, () =>
|
LabelProvider(l1V, null, Body, () =>
|
||||||
{
|
{
|
||||||
if (sel[0] < 0) return data().SkillCredits.ToString();
|
var sheet = data();
|
||||||
long cost = GetRaiseCost(data(), sel[0]);
|
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!";
|
return cost > 0 ? cost.ToString("N0") : "Infinity!";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line-2 elements: pass null → keep dat font.
|
// Line-2 elements: pass null → keep dat font.
|
||||||
var l2L = ByPos(37f, 5f, FooterLine2Label);
|
var l2L = ByPos(37f, 5f, FooterLine2Label);
|
||||||
LabelProvider(l2L, null, Body,
|
LabelProvider(l2L, null, Body, () =>
|
||||||
static () => "Unassigned Experience:");
|
{
|
||||||
|
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);
|
var l2V = ByPos(37f, 200f, FooterLine2Value);
|
||||||
LabelProvider(l2V, null, Body,
|
LabelProvider(l2V, null, Body, () =>
|
||||||
() => data().UnassignedXp.ToString("N0"));
|
{
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -952,30 +1322,33 @@ public static class CharacterStatController
|
||||||
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
|
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
|
||||||
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
|
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void AddTabSpritesToRoot(
|
private static TabVisual AddTabSpritesToRoot(
|
||||||
UiElement root,
|
UiElement root,
|
||||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
float tabX,
|
float tabX,
|
||||||
string label,
|
string label,
|
||||||
bool isOpen)
|
CharacterStatTab tab,
|
||||||
|
Action onClick)
|
||||||
{
|
{
|
||||||
uint leftId = isOpen ? TabOpenLeft : TabClosedLeft;
|
uint leftId = TabClosedLeft;
|
||||||
uint centerId = isOpen ? TabOpenCenter : TabClosedCenter;
|
uint centerId = TabClosedCenter;
|
||||||
uint rightId = isOpen ? TabOpenRight : TabClosedRight;
|
uint rightId = TabClosedRight;
|
||||||
|
|
||||||
// Left cap: 17×25 at (tabX, 0)
|
// 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
|
// 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);
|
var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
|
||||||
root.AddChild(center);
|
root.AddChild(center);
|
||||||
// Right cap: 17×25 at (tabX+75, 0)
|
// 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.
|
// Label text centered on the tab center piece.
|
||||||
// Active (Open) tab: gold; inactive (Closed) tabs: parchment body color.
|
// 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).
|
// 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;
|
string capturedLabel = label;
|
||||||
var labelEl = new UiText
|
var labelEl = new UiText
|
||||||
{
|
{
|
||||||
|
|
@ -991,6 +1364,44 @@ public static class CharacterStatController
|
||||||
};
|
};
|
||||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
||||||
root.AddChild(labelEl);
|
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.
|
/// <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
|
if (node is UiButton btn
|
||||||
&& Math.Abs(btn.Width - canonical.Width) < 0.5f
|
&& Math.Abs(btn.Width - canonical.Width) < 0.5f
|
||||||
&& Math.Abs(btn.Height - canonical.Height) < 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))
|
&& seen.Add(btn))
|
||||||
{
|
{
|
||||||
result.Add(btn);
|
result.Add(btn);
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,125 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
// ── 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]
|
[Fact]
|
||||||
public void GetRaiseCost_Index4Focus_Returns110()
|
public void GetRaiseCost_Index4Focus_Returns110()
|
||||||
{
|
{
|
||||||
|
|
@ -899,6 +1018,15 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── 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>
|
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
||||||
private static UiButton MakeButton()
|
private static UiButton MakeButton()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue