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. /// Scale multiplies the per-type base height in /// — a scaled-up sign or oversized NPC /// gets a proportionally bigger box. Useability (acclient.h:6478 /// ITEM_USEABLE enum) discriminates real pickup items /// (USEABLE_REMOTE bit set, 0.8 m boxes) from same-ItemType-but-non- /// useable scenery like signs (USEABLE_UNDEF, 3 m boxes). /// public readonly record struct TargetInfo( Vector3 WorldPosition, uint ItemType, uint ObjectDescriptionFlags, float Scale, uint? Useability = null, // 2026-05-16: world-space SelectionSphere center + radius. // Comes from the Setup's baked selection_sphere (acclient.h // CSetup::selection_sphere) scaled by entity scale. When // populated, the panel projects the sphere as a screen circle // and uses that as the indicator rect — matches retail // SmartBox::GetObjectBoundingBox (decomp 0x00452e20). When // null, the panel falls back to the per-type height heuristic. Vector3? WorldSphereCenter = null, float? WorldSphereRadius = null); private readonly Func _selectedGuidProvider; private readonly Func _entityResolver; private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> _cameraProvider; /// /// Pixel size of each corner triangle's right-angle legs. /// Retail uses UIRegion::GetWidth(m_rgOnScreenCorners.m_data[1]) /// of the triangle sprite (decomp 0x004f69c8). The retail /// sprite is small — ~8 px legs. 14 was too chunky per user /// feedback on 2026-05-16; 8 matches the retail screenshot. /// public float TriangleSize { get; set; } = 8f; /// /// World-space height of the indicator box for entities that don't /// have a more specific type tag. Items use a smaller value (see /// ). 1.8 m matches a standing humanoid; /// short items still get a small box because the projection /// preserves apparent size. /// public float EntityHeight { get; set; } = 1.8f; /// /// Defensive fallback height when the entity has no usable /// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection /// path active (since commit f4f4143), this fallback only fires /// for entities whose Setup didn't bake a selection sphere — /// rare in practice. The single 1.5 m × scale default is a sane /// midpoint; per-type branches were retired in the 2026-05-16 /// Commit B because the sphere path is authoritative. /// public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null) { if (scale <= 0f) scale = 1f; return 1.5f * scale; } /// /// Box width = projected height × /// . Retail's Vivid Target Indicator /// draws a square box — four corner triangles arranged in a square — /// so 1.0 = width matches height. The earlier 0.5 (humanoid-ish /// aspect) made the box uncomfortably narrow for non-humanoids. /// public float WidthHeightRatio { get; set; } = 1.0f; /// /// 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, 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; var viewProj = view * projection; Vector2 tl, tr, br, bl; if (info.WorldSphereCenter is Vector3 sphereCenter && info.WorldSphereRadius is float sphereRadius && AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect( sphereCenter, sphereRadius, view, projection, viewport, out var rMin, out var rMax, out _, minSidePixels: 12f)) { // 2026-05-16 — retail-faithful path per // SmartBox::GetObjectBoundingBox (decomp 0x00452e20). // Retail uses CPhysicsObj::GetSelectionSphere (the Setup's // baked selection_sphere) and produces the screen rect // from that sphere's projection — NOT from a per-mesh AABB. // // Retail INFLATES the rect by one triangle width/height on // every side before drawing (decomp 0x004f6a0b–0x004f6a99): // edi_3 = arg4->left - eax_21 (shift left by triangleW) // ebp_3 = arg4->top - eax_23 (shift up by triangleH) // width = sphere_width + 2 * triangleW // height = sphere_height + 2 * triangleH // So the four corner triangles sit OUTSIDE the projected // sphere by one triangle leg. float ts = TriangleSize; tl = new Vector2(rMin.X - ts, rMin.Y - ts); tr = new Vector2(rMax.X + ts, rMin.Y - ts); br = new Vector2(rMax.X + ts, rMax.Y + ts); bl = new Vector2(rMin.X - ts, rMax.Y + ts); } else { // Fallback when the AABB isn't available (no setup cached // yet, missing GfxObj bounds, behind the camera). Square // box centred at the entity origin, height from the // per-type heuristic. if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen)) return; float entityHeight = EntityHeightFor(info.ItemType, info.ObjectDescriptionFlags, info.Scale, info.Useability); var headWorld = new Vector3( info.WorldPosition.X, info.WorldPosition.Y, info.WorldPosition.Z + entityHeight); if (!TryProjectToScreen(headWorld, viewProj, viewport, out var headScreen)) return; float screenHeight = MathF.Abs(headScreen.Y - feetScreen.Y); if (screenHeight < MinScreenHeight) screenHeight = MinScreenHeight; float screenWidth = screenHeight * WidthHeightRatio; Vector2 center = (feetScreen + headScreen) * 0.5f; float halfW = screenWidth * 0.5f; float halfH = screenHeight * 0.5f; tl = new Vector2(center.X - halfW, center.Y - halfH); tr = new Vector2(center.X + halfW, center.Y - halfH); br = new Vector2(center.X + halfW, center.Y + halfH); bl = new Vector2(center.X - halfW, center.Y + halfH); } var rgba = RadarBlipColors.For(info.ItemType, info.ObjectDescriptionFlags); uint col = MakeImGuiColor(rgba); var drawList = ImGui.GetBackgroundDrawList(); float t = TriangleSize; // 2026-05-16 — flipped per user feedback. Each corner triangle's // RIGHT-ANGLE apex now points INWARD toward the target (was at // the outer corner pointing outward). Combined with the // TriangleSize inflate on the rect, the apex of each triangle // lands at the projected mesh boundary while the hypotenuse // runs across the outer (inflated) corner — giving the retail // "corner-tick pointing at the entity" look. // // Geometry per corner: // apex = corner + (±t, ±t) ← inward, right-angle here // leg_a end = corner + (±t, 0) ← along horizontal edge // leg_b end = corner + (0, ±t) ← along vertical edge // Hypotenuse runs from leg_a end to leg_b end (the outer // diagonal of the corner). drawList.AddTriangleFilled(tl + new Vector2( t, t), tl + new Vector2( t, 0), tl + new Vector2(0, t), col); drawList.AddTriangleFilled(tr + new Vector2(-t, t), tr + new Vector2(-t, 0), tr + new Vector2(0, t), col); drawList.AddTriangleFilled(br + new Vector2(-t, -t), br + new Vector2(-t, 0), br + new Vector2(0, -t), col); drawList.AddTriangleFilled(bl + new Vector2( t, -t), 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). return ((uint)c.A << 24) | ((uint)c.B << 16) | ((uint)c.G << 8) | c.R; } }