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

@ -75,6 +75,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Ui;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the runtime-created behavior of retail <c>gmRadarUI</c> to the static
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
/// </summary>
public sealed class RadarController
{
public const uint LayoutId = 0x21000074u;
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
public const int RadarPixelRadius = 50;
public const uint RootId = 0x100006D3u;
public const uint CoordinateContainerId = 0x1000003Eu;
public const uint RadarDiscId = 0x1000003Fu;
public const uint NorthTokenId = 0x10000040u;
public const uint EastTokenId = 0x10000041u;
public const uint SouthTokenId = 0x10000042u;
public const uint WestTokenId = 0x10000043u;
public const uint LockButtonId = 0x10000619u;
public const uint DragButtonId = 0x100006A3u;
private static readonly Vector4 CoordinateColor = Vector4.One;
private readonly UiRadar _radar;
private readonly UiElement? _coordinateContainer;
private readonly UiText? _coordinateText;
private readonly UiButton? _lockButton;
private readonly UiElement? _dragButton;
private readonly Action<bool>? _setUiLocked;
private readonly CompassToken[] _tokens;
private float _lastHeading = float.NaN;
private string? _lastCoordinates;
private bool _coordinatesInitialized;
private bool? _lastUiLocked;
private RadarController(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject,
Action<uint?>? hoveredObjectChanged,
Action<bool>? setUiLocked,
UiDatFont? datFont)
{
_radar = layout.Root as UiRadar
?? throw new ArgumentException(
$"Layout root must be {nameof(UiRadar)} (retail class 0x{UiRadar.RetailClassId:X8}).",
nameof(layout));
_radar.Center = ResolveCenter(layout);
_radar.SnapshotProvider = () =>
{
var snapshot = snapshotProvider() ?? UiRadarSnapshot.Empty;
ApplyPresentation(snapshot);
return snapshot;
};
_radar.SelectObject = selectObject;
_radar.HoveredObjectChanged = hoveredObjectChanged;
_setUiLocked = setUiLocked;
_coordinateContainer = layout.FindElement(CoordinateContainerId);
_coordinateText = CreateCoordinateText(_coordinateContainer, datFont);
_lockButton = layout.FindElement(LockButtonId) as UiButton;
_dragButton = layout.FindElement(DragButtonId);
if (_lockButton is not null && _setUiLocked is not null)
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
_tokens =
[
CreateToken(layout.FindElement(NorthTokenId), RadarCompassPoint.North, _radar.Center),
CreateToken(layout.FindElement(EastTokenId), RadarCompassPoint.East, _radar.Center),
CreateToken(layout.FindElement(SouthTokenId), RadarCompassPoint.South, _radar.Center),
CreateToken(layout.FindElement(WestTokenId), RadarCompassPoint.West, _radar.Center),
];
// Retail moves the radar only from its dedicated drag affordance. Making that
// imported Type-2 element hit-testable lets UiRoot's existing draggable-window
// machinery move the UiRadar ancestor while ordinary radar clicks select blips.
if (_dragButton is not null)
_dragButton.ClickThrough = false;
_radar.Draggable = true;
_radar.ResizeX = false;
_radar.ResizeY = false;
_radar.Refresh();
}
/// <summary>
/// Bind the projected snapshot provider and interaction callbacks to an imported radar.
/// No external per-frame call is required; <see cref="UiRadar"/> polls at retail's 25 ms cadence.
/// </summary>
public static RadarController Bind(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject = null,
Action<uint?>? hoveredObjectChanged = null,
Action<bool>? setUiLocked = null,
UiDatFont? datFont = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshotProvider);
return new RadarController(
layout,
snapshotProvider,
selectObject,
hoveredObjectChanged,
setUiLocked,
datFont);
}
private void ApplyPresentation(UiRadarSnapshot snapshot)
{
if (!snapshot.PlayerHeadingDegrees.Equals(_lastHeading))
{
for (int i = 0; i < _tokens.Length; i++)
{
ref readonly var token = ref _tokens[i];
if (token.Element is null)
continue;
var p = RetailRadar.GetCompassTokenTopLeft(
snapshot.PlayerHeadingDegrees,
token.Point,
_radar.Center,
token.Magnitude,
new Vector2(token.Element.Width, token.Element.Height));
token.Element.Left = p.X;
token.Element.Top = p.Y;
}
_lastHeading = snapshot.PlayerHeadingDegrees;
}
if (!_coordinatesInitialized
|| !string.Equals(snapshot.CoordinatesText, _lastCoordinates, StringComparison.Ordinal))
{
_coordinatesInitialized = true;
_lastCoordinates = snapshot.CoordinatesText;
bool visible = !string.IsNullOrEmpty(snapshot.CoordinatesText);
if (_coordinateContainer is not null)
_coordinateContainer.Visible = visible;
if (_coordinateText is not null)
_coordinateText.Visible = visible;
}
if (_lastUiLocked != snapshot.UiLocked)
{
_lastUiLocked = snapshot.UiLocked;
_radar.Draggable = !snapshot.UiLocked;
if (_dragButton is not null)
_dragButton.Visible = !snapshot.UiLocked;
if (_lockButton is not null)
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
}
}
private static Vector2 ResolveCenter(ImportedLayout layout)
{
// ElementReader intentionally imports the common LayoutDesc fields only; gmRadarUI's
// custom center property 0x1000002E is not exposed yet. Derive the exact production
// center (60,60) from the real 120x120 disc instead of duplicating it. Radius property
// 0x1000002D is the recovered RadarPixelRadius constant above.
if (layout.FindElement(RadarDiscId) is { } disc)
return new Vector2(disc.Left + disc.Width * 0.5f, disc.Top + disc.Height * 0.5f);
return new Vector2(layout.Root.Width * 0.5f, MathF.Min(120f, layout.Root.Height) * 0.5f);
}
private static CompassToken CreateToken(UiElement? element, RadarCompassPoint point, Vector2 center)
{
if (element is null)
return new CompassToken(null, 0f, point);
var initialCenter = new Vector2(
element.Left + element.Width * 0.5f,
element.Top + element.Height * 0.5f);
return new CompassToken(element, Vector2.Distance(initialCenter, center), point);
}
private UiText? CreateCoordinateText(UiElement? container, UiDatFont? datFont)
{
if (container is null)
return null;
if (container is UiText importedText)
{
// 0x1000003E inherits UIElement_Text from its base layout. Reuse that imported
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
// strip and no duplicate text node is needed.
importedText.Centered = true;
importedText.Padding = 0f;
importedText.DatFont = datFont ?? importedText.DatFont;
importedText.ClickThrough = true;
importedText.AcceptsFocus = false;
importedText.IsEditControl = false;
importedText.CapturesPointerDrag = false;
importedText.LinesProvider = CoordinateLines;
return importedText;
}
var text = new UiText
{
Name = "gmRadarUI.Coordinates",
Left = 0f,
Top = 0f,
Width = container.Width,
Height = container.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
Padding = 0f,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
ZOrder = int.MaxValue,
LinesProvider = CoordinateLines,
};
container.AddChild(text);
return text;
}
private IReadOnlyList<UiText.Line> CoordinateLines()
=> string.IsNullOrEmpty(_lastCoordinates)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
private readonly record struct CompassToken(
UiElement? Element,
float Magnitude,
RadarCompassPoint Point);
}

View file

@ -0,0 +1,149 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Joins acdream's existing two-table world model into the immutable projected frame
/// consumed by <see cref="UiRadar"/>. This is the App-layer equivalent of retail's
/// <c>gmRadarUI::DrawObjects</c> object-maintenance walk: Core still owns every
/// AC-specific classification and math decision, while the retained widget remains a
/// backend-only renderer.
/// </summary>
public sealed class RadarSnapshotProvider
{
private static readonly Vector2 ProductionCenter = new(60f, 60f);
private readonly ClientObjectTable _objects;
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
private readonly Func<uint> _playerGuid;
private readonly Func<float> _playerYawRadians;
private readonly Func<uint> _playerCellId;
private readonly Func<uint?> _selectedGuid;
private readonly Func<bool> _coordinatesOnRadar;
private readonly Func<bool> _uiLocked;
private readonly Func<uint, RadarRelationshipTraits>? _relationshipFor;
public RadarSnapshotProvider(
ClientObjectTable objects,
IReadOnlyDictionary<uint, WorldEntity> worldEntities,
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns,
Func<uint> playerGuid,
Func<float> playerYawRadians,
Func<uint> playerCellId,
Func<uint?> selectedGuid,
Func<bool> coordinatesOnRadar,
Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
_playerCellId = playerCellId ?? throw new ArgumentNullException(nameof(playerCellId));
_selectedGuid = selectedGuid ?? throw new ArgumentNullException(nameof(selectedGuid));
_coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar));
_uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked));
_relationshipFor = relationshipFor;
}
public UiRadarSnapshot BuildSnapshot()
{
bool uiLocked = _uiLocked();
uint playerGuid = _playerGuid();
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
uint playerCellId = _playerCellId();
bool isOutside = RadarCoordinates.TryFromCell(playerCellId, out var playerCoordinates);
string? coordinates = _coordinatesOnRadar() && isOutside
? playerCoordinates.CombinedText
: null;
uint playerPwd = _spawns.TryGetValue(playerGuid, out var playerSpawn)
? playerSpawn.ObjectDescriptionFlags ?? 0u
: 0u;
var playerTraits = RadarObjectTraits.FromPublicWeenieDescription(
(uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd);
float range = RetailRadar.GetRangeMeters(isOutside);
var blips = new List<UiRadarBlip>(_worldEntities.Count);
foreach (var pair in _worldEntities)
{
uint guid = pair.Key;
if (guid == playerGuid)
continue;
var entity = pair.Value;
var clientObject = _objects.Get(guid);
_spawns.TryGetValue(guid, out var spawn);
byte? rawBehavior = clientObject?.RadarBehavior ?? spawn.RadarBehavior;
if (rawBehavior is null
|| !RetailRadar.IsShowable((RadarBehavior)rawBehavior.Value, hasPhysicsObject: true))
{
continue;
}
uint itemType = (uint)(clientObject?.Type ?? ItemType.None);
if (itemType == 0u)
itemType = spawn.ItemType ?? 0u;
uint pwd = spawn.ObjectDescriptionFlags ?? 0u;
byte colorOverride = clientObject?.RadarBlipColor ?? spawn.RadarBlipColor ?? 0;
var traits = RadarObjectTraits.FromPublicWeenieDescription(
itemType, pwd, colorOverride);
var relationship = _relationshipFor?.Invoke(guid) ?? default;
relationship = relationship with
{
PlayerIsPlayerKiller = playerTraits.IsPlayerKiller,
PlayerIsPkLite = playerTraits.IsPkLite,
};
var shape = RetailRadar.GetBlipShape(traits, relationship);
if (shape == RadarBlipShape.Undef)
continue;
Vector3 playerSpace = MoveToMath.GlobalToLocalVec(
playerEntity.Rotation,
entity.Position - playerEntity.Position);
if (!RetailRadar.TryProject(
playerSpace,
ProductionCenter,
RadarController.RadarPixelRadius,
range,
out var projection))
{
continue;
}
var color = RadarBlipColors.For(traits, relationship)
.DimRgb(projection.RgbMultiplier);
string? name = clientObject?.Name;
if (string.IsNullOrEmpty(name))
name = spawn.Name ?? $"0x{guid:X8}";
blips.Add(new UiRadarBlip(
ObjectId: guid,
Name: name,
PixelX: projection.Pixel.X,
PixelY: projection.Pixel.Y,
Color: new Vector4(color.Red, color.Green, color.Blue, color.Alpha),
Shape: shape,
Selected: _selectedGuid() == guid));
}
return new UiRadarSnapshot(
PlayerHeadingDegrees: heading,
Blips: blips,
CoordinatesText: coordinates,
BlankBlips: false,
UiLocked: uiLocked);
}
}