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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,7 +187,13 @@ public static class CreateObject
|
|||
uint? Priority = null,
|
||||
int? Structure = null,
|
||||
int? MaxStructure = null,
|
||||
float? Workmanship = null);
|
||||
float? Workmanship = null,
|
||||
// PublicWeenieDesc optional-tail bytes (_blipColor/_radar_enum at
|
||||
// acclient.h:37191-37192; retail UnPack 0x005AD470 reads both as u8).
|
||||
// Nullable preserves the wire distinction between an absent flag and
|
||||
// an explicitly transmitted zero (the enum's undefined/default value).
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -720,8 +726,8 @@ public static class CreateObject
|
|||
// 0x00010000 ValidLocations u32 (skip)
|
||||
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
|
||||
// 0x00040000 Priority u32 (skip)
|
||||
// 0x00100000 RadarBlipColor u8 (skip)
|
||||
// 0x00800000 RadarBehavior u8 (skip)
|
||||
// 0x00100000 RadarBlipColor u8 CAPTURE
|
||||
// 0x00800000 RadarBehavior u8 CAPTURE
|
||||
// 0x08000000 PScript u16 (skip)
|
||||
// 0x01000000 Workmanship f32 (skip)
|
||||
// 0x00200000 Burden u16 (skip)
|
||||
|
|
@ -742,6 +748,8 @@ public static class CreateObject
|
|||
uint? useability = null;
|
||||
float? useRadius = null;
|
||||
uint? targetType = null;
|
||||
byte? radarBlipColor = null;
|
||||
byte? radarBehavior = null;
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
|
|
@ -864,12 +872,12 @@ public static class CreateObject
|
|||
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
|
||||
pos += 1;
|
||||
radarBlipColor = body[pos]; pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
|
||||
pos += 1;
|
||||
radarBehavior = body[pos]; pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
|
||||
{
|
||||
|
|
@ -957,7 +965,8 @@ public static class CreateObject
|
|||
ContainerId: wContainerId, WielderId: wWielderId,
|
||||
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
||||
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
||||
Workmanship: wWorkmanship);
|
||||
Workmanship: wWorkmanship,
|
||||
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
|
|
|
|||
|
|
@ -83,5 +83,7 @@ public static class ObjectTableWiring
|
|||
MaxStructure: s.MaxStructure,
|
||||
Workmanship: s.Workmanship,
|
||||
Useability: s.Useability,
|
||||
TargetType: s.TargetType);
|
||||
TargetType: s.TargetType,
|
||||
RadarBlipColor: s.RadarBlipColor,
|
||||
RadarBehavior: s.RadarBehavior);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,63 @@ public sealed class WorldSession : IDisposable
|
|||
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
|
||||
ushort InstanceSequence = 0,
|
||||
ushort MovementSequence = 0,
|
||||
ushort ServerControlSequence = 0);
|
||||
ushort ServerControlSequence = 0,
|
||||
// PublicWeenieDesc optional-tail bytes. null means the corresponding
|
||||
// flag was absent; zero means the server explicitly sent the enum's
|
||||
// undefined/default value.
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire-level CreateObject result into the stable session
|
||||
/// event payload. Kept as a focused seam so every optional field's
|
||||
/// absent-versus-explicit-default semantics can be tested without a UDP
|
||||
/// session.
|
||||
/// </summary>
|
||||
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
|
||||
parsed.Guid,
|
||||
parsed.Position,
|
||||
parsed.SetupTableId,
|
||||
parsed.AnimPartChanges,
|
||||
parsed.TextureChanges,
|
||||
parsed.SubPalettes,
|
||||
parsed.BasePaletteId,
|
||||
parsed.ObjScale,
|
||||
parsed.Name,
|
||||
parsed.ItemType,
|
||||
parsed.MotionState,
|
||||
parsed.MotionTableId,
|
||||
parsed.PhysicsState,
|
||||
parsed.ObjectDescriptionFlags,
|
||||
parsed.Friction,
|
||||
parsed.Elasticity,
|
||||
parsed.Useability,
|
||||
parsed.UseRadius,
|
||||
parsed.TargetType,
|
||||
parsed.IconId,
|
||||
parsed.IconOverlayId,
|
||||
parsed.IconUnderlayId,
|
||||
parsed.UiEffects,
|
||||
parsed.WeenieClassId,
|
||||
parsed.Value,
|
||||
parsed.StackSize,
|
||||
parsed.StackSizeMax,
|
||||
parsed.Burden,
|
||||
parsed.ItemsCapacity,
|
||||
parsed.ContainersCapacity,
|
||||
parsed.ContainerId,
|
||||
parsed.WielderId,
|
||||
parsed.ValidLocations,
|
||||
parsed.CurrentWieldedLocation,
|
||||
parsed.Priority,
|
||||
parsed.Structure,
|
||||
parsed.MaxStructure,
|
||||
parsed.Workmanship,
|
||||
InstanceSequence: parsed.InstanceSequence,
|
||||
MovementSequence: parsed.MovementSequence,
|
||||
ServerControlSequence: parsed.ServerControlSequence,
|
||||
RadarBlipColor: parsed.RadarBlipColor,
|
||||
RadarBehavior: parsed.RadarBehavior);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
@ -799,48 +855,7 @@ public sealed class WorldSession : IDisposable
|
|||
_forcePositionSequence = parsed.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
EntitySpawned?.Invoke(new EntitySpawn(
|
||||
parsed.Value.Guid,
|
||||
parsed.Value.Position,
|
||||
parsed.Value.SetupTableId,
|
||||
parsed.Value.AnimPartChanges,
|
||||
parsed.Value.TextureChanges,
|
||||
parsed.Value.SubPalettes,
|
||||
parsed.Value.BasePaletteId,
|
||||
parsed.Value.ObjScale,
|
||||
parsed.Value.Name,
|
||||
parsed.Value.ItemType,
|
||||
parsed.Value.MotionState,
|
||||
parsed.Value.MotionTableId,
|
||||
parsed.Value.PhysicsState,
|
||||
parsed.Value.ObjectDescriptionFlags,
|
||||
parsed.Value.Friction,
|
||||
parsed.Value.Elasticity,
|
||||
parsed.Value.Useability,
|
||||
parsed.Value.UseRadius,
|
||||
parsed.Value.TargetType,
|
||||
parsed.Value.IconId,
|
||||
parsed.Value.IconOverlayId,
|
||||
parsed.Value.IconUnderlayId,
|
||||
parsed.Value.UiEffects,
|
||||
parsed.Value.WeenieClassId,
|
||||
parsed.Value.Value,
|
||||
parsed.Value.StackSize,
|
||||
parsed.Value.StackSizeMax,
|
||||
parsed.Value.Burden,
|
||||
parsed.Value.ItemsCapacity,
|
||||
parsed.Value.ContainersCapacity,
|
||||
parsed.Value.ContainerId,
|
||||
parsed.Value.WielderId,
|
||||
parsed.Value.ValidLocations,
|
||||
parsed.Value.CurrentWieldedLocation,
|
||||
parsed.Value.Priority,
|
||||
parsed.Value.Structure,
|
||||
parsed.Value.MaxStructure,
|
||||
parsed.Value.Workmanship,
|
||||
InstanceSequence: parsed.Value.InstanceSequence,
|
||||
MovementSequence: parsed.Value.MovementSequence,
|
||||
ServerControlSequence: parsed.Value.ServerControlSequence));
|
||||
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
|
||||
}
|
||||
}
|
||||
else if (op == DeleteObject.Opcode)
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ public sealed class ClientObject
|
|||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||||
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||||
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||||
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
|
||||
public byte? RadarBlipColor { get; set; }
|
||||
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
|
||||
public byte? RadarBehavior { get; set; }
|
||||
public int Structure { get; set; } // charges/uses remaining
|
||||
public int MaxStructure { get; set; }
|
||||
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
||||
|
|
@ -217,7 +221,9 @@ public readonly record struct WeenieData(
|
|||
int? MaxStructure,
|
||||
float? Workmanship,
|
||||
uint? Useability = null,
|
||||
uint? TargetType = null);
|
||||
uint? TargetType = null,
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ public sealed class ClientObjectTable
|
|||
if (d.Priority is { } pr) obj.Priority = pr;
|
||||
if (d.Useability is { } use) obj.Useability = use;
|
||||
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
|
||||
if (d.Structure is { } st) obj.Structure = st;
|
||||
|
|
|
|||
|
|
@ -3,91 +3,190 @@ using AcDream.Core.Items;
|
|||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// B.7 (2026-05-15) — port of retail's <c>gmRadarUI::GetBlipColor</c>
|
||||
/// (named decomp <c>0x004d76f0</c>). Returns the radar / target-indicator
|
||||
/// colour for an entity based on its <see cref="ItemType"/> and the
|
||||
/// raw <c>PublicWeenieDesc._bitfield</c> we already parse out of
|
||||
/// <c>CreateObject</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Dispatch order matches the retail decomp at lines 219913+ of
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>. The
|
||||
/// PWD bit layout matches <c>acclient.h:6431-6463</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>BF_PLAYER = 0x8</c></item>
|
||||
/// <item><c>BF_PLAYER_KILLER = 0x20</c></item>
|
||||
/// <item><c>BF_VENDOR = 0x200</c> (byte[1] & 0x02 in retail)</item>
|
||||
/// <item><c>BF_PORTAL = 0x40000</c></item>
|
||||
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (hostile-flagged player)</item>
|
||||
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>RGBA values</b> are hand-tuned to visually match retail screenshots
|
||||
/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal,
|
||||
/// white default). Real <c>RGBAColor_Radar*</c> constants live in retail
|
||||
/// static data — if they're ever recovered the table can be tightened.
|
||||
/// </para>
|
||||
/// Facts used by retail's radar color/shape classification. Relationship state is
|
||||
/// intentionally separate because fellowship and allegiance membership do not come
|
||||
/// from <c>PublicWeenieDesc</c>.
|
||||
/// </summary>
|
||||
public readonly record struct RadarObjectTraits(
|
||||
bool IsValid = true,
|
||||
byte BlipColorOverride = 0,
|
||||
bool IsPortal = false,
|
||||
bool IsVendor = false,
|
||||
bool IsAttackable = false,
|
||||
bool IsCreature = false,
|
||||
bool IsPlayer = false,
|
||||
bool IsAdmin = false,
|
||||
bool IsHiddenAdmin = false,
|
||||
bool IsPlayerKiller = false,
|
||||
bool IsPkLite = false,
|
||||
bool IsFreePk = false)
|
||||
{
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Build the subset of retail traits present in a CreateObject PWD. Fellowship
|
||||
/// and allegiance state must still be supplied separately.
|
||||
/// </summary>
|
||||
public static RadarObjectTraits FromPublicWeenieDescription(
|
||||
uint itemType,
|
||||
uint bitfield,
|
||||
byte blipColorOverride = 0)
|
||||
=> new(
|
||||
IsValid: (bitfield & 0x80000000u) == 0,
|
||||
BlipColorOverride: blipColorOverride,
|
||||
IsPortal: (bitfield & BfPortal) != 0,
|
||||
IsVendor: (bitfield & BfVendor) != 0,
|
||||
IsAttackable: (bitfield & BfAttackable) != 0,
|
||||
IsCreature: (itemType & (uint)ItemType.Creature) != 0,
|
||||
IsPlayer: (bitfield & BfPlayer) != 0,
|
||||
IsAdmin: (bitfield & BfAdmin) != 0,
|
||||
IsHiddenAdmin: (bitfield & BfHiddenAdmin) != 0,
|
||||
IsPlayerKiller: (bitfield & BfPlayerKiller) != 0,
|
||||
IsPkLite: (bitfield & BfPkLite) != 0,
|
||||
IsFreePk: (bitfield & BfFreePk) != 0);
|
||||
}
|
||||
|
||||
/// <summary>Relationship facts owned by the client player/fellowship systems.</summary>
|
||||
public readonly record struct RadarRelationshipTraits(
|
||||
bool IsFellowshipMember = false,
|
||||
bool IsFellowshipLeader = false,
|
||||
bool IsAllegianceMember = false,
|
||||
bool PlayerIsPlayerKiller = false,
|
||||
bool PlayerIsPkLite = false);
|
||||
|
||||
/// <summary>
|
||||
/// Exact retail radar palette and <c>gmRadarUI::GetBlipColor</c> dispatch.
|
||||
/// Named retail anchors: <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c> and
|
||||
/// <c>RGBAColor_Radar* @ 0x008190E8</c>.
|
||||
/// </summary>
|
||||
public static class RadarBlipColors
|
||||
{
|
||||
public readonly record struct Rgba(byte R, byte G, byte B, byte A);
|
||||
/// <summary>
|
||||
/// Retail stores radar colors as four floats. The full names expose those exact
|
||||
/// channels; R/G/B/A are compatibility RGBA8 projections for older UI callers.
|
||||
/// </summary>
|
||||
public readonly record struct Rgba(float Red, float Green, float Blue, float Alpha)
|
||||
{
|
||||
public byte R => ToByte(Red);
|
||||
public byte G => ToByte(Green);
|
||||
public byte B => ToByte(Blue);
|
||||
public byte A => ToByte(Alpha);
|
||||
|
||||
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)
|
||||
public Rgba DimRgb(float multiplier)
|
||||
=> new(Red * multiplier, Green * multiplier, Blue * multiplier, Alpha);
|
||||
|
||||
/// <summary>ImGui/OpenGL-style packed <c>0xAABBGGRR</c> projection.</summary>
|
||||
public uint ToAbgr32()
|
||||
=> ((uint)A << 24) | ((uint)B << 16) | ((uint)G << 8) | R;
|
||||
|
||||
private static byte ToByte(float value)
|
||||
=> (byte)Math.Clamp((int)MathF.Round(value * 255f), 0, 255);
|
||||
}
|
||||
|
||||
// Verbatim float constants at acclient.exe 0x008190E8..0x00819184.
|
||||
public static readonly Rgba Blue = new(0.25f, 0.660000026f, 1f, 1f);
|
||||
public static readonly Rgba Gold = new(1f, 0.670000017f, 0f, 1f);
|
||||
public static readonly Rgba Yellow = new(1f, 1f, 0.5f, 1f);
|
||||
public static readonly Rgba White = new(1f, 1f, 1f, 1f);
|
||||
public static readonly Rgba Red = new(1f, 0.25f, 0.389999986f, 1f);
|
||||
public static readonly Rgba Purple = new(0.75f, 0.389999986f, 1f, 1f);
|
||||
public static readonly Rgba Pink = new(1f, 0.660000026f, 0.75f, 1f);
|
||||
public static readonly Rgba Green = new(0f, 0.5f, 0.25f, 1f);
|
||||
public static readonly Rgba Cyan = new(0f, 1f, 1f, 1f);
|
||||
public static readonly Rgba BrightGreen = new(0f, 1f, 0f, 1f);
|
||||
|
||||
// Retail role aliases initialized at 0x006EC780..0x006F90E7.
|
||||
public static readonly Rgba Default = White;
|
||||
public static readonly Rgba Item = White;
|
||||
public static readonly Rgba Admin = Cyan;
|
||||
public static readonly Rgba Advocate = Pink;
|
||||
public static readonly Rgba Creature = Gold;
|
||||
public static readonly Rgba LifeStone = Blue;
|
||||
public static readonly Rgba NPC = Yellow;
|
||||
public static readonly Rgba PlayerKiller = Red;
|
||||
public static readonly Rgba Portal = Purple;
|
||||
public static readonly Rgba Sentinel = Cyan;
|
||||
public static readonly Rgba Vendor = Yellow;
|
||||
public static readonly Rgba Fellowship = BrightGreen;
|
||||
public static readonly Rgba FellowshipLeader = BrightGreen;
|
||||
public static readonly Rgba PKLite = Pink;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the radar-blip colour for an entity. Caller supplies the
|
||||
/// raw <see cref="ItemType"/> (from <c>CreateObject.ItemType</c>) and
|
||||
/// <paramref name="pwdBitfield"/> (from
|
||||
/// <c>CreateObject.ObjectDescriptionFlags</c>) — both are already
|
||||
/// parsed and stashed on <c>EntitySpawn</c> at spawn time.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <see cref="Default"/> for friendly players, <see cref="Creature"/>
|
||||
/// for NPCs/monsters, <see cref="Item"/> for everything else.
|
||||
/// Special types (PK, PKLite, Vendor, Portal) win over the base
|
||||
/// type when their flag is set.
|
||||
/// </para>
|
||||
/// Compatibility entry point for metadata already surfaced by CreateObject.
|
||||
/// Classification which requires fellowship state is available through the
|
||||
/// explicit-traits overload.
|
||||
/// </summary>
|
||||
public static Rgba For(uint itemType, uint pwdBitfield)
|
||||
=> For(RadarObjectTraits.FromPublicWeenieDescription(itemType, pwdBitfield));
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c>. An explicit wire
|
||||
/// override wins; otherwise portal/vendor/creature/player classification runs
|
||||
/// in retail order, followed by the fellowship override.
|
||||
/// </summary>
|
||||
public static Rgba For(
|
||||
RadarObjectTraits traits,
|
||||
RadarRelationshipTraits relationship = default)
|
||||
{
|
||||
// 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;
|
||||
if (!traits.IsValid)
|
||||
return Default;
|
||||
|
||||
bool isCreature = (itemType & (uint)ItemType.Creature) != 0;
|
||||
bool isPlayer = (pwdBitfield & 0x8u) != 0;
|
||||
if (traits.BlipColorOverride != 0)
|
||||
return ForOverride(traits.BlipColorOverride);
|
||||
|
||||
// Creature that isn't a player → NPC / monster → yellow.
|
||||
if (isCreature && !isPlayer)
|
||||
if (traits.IsPortal)
|
||||
return Portal;
|
||||
|
||||
if (traits.IsVendor)
|
||||
return Vendor;
|
||||
|
||||
if (traits.IsAttackable && traits.IsCreature && !traits.IsPlayer)
|
||||
return Creature;
|
||||
|
||||
if (isPlayer)
|
||||
{
|
||||
bool isPK = (pwdBitfield & 0x20u) != 0;
|
||||
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
|
||||
if (isPK) return PlayerKiller;
|
||||
if (isPKLite) return PKLite;
|
||||
if (!traits.IsPlayer)
|
||||
return Default;
|
||||
}
|
||||
|
||||
// Not a special type, not a creature, not a player → an item /
|
||||
// object on the ground or in the world.
|
||||
return Item;
|
||||
Rgba color = Default;
|
||||
if (traits.IsAdmin && !traits.IsHiddenAdmin)
|
||||
color = Admin;
|
||||
else if (traits.IsPlayerKiller)
|
||||
color = PlayerKiller;
|
||||
else if (traits.IsPkLite)
|
||||
color = PKLite;
|
||||
else if (traits.IsFreePk)
|
||||
color = Creature;
|
||||
|
||||
if (relationship.IsFellowshipLeader)
|
||||
color = FellowshipLeader;
|
||||
else if (relationship.IsFellowshipMember)
|
||||
color = Fellowship;
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
/// <summary>Retail PWD <c>_blipColor</c> override table (values 1..10).</summary>
|
||||
public static Rgba ForOverride(byte overrideValue)
|
||||
=> overrideValue switch
|
||||
{
|
||||
1 => Blue,
|
||||
2 => Gold,
|
||||
3 => White,
|
||||
4 => Purple,
|
||||
5 => Red,
|
||||
6 => Pink,
|
||||
7 => Green,
|
||||
8 => Yellow,
|
||||
9 => Cyan,
|
||||
10 => BrightGreen,
|
||||
_ => Default,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
275
src/AcDream.Core/Ui/RetailRadar.cs
Normal file
275
src/AcDream.Core/Ui/RetailRadar.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>Verbatim retail <c>RadarEnum</c> values from <c>acclient.h:6467</c>.</summary>
|
||||
public enum RadarBehavior : byte
|
||||
{
|
||||
Undef = 0,
|
||||
ShowNever = 1,
|
||||
ShowMovement = 2,
|
||||
ShowAttacking = 3,
|
||||
ShowAlways = 4,
|
||||
}
|
||||
|
||||
/// <summary>Verbatim retail <c>RadarBlipShape</c> values from <c>acclient.h:6575</c>.</summary>
|
||||
public enum RadarBlipShape
|
||||
{
|
||||
Undef = 0,
|
||||
Circle = 1,
|
||||
Box = 2,
|
||||
X = 3,
|
||||
Plus = 4,
|
||||
Triangle = 5,
|
||||
InvertedTriangle = 6,
|
||||
XBox = 7,
|
||||
|
||||
Default = Plus,
|
||||
AllegianceMember = Box,
|
||||
FellowshipLeader = Triangle,
|
||||
Fellowship = InvertedTriangle,
|
||||
Threat = X,
|
||||
ThreatAllegiance = XBox,
|
||||
}
|
||||
|
||||
public enum RadarCompassPoint
|
||||
{
|
||||
North,
|
||||
East,
|
||||
South,
|
||||
West,
|
||||
}
|
||||
|
||||
public readonly record struct RadarPixelOffset(int X, int Y);
|
||||
|
||||
public readonly record struct RadarProjection(Vector2 Pixel, float RgbMultiplier);
|
||||
|
||||
/// <summary>
|
||||
/// Pure retail radar behavior used by the retained UI. The caller supplies positions
|
||||
/// already converted to player space, matching retail's
|
||||
/// <c>SmartBox::convert_to_player_space</c> call.
|
||||
/// </summary>
|
||||
public static class RetailRadar
|
||||
{
|
||||
public const float OutdoorRangeMeters = 75f;
|
||||
public const float IndoorRangeMeters = 25f;
|
||||
public const float AltitudeDimThresholdMeters = 5f;
|
||||
public const float AltitudeDimMultiplier = 0.65f;
|
||||
public const float UpdateIntervalSeconds = 0.025f;
|
||||
|
||||
private static readonly RadarPixelOffset[] PointPixels = [new(0, 0)];
|
||||
private static readonly RadarPixelOffset[] EdgePixels = [new(0, -1), new(0, 1), new(-1, 0), new(1, 0)];
|
||||
private static readonly RadarPixelOffset[] CornerPixels = [new(1, 1), new(-1, -1), new(-1, 1), new(1, -1)];
|
||||
private static readonly RadarPixelOffset[] BoxPixels = [.. EdgePixels, .. CornerPixels];
|
||||
private static readonly RadarPixelOffset[] XPixels = [new(0, 0), .. CornerPixels];
|
||||
private static readonly RadarPixelOffset[] PlusPixels = [new(0, 0), .. EdgePixels];
|
||||
private static readonly RadarPixelOffset[] TrianglePixels = [new(0, 0), new(-1, 1), new(0, 1), new(1, 1)];
|
||||
private static readonly RadarPixelOffset[] InvertedTrianglePixels = [new(0, 0), new(-1, -1), new(0, -1), new(1, -1)];
|
||||
private static readonly RadarPixelOffset[] XBoxPixels =
|
||||
[
|
||||
.. EdgePixels,
|
||||
.. CornerPixels,
|
||||
new(-2, -2),
|
||||
new(2, -2),
|
||||
new(-2, 2),
|
||||
new(2, 2),
|
||||
];
|
||||
|
||||
private static readonly RadarPixelOffset[] PlayerMarkerPixelArray =
|
||||
[
|
||||
new(0, 0),
|
||||
.. EdgePixels,
|
||||
new(-2, 0),
|
||||
new(2, 0),
|
||||
new(0, -2),
|
||||
new(0, 2),
|
||||
];
|
||||
|
||||
private static readonly RadarPixelOffset[] SelectionPixelArray = BuildSelectionPixels();
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACCWeenieObject::InqShowableOnRadar @ 0x0058C250</c>. The enum names do
|
||||
/// not imply a live motion/attack test here: retail accepts all three values.
|
||||
/// </summary>
|
||||
public static bool IsShowable(RadarBehavior behavior, bool hasPhysicsObject)
|
||||
=> hasPhysicsObject && behavior is
|
||||
RadarBehavior.ShowMovement or RadarBehavior.ShowAttacking or RadarBehavior.ShowAlways;
|
||||
|
||||
/// <summary><c>gmRadarUI::GetBlipShape @ 0x004D7B60</c>.</summary>
|
||||
public static RadarBlipShape GetBlipShape(
|
||||
RadarObjectTraits target,
|
||||
RadarRelationshipTraits relationship = default)
|
||||
{
|
||||
if (!target.IsValid)
|
||||
return RadarBlipShape.Undef;
|
||||
|
||||
if (relationship.IsFellowshipLeader)
|
||||
return RadarBlipShape.FellowshipLeader;
|
||||
|
||||
if (relationship.IsFellowshipMember)
|
||||
return RadarBlipShape.Fellowship;
|
||||
|
||||
if (relationship.IsAllegianceMember)
|
||||
return RadarBlipShape.AllegianceMember;
|
||||
|
||||
if ((target.IsPlayerKiller && relationship.PlayerIsPlayerKiller) ||
|
||||
(target.IsPkLite && relationship.PlayerIsPkLite))
|
||||
{
|
||||
return RadarBlipShape.Threat;
|
||||
}
|
||||
|
||||
return RadarBlipShape.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact points filled by retail's DrawPoint/Edges/Corners/Cross/X/Triangle/
|
||||
/// InvertedTriangle/Hollow/XBox functions at <c>0x004D7C30..0x004D8531</c>.
|
||||
/// </summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> GetBlipPixels(RadarBlipShape shape)
|
||||
=> shape switch
|
||||
{
|
||||
RadarBlipShape.Circle => PointPixels,
|
||||
RadarBlipShape.Box => BoxPixels,
|
||||
RadarBlipShape.X => XPixels,
|
||||
RadarBlipShape.Plus => PlusPixels,
|
||||
RadarBlipShape.Triangle => TrianglePixels,
|
||||
RadarBlipShape.InvertedTriangle => InvertedTrianglePixels,
|
||||
RadarBlipShape.XBox => XBoxPixels,
|
||||
_ => [],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed bright-green player marker drawn by <c>gmRadarUI::DrawChildren
|
||||
/// @ 0x004D9720</c>: a plus with four extra points two pixels from center.
|
||||
/// </summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> PlayerMarkerPixels => PlayerMarkerPixelArray;
|
||||
|
||||
/// <summary>Selected-object outline from <c>gmRadarUI::DrawSelected @ 0x004D7FE0</c>.</summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> SelectionPixels => SelectionPixelArray;
|
||||
|
||||
public static float GetRangeMeters(bool isOutside)
|
||||
=> isOutside ? OutdoorRangeMeters : IndoorRangeMeters;
|
||||
|
||||
/// <summary>
|
||||
/// Project a player-local position to radar pixels exactly as
|
||||
/// <c>gmRadarUI::DrawObjects @ 0x004D9380</c>. Objects at or beyond
|
||||
/// <c>(range - 1 m)</c> are excluded. Positive player-space Y is screen-up.
|
||||
/// </summary>
|
||||
public static bool TryProject(
|
||||
Vector3 playerSpaceMeters,
|
||||
Vector2 centerPixels,
|
||||
int radarRadiusPixels,
|
||||
float radarRangeMeters,
|
||||
out RadarProjection projection)
|
||||
{
|
||||
float inclusionRadius = radarRangeMeters - 1f;
|
||||
float horizontalDistanceSquared =
|
||||
(playerSpaceMeters.X * playerSpaceMeters.X) +
|
||||
(playerSpaceMeters.Y * playerSpaceMeters.Y);
|
||||
|
||||
if (horizontalDistanceSquared >= inclusionRadius * inclusionRadius)
|
||||
{
|
||||
projection = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
float scale = radarRadiusPixels / radarRangeMeters;
|
||||
projection = new RadarProjection(
|
||||
new Vector2(
|
||||
(int)(centerPixels.X + (playerSpaceMeters.X * scale)),
|
||||
(int)(centerPixels.Y - (playerSpaceMeters.Y * scale))),
|
||||
GetAltitudeRgbMultiplier(playerSpaceMeters.Z));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>RGB is dimmed at exactly 5 m; alpha is left unchanged by the caller.</summary>
|
||||
public static float GetAltitudeRgbMultiplier(float playerSpaceZMeters)
|
||||
=> MathF.Abs(playerSpaceZMeters) < AltitudeDimThresholdMeters
|
||||
? 1f
|
||||
: AltitudeDimMultiplier;
|
||||
|
||||
/// <summary>
|
||||
/// Compute a compass token's top-left pixel from
|
||||
/// <c>gmRadarUI::UpdateCompassTokens @ 0x004D9060</c>. Heading is the physical
|
||||
/// player's heading in degrees, not camera yaw.
|
||||
/// </summary>
|
||||
public static Vector2 GetCompassTokenTopLeft(
|
||||
float playerHeadingDegrees,
|
||||
RadarCompassPoint point,
|
||||
Vector2 radarCenterPixels,
|
||||
float tokenMagnitudePixels,
|
||||
Vector2 tokenSizePixels)
|
||||
{
|
||||
// Retail stores headingRadians as float, then performs the trig on x87.
|
||||
// Preserve that float rounding while using double sin/cos like the x87 path.
|
||||
float headingRadians = playerHeadingDegrees * 0.0174532924f;
|
||||
double angle = headingRadians + (point switch
|
||||
{
|
||||
RadarCompassPoint.North => Math.PI,
|
||||
RadarCompassPoint.East => (double)1.57079637f,
|
||||
RadarCompassPoint.South => 0d,
|
||||
RadarCompassPoint.West => (double)4.71238899f,
|
||||
_ => 0d,
|
||||
});
|
||||
|
||||
float offsetX = (float)(Math.Sin(angle) * tokenMagnitudePixels);
|
||||
float tokenCenterX = (float)(offsetX + radarCenterPixels.X);
|
||||
float tokenCenterY = (float)((Math.Cos(angle) * tokenMagnitudePixels) + radarCenterPixels.Y);
|
||||
|
||||
return new Vector2(
|
||||
(int)(tokenCenterX - (tokenSizePixels.X * 0.5f)),
|
||||
(int)(tokenCenterY - (tokenSizePixels.Y * 0.5f)));
|
||||
}
|
||||
|
||||
private static RadarPixelOffset[] BuildSelectionPixels()
|
||||
{
|
||||
var pixels = new RadarPixelOffset[20];
|
||||
int index = 0;
|
||||
for (int x = -2; x <= 2; x++)
|
||||
{
|
||||
pixels[index++] = new RadarPixelOffset(x, 3);
|
||||
pixels[index++] = new RadarPixelOffset(x, -3);
|
||||
}
|
||||
|
||||
for (int y = -2; y <= 2; y++)
|
||||
{
|
||||
pixels[index++] = new RadarPixelOffset(3, y);
|
||||
pixels[index++] = new RadarPixelOffset(-3, y);
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail outdoor coordinate text derived from the player's landcell.</summary>
|
||||
public readonly record struct RadarCoordinates(double X, double Y)
|
||||
{
|
||||
public string XText => FormatAxis(X, "W", "E");
|
||||
public string YText => FormatAxis(Y, "S", "N");
|
||||
public string CombinedText => $"{YText},{XText}";
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>CPlayerSystem::InqPlayerCoords @ 0x00560090</c>. Indoor cells
|
||||
/// fail because retail obtains coordinates through <c>get_landscape_coord</c>.
|
||||
/// </summary>
|
||||
public static bool TryFromCell(uint cellId, out RadarCoordinates coordinates)
|
||||
{
|
||||
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
|
||||
{
|
||||
coordinates = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
coordinates = new RadarCoordinates(
|
||||
((lx - 1024) * 0.1) + 0.5,
|
||||
((ly - 1024) * 0.1) + 0.5);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatAxis(double value, string negativeSuffix, string positiveSuffix)
|
||||
{
|
||||
string suffix = value < 0d ? negativeSuffix : value > 0d ? positiveSuffix : string.Empty;
|
||||
return $"{Math.Abs(value).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}{suffix}";
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,9 @@ public sealed record GameplaySettings(
|
|||
ShowTooltips: true,
|
||||
VividTargetingIndicator: false,
|
||||
SideBySideVitals: false,
|
||||
CoordinatesOnRadar: false,
|
||||
// Retail default character-options mask 0x50C4A54A includes
|
||||
// CoordinatesOnRadar (0x00400000).
|
||||
CoordinatesOnRadar: true,
|
||||
SpellDuration: true,
|
||||
AllowGive: true,
|
||||
ShowHelm: true,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings;
|
|||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>Per-character retained-window position in screen pixels.</summary>
|
||||
public readonly record struct UiWindowPosition(float X, float Y);
|
||||
|
||||
/// <summary>
|
||||
/// JSON-backed persistence for non-keybind settings (Display today; future
|
||||
/// tabs Audio / Gameplay / Chat / Character will be added to the same
|
||||
|
|
@ -290,6 +293,70 @@ public sealed class SettingsStore
|
|||
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
/// <summary>Load a named retained-window position for one character.</summary>
|
||||
public UiWindowPosition? LoadWindowPosition(string toonKey, string windowName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
|
||||
var node = root?["windowPositions"]?[toonKey]?[windowName] as JsonObject;
|
||||
if (node is null) return null;
|
||||
float? x = node["x"]?.GetValue<float>();
|
||||
float? y = node["y"]?.GetValue<float>();
|
||||
return x is not null && y is not null ? new UiWindowPosition(x.Value, y.Value) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load window position from {_path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a named retained-window position under
|
||||
/// <c>windowPositions[toonKey][windowName]</c>, preserving all settings sections.
|
||||
/// </summary>
|
||||
public void SaveWindowPosition(string toonKey, string windowName, UiWindowPosition position)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
JsonObject root;
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
|
||||
catch { root = new JsonObject(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
root = new JsonObject();
|
||||
}
|
||||
|
||||
if (root["windowPositions"] is not JsonObject positions)
|
||||
{
|
||||
positions = new JsonObject();
|
||||
root["windowPositions"] = positions;
|
||||
}
|
||||
if (positions[toonKey] is not JsonObject toon)
|
||||
{
|
||||
toon = new JsonObject();
|
||||
positions[toonKey] = toon;
|
||||
}
|
||||
toon[windowName] = new JsonObject
|
||||
{
|
||||
["x"] = position.X,
|
||||
["y"] = position.Y,
|
||||
};
|
||||
root["version"] = CurrentSchemaVersion;
|
||||
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue