fix(ui): match retail vitae and character info
Restore Vitae's omitted penalty paragraph, replace the invented character summary with gmCharacterInfoUI's ordered report and property meanings, preserve authored translucent body surfaces, and initialize the end-session button in its visible Normal DAT state. Release build and all 5,830 tests pass with five intentional skips. Connected visual gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d1d603105f
commit
16c21e299c
21 changed files with 771 additions and 303 deletions
|
|
@ -160,6 +160,8 @@ public static class SampleData
|
|||
|
||||
UnspentSkillCredits = 12,
|
||||
SpecializedSkillCredits = 4,
|
||||
ChessRank = 12,
|
||||
FishingSkill = 4,
|
||||
|
||||
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
||||
SkillCredits = 96,
|
||||
|
|
@ -196,7 +198,10 @@ public static class SampleData
|
|||
new(37, "Fletching", 0x06001A55u, CharacterSkillAdvancementClass.Untrained, 10, 10, false, 4, 8, 0L),
|
||||
},
|
||||
|
||||
AugmentationName = "Swords",
|
||||
CharacterInfoProperties = new Dictionary<uint, int>
|
||||
{
|
||||
[0x162u] = 2, // Swords melee mastery
|
||||
},
|
||||
|
||||
BurdenCurrent = 1200,
|
||||
BurdenMax = 4500,
|
||||
|
|
|
|||
|
|
@ -1,197 +1,410 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Per-window controller for the Character Information child in the production
|
||||
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
|
||||
///
|
||||
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
|
||||
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
|
||||
/// <c>AppendStringInfo</c> into the same element. This controller replicates that report
|
||||
/// structure using acdream's <see cref="UiText.LinesProvider"/>.</para>
|
||||
///
|
||||
/// <para>Section order (ported from <c>gmCharacterInfoUI::Update</c> 0x004ba790):
|
||||
/// <list type="number">
|
||||
/// <item>Birth / age / deaths (<c>UpdatePlayerBirthAgeDeaths</c> 0x004b8cb0)</item>
|
||||
/// <item>Vitals / endurance (<c>UpdateEnduranceInfo</c> 0x004b8eb0)</item>
|
||||
/// <item>Innate attributes (<c>UpdateInnateAttributeInfo</c> 0x004b87e0):
|
||||
/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self</item>
|
||||
/// <item>Skills summary (<c>UpdateFakeSkills</c> 0x004b8930)</item>
|
||||
/// <item>Augmentations (<c>UpdateAugmentations</c> 0x004b9000)</item>
|
||||
/// <item>Burden / load (<c>UpdateLoad</c> 0x004b8a20)</item>
|
||||
/// </list>
|
||||
/// Each section is separated by a blank line (retail: <c>AppendStringInfo(m_pMainText, &var_120)</c>
|
||||
/// with an empty StringInfo between sections).</para>
|
||||
///
|
||||
/// <para>StringInfo resolution is NOT ported — the full dat string-table lookup is a
|
||||
/// later refinement. For this pilot, labels are composed directly from the well-known
|
||||
/// AC attribute / stat names confirmed in the decomp string literals and data identifiers.</para>
|
||||
/// Retained port of retail <c>gmCharacterInfoUI</c>. Retail writes one
|
||||
/// localized report into <c>m_pMainText</c>; it is not a second character
|
||||
/// sheet and therefore does not repeat name, level, title, or current vitals.
|
||||
/// </summary>
|
||||
public static class CharacterController
|
||||
{
|
||||
public const uint LayoutId = 0x2100006Eu;
|
||||
public const uint RootId = 0x10000183u;
|
||||
|
||||
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
|
||||
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
|
||||
public const uint MainTextId = 0x1000011du;
|
||||
public const uint MainTextId = 0x1000011Du;
|
||||
public const uint ScrollbarId = 0x1000011Eu;
|
||||
public const uint CloseId = 0x100000FCu;
|
||||
|
||||
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
|
||||
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
|
||||
private static readonly AugmentationDescriptor[] Augmentations =
|
||||
[
|
||||
new(0xDAu, "ID_CharacterInfo_Augmentation_Attribute_Strength", true),
|
||||
new(0xDBu, "ID_CharacterInfo_Augmentation_Attribute_Endurance", true),
|
||||
new(0xDCu, "ID_CharacterInfo_Augmentation_Attribute_Coordination", true),
|
||||
new(0xDDu, "ID_CharacterInfo_Augmentation_Attribute_Quickness", true),
|
||||
new(0xDEu, "ID_CharacterInfo_Augmentation_Attribute_Focus", true),
|
||||
new(0xDFu, "ID_CharacterInfo_Augmentation_Attribute_Self", true),
|
||||
new(0xF0u, "ID_CharacterInfo_Augmentation_Resist_Slash", true),
|
||||
new(0xF1u, "ID_CharacterInfo_Augmentation_Resist_Pierce", true),
|
||||
new(0xF2u, "ID_CharacterInfo_Augmentation_Resist_Blunt", true),
|
||||
new(0xF3u, "ID_CharacterInfo_Augmentation_Resist_Acid", true),
|
||||
new(0x147u, "ID_CharacterInfo_Augmentation_Resist_Nether", true),
|
||||
new(0xF4u, "ID_CharacterInfo_Augmentation_Resist_Fire", true),
|
||||
new(0xF5u, "ID_CharacterInfo_Augmentation_Resist_Frost", true),
|
||||
new(0xF6u, "ID_CharacterInfo_Augmentation_Resist_Lightning", true),
|
||||
new(0xE0u, "ID_CharacterInfo_Augmentation_Spec_Gearcraft", false),
|
||||
new(0xE1u, "ID_CharacterInfo_Augmentation_Spec_WeaponTinkering", false),
|
||||
new(0xE2u, "ID_CharacterInfo_Augmentation_Spec_MagicItemTinkering", false),
|
||||
new(0xE3u, "ID_CharacterInfo_Augmentation_Spec_ArmorTinkering", false),
|
||||
new(0xE4u, "ID_CharacterInfo_Augmentation_Spec_ItemTinkering", false),
|
||||
new(0x125u, "ID_CharacterInfo_Augmentation_Spec_Salvaging", false),
|
||||
new(0xE5u, "ID_CharacterInfo_Augmentation_ExtraPackSlot", false),
|
||||
new(0xE6u, "ID_CharacterInfo_Augmentation_IncreasedCarryingCapacity", true),
|
||||
new(0xE7u, "ID_CharacterInfo_Augmentation_LessDeathItemLoss", true),
|
||||
new(0xE8u, "ID_CharacterInfo_Augmentation_SpellsRemainPastDeath", false),
|
||||
new(0xE9u, "ID_CharacterInfo_Augmentation_CriticalDefense", false),
|
||||
new(0xEAu, "ID_CharacterInfo_Augmentation_BonusXP", false),
|
||||
new(0xEBu, "ID_CharacterInfo_Augmentation_BonusSalvage", true),
|
||||
new(0xECu, "ID_CharacterInfo_Augmentation_BonusImbueChance", false),
|
||||
new(0xEDu, "ID_CharacterInfo_Augmentation_FasterRegen", true),
|
||||
new(0xEEu, "ID_CharacterInfo_Augmentation_IncreasedSpellDuration", true),
|
||||
new(0x126u, "ID_CharacterInfo_Augmentation_Infused_CreatureMagic", true),
|
||||
new(0x127u, "ID_CharacterInfo_Augmentation_Infused_ItemMagic", true),
|
||||
new(0x128u, "ID_CharacterInfo_Augmentation_Infused_LifeMagic", true),
|
||||
new(0x129u, "ID_CharacterInfo_Augmentation_Infused_WarMagic", true),
|
||||
new(0x148u, "ID_CharacterInfo_Augmentation_Infused_VoidMagic", true),
|
||||
new(0x12Cu, "ID_CharacterInfo_Augmentation_SkilledMelee", true),
|
||||
new(0x12Du, "ID_CharacterInfo_Augmentation_SkilledMissile", true),
|
||||
new(0x12Eu, "ID_CharacterInfo_Augmentation_SkilledMagic", true),
|
||||
new(0x135u, "ID_CharacterInfo_Augmentation_DamageBonus", true),
|
||||
new(0x136u, "ID_CharacterInfo_Augmentation_DamageResist", true),
|
||||
new(0x12Au, "ID_CharacterInfo_Augmentation_CriticalExpertise", false),
|
||||
new(0x12Bu, "ID_CharacterInfo_Augmentation_CriticalPower", false),
|
||||
new(0x146u, "ID_CharacterInfo_Augmentation_JackOfAllTrades", false),
|
||||
];
|
||||
|
||||
/// <summary>Yellow section header — retail uses a different StringInfo color per section header.</summary>
|
||||
private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f);
|
||||
internal static IReadOnlyList<string> AugmentationStringKeys
|
||||
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Bind the character report text element found in <paramref name="layout"/> to
|
||||
/// <paramref name="data"/>. The report is regenerated each frame via
|
||||
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
|
||||
/// <see cref="CharacterSheet"/> without re-binding.
|
||||
///
|
||||
/// <para>Production data resolves the element as a <see cref="UiText"/>
|
||||
/// (Type 12). A synthetic fallback is created only for deliberately reduced
|
||||
/// test layouts.</para>
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported 0x2100006E / 0x10000183 layout tree.</param>
|
||||
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
|
||||
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
|
||||
/// (falls back to the BitmapFont debug path).</param>
|
||||
public static void Bind(
|
||||
ImportedLayout layout,
|
||||
Func<CharacterSheet> data,
|
||||
UiDatFont? datFont = null,
|
||||
Action? close = null)
|
||||
Action? close = null,
|
||||
CharacterInfoStrings? strings = null)
|
||||
{
|
||||
// End-of-retail data authors m_pMainText under the Character Information
|
||||
// child. The fallback exists only so focused controller tests can bind a
|
||||
// deliberately reduced tree without fabricating production structure.
|
||||
var root = layout.Root;
|
||||
if (root is null) return;
|
||||
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
UiElement root = layout.Root;
|
||||
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
|
||||
{
|
||||
EventId = MainTextId,
|
||||
Name = "m_pMainText",
|
||||
Left = 12f,
|
||||
Top = 44f, // below the window header row
|
||||
Width = System.Math.Max(40f, root.Width - 24f),
|
||||
Height = System.Math.Max(40f, root.Height - 56f),
|
||||
Anchors = AnchorEdges.None,
|
||||
ZOrder = 1_000_000, // draw above the static chrome
|
||||
DatFont = datFont,
|
||||
EventId = MainTextId,
|
||||
Name = "m_pMainText",
|
||||
Left = 12f,
|
||||
Top = 44f,
|
||||
Width = Math.Max(40f, root.Width - 24f),
|
||||
Height = Math.Max(40f, root.Height - 56f),
|
||||
ZOrder = 1_000_000,
|
||||
DatFont = datFont,
|
||||
ClickThrough = false,
|
||||
};
|
||||
if (text.Parent is null)
|
||||
root.AddChild(text);
|
||||
else if (text.DatFont is null && datFont is not null)
|
||||
text.DatFont = datFont;
|
||||
|
||||
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
|
||||
text.PreserveEndOnLayout = false;
|
||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
|
||||
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = text.Scroll;
|
||||
text.LinesProvider = () => BuildReport(data());
|
||||
|
||||
if (layout.FindElement(CloseId) is UiButton closeButton)
|
||||
closeButton.OnClick = close;
|
||||
}
|
||||
|
||||
// ── Report builder ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the complete character report as a line list.
|
||||
/// Order mirrors <c>gmCharacterInfoUI::Update</c> (0x004ba790).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UiText.Line> BuildReport(CharacterSheet s)
|
||||
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
|
||||
{
|
||||
var lines = new List<UiText.Line>();
|
||||
var body = new StringBuilder();
|
||||
|
||||
// ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ──
|
||||
// Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime;
|
||||
// InqInt(0x2b) = NumDeaths.
|
||||
AppendHeader(lines, s.Name);
|
||||
Append(lines, $"Level {s.Level}");
|
||||
if (s.Race is not null)
|
||||
Append(lines, s.Race);
|
||||
if (s.Heritage is not null)
|
||||
Append(lines, s.Heritage);
|
||||
if (s.Title is not null)
|
||||
Append(lines, s.Title);
|
||||
AppendBlank(lines);
|
||||
// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths @ 0x004B8CB0.
|
||||
string? birth = sheet.BirthTimestamp is int timestamp
|
||||
? FormatRetailDate(timestamp)
|
||||
: sheet.BirthDate;
|
||||
if (!string.IsNullOrEmpty(birth))
|
||||
AppendTokens(body, strings.Birth, birth);
|
||||
|
||||
if (s.BirthDate is not null)
|
||||
Append(lines, $"Birth: {s.BirthDate}");
|
||||
if (s.PlayTime is not null)
|
||||
Append(lines, $"Age: {s.PlayTime}");
|
||||
Append(lines, $"Deaths: {s.Deaths}");
|
||||
AppendBlank(lines);
|
||||
string? played = sheet.TotalPlayTimeSeconds is int seconds
|
||||
? FormatRetailDuration(seconds)
|
||||
: sheet.PlayTime;
|
||||
if (!string.IsNullOrEmpty(played))
|
||||
AppendTokens(body, strings.Played, played);
|
||||
|
||||
// ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ──
|
||||
// Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base.
|
||||
// Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums.
|
||||
AppendHeader(lines, "Vitals");
|
||||
Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}");
|
||||
Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}");
|
||||
Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──
|
||||
// InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination,
|
||||
// Focus, Self (confirmed from the AddVariable_Int sequence in the decomp).
|
||||
AppendHeader(lines, "Attributes");
|
||||
Append(lines, $"Strength: {s.Strength}");
|
||||
Append(lines, $"Endurance: {s.Endurance}");
|
||||
Append(lines, $"Quickness: {s.Quickness}");
|
||||
Append(lines, $"Coordination: {s.Coordination}");
|
||||
Append(lines, $"Focus: {s.Focus}");
|
||||
Append(lines, $"Self: {s.Self}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ──
|
||||
// Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits.
|
||||
AppendHeader(lines, "Skills");
|
||||
Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}");
|
||||
if (s.SpecializedSkillCredits > 0)
|
||||
Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ──
|
||||
// Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown.
|
||||
AppendHeader(lines, "Augmentations");
|
||||
if (s.AugmentationName is not null)
|
||||
Append(lines, s.AugmentationName);
|
||||
else
|
||||
Append(lines, "None");
|
||||
AppendBlank(lines);
|
||||
|
||||
// ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ──
|
||||
// Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance).
|
||||
// When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty".
|
||||
// When aug > 0: shows "N augmentations (X.X% bonus)".
|
||||
AppendHeader(lines, "Encumbrance");
|
||||
Append(lines, $"Burden: {s.BurdenCurrent}");
|
||||
Append(lines, $"Capacity: {s.BurdenMax}");
|
||||
if (s.BurdenMax > 0)
|
||||
switch (sheet.Deaths)
|
||||
{
|
||||
int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax);
|
||||
Append(lines, $"Load: {pct}%");
|
||||
case 0: body.Append(strings.DeathsNone); break;
|
||||
case 1: body.Append(strings.DeathsOne); break;
|
||||
case 2: body.Append(strings.DeathsTwo); break;
|
||||
default: AppendTokens(body, strings.DeathsMany, sheet.Deaths); break;
|
||||
}
|
||||
AppendSectionBreak(body);
|
||||
|
||||
return lines;
|
||||
// gmCharacterInfoUI::UpdateEnduranceInfo @ 0x004B8EB0.
|
||||
string resist = ResistanceGrade(sheet.Strength + sheet.Endurance,
|
||||
200, 260, 320, 380, 440);
|
||||
string regeneration = ResistanceGrade(
|
||||
sheet.Strength + (2 * sheet.Endurance),
|
||||
200, 346, 470, 580, 690);
|
||||
// ID_CharacterInfo_Resists references the named RESIST variable twice
|
||||
// (Natural and Drain) and REGEN once.
|
||||
body.Append(strings.Resists[0]).Append(resist)
|
||||
.Append(strings.Resists[1]).Append(resist)
|
||||
.Append(strings.Resists[2]).Append(regeneration)
|
||||
.Append(strings.Resists[3]);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateInnateAttributeInfo @ 0x004B87E0.
|
||||
AppendTokens(body, strings.Innates,
|
||||
sheet.Strength, sheet.Endurance, sheet.Coordination,
|
||||
sheet.Quickness, sheet.Focus, sheet.Self);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateFakeSkills @ 0x004B8930.
|
||||
AppendTokens(body, strings.Chess, sheet.ChessRank);
|
||||
AppendTokens(body, strings.Fishing, sheet.FishingSkill);
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateAugmentations @ 0x004B9000: no heading
|
||||
// and no synthetic "None" line; only qualities greater than zero.
|
||||
AppendMastery(body, strings.MeleeMastery,
|
||||
Get(sheet, 0x162u), MeleeMasteryName);
|
||||
AppendMastery(body, strings.RangedMastery,
|
||||
Get(sheet, 0x163u), RangedMasteryName);
|
||||
AppendMastery(body, strings.SummoningMastery,
|
||||
Get(sheet, 0x16Au), SummoningMasteryName);
|
||||
foreach (AugmentationDescriptor descriptor in Augmentations)
|
||||
{
|
||||
int value = Get(sheet, descriptor.PropertyId);
|
||||
if (value <= 0
|
||||
|| !strings.AugmentationText.TryGetValue(
|
||||
descriptor.StringKey, out string[]? tokens))
|
||||
continue;
|
||||
if (descriptor.IncludeValue)
|
||||
AppendTokens(body, tokens, value);
|
||||
else
|
||||
body.Append(ResolvePlural(tokens[0], value));
|
||||
}
|
||||
AppendSectionBreak(body);
|
||||
|
||||
// gmCharacterInfoUI::UpdateLoad @ 0x004B8A20.
|
||||
int capacity = BurdenMath.EncumbranceCapacity(
|
||||
sheet.Strength, sheet.EncumbranceAugmentations);
|
||||
float load = BurdenMath.LoadRatio(capacity, sheet.BurdenCurrent);
|
||||
if (load < 1f)
|
||||
body.Append(strings.LoadNone);
|
||||
else
|
||||
AppendTokens(body, strings.LoadBurdened,
|
||||
sheet.BurdenCurrent - capacity,
|
||||
BurdenMath.LoadPenaltyPercent(load));
|
||||
if (sheet.EncumbranceAugmentations > 0)
|
||||
AppendTokens(body, strings.LoadAugmentations,
|
||||
sheet.EncumbranceAugmentations,
|
||||
sheet.EncumbranceAugmentations * 20f);
|
||||
|
||||
return body.ToString().TrimEnd('\r', '\n');
|
||||
}
|
||||
|
||||
// ── Line helpers ──────────────────────────────────────────────────────────
|
||||
private static int Get(CharacterSheet sheet, uint id)
|
||||
=> sheet.CharacterInfoProperties.TryGetValue(id, out int value) ? value : 0;
|
||||
|
||||
private static void AppendHeader(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, HeaderColor));
|
||||
private static void AppendMastery(
|
||||
StringBuilder body,
|
||||
string[] tokens,
|
||||
int value,
|
||||
Func<int, string> name)
|
||||
{
|
||||
if (value > 0)
|
||||
AppendTokens(body, tokens, name(value));
|
||||
}
|
||||
|
||||
private static void Append(List<UiText.Line> lines, string text)
|
||||
=> lines.Add(new UiText.Line(text, BodyColor));
|
||||
private static string MeleeMasteryName(int value) => value switch
|
||||
{
|
||||
1 => "Unarmed Weapons",
|
||||
2 => "Swords",
|
||||
3 => "Axes",
|
||||
4 => "Maces",
|
||||
5 => "Spears",
|
||||
6 => "Daggers",
|
||||
7 => "Staves",
|
||||
11 => "Two Handed Weapons",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static void AppendBlank(List<UiText.Line> lines)
|
||||
=> lines.Add(new UiText.Line(string.Empty, BodyColor));
|
||||
private static string RangedMasteryName(int value) => value switch
|
||||
{
|
||||
8 => "Bows",
|
||||
9 => "Crossbows",
|
||||
10 => "Thrown Weapons",
|
||||
12 => "Magical Spells",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static string SummoningMasteryName(int value) => value switch
|
||||
{
|
||||
1 => "Primalist",
|
||||
2 => "Necromancer",
|
||||
3 => "Naturalist",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
internal static string ResistanceGrade(
|
||||
int value,
|
||||
int none,
|
||||
int poor,
|
||||
int mediocre,
|
||||
int hardy,
|
||||
int resilient)
|
||||
=> value <= none ? "None"
|
||||
: value <= poor ? "Poor"
|
||||
: value <= mediocre ? "Mediocre"
|
||||
: value <= hardy ? "Hardy"
|
||||
: value <= resilient ? "Resilient"
|
||||
: "Indomitable";
|
||||
|
||||
internal static string FormatRetailDuration(int totalSeconds)
|
||||
{
|
||||
long remaining = Math.Max(0, totalSeconds);
|
||||
(long seconds, string singular, string plural)[] units =
|
||||
[
|
||||
(31_536_000, "year", "years"),
|
||||
(2_592_000, "month", "months"),
|
||||
(604_800, "week", "weeks"),
|
||||
(86_400, "day", "days"),
|
||||
(3_600, "hour", "hours"),
|
||||
(60, "minute", "minutes"),
|
||||
(1, "second", "seconds"),
|
||||
];
|
||||
var parts = new List<string>();
|
||||
foreach (var unit in units)
|
||||
{
|
||||
long count = remaining / unit.seconds;
|
||||
remaining %= unit.seconds;
|
||||
if (count != 0)
|
||||
parts.Add($"{count} {(count == 1 ? unit.singular : unit.plural)}");
|
||||
}
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
|
||||
private static string FormatRetailDate(int unixSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(unixSeconds)
|
||||
.LocalDateTime.ToString("G", CultureInfo.CurrentCulture);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return unixSeconds.ToString(CultureInfo.CurrentCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendTokens(
|
||||
StringBuilder body,
|
||||
IReadOnlyList<string> tokens,
|
||||
params object[] values)
|
||||
{
|
||||
for (int i = 0; i < tokens.Count; i++)
|
||||
{
|
||||
int pluralValue = values.Length == 0 ? 0
|
||||
: NumericValue(values[Math.Min(i, values.Length - 1)]);
|
||||
body.Append(ResolvePlural(tokens[i], pluralValue));
|
||||
if (i < values.Length)
|
||||
body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePlural(string text, int value)
|
||||
=> text.Replace("{time[1]|times}", value == 1 ? "time" : "times",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static int NumericValue(object value) => value switch
|
||||
{
|
||||
byte v => v,
|
||||
short v => v,
|
||||
int v => v,
|
||||
long v => checked((int)v),
|
||||
float v => (int)v,
|
||||
double v => (int)v,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static void AppendSectionBreak(StringBuilder body)
|
||||
{
|
||||
if (body.Length != 0 && body[^1] != '\n') body.Append('\n');
|
||||
body.Append('\n');
|
||||
}
|
||||
|
||||
private readonly record struct AugmentationDescriptor(
|
||||
uint PropertyId,
|
||||
string StringKey,
|
||||
bool IncludeValue);
|
||||
}
|
||||
|
||||
public sealed record CharacterInfoStrings(
|
||||
string[] Birth,
|
||||
string[] Played,
|
||||
string DeathsNone,
|
||||
string DeathsOne,
|
||||
string DeathsTwo,
|
||||
string[] DeathsMany,
|
||||
string[] Resists,
|
||||
string[] Innates,
|
||||
string[] Chess,
|
||||
string[] Fishing,
|
||||
string LoadNone,
|
||||
string[] LoadBurdened,
|
||||
string[] LoadAugmentations,
|
||||
string[] MeleeMastery,
|
||||
string[] RangedMastery,
|
||||
string[] SummoningMastery,
|
||||
IReadOnlyDictionary<string, string[]> AugmentationText)
|
||||
{
|
||||
public static CharacterInfoStrings FromDat(
|
||||
Func<string, string[]?> resolve)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
CharacterInfoStrings fallback = English;
|
||||
string[] Get(string key, string[] defaultValue)
|
||||
=> resolve(key) is { Length: > 0 } value ? value : defaultValue;
|
||||
string GetOne(string key, string defaultValue)
|
||||
=> Get(key, [defaultValue])[0];
|
||||
|
||||
var augmentations = new Dictionary<string, string[]>();
|
||||
foreach (string key in CharacterController.AugmentationStringKeys)
|
||||
if (resolve(key) is { Length: > 0 } value)
|
||||
augmentations[key] = value;
|
||||
|
||||
return new CharacterInfoStrings(
|
||||
Get("ID_CharacterInfo_Birth", fallback.Birth),
|
||||
Get("ID_CharacterInfo_Played", fallback.Played),
|
||||
GetOne("ID_CharacterInfo_Deaths_None", fallback.DeathsNone),
|
||||
GetOne("ID_CharacterInfo_Deaths_One", fallback.DeathsOne),
|
||||
GetOne("ID_CharacterInfo_Deaths_Two", fallback.DeathsTwo),
|
||||
Get("ID_CharacterInfo_Deaths_Many", fallback.DeathsMany),
|
||||
Get("ID_CharacterInfo_Resists", fallback.Resists),
|
||||
Get("ID_CharacterInfo_Innates", fallback.Innates),
|
||||
Get("ID_CharacterInfo_Chess", fallback.Chess),
|
||||
Get("ID_CharacterInfo_Fishing", fallback.Fishing),
|
||||
GetOne("ID_CharacterInfo_Load_None", fallback.LoadNone),
|
||||
Get("ID_CharacterInfo_Load_Burdened", fallback.LoadBurdened),
|
||||
Get("ID_CharacterInfo_Load_Augmentations", fallback.LoadAugmentations),
|
||||
Get("ID_CharacterInfo_Mastery_Melee", fallback.MeleeMastery),
|
||||
Get("ID_CharacterInfo_Mastery_Ranged", fallback.RangedMastery),
|
||||
Get("ID_CharacterInfo_Mastery_Summoning", fallback.SummoningMastery),
|
||||
augmentations);
|
||||
}
|
||||
|
||||
public static CharacterInfoStrings English { get; } = new(
|
||||
["You were born on ", ".\n"],
|
||||
["You have played for ", ".\n"],
|
||||
"You have never died!\n",
|
||||
"You have died only once!\n",
|
||||
"You have died twice.\n",
|
||||
["You have died ", " times.\n"],
|
||||
["Natural Resistances: ", "\nDrain Resistances: ", "\nRegeneration Bonus: ", "\n"],
|
||||
["Innate Strength: ", "\nInnate Endurance: ", "\nInnate Coordination: ",
|
||||
"\nInnate Quickness: ", "\nInnate Focus: ", "\nInnate Self: ", "\n"],
|
||||
["Chess Rank: ", "\n"],
|
||||
["Fishing Skill: ", "\n"],
|
||||
"You are not overburdened at this time.\n",
|
||||
["You are currently overburdened by ",
|
||||
" burden units. This is reducing your Run, Jump, Melee Defense and Missile Defense skills by ",
|
||||
"%.\n"],
|
||||
["You have been augmented ",
|
||||
" times with the Might of the Seventh Mule. This has increased your carrying capacity by ",
|
||||
"%.\n"],
|
||||
["Your melee mastery is ", ".\n"],
|
||||
["Your ranged mastery is ", ".\n\n"],
|
||||
["Your summoning mastery is ", ".\n\n"],
|
||||
new Dictionary<string, string[]>());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -70,6 +71,12 @@ public sealed class CharacterSheet
|
|||
/// <summary>Total deaths (retail InqInt(0x2b) = NumDeaths).</summary>
|
||||
public int Deaths { get; init; }
|
||||
|
||||
/// <summary>Raw retail PropertyInt 0x62. Null means the quality was absent.</summary>
|
||||
public int? BirthTimestamp { get; init; }
|
||||
|
||||
/// <summary>Raw retail PropertyInt 0x7D seconds. Null means the quality was absent.</summary>
|
||||
public int? TotalPlayTimeSeconds { get; init; }
|
||||
|
||||
// ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ─────────────────────────────
|
||||
|
||||
public int HealthCurrent { get; init; }
|
||||
|
|
@ -90,12 +97,18 @@ public sealed class CharacterSheet
|
|||
public int Self { get; init; }
|
||||
|
||||
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
|
||||
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
|
||||
// Character Information uses 0xB5/0xC0 for Chess/Fishing; skill credits use 0x18.
|
||||
// InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
|
||||
|
||||
public int UnspentSkillCredits { get; init; }
|
||||
public int SpecializedSkillCredits { get; init; }
|
||||
|
||||
/// <summary>Retail Character Information PropertyInt 0xB5.</summary>
|
||||
public int ChessRank { get; init; }
|
||||
|
||||
/// <summary>Retail Character Information PropertyInt 0xC0.</summary>
|
||||
public int FishingSkill { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
||||
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
||||
|
|
@ -144,11 +157,20 @@ public sealed class CharacterSheet
|
|||
/// e.g. "Swords", "Two Handed Weapons". Null = "None".</summary>
|
||||
public string? AugmentationName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw PropertyInt snapshot used by retail's data-driven mastery,
|
||||
/// luminance, and augmentation report. The controller reads only the
|
||||
/// quality ids cited by <c>gmCharacterInfoUI::UpdateAugmentations</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, int> CharacterInfoProperties { get; init; }
|
||||
= new Dictionary<uint, int>();
|
||||
|
||||
// ── Burden / load (UpdateLoad 0x004b8a20) ───────────────────────────────
|
||||
// Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance).
|
||||
|
||||
public int BurdenCurrent { get; init; }
|
||||
public int BurdenMax { get; init; }
|
||||
public int EncumbranceAugmentations { get; init; }
|
||||
}
|
||||
|
||||
public enum CharacterSkillAdvancementClass
|
||||
|
|
|
|||
|
|
@ -31,12 +31,10 @@ public sealed class CharacterSheetProvider
|
|||
private const uint UnassignedXpPropertyId = 2u;
|
||||
|
||||
/// <summary>
|
||||
/// Skill-credit debit fallback chain, most-specific first. Retail reads
|
||||
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
|
||||
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
|
||||
/// the live server population varies, so debit whichever is present.
|
||||
/// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and
|
||||
/// 0xC0 are Chess Rank and Fishing Skill and must never be debited.
|
||||
/// </summary>
|
||||
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
|
||||
private static readonly uint[] SkillCreditPropertyIds = { 0x18u };
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly LocalPlayerState _localPlayer;
|
||||
|
|
@ -109,7 +107,7 @@ public sealed class CharacterSheetProvider
|
|||
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||
var xp = ComputeLevelXp(level, totalXp);
|
||||
|
||||
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
|
||||
int skillCredits = props.GetInt(0x18u);
|
||||
|
||||
return new CharacterSheet
|
||||
{
|
||||
|
|
@ -138,8 +136,17 @@ public sealed class CharacterSheetProvider
|
|||
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||
|
||||
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
|
||||
SpecializedSkillCredits = props.GetInt(0xC0u),
|
||||
UnspentSkillCredits = skillCredits,
|
||||
SpecializedSkillCredits = 0,
|
||||
ChessRank = props.GetInt(0xB5u),
|
||||
FishingSkill = props.GetInt(0xC0u),
|
||||
BirthTimestamp = props.Ints.TryGetValue(0x62u, out int born)
|
||||
? born
|
||||
: null,
|
||||
TotalPlayTimeSeconds = props.Ints.TryGetValue(0x7Du, out int played)
|
||||
? played
|
||||
: null,
|
||||
Deaths = props.GetInt(0x2Bu),
|
||||
SkillCredits = skillCredits,
|
||||
UnassignedXp = unassignedXp,
|
||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||
|
|
@ -147,6 +154,8 @@ public sealed class CharacterSheetProvider
|
|||
Skills = BuildLiveCharacterSkills(),
|
||||
BurdenCurrent = props.GetInt(5u),
|
||||
BurdenMax = props.GetInt(96u),
|
||||
EncumbranceAugmentations = props.GetInt(0xE6u),
|
||||
CharacterInfoProperties = new Dictionary<uint, int>(props.Ints),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,23 @@ public sealed class DatStringResolver
|
|||
return entry.Strings[index].Value;
|
||||
}
|
||||
|
||||
/// <summary>Returns every literal token for one retail StringInfo entry.</summary>
|
||||
public string[]? ResolveAll(uint tableId, uint stringId)
|
||||
{
|
||||
if (tableId == 0u || stringId == 0u)
|
||||
return null;
|
||||
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
||||
{
|
||||
table = _dats.Get<StringTable>(tableId);
|
||||
_tables[tableId] = table;
|
||||
}
|
||||
return table is not null
|
||||
&& table.Strings.TryGetValue(stringId, out var entry)
|
||||
&& entry.Strings.Count != 0
|
||||
? entry.Strings.Select(value => value.Value).ToArray()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact retail ELF-style string hash used for StringInfo keys.
|
||||
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ public sealed class IndicatorBarController : IRetainedPanelController
|
|||
_lastLinkFlash = now;
|
||||
_link.TrySetRetailState(ConnectionGoodState);
|
||||
_miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted);
|
||||
// Ordinary button 0x100000FA has no hover state: retail begins in its
|
||||
// authored Normal state (0x06007BB1) before any pointer notice.
|
||||
_endCharacterSession.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||
UpdateEnchantments();
|
||||
UpdateBurden();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ public static class RetailWindowFrame
|
|||
public bool ConstrainResizeToParent { get; init; }
|
||||
|
||||
public float Opacity { get; init; } = 1f;
|
||||
public bool DrawChromeCenter { get; init; } = true;
|
||||
public bool Visible { get; init; } = true;
|
||||
|
||||
public AnchorEdges ContentAnchors { get; init; } =
|
||||
|
|
@ -99,6 +100,9 @@ public static class RetailWindowFrame
|
|||
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
|
||||
};
|
||||
|
||||
if (outerFrame is UiNineSlicePanel nineSlice)
|
||||
nineSlice.DrawCenterFill = options.DrawChromeCenter;
|
||||
|
||||
const int border = RetailChromeSprites.Border;
|
||||
content.Left = border;
|
||||
content.Top = border;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ public sealed class VitaeUiController : IRetainedPanelController
|
|||
body = _strings.LostPrefix
|
||||
+ lostPercent
|
||||
+ _strings.LostSuffix
|
||||
+ _strings.SkillsPrefix
|
||||
+ lostPercent
|
||||
+ _strings.SkillsSuffix
|
||||
+ _strings.RecoveryPrefix
|
||||
+ remaining
|
||||
+ _strings.RecoverySuffix;
|
||||
|
|
@ -146,13 +149,17 @@ public sealed record VitaeStrings(
|
|||
string FullStrength,
|
||||
string LostPrefix,
|
||||
string LostSuffix,
|
||||
string SkillsPrefix,
|
||||
string SkillsSuffix,
|
||||
string RecoveryPrefix,
|
||||
string RecoverySuffix)
|
||||
{
|
||||
public static VitaeStrings English { get; } = new(
|
||||
"Your Vitae, or life force, is at full strength.",
|
||||
"Due to your recent death, you have temporarily lost ",
|
||||
" % of your Vitae, or life force.",
|
||||
"% of your Vitae, or life force.",
|
||||
"\n\nThis means that your health, stamina, mana, and skills are temporarily reduced by ",
|
||||
"%. A reduction of less than 15% will not hinder you much, but beware losing much more than that.",
|
||||
"\n\nYou will regain 1% of your Vitae once you earn ",
|
||||
" more experience.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -919,6 +919,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
|
|
@ -943,12 +944,14 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
ElementInfo? rootInfo;
|
||||
ImportedLayout? layout;
|
||||
CharacterInfoStrings strings;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
rootInfo = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats,
|
||||
CharacterController.LayoutId,
|
||||
CharacterController.RootId);
|
||||
var resolver = new DatStringResolver(_bindings.Assets.Dats);
|
||||
layout = rootInfo is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
|
|
@ -956,7 +959,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
new DatStringResolver(_bindings.Assets.Dats).Resolve);
|
||||
resolver.Resolve);
|
||||
strings = CharacterInfoStrings.FromDat(key => resolver.ResolveAll(
|
||||
0x23000001u, DatStringResolver.ComputeHash(key)));
|
||||
}
|
||||
if (rootInfo is null || layout is null) return;
|
||||
|
||||
|
|
@ -964,7 +969,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
layout,
|
||||
_bindings.Character.Provider.BuildSheet,
|
||||
_bindings.Assets.DefaultFont,
|
||||
() => CloseWindow(WindowNames.CharacterInformation));
|
||||
() => CloseWindow(WindowNames.CharacterInformation),
|
||||
strings);
|
||||
RegisterIndicatorDetailPanel(
|
||||
RetailPanelCatalog.CharacterInformation,
|
||||
WindowNames.CharacterInformation,
|
||||
|
|
@ -1042,11 +1048,19 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
resolver.Resolve);
|
||||
VitaeStrings fallback = VitaeStrings.English;
|
||||
strings = new VitaeStrings(
|
||||
resolver.Resolve(0x23000001u, 0x0142DDECu) ?? fallback.FullStrength,
|
||||
resolver.Resolve(0x23000001u, 0x045D7665u, 0) ?? fallback.LostPrefix,
|
||||
resolver.Resolve(0x23000001u, 0x045D7665u, 1) ?? fallback.LostSuffix,
|
||||
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 0) ?? fallback.RecoveryPrefix,
|
||||
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 1) ?? fallback.RecoverySuffix);
|
||||
Resolve("ID_Vitae_Text_Full", 0, fallback.FullStrength),
|
||||
Resolve("ID_Vitae_Text_Vitae", 0, fallback.LostPrefix),
|
||||
Resolve("ID_Vitae_Text_Vitae", 1, fallback.LostSuffix),
|
||||
Resolve("ID_Vitae_Text_Skills", 0, fallback.SkillsPrefix),
|
||||
Resolve("ID_Vitae_Text_Skills", 1, fallback.SkillsSuffix),
|
||||
Resolve("ID_Vitae_Text_Experience", 0, fallback.RecoveryPrefix),
|
||||
Resolve("ID_Vitae_Text_Experience", 1, fallback.RecoverySuffix));
|
||||
|
||||
string Resolve(string key, int token, string fallbackValue)
|
||||
=> resolver.Resolve(
|
||||
0x23000001u,
|
||||
DatStringResolver.ComputeHash(key),
|
||||
token) ?? fallbackValue;
|
||||
}
|
||||
if (rootInfo is null || layout is null) return;
|
||||
|
||||
|
|
@ -1127,6 +1141,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
|
|
@ -1139,6 +1154,28 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
&& restorePrevious);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's Vitae and Character Information children carry their own
|
||||
/// 0x06004CC2 body surface below the 25px title row. The standalone shared
|
||||
/// window contributes the border only in that case; painting the same
|
||||
/// translucent center twice makes the panel look opaque.
|
||||
/// </summary>
|
||||
internal static bool AuthorsFullPanelCenter(ElementInfo rootInfo)
|
||||
{
|
||||
const float RetailTitleHeight = 25f;
|
||||
return Visit(rootInfo);
|
||||
|
||||
bool Visit(ElementInfo info)
|
||||
{
|
||||
bool coversBody = info.Width >= rootInfo.Width
|
||||
&& info.Height >= rootInfo.Height - RetailTitleHeight;
|
||||
if (coversBody && info.StateMedia.Values.Any(
|
||||
media => media.File == RetailChromeSprites.CenterFill))
|
||||
return true;
|
||||
return info.Children.Any(Visit);
|
||||
}
|
||||
}
|
||||
|
||||
private void MountIndicators()
|
||||
{
|
||||
ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ public class UiNineSlicePanel : UiPanel
|
|||
|
||||
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this wrapper paints the shared center surface. Some retail child
|
||||
/// panels already author that same full-height surface; their standalone
|
||||
/// wrapper supplies only the border so translucency is not compounded.
|
||||
/// </summary>
|
||||
public bool DrawCenterFill { get; set; } = true;
|
||||
|
||||
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
_resolve = resolve;
|
||||
|
|
@ -66,7 +73,8 @@ public class UiNineSlicePanel : UiPanel
|
|||
// and draws in OnDrawAfterChildren (over the content's edges) so content can
|
||||
// never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang).
|
||||
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
|
||||
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
|
||||
if (DrawCenterFill)
|
||||
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
|
||||
}
|
||||
|
||||
protected override void OnDrawAfterChildren(UiRenderContext ctx)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue