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

@ -760,6 +760,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.6 — retail gmRadarUI (LayoutDesc 0x21000074). Behavior lives in
// Core + the retained controller; GameWindow supplies live state only.
private AcDream.App.UI.Layout.RadarController? _radarController;
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
private AcDream.App.UI.UiRadar? _radarRoot;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
@ -1738,6 +1743,11 @@ public sealed class GameWindow : IDisposable
try
{
settingsStore.SaveGameplay(gameplay);
// Runtime consumers (including retained gmRadarUI) read
// the live persisted snapshot, not SettingsVM's draft.
_persistedGameplay = gameplay;
if (_uiHost is not null)
_uiHost.Root.UiLocked = gameplay.LockUI;
Console.WriteLine(
"settings: gameplay saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
@ -1998,6 +2008,7 @@ public sealed class GameWindow : IDisposable
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -2025,6 +2036,7 @@ public sealed class GameWindow : IDisposable
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
_uiHost.Root.WindowMoved += OnRetailWindowMoved;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
@ -2110,6 +2122,52 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
// Phase D.6 — retail gmRadarUI. The importer owns the exact static
// LayoutDesc 0x21000074 shell; RadarController owns retained behavior;
// RadarSnapshotProvider is the thin live-world adapter.
AcDream.App.UI.Layout.ImportedLayout? radarLayout;
lock (_datLock)
radarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, AcDream.App.UI.Layout.RadarController.LayoutId,
ResolveChrome, vitalsDatFont, ResolveDatFont);
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
{
_radarRoot = radarRoot;
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
_lastSpawnByGuid,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
_radarController = AcDream.App.UI.Layout.RadarController.Bind(
radarLayout,
snapshotProvider: _radarSnapshotProvider.BuildSnapshot,
selectObject: guid => SelectedGuid = guid,
setUiLocked: SetRetailUiLocked,
datFont: vitalsDatFont);
radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f);
radarRoot.Top = 10f;
radarRoot.ClickThrough = false;
// User-positioned top-level window: Anchors.None prevents the
// per-frame anchor pass from undoing drag-handle movement.
radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
radarRoot.Resizable = false;
radarRoot.ConstrainDragToParent = true;
_uiHost.Root.AddChild(radarRoot);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot);
ApplySavedRadarPosition();
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
else
{
Console.WriteLine("[D.6] radar: LayoutDesc 0x21000074 not found or root class mismatch.");
}
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
@ -2866,6 +2924,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name;
ApplySavedRadarPosition();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
@ -11776,6 +11835,62 @@ public sealed class GameWindow : IDisposable
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
}
private void SetRetailUiLocked(bool locked)
{
if (_persistedGameplay.LockUI == locked)
return;
_persistedGameplay = _persistedGameplay with { LockUI = locked };
if (_uiHost is not null)
_uiHost.Root.UiLocked = locked;
if (_settingsVm is not null)
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
try
{
_settingsStore?.SaveGameplay(_persistedGameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
}
}
private void ApplySavedRadarPosition()
{
if (_radarRoot is null || _settingsStore is null || _window is null)
return;
var saved = _settingsStore.LoadWindowPosition(
_activeToonKey, AcDream.App.UI.WindowNames.Radar);
if (saved is not { } position)
return;
_radarRoot.Left = System.Math.Clamp(
position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width));
_radarRoot.Top = System.Math.Clamp(
position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height));
}
private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window)
{
if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null)
return;
try
{
_settingsStore.SaveWindowPosition(
_activeToonKey,
name,
new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition(
window.Left, window.Top));
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar position save failed: {ex.Message}");
}
}
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)

View file

@ -63,6 +63,31 @@ public static class FixtureProvider
manaText: () => "90/100");
break;
case RadarController.LayoutId: // gmRadarUI
RadarController.Bind(
layout,
() => new UiRadarSnapshot(
PlayerHeadingDegrees: 32f,
Blips:
[
new UiRadarBlip(0x50000001u, "Portal to Holtburg", 82f, 43f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Portal),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000002u, "Drudge Prowler", 43f, 76f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Creature),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000003u, "Town Crier", 68f, 87f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.NPC),
AcDream.Core.Ui.RadarBlipShape.Box),
new UiRadarBlip(0x50000004u, "Selected PK", 39f, 39f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.PlayerKiller),
AcDream.Core.Ui.RadarBlipShape.X,
Selected: true),
],
CoordinatesText: "42.1N,33.3E"),
datFont: stack.VitalsDatFont);
break;
case 0x21000016u: // toolbar
ToolbarController.Bind(
layout,
@ -156,4 +181,7 @@ public static class FixtureProvider
return handle;
};
private static System.Numerics.Vector4 RadarColor(AcDream.Core.Ui.RadarBlipColors.Rgba color)
=> new(color.Red, color.Green, color.Blue, color.Alpha);
}

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);
}
}

