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

@ -565,6 +565,11 @@ public sealed class GameWindow : IDisposable
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
// B.7 (2026-05-15): Vivid Target Indicator — four corner triangles
// around the selected entity, colour-coded by ItemType + PWD bits.
// Lives alongside the debug panels; cheap to construct + ignore
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
@ -1150,6 +1155,41 @@ public sealed class GameWindow : IDisposable
_imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
_panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// B.7 Vivid Target Indicator — corner-triangle highlight
// around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame:
// - selected guid → _selectedGuid (set by PickAndStoreSelection)
// - entity resolver → position from _entitiesByServerGuid +
// itemType / PWD bits from cached LiveEntityInfo + last spawn
// - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0.
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
selectedGuidProvider: () => _selectedGuid,
entityResolver: guid =>
{
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
return null;
uint rawItemType = 0;
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info))
rawItemType = (uint)info.ItemType;
uint pwdBits = 0;
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)
&& spawn.ObjectDescriptionFlags is { } odf)
pwdBits = odf;
return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
entity.Position, rawItemType, pwdBits);
},
cameraProvider: () =>
{
if (_cameraController is null || _window is null)
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
var cam = _cameraController.Active;
return (cam.View, cam.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
});
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
@ -7043,6 +7083,11 @@ public sealed class GameWindow : IDisposable
}
_panelHost.RenderAll(ctx);
// B.7 Vivid Target Indicator: draws corner triangles to the
// ImGui background draw list so it appears behind any docked
// panels but still over the 3D scene. Cheap when no
// selection — internal early-return on null guid.
_targetIndicator?.Render();
_imguiBootstrap.Render();
}

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