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:
parent
8544a785d7
commit
c7e5f9f00f
2 changed files with 187 additions and 0 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
142
src/AcDream.App/UI/TargetIndicatorPanel.cs
Normal file
142
src/AcDream.App/UI/TargetIndicatorPanel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue