diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bc81639..c5a4b5c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); } diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs new file mode 100644 index 0000000..25a74ed --- /dev/null +++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs @@ -0,0 +1,142 @@ +using System; +using System.Numerics; +using AcDream.Core.Ui; +using ImGuiNET; + +namespace AcDream.App.UI; + +/// +/// 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 VividTargetIndicator +/// (named decomp at 0x004d6165 / 0x004f5ce0). +/// +/// +/// 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. +/// +/// +/// +/// The panel pulls its inputs through delegates supplied by the host +/// () so it doesn't have to depend +/// on internal state types: +/// +/// +/// selectedGuidProvider — host's current _selectedGuid. +/// entityResolver — returns +/// for a given guid, or null if +/// the entity is no longer in the world (despawned). +/// cameraProvider — host's active camera + viewport +/// dimensions; called once per frame. +/// +/// +public sealed class TargetIndicatorPanel +{ + /// + /// What the panel needs to know about the selected entity per frame. + /// ItemType + ObjectDescriptionFlags feed + /// for colour selection. + /// + public readonly record struct TargetInfo( + Vector3 WorldPosition, + uint ItemType, + uint ObjectDescriptionFlags); + + private readonly Func _selectedGuidProvider; + private readonly Func _entityResolver; + private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider; + + /// + /// 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. + /// + public float HalfExtent { get; set; } = 24f; + + /// + /// Pixel size of each corner triangle's right-angle legs. + /// + public float TriangleSize { get; set; } = 8f; + + /// + /// 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. + /// + public float WorldVerticalOffset { get; set; } = 0.9f; + + public TargetIndicatorPanel( + Func selectedGuidProvider, + Func entityResolver, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> cameraProvider) + { + _selectedGuidProvider = selectedGuidProvider; + _entityResolver = entityResolver; + _cameraProvider = cameraProvider; + } + + /// + /// 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. + /// + 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; + } +}