acdream/src/AcDream.App/UI/UiRadar.cs

239 lines
8.2 KiB
C#

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