Restore the authored examination geometry and top-origin item list, then port retail's ordered weapon, armor, magic, requirement, capacity, cooldown, use, and description branches into a dedicated formatter with DAT spell prose. Keep the remaining specialized display-name and preview gaps explicit in AP-110. Co-authored-by: Codex <codex@openai.com>
525 lines
18 KiB
C#
525 lines
18 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
public enum CreatureAppraisalValueStyle
|
|
{
|
|
Normal,
|
|
Positive,
|
|
Negative,
|
|
Incomplete,
|
|
}
|
|
|
|
public readonly record struct CreatureAppraisalRow(
|
|
string Label,
|
|
string Value,
|
|
CreatureAppraisalValueStyle Style);
|
|
|
|
public enum CreatureAppraisalRowLayer
|
|
{
|
|
Combined,
|
|
Background,
|
|
Foreground,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure projection of retail's six <c>AttributeInfoRegion</c> and three
|
|
/// <c>Attribute2ndInfoRegion</c> tokens. Ordering and formatting come from
|
|
/// <c>BasicCreatureExamineUI</c> and the two Update overloads at
|
|
/// 0x004F1D90/0x004F1E80.
|
|
/// </summary>
|
|
public static class CreatureAppraisalRows
|
|
{
|
|
private const string Unknown = "???";
|
|
private const uint DamageRating = 0x133u;
|
|
private const uint DamageResistRating = 0x134u;
|
|
private const uint CritRating = 0x139u;
|
|
private const uint CritDamageRating = 0x13Au;
|
|
private const uint CritResistRating = 0x13Bu;
|
|
private const uint CritDamageResistRating = 0x13Cu;
|
|
private const uint HealingBoostRating = 0x143u;
|
|
private const uint DotResistRating = 0x15Eu;
|
|
private const uint LifeResistRating = 0x15Fu;
|
|
|
|
public static IReadOnlyList<CreatureAppraisalRow> Build(
|
|
AppraiseInfoParser.CreatureProfile profile,
|
|
bool success)
|
|
{
|
|
return
|
|
[
|
|
Primary("Strength", profile.Strength, 0, profile, success),
|
|
Primary("Endurance", profile.Endurance, 1, profile, success),
|
|
Primary("Coordination", profile.Coordination, 3, profile, success),
|
|
Primary("Quickness", profile.Quickness, 2, profile, success),
|
|
Primary("Focus", profile.Focus, 4, profile, success),
|
|
Primary("Self", profile.Self, 5, profile, success),
|
|
Secondary(
|
|
"Health",
|
|
profile.Health,
|
|
profile.HealthMax,
|
|
showPercent: true,
|
|
enchantmentBit: 6,
|
|
profile,
|
|
success),
|
|
Secondary(
|
|
"Stamina",
|
|
profile.Stamina,
|
|
profile.StaminaMax,
|
|
showPercent: false,
|
|
enchantmentBit: 7,
|
|
profile,
|
|
success),
|
|
Secondary(
|
|
"Mana",
|
|
profile.Mana,
|
|
profile.ManaMax,
|
|
showPercent: false,
|
|
enchantmentBit: 8,
|
|
profile,
|
|
success),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0</c>'s
|
|
/// separate miscellaneous list. Retail reads all nine rating properties,
|
|
/// emits a leading and trailing blank row when any display group exists,
|
|
/// and uses Crit/CritResist only to decide whether their paired row exists.
|
|
/// HealingBoost is read but not displayed by this retail build.
|
|
/// </summary>
|
|
public static IReadOnlyList<CreatureAppraisalRow> BuildExtra(
|
|
PropertyBundle properties)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(properties);
|
|
|
|
int damage = Get(properties, DamageRating);
|
|
int damageResist = Get(properties, DamageResistRating);
|
|
int crit = Get(properties, CritRating);
|
|
int critDamage = Get(properties, CritDamageRating);
|
|
int critResist = Get(properties, CritResistRating);
|
|
int critDamageResist = Get(properties, CritDamageResistRating);
|
|
_ = Get(properties, HealingBoostRating);
|
|
int dotResist = Get(properties, DotResistRating);
|
|
int lifeResist = Get(properties, LifeResistRating);
|
|
|
|
bool showRating = damage > 0 || crit > 0 || critDamage > 0;
|
|
bool showResist =
|
|
damageResist > 0 || critResist > 0 || critDamageResist > 0;
|
|
bool showDotLife = dotResist > 0 || lifeResist > 0;
|
|
if (!showRating && !showResist && !showDotLife)
|
|
return Array.Empty<CreatureAppraisalRow>();
|
|
|
|
var rows = new List<CreatureAppraisalRow>(5)
|
|
{
|
|
Blank(),
|
|
};
|
|
if (showRating)
|
|
{
|
|
rows.Add(new CreatureAppraisalRow(
|
|
"Dmg/CritDmg",
|
|
$"Rating: {Number(damage)}/{Number(critDamage)}",
|
|
CreatureAppraisalValueStyle.Normal));
|
|
}
|
|
if (showResist)
|
|
{
|
|
rows.Add(new CreatureAppraisalRow(
|
|
"Dmg/CritDmg",
|
|
$"Resist: {Number(damageResist)}/{Number(critDamageResist)}",
|
|
CreatureAppraisalValueStyle.Normal));
|
|
}
|
|
if (showDotLife)
|
|
{
|
|
rows.Add(new CreatureAppraisalRow(
|
|
"DoT/Life:",
|
|
$"Resist: {Number(dotResist)}/{Number(lifeResist)}",
|
|
CreatureAppraisalValueStyle.Normal));
|
|
}
|
|
rows.Add(Blank());
|
|
return rows;
|
|
}
|
|
|
|
private static CreatureAppraisalRow Blank() =>
|
|
new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal);
|
|
|
|
private static int Get(PropertyBundle properties, uint id) =>
|
|
properties.Ints.TryGetValue(id, out int value) ? value : 0;
|
|
|
|
private static string Number(int value) =>
|
|
value.ToString(CultureInfo.CurrentCulture);
|
|
|
|
private static CreatureAppraisalRow Primary(
|
|
string label,
|
|
uint? value,
|
|
int enchantmentBit,
|
|
AppraiseInfoParser.CreatureProfile profile,
|
|
bool success)
|
|
=> new(
|
|
label,
|
|
value is > 0
|
|
? value.Value.ToString(CultureInfo.CurrentCulture)
|
|
: Unknown,
|
|
Style(enchantmentBit, profile, success));
|
|
|
|
private static CreatureAppraisalRow Secondary(
|
|
string label,
|
|
uint? current,
|
|
uint? maximum,
|
|
bool showPercent,
|
|
int enchantmentBit,
|
|
AppraiseInfoParser.CreatureProfile profile,
|
|
bool success)
|
|
{
|
|
string value = Unknown;
|
|
if (current is > 0 && maximum is > 0)
|
|
{
|
|
int percent = RoundedPercent(current.Value, maximum.Value);
|
|
if (success)
|
|
{
|
|
value = showPercent
|
|
? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)"
|
|
: $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}";
|
|
}
|
|
else if (showPercent)
|
|
{
|
|
value = $"{percent.ToString(CultureInfo.CurrentCulture)} %";
|
|
}
|
|
}
|
|
|
|
return new CreatureAppraisalRow(
|
|
label,
|
|
value,
|
|
Style(enchantmentBit, profile, success));
|
|
}
|
|
|
|
private static int RoundedPercent(uint numerator, uint denominator)
|
|
=> denominator == 0u
|
|
? 0
|
|
: (int)Math.Min(
|
|
int.MaxValue,
|
|
((100L * numerator) + denominator / 2L) / denominator);
|
|
|
|
private static CreatureAppraisalValueStyle Style(
|
|
int bit,
|
|
AppraiseInfoParser.CreatureProfile profile,
|
|
bool success)
|
|
{
|
|
if (!success)
|
|
return CreatureAppraisalValueStyle.Incomplete;
|
|
|
|
ushort highlight = profile.AttributeHighlights ?? 0;
|
|
if ((highlight & (1 << bit)) == 0)
|
|
return CreatureAppraisalValueStyle.Normal;
|
|
|
|
ushort color = profile.AttributeColors ?? 0;
|
|
return (color & (1 << bit)) != 0
|
|
? CreatureAppraisalValueStyle.Positive
|
|
: CreatureAppraisalValueStyle.Negative;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Instantiates LayoutDesc 0x2100006B's InfoRegion token template
|
|
/// 0x10000166, matching <c>UIElement_ListBox::AddItemFromTemplateList</c>.
|
|
/// </summary>
|
|
public sealed class CreatureAppraisalRowTemplateFactory
|
|
{
|
|
public const uint TemplateId = 0x10000166u;
|
|
public const uint LabelId = 0x1000012Au;
|
|
public const uint ValueId = 0x1000012Bu;
|
|
|
|
private readonly ElementInfo _template;
|
|
private readonly Func<uint, (uint Texture, int Width, int Height)> _resolveSprite;
|
|
private readonly UiDatFont? _defaultFont;
|
|
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
|
|
|
|
public CreatureAppraisalRowTemplateFactory(
|
|
ElementInfo template,
|
|
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
|
|
UiDatFont? defaultFont,
|
|
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
|
|
{
|
|
_template = template ?? throw new ArgumentNullException(nameof(template));
|
|
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
|
|
_defaultFont = defaultFont;
|
|
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
|
|
}
|
|
|
|
public float Width => _template.Width;
|
|
public float Height => _template.Height;
|
|
|
|
public static CreatureAppraisalRowTemplateFactory? TryLoad(
|
|
IDatReaderWriter dats,
|
|
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
|
|
UiDatFont? defaultFont,
|
|
Func<uint, UiDatFont?>? resolveFont)
|
|
{
|
|
ElementInfo? template = LayoutImporter.ImportInfos(
|
|
dats,
|
|
AppraisalUiController.LayoutId,
|
|
TemplateId);
|
|
if (template is null
|
|
|| Find(template, LabelId) is null
|
|
|| Find(template, ValueId) is null
|
|
|| template.Width <= 0f
|
|
|| template.Height <= 0f)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fonts = new Dictionary<uint, UiDatFont?>();
|
|
CaptureFonts(template, resolveFont, fonts);
|
|
return new CreatureAppraisalRowTemplateFactory(
|
|
template,
|
|
resolveSprite,
|
|
defaultFont,
|
|
fonts);
|
|
}
|
|
|
|
public UiTemplateListSlot Create(
|
|
CreatureAppraisalRow row,
|
|
CreatureAppraisalRowLayer layer = CreatureAppraisalRowLayer.Combined)
|
|
{
|
|
ImportedLayout content = LayoutImporter.Build(
|
|
_template,
|
|
_resolveSprite,
|
|
_defaultFont,
|
|
did => _fonts.TryGetValue(did, out UiDatFont? font)
|
|
? font
|
|
: _defaultFont);
|
|
UiText label = Required<UiText>(content, LabelId);
|
|
UiText value = Required<UiText>(content, ValueId);
|
|
if (layer == CreatureAppraisalRowLayer.Background)
|
|
{
|
|
label.LinesProvider = static () => Array.Empty<UiText.Line>();
|
|
value.LinesProvider = static () => Array.Empty<UiText.Line>();
|
|
}
|
|
else
|
|
{
|
|
label.LinesProvider = () =>
|
|
[new UiText.Line(row.Label, label.DefaultColor)];
|
|
value.LinesProvider = () =>
|
|
[new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))];
|
|
}
|
|
|
|
if (layer == CreatureAppraisalRowLayer.Foreground
|
|
&& content.Root is UiDatElement root)
|
|
{
|
|
root.MediaVisible = false;
|
|
}
|
|
|
|
return new UiTemplateListSlot(
|
|
content,
|
|
entryId: 0u,
|
|
content.Root is IUiDatStateful stateful
|
|
? stateful.ActiveRetailStateId
|
|
: UiStateInfo.DirectStateId,
|
|
selectedState: null);
|
|
}
|
|
|
|
private static Vector4 ResolveColor(
|
|
CreatureAppraisalValueStyle style,
|
|
Vector4 normal)
|
|
{
|
|
// Retail selects one of four authored FontInfo entries here. Keep the
|
|
// semantic state in the row model, but do not invent substitute RGB
|
|
// values while that FontInfo-list binding remains unported (AP-110).
|
|
_ = style;
|
|
return normal;
|
|
}
|
|
|
|
private static T Required<T>(ImportedLayout content, uint id)
|
|
where T : UiElement
|
|
=> content.FindElement(id) as T
|
|
?? throw new InvalidOperationException(
|
|
$"Retail creature appraisal template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
|
|
|
|
private static ElementInfo? Find(ElementInfo root, uint id)
|
|
{
|
|
if (root.Id == id)
|
|
return root;
|
|
foreach (ElementInfo child in root.Children)
|
|
if (Find(child, id) is { } found)
|
|
return found;
|
|
return null;
|
|
}
|
|
|
|
private static void CaptureFonts(
|
|
ElementInfo info,
|
|
Func<uint, UiDatFont?>? resolveFont,
|
|
Dictionary<uint, UiDatFont?> fonts)
|
|
{
|
|
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
|
|
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
|
|
foreach (ElementInfo child in info.Children)
|
|
CaptureFonts(child, resolveFont, fonts);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces retail viewport compositing with the modern RTT seam:
|
|
/// authored row chrome is below the creature viewport, while a media-free
|
|
/// instance of the same authored template keeps label/value text above it.
|
|
/// Both lists share one pixel scroll model so their rows cannot drift.
|
|
/// </summary>
|
|
public sealed class CreatureAppraisalLayeredList
|
|
{
|
|
public const float TextInset = 8f;
|
|
|
|
private readonly CreatureAppraisalRowTemplateFactory _templates;
|
|
|
|
private CreatureAppraisalLayeredList(
|
|
CreatureAppraisalRowTemplateFactory templates,
|
|
UiItemList background,
|
|
UiItemList foreground)
|
|
{
|
|
_templates = templates;
|
|
Background = background;
|
|
Foreground = foreground;
|
|
}
|
|
|
|
public UiItemList Background { get; }
|
|
public UiItemList Foreground { get; }
|
|
|
|
public static CreatureAppraisalLayeredList Create(
|
|
UiElement panel,
|
|
UiElement backgroundHost,
|
|
UiViewport viewport,
|
|
CreatureAppraisalRowTemplateFactory templates,
|
|
int backgroundZOrder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(panel);
|
|
ArgumentNullException.ThrowIfNull(backgroundHost);
|
|
ArgumentNullException.ThrowIfNull(viewport);
|
|
ArgumentNullException.ThrowIfNull(templates);
|
|
|
|
int foregroundZOrder = backgroundHost.ZOrder;
|
|
backgroundHost.ZOrder = backgroundZOrder;
|
|
var scroll = new UiScrollable();
|
|
var background = NewList(
|
|
// The authored row chrome keeps its LayoutDesc origin. Only the
|
|
// foreground text is inset; moving both layers clips the left
|
|
// edge of the retail separator/background media.
|
|
left: 0f,
|
|
top: 0f,
|
|
width: templates.Width,
|
|
height: backgroundHost.Height,
|
|
zOrder: 0,
|
|
scroll: scroll);
|
|
background.ClickThrough = true;
|
|
backgroundHost.AddChild(background);
|
|
|
|
var foreground = NewList(
|
|
left: backgroundHost.Left + TextInset,
|
|
top: backgroundHost.Top,
|
|
width: templates.Width,
|
|
height: backgroundHost.Height,
|
|
zOrder: foregroundZOrder,
|
|
scroll: scroll);
|
|
foreground.Anchors = AnchorEdges.Left | AnchorEdges.Top
|
|
| AnchorEdges.Right | AnchorEdges.Bottom;
|
|
panel.AddChild(foreground);
|
|
|
|
return new CreatureAppraisalLayeredList(
|
|
templates,
|
|
background,
|
|
foreground);
|
|
}
|
|
|
|
public void Rebuild(IReadOnlyList<CreatureAppraisalRow> rows)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rows);
|
|
using (Background.DeferLayout())
|
|
using (Foreground.DeferLayout())
|
|
{
|
|
Background.Flush();
|
|
Foreground.Flush();
|
|
foreach (CreatureAppraisalRow row in rows)
|
|
{
|
|
Background.AddItem(_templates.Create(
|
|
row,
|
|
CreatureAppraisalRowLayer.Background));
|
|
Foreground.AddItem(_templates.Create(
|
|
row,
|
|
CreatureAppraisalRowLayer.Foreground));
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Flush()
|
|
{
|
|
Background.Flush();
|
|
Foreground.Flush();
|
|
}
|
|
|
|
public void ResetScroll() => Foreground.Scroll.SetScrollY(0);
|
|
|
|
private static UiItemList NewList(
|
|
float left,
|
|
float top,
|
|
float width,
|
|
float height,
|
|
int zOrder,
|
|
UiScrollable scroll)
|
|
{
|
|
return new UiItemList(scroll: scroll)
|
|
{
|
|
Left = left,
|
|
Top = top,
|
|
Width = width,
|
|
Height = height,
|
|
ZOrder = zOrder,
|
|
Columns = 1,
|
|
CellWidth = width,
|
|
CellHeight = 20f,
|
|
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0</c> resolves
|
|
/// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces
|
|
/// underscores with spaces.
|
|
/// </summary>
|
|
public sealed class CreatureDisplayNameResolver
|
|
{
|
|
public const uint MapperDid = 0x2200000Eu;
|
|
private readonly IReadOnlyDictionary<uint, string> _names;
|
|
|
|
public CreatureDisplayNameResolver(
|
|
IReadOnlyDictionary<uint, string> names)
|
|
{
|
|
_names = names ?? throw new ArgumentNullException(nameof(names));
|
|
}
|
|
|
|
public static CreatureDisplayNameResolver Load(IDatReaderWriter dats)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dats);
|
|
var names = new Dictionary<uint, string>();
|
|
var visited = new HashSet<uint>();
|
|
uint did = MapperDid;
|
|
while (did != 0u && visited.Add(did))
|
|
{
|
|
EnumMapper? mapper = dats.Get<EnumMapper>(did);
|
|
if (mapper is null)
|
|
break;
|
|
foreach ((uint id, PStringBase<byte> text) in mapper.IdToStringMap)
|
|
names.TryAdd(id, text.Value.Replace('_', ' '));
|
|
did = mapper.BaseEnumMap;
|
|
}
|
|
return new CreatureDisplayNameResolver(names);
|
|
}
|
|
|
|
public string Resolve(int creatureType)
|
|
=> creatureType > 0
|
|
&& _names.TryGetValue((uint)creatureType, out string? name)
|
|
? name
|
|
: string.Empty;
|
|
}
|