diff --git a/src/AcDream.Core/Ui/RadarBlipColors.cs b/src/AcDream.Core/Ui/RadarBlipColors.cs new file mode 100644 index 0000000..685f156 --- /dev/null +++ b/src/AcDream.Core/Ui/RadarBlipColors.cs @@ -0,0 +1,93 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Ui; + +/// +/// B.7 (2026-05-15) — port of retail's gmRadarUI::GetBlipColor +/// (named decomp 0x004d76f0). Returns the radar / target-indicator +/// colour for an entity based on its and the +/// raw PublicWeenieDesc._bitfield we already parse out of +/// CreateObject. +/// +/// +/// Used by the Vivid Target Indicator (Phase B.7) to colour the four +/// corner triangles around the selected entity. Same value retail +/// would have shown on the radar blip — so the indicator + radar agree. +/// +/// +/// +/// Dispatch order matches the retail decomp at lines 219913+ of +/// docs/research/named-retail/acclient_2013_pseudo_c.txt. The +/// PWD bit layout matches acclient.h:6431-6463: +/// +/// BF_PLAYER = 0x8 +/// BF_PLAYER_KILLER = 0x20 +/// BF_VENDOR = 0x200 (byte[1] & 0x02 in retail) +/// BF_PORTAL = 0x40000 +/// BF_FREE_PKSTATUS = 0x200000 (hostile-flagged player) +/// BF_PKLITE_PKSTATUS = 0x2000000 +/// +/// +/// +/// +/// RGBA values are hand-tuned to visually match retail screenshots +/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal, +/// white default). Real RGBAColor_Radar* constants live in retail +/// static data — if they're ever recovered the table can be tightened. +/// +/// +public static class RadarBlipColors +{ + public readonly record struct Rgba(byte R, byte G, byte B, byte A); + + public static readonly Rgba Item = new(220, 220, 220, 255); // light grey (default object) + public static readonly Rgba Default = new(255, 255, 255, 255); // white (friendly player) + public static readonly Rgba Creature = new(255, 220, 80, 255); // yellow (NPC / monster) + public static readonly Rgba PlayerKiller = new(255, 64, 64, 255); // red (PK) + public static readonly Rgba PKLite = new(255, 128, 192, 255); // pink (PKLite) + public static readonly Rgba Vendor = new( 64, 192, 64, 255); // green (vendor NPC) + public static readonly Rgba Portal = new( 64, 192, 255, 255); // cyan (portal) + + /// + /// Resolve the radar-blip colour for an entity. Caller supplies the + /// raw (from CreateObject.ItemType) and + /// (from + /// CreateObject.ObjectDescriptionFlags) — both are already + /// parsed and stashed on EntitySpawn at spawn time. + /// + /// + /// Returns for friendly players, + /// for NPCs/monsters, for everything else. + /// Special types (PK, PKLite, Vendor, Portal) win over the base + /// type when their flag is set. + /// + /// + public static Rgba For(uint itemType, uint pwdBitfield) + { + // Special-type early returns. Order matches retail dispatch + // (Portal first, then Vendor) — same target can't logically + // be both, but in case of bit collision retail's order wins. + if ((pwdBitfield & 0x40000u) != 0) return Portal; + if ((pwdBitfield & 0x200u) != 0) return Vendor; + + bool isCreature = (itemType & (uint)ItemType.Creature) != 0; + bool isPlayer = (pwdBitfield & 0x8u) != 0; + + // Creature that isn't a player → NPC / monster → yellow. + if (isCreature && !isPlayer) + return Creature; + + if (isPlayer) + { + bool isPK = (pwdBitfield & 0x20u) != 0; + bool isPKLite = (pwdBitfield & 0x2000000u) != 0; + if (isPK) return PlayerKiller; + if (isPKLite) return PKLite; + return Default; + } + + // Not a special type, not a creature, not a player → an item / + // object on the ground or in the world. + return Item; + } +} diff --git a/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs b/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs new file mode 100644 index 0000000..482ea3e --- /dev/null +++ b/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs @@ -0,0 +1,83 @@ +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; + + [Fact] + public void Item_NoFlags_ReturnsItemColor() + { + // 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); + } + + [Fact] + public void Misc_NoFlags_ReturnsItemColor() + { + var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0); + Assert.Equal(RadarBlipColors.Item, result); + } + + [Fact] + public void Creature_NotPlayer_ReturnsCreatureColor() + { + // NPC: itemType has Creature bit, no Player flag. + var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0); + Assert.Equal(RadarBlipColors.Creature, result); + } + + [Fact] + public void FriendlyPlayer_ReturnsDefaultColor() + { + // Friendly player: Creature itemType + Player flag, no PK bits. + var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER); + Assert.Equal(RadarBlipColors.Default, result); + } + + [Fact] + public void PK_Player_ReturnsPlayerKillerColor() + { + var result = RadarBlipColors.For((uint)ItemType.Creature, + pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER); + Assert.Equal(RadarBlipColors.PlayerKiller, result); + } + + [Fact] + public void PKLite_Player_ReturnsPKLiteColor() + { + var result = RadarBlipColors.For((uint)ItemType.Creature, + pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS); + Assert.Equal(RadarBlipColors.PKLite, result); + } + + [Fact] + public void Vendor_BeatsCreatureFlag() + { + // 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); + } + + [Fact] + public void Portal_TopPriority() + { + // 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); + } +}