View file

@ -156,6 +156,10 @@ public abstract class UiElement
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; }
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
public bool ConstrainDragToParent { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Ui;
namespace AcDream.App.UI;
/// <summary>
/// One already-projected radar object. <paramref name="PixelX"/> and
/// <paramref name="PixelY"/> are local pixels in the 120x120 radar disc. World-to-player
/// projection stays in the Core retail port; this record is the backend-facing draw seam.
/// </summary>
public readonly record struct UiRadarBlip(
uint ObjectId,
string Name,
float PixelX,
float PixelY,
Vector4 Color,
RadarBlipShape Shape,
bool Selected = false);
/// <summary>
/// Immutable frame consumed by <see cref="UiRadar"/>. A null
/// <paramref name="CoordinatesText"/> hides retail's coordinate strip (indoors or when
/// CoordinatesOnRadar is disabled). <paramref name="BlankBlips"/> mirrors
/// <c>ClientUISystem::m_bRadarBlank</c>: static chrome and the player marker remain visible.
/// </summary>
public sealed record UiRadarSnapshot(
float PlayerHeadingDegrees,
IReadOnlyList<UiRadarBlip> Blips,
string? CoordinatesText,
bool BlankBlips = false,
bool UiLocked = false)
{
public static UiRadarSnapshot Empty { get; } = new(
0f,
Array.Empty<UiRadarBlip>(),
null);
}
/// <summary>
/// Retained-mode implementation of retail <c>gmRadarUI</c>, registered as class id
/// <c>0x10000010</c>. The imported LayoutDesc supplies all static sprites; this widget
/// draws the dynamic object blips and fixed player marker after those children.
///
/// <para>Retail references:</para>
/// <list type="bullet">
/// <item><c>gmRadarUI::DrawBlip</c> 0x004D8A30 and shape helpers 0x004D7C30-0x004D8531.</item>
/// <item><c>gmRadarUI::DrawChildren</c> 0x004D9720 (static children, objects, player marker).</item>
/// <item><c>gmRadarUI::UseTime</c> 0x004D98A0 (25 ms refresh cadence).</item>
/// </list>
/// </summary>
public sealed class UiRadar : UiElement
{
public const uint RetailClassId = 0x10000010u;
public const float RetailRefreshSeconds = RetailRadar.UpdateIntervalSeconds;
public const float HoverRadiusPixels = 6f;
private static readonly Vector4 PlayerMarkerColor = new(0f, 1f, 0f, 1f);
private UiRadarSnapshot _snapshot = UiRadarSnapshot.Empty;
private double _refreshAccumulator;
private uint? _hoveredObjectId;
private string? _hoveredObjectName;
/// <summary>Local center of the radar disc. Filled by <c>RadarController</c> from LayoutDesc 0x21000074.</summary>
public Vector2 Center { get; set; } = new(60f, 60f);
/// <summary>Polled at retail's 25 ms cadence. The provider performs Core-to-UI adaptation.</summary>
public Func<UiRadarSnapshot>? SnapshotProvider { get; set; }
/// <summary>Invoked when the user clicks the nearest blip under the pointer.</summary>
public Action<uint>? SelectObject { get; set; }
/// <summary>Optional host hook for target highlights/status text.</summary>
public Action<uint?>? HoveredObjectChanged { get; set; }
public UiRadarSnapshot Snapshot => _snapshot;
public uint? HoveredObjectId => _hoveredObjectId;
/// <summary>The radar itself must receive a click inside a draggable window; its dedicated
/// drag-handle child remains a non-HandlesClick target so UiRoot moves the window from there.</summary>
public override bool HandlesClick => true;
/// <summary>Apply one projected frame immediately. Used once by the binder, then by the 25 ms tick.</summary>
public void ApplySnapshot(UiRadarSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
_snapshot = snapshot;
// An object can disappear between pointer events. Clear stale hover immediately so a
// later click cannot select a guid that is no longer in the provider snapshot.
if (_hoveredObjectId is uint hovered)
{
bool stillPresent = false;
for (int i = 0; i < snapshot.Blips.Count; i++)
{
if (snapshot.Blips[i].ObjectId == hovered)
{
stillPresent = true;
break;
}
}
if (!stillPresent)
SetHovered(null, null);
}
}
/// <summary>Poll the provider now, without waiting for the next retained-tree tick.</summary>
public void Refresh()
{
if (SnapshotProvider is { } provider)
ApplySnapshot(provider() ?? UiRadarSnapshot.Empty);
}
protected override void OnTick(double deltaSeconds)
{
if (SnapshotProvider is null)
return;
_refreshAccumulator += deltaSeconds;
if (_refreshAccumulator < RetailRefreshSeconds)
return;
// Retail schedules the next update from Timer::cur_time rather than replaying missed
// ticks. Keep only the sub-interval remainder after a long frame.
_refreshAccumulator %= RetailRefreshSeconds;
Refresh();
}
protected override bool OnHitTest(float localX, float localY)
{
bool inside = base.OnHitTest(localX, localY);
if (!inside)
{
SetHovered(null, null);
return false;
}
UpdateHoveredBlip(localX, localY);
return true;
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.HoverLeave)
{
SetHovered(null, null);
return false;
}
if (e.Type == UiEventType.Click && _hoveredObjectId is uint guid)
{
SelectObject?.Invoke(guid);
return SelectObject is not null;
}
return false;
}
public override string? GetTooltipText() => _hoveredObjectName;
protected override void OnDrawAfterChildren(UiRenderContext ctx)
{
// Retail order is UIRegion::DrawChildren -> DrawObjects -> fixed bright-green player
// marker (gmRadarUI::DrawChildren 0x004D9720). OnDrawAfterChildren gives the retained
// tree that exact composition order without reimplementing any static DAT sprite.
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
DrawBlip(ctx, _snapshot.Blips[i]);
}
int cx = RoundPixel(Center.X);
int cy = RoundPixel(Center.Y);
DrawPixels(ctx, cx, cy, PlayerMarkerColor, RetailRadar.PlayerMarkerPixels);
}
private void UpdateHoveredBlip(float localX, float localY)
{
uint? nearestId = null;
string? nearestName = null;
float nearestDistanceSquared = HoverRadiusPixels * HoverRadiusPixels;
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
{
var blip = _snapshot.Blips[i];
float dx = localX - blip.PixelX;
float dy = localY - blip.PixelY;
float distanceSquared = dx * dx + dy * dy;
if (distanceSquared <= nearestDistanceSquared)
{
nearestDistanceSquared = distanceSquared;
nearestId = blip.ObjectId;
nearestName = blip.Name;
}
}
}
SetHovered(nearestId, nearestName);
}
private void SetHovered(uint? id, string? name)
{
if (_hoveredObjectId == id && _hoveredObjectName == name)
return;
_hoveredObjectId = id;
_hoveredObjectName = name;
HoveredObjectChanged?.Invoke(id);
}
private static void DrawBlip(UiRenderContext ctx, in UiRadarBlip blip)
{
int x = RoundPixel(blip.PixelX);
int y = RoundPixel(blip.PixelY);
DrawPixels(ctx, x, y, blip.Color, RetailRadar.GetBlipPixels(blip.Shape));
if (blip.Selected)
DrawPixels(ctx, x, y, blip.Color, RetailRadar.SelectionPixels);
}
private static void DrawPixels(
UiRenderContext ctx,
int centerX,
int centerY,
Vector4 color,
ReadOnlySpan<RadarPixelOffset> offsets)
{
for (int i = 0; i < offsets.Length; i++)
ctx.DrawFill(centerX + offsets[i].X, centerY + offsets[i].Y, 1, 1, color);
}
private static int RoundPixel(float value)
=> checked((int)MathF.Round(value, MidpointRounding.ToEven));
}

