acdream/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
Erik 7eaa68a5f4 feat(ui): port retail creature appraisal presentation
Render assessed creatures through the shared private viewport with retail heading, bounding-box camera, and light. Build the exact authored nine-row stat list and resolve creature names from the retail EnumMapper while keeping remaining font/sequencer adaptations explicit.

Co-authored-by: Codex <codex@openai.com>
2026-07-23 12:55:24 +02:00

304 lines
10 KiB
C#

using System.Globalization;
using System.Numerics;
using AcDream.Content;
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);
/// <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 = "???";
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),
];
}
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)
{
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);
label.LinesProvider = () =>
[new UiText.Line(row.Label, label.DefaultColor)];
value.LinesProvider = () =>
[new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))];
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>
/// 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;
}