feat(ui): port retail radar and compass
This commit is contained in:
parent
c4af181b92
commit
3cbe4b00a1
43 changed files with 2882 additions and 262 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
239
src/AcDream.App/UI/Layout/RadarController.cs
Normal file
239
src/AcDream.App/UI/Layout/RadarController.cs
Normal 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);
|
||||
}
|
||||
149
src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs
Normal file
149
src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
239
src/AcDream.App/UI/UiRadar.cs
Normal file
239
src/AcDream.App/UI/UiRadar.cs
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue