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>
This commit is contained in:
Erik 2026-07-23 12:55:24 +02:00
parent f1a7912160
commit 7eaa68a5f4
26 changed files with 2780 additions and 237 deletions

View file

@ -10,6 +10,7 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class AppraisalUiControllerTests
{
private const uint ObjectId = 0x50000001u;
private static (uint, int, int) NoTexture(uint _) => (0u, 0, 0);
[Fact]
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
@ -146,6 +147,114 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(1, shown);
}
[Fact]
public void CreatureResponse_UsesRetailHeaderAndNineOrderedTemplateRows()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Specter",
Type = ItemType.Creature,
});
using var interaction = NewInteraction(objects, []);
UiText characterLabel = Assert.IsType<UiText>(
layout.FindElement(0x1000014Au));
UiText levelLabel = Assert.IsType<UiText>(
layout.FindElement(0x1000014Bu));
characterLabel.LinesProvider = () =>
[new UiText.Line("Character", characterLabel.DefaultColor)];
levelLabel.LinesProvider = () =>
[new UiText.Line("Level", levelLabel.DefaultColor)];
var templates = new CreatureAppraisalRowTemplateFactory(
FixtureLoader.LoadExaminationRowTemplateInfos(),
NoTexture,
defaultFont: null);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
[],
() => { },
() => { },
templates,
new CreatureDisplayNameResolver(
new Dictionary<uint, string> { [77u] = "Ghost" }))!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[2u] = 77;
properties.Ints[25u] = 80;
var profile = new AppraiseInfoParser.CreatureProfile(
Flags: 0x08u,
Health: 295u,
HealthMax: 295u,
Strength: 120u,
Endurance: 190u,
Quickness: 190u,
Coordination: 190u,
Focus: 330u,
Self: 350u,
Stamina: 190u,
Mana: 550u,
StaminaMax: 190u,
ManaMax: 550u,
AttributeHighlights: (ushort)0,
AttributeColors: (ushort)0);
Assert.True(controller.Apply(Parsed(properties, profile)));
Assert.Equal(
"Character",
Assert.Single(characterLabel.LinesProvider()).Text);
Assert.Equal("Level", Assert.Single(levelLabel.LinesProvider()).Text);
Assert.Equal(
"80",
Assert.Single(((UiText)layout.FindElement(
AppraisalUiController.CreatureLevelValueId)!)
.LinesProvider()).Text);
Assert.Equal(
"Ghost",
Assert.Single(((UiText)layout.FindElement(
AppraisalUiController.CreatureDisplayNameId)!)
.LinesProvider()).Text);
UiElement host = layout.FindElement(
AppraisalUiController.CreatureStatsListId)!;
UiItemList list = Assert.Single(host.Children.OfType<UiItemList>());
Assert.Equal(9, list.GetNumUIItems());
string[] labels = new string[9];
string[] values = new string[9];
for (int index = 0; index < 9; index++)
{
UiTemplateListSlot row =
Assert.IsType<UiTemplateListSlot>(list.GetItem(index));
labels[index] = Assert.Single(
((UiText)row.Content.FindElement(
CreatureAppraisalRowTemplateFactory.LabelId)!)
.LinesProvider()).Text;
values[index] = Assert.Single(
((UiText)row.Content.FindElement(
CreatureAppraisalRowTemplateFactory.ValueId)!)
.LinesProvider()).Text;
}
Assert.Equal(
[
"Strength", "Endurance", "Coordination", "Quickness",
"Focus", "Self", "Health", "Stamina", "Mana",
],
labels);
Assert.Equal(
[
"120", "190", "190", "190", "330", "350",
"295/295 (100 %)", "190/190", "550/550",
],
values);
}
[Fact]
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
{
@ -429,7 +538,9 @@ public sealed class AppraisalUiControllerTests
List<(uint ObjectId, string Text)> inscriptions,
List<string> messages,
Action show,
Action close)
Action close,
CreatureAppraisalRowTemplateFactory? creatureRows = null,
CreatureDisplayNameResolver? creatureNames = null)
=> AppraisalUiController.Bind(
layout,
objects,
@ -440,7 +551,9 @@ public sealed class AppraisalUiControllerTests
(objectId, text) => inscriptions.Add((objectId, text)),
messages.Add,
show,
close);
close,
creatureRows,
creatureNames);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,

