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
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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue