feat(B.7): TargetIndicatorPanel — corner triangles around selected entity

Per the B.7 design spec, wires a Vivid-Target-Indicator-style overlay
into GameWindow's ImGui pass:

  TargetIndicatorPanel (src/AcDream.App/UI/TargetIndicatorPanel.cs)
    - Three delegates injected from GameWindow:
        selectedGuidProvider  -> _selectedGuid
        entityResolver        -> (worldPos, itemType, pwdBits) from
                                 _entitiesByServerGuid + _liveEntityInfoByGuid
                                 + _lastSpawnByGuid
        cameraProvider        -> (view, projection, viewport) from
                                 _cameraController.Active + _window.Size
    - Per-frame Render():
        * Bail on null selection / despawned entity / zero viewport.
        * Project entity world position (+0.9m mid-body offset) to NDC.
        * Bail off-screen (no edge arrow in MVP).
        * Convert to viewport pixel coords, draw 4 right-angle triangles
          at corners of a 48px square around the projected center.
        * Colour from RadarBlipColors.For(itemType, pwdBits).

  GameWindow wiring:
    - Construct _targetIndicator right after _panelHost during ImGui init.
    - Call _targetIndicator?.Render() between _panelHost.RenderAll and
      _imguiBootstrap.Render — draws to the ImGui background list so
      docked panels can occlude the indicator if they overlap.

Build green. Core.Tests went 1046 -> 1054 (+8 RadarBlipColors tests
from the prior commit). Baseline failures unchanged at 8.

Visual verification next: launch, click an NPC → yellow corners; click
an item -> white corners; deselect -> corners disappear.
This commit is contained in:
Erik 2026-05-15 06:54:24 +02:00
parent 8544a785d7
commit c7e5f9f00f
2 changed files with 187 additions and 0 deletions

View file

@ -0,0 +1,142 @@
using System;
using System.Numerics;
using AcDream.Core.Ui;
using ImGuiNET;
namespace AcDream.App.UI;
/// <summary>
/// B.7 (2026-05-15) — Vivid Target Indicator. Draws four small
/// corner triangles around the currently-selected entity, colour-coded
/// by entity type (NPCs yellow, items white-ish, PKs red, etc.).
/// Retail-faithful equivalent of <c>VividTargetIndicator</c>
/// (named decomp at <c>0x004d6165</c> / <c>0x004f5ce0</c>).
///
/// <para>
/// MVP scope: on-screen indicator only, drawn via ImGui's background
/// draw list. Deferred to follow-ups: off-screen edge arrow, DAT-loaded
/// triangle sprite, mesh-tint highlight, player-option toggle.
/// </para>
///
/// <para>
/// The panel pulls its inputs through delegates supplied by the host
/// (<see cref="Rendering.GameWindow"/>) so it doesn't have to depend
/// on internal state types:
/// </para>
/// <list type="bullet">
/// <item><c>selectedGuidProvider</c> — host's current <c>_selectedGuid</c>.</item>
/// <item><c>entityResolver</c> — returns
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
/// the entity is no longer in the world (despawned).</item>
/// <item><c>cameraProvider</c> — host's active camera + viewport
/// dimensions; called once per frame.</item>
/// </list>
/// </summary>
public sealed class TargetIndicatorPanel
{
/// <summary>
/// What the panel needs to know about the selected entity per frame.
/// <c>ItemType</c> + <c>ObjectDescriptionFlags</c> feed
/// <see cref="RadarBlipColors.For"/> for colour selection.
/// </summary>
public readonly record struct TargetInfo(
Vector3 WorldPosition,
uint ItemType,
uint ObjectDescriptionFlags);
private readonly Func<uint?> _selectedGuidProvider;
private readonly Func<uint, TargetInfo?> _entityResolver;
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider;
/// <summary>
/// Pixel half-extent of the bounding square. Triangles sit at the
/// four corners of a 2 × HalfExtent square centred on the entity.
/// Future: scale by apparent radius (project entity bounds + read
/// pixel size) instead of a fixed value.
/// </summary>
public float HalfExtent { get; set; } = 24f;
/// <summary>
/// Pixel size of each corner triangle's right-angle legs.
/// </summary>
public float TriangleSize { get; set; } = 8f;
/// <summary>
/// World-space vertical offset to place the indicator box around
/// the entity's torso/body rather than its feet. 0.9 m matches
/// rough humanoid mid-body height.
/// </summary>
public float WorldVerticalOffset { get; set; } = 0.9f;
public TargetIndicatorPanel(
Func<uint?> selectedGuidProvider,
Func<uint, TargetInfo?> entityResolver,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider)
{
_selectedGuidProvider = selectedGuidProvider;
_entityResolver = entityResolver;
_cameraProvider = cameraProvider;
}
/// <summary>
/// Per-frame render call. No-op if nothing is selected, the selected
/// entity is gone, or the entity is off-screen / behind the camera.
/// Draws to the ImGui background draw list so it appears behind
/// other panels.
/// </summary>
public void Render()
{
if (_selectedGuidProvider() is not uint guid) return;
if (_entityResolver(guid) is not TargetInfo info) return;
var (view, projection, viewport) = _cameraProvider();
if (viewport.X <= 0 || viewport.Y <= 0) return;
// Project world position (lifted to mid-body height) to clip space.
var worldPos = new Vector4(
info.WorldPosition.X,
info.WorldPosition.Y,
info.WorldPosition.Z + WorldVerticalOffset,
1f);
var clip = Vector4.Transform(worldPos, view * projection);
if (clip.W <= 0.001f) return; // behind / on the near plane
// NDC and viewport conversion.
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
if (ndcX < -1f || ndcX > 1f || ndcY < -1f || ndcY > 1f)
return; // off-screen — MVP doesn't render the edge arrow.
// GL NDC Y points UP; screen Y points DOWN. Flip Y.
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
var rgba = RadarBlipColors.For(info.ItemType, info.ObjectDescriptionFlags);
uint col = MakeImGuiColor(rgba);
var drawList = ImGui.GetBackgroundDrawList();
// Four corners of the bounding square.
Vector2 tl = new(screenX - HalfExtent, screenY - HalfExtent);
Vector2 tr = new(screenX + HalfExtent, screenY - HalfExtent);
Vector2 br = new(screenX + HalfExtent, screenY + HalfExtent);
Vector2 bl = new(screenX - HalfExtent, screenY + HalfExtent);
float t = TriangleSize;
// Each corner triangle: right-angle at the corner itself, legs
// pointing INTO the bounding square (toward the entity). Mirrors
// retail's selection bracket pose where the triangle "hugs"
// the corner of the rectangle.
drawList.AddTriangleFilled(tl, tl + new Vector2( t, 0), tl + new Vector2(0, t), col);
drawList.AddTriangleFilled(tr, tr + new Vector2(-t, 0), tr + new Vector2(0, t), col);
drawList.AddTriangleFilled(br, br + new Vector2(-t, 0), br + new Vector2(0, -t), col);
drawList.AddTriangleFilled(bl, bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
}
private static uint MakeImGuiColor(RadarBlipColors.Rgba c)
{
// ImGui packed colour is 0xAABBGGRR (little-endian RGBA).
return ((uint)c.A << 24) | ((uint)c.B << 16) | ((uint)c.G << 8) | c.R;
}
}