239 lines
8.2 KiB
C#
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));
|
|
}
|