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).
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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=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<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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue