diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs index 25a74ed..18bc025 100644 --- a/src/AcDream.App/UI/TargetIndicatorPanel.cs +++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs @@ -48,25 +48,33 @@ public sealed class TargetIndicatorPanel 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; + public float TriangleSize { get; set; } = 10f; /// - /// 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. + /// World-space height of the indicator box. The panel projects feet + /// (at WorldPosition) and head (at WorldPosition.Z + + /// EntityHeight) to screen space and uses the projected pixel + /// distance as the box height. 1.8 m matches a standing humanoid; + /// short items still get a small box because the projection + /// preserves apparent size. /// - public float WorldVerticalOffset { get; set; } = 0.9f; + public float EntityHeight { get; set; } = 1.8f; + + /// + /// Box width = projected height × + /// . Humanoids are about half as + /// wide as tall on screen, so 0.5 is a sensible default. + /// + public float WidthHeightRatio { get; set; } = 0.5f; + + /// + /// Floor for the projected screen height (pixels). Prevents the + /// indicator from collapsing to a point on far-away entities. + /// + public float MinScreenHeight { get; set; } = 16f; public TargetIndicatorPanel( Func selectedGuidProvider, @@ -92,36 +100,46 @@ public sealed class TargetIndicatorPanel 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( + var viewProj = view * projection; + + // Project the entity's feet and head to screen space. Apparent + // pixel height (= |headY - feetY|) gives the box height; halve + // it for width. Closer entity → bigger projected height → bigger + // box. Distance scaling is automatic from the perspective + // projection. + if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen)) + return; + var headWorld = new Vector3( 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 + info.WorldPosition.Z + EntityHeight); + if (!TryProjectToScreen(headWorld, viewProj, viewport, out var headScreen)) + return; - // 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. + // Apparent height + width. + float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y); + if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight; + float screenWidth = screenHeight * WidthHeightRatio; - // 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; + // Box center = midpoint of feet/head projection. Use the X from + // the head projection only (feet may project to a slightly + // different X if the camera looks down at an angle; midpoint is + // a stable centre regardless). + Vector2 center = (feetScreen + headScreen) * 0.5f; + + float halfW = screenWidth * 0.5f; + float halfH = screenHeight * 0.5f; + + Vector2 tl = new(center.X - halfW, center.Y - halfH); + Vector2 tr = new(center.X + halfW, center.Y - halfH); + Vector2 br = new(center.X + halfW, center.Y + halfH); + Vector2 bl = new(center.X - halfW, center.Y + halfH); 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 @@ -134,6 +152,38 @@ public sealed class TargetIndicatorPanel drawList.AddTriangleFilled(bl, bl + new Vector2( t, 0), bl + new Vector2(0, -t), col); } + /// + /// Project a world-space point to screen-space pixels. Returns + /// false if the point is behind the camera or outside the + /// expanded viewport (±20 % margin so a tall entity whose feet are + /// just off the bottom of the screen still gets its head projected). + /// + private static bool TryProjectToScreen( + Vector3 world, + Matrix4x4 viewProj, + Vector2 viewport, + out Vector2 screen) + { + var clip = Vector4.Transform(new Vector4(world, 1f), viewProj); + if (clip.W <= 0.001f) + { + screen = Vector2.Zero; + return false; + } + float ndcX = clip.X / clip.W; + float ndcY = clip.Y / clip.W; + const float margin = 1.2f; + if (ndcX < -margin || ndcX > margin || ndcY < -margin || ndcY > margin) + { + screen = Vector2.Zero; + return false; + } + screen = new Vector2( + (ndcX * 0.5f + 0.5f) * viewport.X, + (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y); + return true; + } + private static uint MakeImGuiColor(RadarBlipColors.Rgba c) { // ImGui packed colour is 0xAABBGGRR (little-endian RGBA).