View file

@ -0,0 +1,100 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CreatureAppraisalRowsTests
{
[Fact]
public void FailedAssessmentShowsOnlyHealthPercentAndUnknownOtherValues()
{
var profile = Profile(
health: 25u,
healthMax: 100u,
highlights: (ushort)0,
colors: (ushort)0);
IReadOnlyList<CreatureAppraisalRow> rows =
CreatureAppraisalRows.Build(profile, success: false);
Assert.All(
rows,
row => Assert.Equal(
CreatureAppraisalValueStyle.Incomplete,
row.Style));
Assert.Equal("25 %", rows[6].Value);
Assert.Equal("???", rows[0].Value);
Assert.Equal("???", rows[7].Value);
Assert.Equal("???", rows[8].Value);
}
[Fact]
public void EnchantmentBitsSelectRetailPositiveAndNegativeStyles()
{
var profile = Profile(
health: 100u,
healthMax: 100u,
highlights: (ushort)((1 << 0) | (1 << 6)),
colors: (ushort)(1 << 0));
IReadOnlyList<CreatureAppraisalRow> rows =
CreatureAppraisalRows.Build(profile, success: true);
Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style);
Assert.Equal(CreatureAppraisalValueStyle.Negative, rows[6].Style);
Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style);
}
[Fact]
public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry()
{
ElementInfo template =
FixtureLoader.LoadExaminationRowTemplateInfos();
Assert.Equal(CreatureAppraisalRowTemplateFactory.TemplateId, template.Id);
Assert.Equal((292f, 20f), (template.Width, template.Height));
ElementInfo label = Assert.Single(
template.Children,
child => child.Id == CreatureAppraisalRowTemplateFactory.LabelId);
ElementInfo value = Assert.Single(
template.Children,
child => child.Id == CreatureAppraisalRowTemplateFactory.ValueId);
Assert.Equal((0f, 128f), (label.X, label.Width));
Assert.Equal((27f, 256f), (value.X, value.Width));
Assert.Equal(0x40000001u, label.FontDid);
Assert.Equal(0x40000001u, value.FontDid);
}
[Fact]
public void CreatureNameResolverUsesLoadedRetailMappingAndSafeFallback()
{
var resolver = new CreatureDisplayNameResolver(
new Dictionary<uint, string> { [77u] = "Ghost" });
Assert.Equal("Ghost", resolver.Resolve(77));
Assert.Equal(string.Empty, resolver.Resolve(0));
Assert.Equal(string.Empty, resolver.Resolve(999));
}
private static AppraiseInfoParser.CreatureProfile Profile(
uint health,
uint healthMax,
ushort highlights,
ushort colors)
=> new(
Flags: 0x09u,
Health: health,
HealthMax: healthMax,
Strength: null,
Endurance: null,
Quickness: null,
Coordination: null,
Focus: null,
Self: null,
Stamina: 50u,
Mana: 75u,
StaminaMax: 100u,
ManaMax: 100u,
AttributeHighlights: highlights,
AttributeColors: colors);
}

View file

@ -113,6 +113,9 @@ public static class FixtureLoader
public static ElementInfo LoadExaminationInfos()
=> LoadInfos("examine_2100006B_100005F2.json");
public static ElementInfo LoadExaminationRowTemplateInfos()
=> LoadInfos("examine_row_2100006B_10000166.json");
public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -132,6 +132,8 @@ public sealed class RetailLayoutFixtureGenerator
(CharacterController.LayoutId, CharacterController.RootId,
"character_info_2100006E_10000183.json"),
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
(AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId,
"examine_row_2100006B_10000166.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
@ -145,6 +147,10 @@ public sealed class RetailLayoutFixtureGenerator
}));
}
CreatureDisplayNameResolver creatureNames =
CreatureDisplayNameResolver.Load(dats);
Assert.Equal("Ghost", creatureNames.Resolve(77));
ElementInfo? miniGame = LayoutImporter.ImportInfos(
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
Assert.NotNull(miniGame);

File diff suppressed because it is too large Load diff