feat(ui): port retail radar and compass

This commit is contained in:
Erik 2026-07-10 16:14:37 +02:00
parent c4af181b92
commit 3cbe4b00a1
43 changed files with 2882 additions and 262 deletions

View file

@ -1,83 +1,132 @@
using AcDream.Core.Items;
using AcDream.Core.Ui;
using Xunit;
namespace AcDream.Core.Tests.Ui;
public sealed class RadarBlipColorsTests
{
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463
private const uint BF_PLAYER = 0x8u;
private const uint BF_PLAYER_KILLER = 0x20u;
private const uint BF_VENDOR = 0x200u;
private const uint BF_PORTAL = 0x40000u;
private const uint BF_PKLITE_PKSTATUS = 0x2000000u;
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
private const uint BfPlayer = 0x00000008u;
private const uint BfAttackable = 0x00000010u;
private const uint BfPlayerKiller = 0x00000020u;
private const uint BfHiddenAdmin = 0x00000040u;
private const uint BfVendor = 0x00000200u;
private const uint BfPortal = 0x00040000u;
private const uint BfAdmin = 0x00100000u;
private const uint BfFreePk = 0x00200000u;
private const uint BfPkLite = 0x02000000u;
[Fact]
public void Item_NoFlags_ReturnsItemColor()
public void Palette_UsesExactRetailFloatConstants()
{
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature.
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
Assert.Equal(new RadarBlipColors.Rgba(0.25f, 0.660000026f, 1f, 1f), RadarBlipColors.Blue);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.670000017f, 0f, 1f), RadarBlipColors.Gold);
Assert.Equal(new RadarBlipColors.Rgba(1f, 1f, 0.5f, 1f), RadarBlipColors.Yellow);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.25f, 0.389999986f, 1f), RadarBlipColors.Red);
Assert.Equal(new RadarBlipColors.Rgba(0.75f, 0.389999986f, 1f, 1f), RadarBlipColors.Purple);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.660000026f, 0.75f, 1f), RadarBlipColors.Pink);
Assert.Equal(new RadarBlipColors.Rgba(0f, 0.5f, 0.25f, 1f), RadarBlipColors.Green);
}
[Fact]
public void Misc_NoFlags_ReturnsItemColor()
public void Override_MapsValuesOneThroughTenInRetailOrder()
{
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
RadarBlipColors.Rgba[] expected =
[
RadarBlipColors.Blue,
RadarBlipColors.Gold,
RadarBlipColors.White,
RadarBlipColors.Purple,
RadarBlipColors.Red,
RadarBlipColors.Pink,
RadarBlipColors.Green,
RadarBlipColors.Yellow,
RadarBlipColors.Cyan,
RadarBlipColors.BrightGreen,
];
for (byte value = 1; value <= expected.Length; value++)
Assert.Equal(expected[value - 1], RadarBlipColors.ForOverride(value));
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.ForOverride(11));
}
[Fact]
public void Creature_NotPlayer_ReturnsCreatureColor()
public void WireOverride_WinsOverObjectClassification()
{
// NPC: itemType has Creature bit, no Player flag.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Creature, result);
var traits = new RadarObjectTraits(
BlipColorOverride: 1,
IsPortal: true,
IsVendor: true,
IsPlayer: true,
IsPlayerKiller: true);
Assert.Equal(RadarBlipColors.Blue, RadarBlipColors.For(traits));
}
[Fact]
public void FriendlyPlayer_ReturnsDefaultColor()
public void PortalAndVendor_UsePurpleAndYellow()
{
// Friendly player: Creature itemType + Player flag, no PK bits.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER);
Assert.Equal(RadarBlipColors.Default, result);
Assert.Equal(
RadarBlipColors.Purple,
RadarBlipColors.For((uint)ItemType.Creature, BfPortal | BfVendor));
Assert.Equal(
RadarBlipColors.Yellow,
RadarBlipColors.For((uint)ItemType.Creature, BfVendor));
}
[Fact]
public void PK_Player_ReturnsPlayerKillerColor()
public void Creature_MustBeAttackableAndNotAPlayer()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.PlayerKiller, result);
Assert.Equal(
RadarBlipColors.Gold,
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable));
Assert.Equal(
RadarBlipColors.White,
RadarBlipColors.For((uint)ItemType.Creature, 0));
Assert.Equal(
RadarBlipColors.White,
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable | BfPlayer));
}
[Fact]
public void PKLite_Player_ReturnsPKLiteColor()
public void Player_StatusDispatch_MatchesRetailPriority()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
Assert.Equal(RadarBlipColors.PKLite, result);
uint creature = (uint)ItemType.Creature;
Assert.Equal(RadarBlipColors.Cyan, RadarBlipColors.For(creature, BfPlayer | BfAdmin));
Assert.Equal(RadarBlipColors.Red, RadarBlipColors.For(creature, BfPlayer | BfPlayerKiller));
Assert.Equal(RadarBlipColors.Pink, RadarBlipColors.For(creature, BfPlayer | BfPkLite));
Assert.Equal(RadarBlipColors.Gold, RadarBlipColors.For(creature, BfPlayer | BfFreePk));
Assert.Equal(
RadarBlipColors.Red,
RadarBlipColors.For(creature, BfPlayer | BfAdmin | BfHiddenAdmin | BfPlayerKiller));
}
[Fact]
public void Vendor_BeatsCreatureFlag()
public void Fellowship_OverridesPlayerStatusColor()
{
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per
// retail's dispatch order (vendor check happens before creature
// check at 0x004d7946-004d7973).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_VENDOR);
Assert.Equal(RadarBlipColors.Vendor, result);
var traits = new RadarObjectTraits(IsPlayer: true, IsPlayerKiller: true);
var relationship = new RadarRelationshipTraits(
IsFellowshipMember: true,
IsFellowshipLeader: true);
Assert.Equal(RadarBlipColors.BrightGreen, RadarBlipColors.For(traits, relationship));
}
[Fact]
public void Portal_TopPriority()
public void InvalidDescription_ReturnsDefault()
{
// Portal flag wins over everything else (retail dispatch order
// checks BF_PORTAL first).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.Portal, result);
var traits = new RadarObjectTraits(IsValid: false, BlipColorOverride: 1, IsPortal: true);
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.For(traits));
}
[Fact]
public void Rgba8Projection_RemainsAvailableToExistingUiConsumers()
{
Assert.Equal((byte)64, RadarBlipColors.Blue.R);
Assert.Equal((byte)168, RadarBlipColors.Blue.G);
Assert.Equal(0xFFFFA840u, RadarBlipColors.Blue.ToAbgr32());
}
}

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Ui;
public sealed class RetailRadarTests
{
[Theory]
[InlineData(RadarBehavior.Undef, true, false)]
[InlineData(RadarBehavior.ShowNever, true, false)]
[InlineData(RadarBehavior.ShowMovement, true, true)]
[InlineData(RadarBehavior.ShowAttacking, true, true)]
[InlineData(RadarBehavior.ShowAlways, true, true)]
[InlineData(RadarBehavior.ShowAlways, false, false)]
public void Showability_AcceptsRetailEnumValuesWithoutLiveStateChecks(
RadarBehavior behavior,
bool hasPhysics,
bool expected)
=> Assert.Equal(expected, RetailRadar.IsShowable(behavior, hasPhysics));
[Fact]
public void ShapeClassification_UsesFellowshipAllegianceAndMutualPkPriority()
{
var pk = new RadarObjectTraits(IsPlayerKiller: true);
var pkLite = new RadarObjectTraits(IsPkLite: true);
Assert.Equal(RadarBlipShape.Default, RetailRadar.GetBlipShape(pk));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pkLite, new RadarRelationshipTraits(PlayerIsPkLite: true)));
Assert.Equal(
RadarBlipShape.AllegianceMember,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Fellowship,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.FellowshipLeader,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsFellowshipLeader: true)));
Assert.Equal(
RadarBlipShape.Undef,
RetailRadar.GetBlipShape(new RadarObjectTraits(IsValid: false)));
}
[Fact]
public void BlipPixelShapes_MatchRetailDrawRoutines()
{
AssertOffsets(RadarBlipShape.Circle, [(0, 0)]);
AssertOffsets(RadarBlipShape.Plus, [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)]);
AssertOffsets(RadarBlipShape.X, [(0, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(
RadarBlipShape.Box,
[(0, -1), (0, 1), (-1, 0), (1, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(RadarBlipShape.Triangle, [(0, 0), (-1, 1), (0, 1), (1, 1)]);
AssertOffsets(RadarBlipShape.InvertedTriangle, [(0, 0), (-1, -1), (0, -1), (1, -1)]);
AssertOffsets(
RadarBlipShape.XBox,
[
(0, -1), (0, 1), (-1, 0), (1, 0),
(1, 1), (-1, -1), (-1, 1), (1, -1),
(-2, -2), (2, -2), (-2, 2), (2, 2),
]);
Assert.Empty(RetailRadar.GetBlipPixels(RadarBlipShape.Undef).ToArray());
}
[Fact]
public void CenterMarkerAndSelectionOutline_MatchRetailPixelCountsAndExtents()
{
var player = RetailRadar.PlayerMarkerPixels.ToArray();
Assert.Equal(
new[]
{
new RadarPixelOffset(0, 0),
new RadarPixelOffset(0, -1),
new RadarPixelOffset(0, 1),
new RadarPixelOffset(-1, 0),
new RadarPixelOffset(1, 0),
new RadarPixelOffset(-2, 0),
new RadarPixelOffset(2, 0),
new RadarPixelOffset(0, -2),
new RadarPixelOffset(0, 2),
},
player);
var selected = RetailRadar.SelectionPixels.ToArray();
Assert.Equal(20, selected.Length);
Assert.All(selected, p => Assert.True(Math.Abs(p.X) == 3 || Math.Abs(p.Y) == 3));
for (int coordinate = -2; coordinate <= 2; coordinate++)
{
Assert.Contains(new RadarPixelOffset(coordinate, -3), selected);
Assert.Contains(new RadarPixelOffset(coordinate, 3), selected);
Assert.Contains(new RadarPixelOffset(-3, coordinate), selected);
Assert.Contains(new RadarPixelOffset(3, coordinate), selected);
}
}
[Fact]
public void Projection_UsesRetailRangeScaleAndScreenYAxis()
{
bool visible = RetailRadar.TryProject(
new Vector3(10f, 20f, 4.999f),
new Vector2(60f, 60f),
radarRadiusPixels: 55,
radarRangeMeters: RetailRadar.OutdoorRangeMeters,
out var projection);
Assert.True(visible);
Assert.Equal(new Vector2(67f, 45f), projection.Pixel);
Assert.Equal(1f, projection.RgbMultiplier);
}
[Fact]
public void Projection_ExcludesExactRangeMinusOneBoundary()
{
Assert.True(RetailRadar.TryProject(
new Vector3(73.999f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
Assert.False(RetailRadar.TryProject(
new Vector3(74f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
}
[Theory]
[InlineData(4.999f, 1f)]
[InlineData(-4.999f, 1f)]
[InlineData(5f, 0.65f)]
[InlineData(-5f, 0.65f)]
public void AltitudeDim_UsesStrictFiveMeterThreshold(float z, float expected)
=> Assert.Equal(expected, RetailRadar.GetAltitudeRgbMultiplier(z));
[Fact]
public void CompassTokens_OrbitUsingPhysicalHeading()
{
Vector2 center = new(60f, 60f);
Vector2 size = new(10f, 10f);
Assert.Equal(
new Vector2(55f, 0f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.North, center, 55f, size));
Assert.Equal(
// Retail's east offset is the float 1.57079637; after x87 cosine
// and C++ float-to-int truncation its center is one subpixel above 60.
new Vector2(110f, 54f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.East, center, 55f, size));
Assert.Equal(
new Vector2(55f, 110f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.South, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.West, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(90f, RadarCompassPoint.North, center, 55f, size));
}
[Fact]
public void Coordinates_UseOutdoorLandcellAndRetailAxisOrder()
{
uint cellId = LandDefs.LcoordToGid(1358, 1440);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal(33.9, coordinates.X, precision: 10);
Assert.Equal(42.1, coordinates.Y, precision: 10);
Assert.Equal("33.9E", coordinates.XText);
Assert.Equal("42.1N", coordinates.YText);
Assert.Equal("42.1N,33.9E", coordinates.CombinedText);
}
[Fact]
public void Coordinates_FormatWestSouthAndZeroWithoutDirection()
{
uint cellId = LandDefs.LcoordToGid(900, 800);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal("11.9W", coordinates.XText);
Assert.Equal("21.9S", coordinates.YText);
cellId = LandDefs.LcoordToGid(1019, 1019);
Assert.True(RadarCoordinates.TryFromCell(cellId, out coordinates));
Assert.Equal("0.0", coordinates.XText);
Assert.Equal("0.0,0.0", coordinates.CombinedText);
}
[Fact]
public void Coordinates_AreUnavailableIndoors()
=> Assert.False(RadarCoordinates.TryFromCell(0xA9B40164u, out _));
private static void AssertOffsets(RadarBlipShape shape, (int X, int Y)[] expected)
=> Assert.Equal(
expected.Select(p => new RadarPixelOffset(p.X, p.Y)),
RetailRadar.GetBlipPixels(shape).ToArray());
}