feat(ui): complete retail creature appraisal details

Port the separate creature rating list, layer the animated preview between authored row chrome and text, and follow selection while the examination floaty is visible. Preserve the remaining item-preview and font-state gaps in AP-110.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 14:04:19 +02:00
parent 7eaa68a5f4
commit d96ea2de98
11 changed files with 578 additions and 61 deletions

View file

@ -4,6 +4,7 @@ using System.Text;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
@ -29,6 +30,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint CreaturePanelId = 0x10000140u;
public const uint CreatureViewportId = 0x10000148u;
public const uint CreatureStatsListId = 0x10000149u;
public const uint CreatureExtraListId = 0x10000335u;
public const uint CreatureDisplayNameId = 0x1000014Eu;
public const uint CreatureLevelValueId = 0x1000014Cu;
public const uint SpellPanelId = 0x10000153u;
@ -41,12 +43,14 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly ImportedLayout _layout;
private readonly ClientObjectTable _objects;
private readonly ItemInteractionController _interaction;
private readonly SelectionState _selection;
private readonly CombatState _combat;
private readonly Spellbook _spellbook;
private readonly Func<string> _playerName;
private readonly Action<uint, string> _sendSetInscription;
private readonly Action<string> _systemMessage;
private readonly Action _show;
private readonly Action _closeWindow;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
private readonly UiElement? _spellPanel;
@ -57,7 +61,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly UiText? _signature;
private readonly UiDatElement? _inscriptionBackground;
private readonly UiButton? _close;
private readonly UiItemList? _creatureStats;
private readonly CreatureAppraisalLayeredList? _creatureStats;
private readonly CreatureAppraisalLayeredList? _creatureExtra;
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
private readonly CreatureDisplayNameResolver _creatureNames;
private AppraisalView _activeView;
@ -71,6 +76,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private string _scribeName = string.Empty;
private string _oldInscription = string.Empty;
private bool _presentationInscribable;
private bool _windowVisible;
private double _refreshElapsed;
private bool _disposed;
@ -78,6 +84,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
SelectionState selection,
CombatState combat,
Spellbook spellbook,
Func<string> playerName,
@ -95,12 +102,14 @@ public sealed class AppraisalUiController : IRetainedPanelController
_layout = layout;
_objects = objects;
_interaction = interaction;
_selection = selection;
_combat = combat;
_spellbook = spellbook;
_playerName = playerName;
_sendSetInscription = sendSetInscription;
_systemMessage = systemMessage;
_show = show;
_closeWindow = close;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
_spellPanel = layout.FindElement(SpellPanelId);
@ -150,24 +159,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
if (creatureRowTemplates is not null
&& layout.FindElement(CreatureStatsListId) is { } statsHost)
&& layout.FindElement(CreatureStatsListId) is { } statsHost
&& layout.FindElement(CreatureExtraListId) is { } extraHost
&& layout.FindElement(CreatureViewportId) is UiViewport viewport)
{
_creatureStats = statsHost as UiItemList
?? new UiItemList
{
Width = statsHost.Width,
Height = statsHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
};
if (!ReferenceEquals(_creatureStats, statsHost))
statsHost.AddChild(_creatureStats);
_creatureStats.Flush();
_creatureStats.Columns = 1;
_creatureStats.CellWidth = creatureRowTemplates.Width;
_creatureStats.CellHeight = creatureRowTemplates.Height;
_creatureStats = CreatureAppraisalLayeredList.Create(
_creaturePanel,
statsHost,
viewport,
creatureRowTemplates,
backgroundZOrder: viewport.ZOrder - 2);
_creatureExtra = CreatureAppraisalLayeredList.Create(
_creaturePanel,
extraHost,
viewport,
creatureRowTemplates,
backgroundZOrder: viewport.ZOrder - 1);
}
_selection.Changed += HandleSelectionChanged;
SetActiveView(AppraisalView.Item);
}
@ -178,6 +188,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
SelectionState selection,
CombatState combat,
Spellbook spellbook,
Func<string> playerName,
@ -191,6 +202,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(playerName);
@ -209,6 +221,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
layout,
objects,
interaction,
selection,
combat,
spellbook,
playerName,
@ -274,7 +287,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
public void Tick(double deltaSeconds)
{
if (!_layout.Root.Visible
if (!_windowVisible
|| _activeView is not (AppraisalView.Creature or AppraisalView.Character)
|| _combat.CurrentMode == CombatMode.NonCombat
|| CurrentObjectId == 0)
@ -522,18 +535,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (_creatureStats is null || _creatureRowTemplates is null)
return;
using (_creatureStats.DeferLayout())
{
_creatureStats.Flush();
if (appraisal.CreatureProfile is not { } profile)
return;
foreach (CreatureAppraisalRow row in CreatureAppraisalRows.Build(
profile,
appraisal.Success))
{
_creatureStats.AddItem(_creatureRowTemplates.Create(row));
}
}
_creatureStats.Rebuild(
appraisal.CreatureProfile is { } profile
? CreatureAppraisalRows.Build(profile, appraisal.Success)
: Array.Empty<CreatureAppraisalRow>());
_creatureExtra?.Rebuild(
CreatureAppraisalRows.BuildExtra(appraisal.Properties));
}
private void ConfigureScrollableText(
@ -577,11 +584,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>();
_creatureStats?.Flush();
_creatureExtra?.Flush();
}
private void ResetCreatureScroll()
{
_creatureStats?.Scroll.SetScrollY(0);
_creatureStats?.ResetScroll();
_creatureExtra?.ResetScroll();
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
text.Scroll.SetScrollY(0);
}
@ -647,6 +656,29 @@ public sealed class AppraisalUiController : IRetainedPanelController
public void OnShown()
{
_windowVisible = true;
}
public void OnHidden() => _windowVisible = false;
/// <summary>
/// Port of <c>gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0</c>.
/// Retail initializes <c>m_examineNewlySelectedItem</c> to one: while the
/// floaty window is visible, a new selection is appraised immediately and
/// clearing selection hides the window.
/// </summary>
private void HandleSelectionChanged(SelectionTransition transition)
{
if (!_windowVisible || _disposed)
return;
if (transition.SelectedObjectId is uint objectId && objectId != 0u)
{
_interaction.ExamineSelectedOrEnterMode(objectId);
return;
}
_closeWindow();
}
public void Dispose()
@ -654,6 +686,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (_disposed)
return;
_disposed = true;
_selection.Changed -= HandleSelectionChanged;
if (_close is not null)
_close.OnClick = null;
if (_inscriptionField is not null)

View file

@ -1,6 +1,7 @@
using System.Globalization;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -21,6 +22,13 @@ public readonly record struct CreatureAppraisalRow(
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
@ -30,6 +38,15 @@ public readonly record struct CreatureAppraisalRow(
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,
@ -70,6 +87,73 @@ public static class CreatureAppraisalRows
];
}
/// <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,
@ -198,7 +282,9 @@ public sealed class CreatureAppraisalRowTemplateFactory
fonts);
}
public UiTemplateListSlot Create(CreatureAppraisalRow row)
public UiTemplateListSlot Create(
CreatureAppraisalRow row,
CreatureAppraisalRowLayer layer = CreatureAppraisalRowLayer.Combined)
{
ImportedLayout content = LayoutImporter.Build(
_template,
@ -209,10 +295,24 @@ public sealed class CreatureAppraisalRowTemplateFactory
: _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))];
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,
@ -262,6 +362,124 @@ public sealed class CreatureAppraisalRowTemplateFactory
}
}
/// <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 = 4f;
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(
left: TextInset,
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

View file

@ -158,10 +158,17 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Controls only this element's authored media. Descendants still draw.
/// Layered retail composites use this to place a template's chrome behind a
/// viewport while retaining its text descendants above that viewport.
/// </summary>
public bool MediaVisible { get; set; } = true;
protected override void OnDraw(UiRenderContext ctx)
{
var (file, _) = ActiveMedia();
if (file != 0)
if (MediaVisible && file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)

View file

@ -994,6 +994,7 @@ public sealed class RetailUiRuntime : IDisposable
layout,
_bindings.Inventory.Objects,
_bindings.Inventory.ItemInteraction,
_bindings.Inventory.Selection,
_bindings.Combat.State,
_bindings.Magic.Spellbook,
_bindings.Appraisal.PlayerName,

View file

@ -26,10 +26,13 @@ public sealed class UiItemList : UiElement
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
public UiScrollable Scroll { get; } = new();
public UiScrollable Scroll { get; }
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
public UiItemList(
Func<uint, (uint tex, int w, int h)>? spriteResolve = null,
UiScrollable? scroll = null)
{
Scroll = scroll ?? new UiScrollable();
SpriteResolve = spriteResolve;
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });