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

@ -0,0 +1,196 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class CreatureAppraisalPresentationTests
{
[Fact]
public void CameraFitsRetailVerticalSpanAndKeepsIdentityLookDirection()
{
var camera = new CreatureAppraisalCamera { Aspect = 2f };
camera.Fit(
new Vector3(-1f, -0.5f, 0f),
new Vector3(1f, 0.5f, 4f));
Assert.Equal(0f, camera.Eye.X, 5);
Assert.Equal(-(4f * 1.20710683f + 0.5f), camera.Eye.Y, 5);
Assert.Equal(2f, camera.Eye.Z, 5);
Vector3 forward = -new Vector3(
camera.View.M13,
camera.View.M23,
camera.View.M33);
Assert.Equal(Vector3.UnitY, forward);
}
[Fact]
public void CameraUsesViewportAspectWhenCreatureIsWiderThanTall()
{
var camera = new CreatureAppraisalCamera { Aspect = 1f };
camera.Fit(
new Vector3(-4f, -0.5f, 0f),
new Vector3(4f, 0.5f, 2f));
Assert.Equal(-(8f * 1.20710683f + 0.5f), camera.Eye.Y, 5);
}
[Fact]
public void CloneFactoryReusesIdentityAndFollowsCurrentAnimatedMeshRefs()
{
var firstMeshes = new[]
{
new MeshRef(0x01000001u, Matrix4x4.Identity),
};
var source = Entity(
id: 17u,
setup: 0x02000001u,
firstMeshes);
source.SetLocalBounds(
new Vector3(-1f, -0.5f, 0f),
new Vector3(1f, 0.5f, 2f));
var lookup = new Lookup { Entity = source };
var factory = new RetailCreatureAppraisalCloneFactory(lookup);
Assert.True(factory.TrySynchronize(
17u,
currentClone: null,
out WorldEntity? firstClone,
out Vector3 firstMin,
out Vector3 firstMax));
var nextMeshes = new[]
{
new MeshRef(
0x01000001u,
Matrix4x4.CreateTranslation(0f, 0f, 0.25f)),
};
source.MeshRefs = nextMeshes;
Assert.True(factory.TrySynchronize(
17u,
firstClone,
out WorldEntity? nextClone,
out Vector3 nextMin,
out Vector3 nextMax));
Assert.Same(firstClone, nextClone);
Assert.Same(nextMeshes, nextClone!.MeshRefs);
Assert.Equal(CreatureAppraisalEntityBuilder.RenderId, nextClone.Id);
Assert.Equal(firstMin, nextMin);
Assert.Equal(firstMax, nextMax);
Assert.Equal([17u, 17u], lookup.Requests);
}
[Fact]
public void PresenterRendersVisibleTargetAndClearsWhenProjectionDisappears()
{
WorldEntity clone = Entity(
CreatureAppraisalEntityBuilder.RenderId,
0x02000001u,
[new MeshRef(0x01000001u, Matrix4x4.Identity)]);
var renderer = new Renderer { Texture = 73u };
var view = new View();
var factory = new Factory { Clone = clone };
var presenter = new CreatureAppraisalFramePresenter(
renderer,
view,
factory);
presenter.Render();
factory.Available = false;
presenter.Render();
Assert.Equal([17u, 17u], factory.Requests);
Assert.Same(clone, renderer.Creatures[0]);
Assert.Null(renderer.Creatures[1]);
Assert.Equal([(300, 265)], renderer.RenderSizes);
Assert.Equal([73u, 0u], view.Textures);
}
private static WorldEntity Entity(
uint id,
uint setup,
IReadOnlyList<MeshRef> meshes)
=> new()
{
Id = id,
ServerGuid = id,
SourceGfxObjOrSetupId = setup,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = meshes,
};
private sealed class Lookup : ICreatureAppraisalEntityLookup
{
public WorldEntity? Entity { get; init; }
public List<uint> Requests { get; } = [];
public bool TryGet(uint serverGuid, out WorldEntity entity)
{
Requests.Add(serverGuid);
entity = Entity!;
return Entity is not null;
}
}
private sealed class Renderer : ICreatureAppraisalRenderer
{
public uint Texture { get; init; }
public List<WorldEntity?> Creatures { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public void SetCreature(
WorldEntity? creature,
Vector3 boundsMin,
Vector3 boundsMax)
=> Creatures.Add(creature);
public uint Render(int width, int height)
{
RenderSizes.Add((width, height));
return Texture;
}
}
private sealed class View : ICreatureAppraisalFrameView
{
public List<uint> Textures { get; } = [];
public bool TryGetVisibleTarget(
out uint serverGuid,
out int width,
out int height)
{
serverGuid = 17u;
width = 300;
height = 265;
return true;
}
public void SetTextureHandle(uint textureHandle) =>
Textures.Add(textureHandle);
}
private sealed class Factory : ICreatureAppraisalCloneFactory
{
public bool Available { get; set; } = true;
public WorldEntity? Clone { get; init; }
public List<uint> Requests { get; } = [];
public bool TrySynchronize(
uint serverGuid,
WorldEntity? currentClone,
out WorldEntity? synchronizedClone,
out Vector3 boundsMin,
out Vector3 boundsMax)
{
Requests.Add(serverGuid);
synchronizedClone = Available ? Clone : null;
boundsMin = new Vector3(-1f);
boundsMax = new Vector3(1f);
return Available;
}
}
}

View file

@ -13,7 +13,7 @@ public sealed class GameWindowRenderLeafCompositionTests
AssertAppearsInOrder(
presentation,
"_portal.Draw(",
"_paperdoll?.Render();",
"_entityViewports?.Render();",
"_gameplayUi?.Render(",
"_devTools?.Render(",
"_screenshots?.CapturePending(");

View file

@ -52,7 +52,7 @@ public sealed class PrivatePresentationRendererTests
var presentation = new PrivatePresentationRenderer(
new Portal(calls),
new FoundationSource(portalVisible: true),
paperdoll: null,
entityViewports: null,
gameplayUi: null,
devTools: null,
screenshots: null);
@ -64,6 +64,20 @@ public sealed class PrivatePresentationRendererTests
Assert.False(outcome.ScreenshotCaptured);
}
[Fact]
public void PrivateEntityViewportGroup_RendersEveryViewportInOrder()
{
List<string> calls = [];
var group = new PrivateEntityViewportFrameGroup(
new NamedViewport(calls, "paperdoll"),
null,
new NamedViewport(calls, "examination"));
group.Render();
Assert.Equal(["paperdoll", "examination"], calls);
}
[Fact]
public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource()
{
@ -72,7 +86,7 @@ public sealed class PrivatePresentationRendererTests
var presentation = new PrivatePresentationRenderer(
new MutatingPortal(calls, () => foundation.PortalVisible = false),
foundation,
paperdoll: null,
entityViewports: null,
gameplayUi: null,
devTools: null,
screenshots: null);
@ -209,11 +223,17 @@ public sealed class PrivatePresentationRendererTests
}
}
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame
private sealed class Paperdoll(List<string> calls) : IPrivateEntityViewportFrame
{
public void Render() => calls.Add("paperdoll");
}
private sealed class NamedViewport(List<string> calls, string name) :
IPrivateEntityViewportFrame
{
public void Render() => calls.Add(name);
}
private sealed class GameplayUi(List<string> calls) : IRetainedGameplayUiFrame
{
public RenderFrameInput Input { get; private set; }

View file

@ -146,7 +146,7 @@ public sealed class RenderFrameRecoveryIntegrationTests
public void Draw(int width, int height) => failure.ThrowIf("portal");
}
private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame
private sealed class Paperdoll(FailureSwitch failure) : IPrivateEntityViewportFrame
{
public void Render() => failure.ThrowIf("paperdoll");
}