View file

@ -68,6 +68,10 @@ public sealed class UiRoot : UiElement
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary>
public bool UiLocked { get; set; }
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
@ -79,7 +83,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
return window is { Resizable: true }
return !UiLocked && window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None;
}
@ -90,7 +94,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
if (target is null || window is not { Draggable: true })
if (UiLocked || target is null || window is not { Draggable: true })
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
@ -141,6 +145,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
/// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -221,8 +228,16 @@ public sealed class UiRoot : UiElement
// Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null)
{
_windowDragTarget.Left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY;
float left = x - _windowDragOffX;
float top = y - _windowDragOffY;
if (_windowDragTarget.ConstrainDragToParent
&& _windowDragTarget.Parent is { } parent)
{
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - _windowDragTarget.Width));
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - _windowDragTarget.Height));
}
_windowDragTarget.Left = left;
_windowDragTarget.Top = top;
return;
}
@ -285,7 +300,7 @@ public sealed class UiRoot : UiElement
var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window);
if (btn == UiMouseButton.Left && window is not null)
if (btn == UiMouseButton.Left && window is not null && !UiLocked)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
@ -370,8 +385,15 @@ public sealed class UiRoot : UiElement
if (_windowDragTarget is not null)
{
var movedWindow = _windowDragTarget;
_windowDragTarget = null;
ReleaseCapture();
foreach (var pair in _windows)
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
return;
}

View file

@ -9,4 +9,5 @@ public static class WindowNames
public const string Character = "character";
public const string Inventory = "inventory";
public const string Chat = "chat";
public const string Radar = "radar";
}