using System.Numerics; using AcDream.Core.Selection; using AcDream.Core.Ui; using AcDream.Content; namespace AcDream.App.UI.Layout; public readonly record struct VividTargetInfo( Vector3 SelectionSphereCenter, float SelectionSphereRadius, uint ItemType, uint ObjectDescriptionFlags); public sealed record VividTargetRuntimeBindings( SelectionState Selection, Func PlayerGuid, Func Enabled, Func ResolveTarget, Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> Camera); /// /// Retained-UI port of VividTargetIndicator @ 0x004F5CE0..0x004F6DF6. /// Client-enum category 0x10000009 values 1..4 are the four on-screen corners; /// values 5..12 are retail's 24x24 off-screen direction images. SmartBox computes /// this presentation from the live object's selection sphere, independently of /// whether the world renderer drew or occluded the object. /// public sealed class VividTargetIndicatorController { private const uint ClientEnumCategory = 0x10000009u; private const uint FirstSourceImageEnum = 1u; private const int SourceImageCount = 12; private const float ViewportMargin = 8f; private readonly UiPanel _onScreen; private readonly UiTextureElement[] _corners; private readonly UiTextureElement _offScreen; private readonly VividTargetRuntimeBindings _bindings; private readonly VividTargetSource[] _sources; private readonly (float Width, float Height)[] _cornerSizes; private VividTargetIndicatorController( UiPanel onScreen, UiTextureElement[] corners, UiTextureElement offScreen, VividTargetSource[] sources, VividTargetRuntimeBindings bindings) { _onScreen = onScreen; _corners = corners; _offScreen = offScreen; _sources = sources; _cornerSizes = sources.Take(4) .Select(source => (source.Width, source.Height)) .ToArray(); _bindings = bindings; } public static VividTargetIndicatorController? Mount( UiRoot host, RetailUiAssets assets, VividTargetRuntimeBindings bindings) { var onScreen = new UiPanel { Name = "VividTargetIndicatorOnScreen", BackgroundColor = Vector4.Zero, BorderColor = Vector4.Zero, ClickThrough = true, Visible = false, ZOrder = -10_000, Anchors = AnchorEdges.None, }; var sources = new VividTargetSource[SourceImageCount]; for (uint i = 0; i < SourceImageCount; i++) { uint did; lock (assets.DatLock) did = RetailDataIdResolver.Resolve( assets.Dats, enumValue: FirstSourceImageEnum + i, enumCategory: ClientEnumCategory); if (did == 0u) return null; var resolved = assets.ResolveSprite(did); if (resolved.Texture == 0u || resolved.Width <= 0 || resolved.Height <= 0) return null; sources[i] = new VividTargetSource( resolved.Texture, resolved.Width, resolved.Height); } var corners = new UiTextureElement[4]; for (int i = 0; i < corners.Length; i++) { VividTargetSource source = sources[i]; corners[i] = new UiTextureElement { Name = $"VividTargetCorner{i + 1}", Texture = source.Texture, Width = source.Width, Height = source.Height, ClickThrough = true, Anchors = AnchorEdges.None, }; onScreen.AddChild(corners[i]); } var offScreen = new UiTextureElement { Name = "VividTargetIndicatorOffScreen", Visible = false, ClickThrough = true, ZOrder = -10_000, Anchors = AnchorEdges.None, }; host.AddChild(onScreen); host.AddChild(offScreen); return new VividTargetIndicatorController( onScreen, corners, offScreen, sources, bindings); } public void Tick() { if (!_bindings.Enabled() || _bindings.Selection.SelectedObjectId is not uint guid || guid == 0u || guid == _bindings.PlayerGuid() || _bindings.ResolveTarget(guid) is not VividTargetInfo target) { Hide(); return; } var camera = _bindings.Camera(); VividTargetProjection projection = ComputeProjection( target.SelectionSphereCenter, target.SelectionSphereRadius, camera.View, camera.Projection, camera.Viewport); if (projection.Status == VividTargetProjectionStatus.Invalid) { Hide(); return; } RadarBlipColors.Rgba color = RadarBlipColors.For( target.ItemType, target.ObjectDescriptionFlags); var tint = new Vector4(color.Red, color.Green, color.Blue, color.Alpha); if (projection.Status == VividTargetProjectionStatus.OnScreen) { VividTargetLayout layout = ComputeLayout( projection.RectMin, projection.RectMax, camera.Viewport, _cornerSizes); _onScreen.Left = layout.RootPosition.X; _onScreen.Top = layout.RootPosition.Y; _onScreen.Width = layout.RootSize.X; _onScreen.Height = layout.RootSize.Y; for (int i = 0; i < _corners.Length; i++) { SetCorner(i, layout.CornerPositions[i].X, layout.CornerPositions[i].Y); _corners[i].Tint = tint; } _offScreen.Visible = false; _onScreen.Visible = true; return; } uint imageEnum = SelectOffScreenImageEnum(projection.AngleDegrees); VividTargetSource offScreenSource = _sources[imageEnum - FirstSourceImageEnum]; Vector2 position = ComputeOffScreenPosition( projection.AngleDegrees, camera.Viewport, new Vector2(offScreenSource.Width, offScreenSource.Height)); _offScreen.Texture = offScreenSource.Texture; _offScreen.Left = position.X; _offScreen.Top = position.Y; _offScreen.Width = offScreenSource.Width; _offScreen.Height = offScreenSource.Height; _offScreen.Tint = tint; _onScreen.Visible = false; _offScreen.Visible = true; } private void Hide() { _onScreen.Visible = false; _offScreen.Visible = false; } private void SetCorner(int index, float left, float top) { _corners[index].Left = left; _corners[index].Top = top; } internal static VividTargetProjection ComputeProjection( Vector3 worldCenter, float worldRadius, Matrix4x4 view, Matrix4x4 projection, Vector2 viewport) { if (viewport.X <= 0f || viewport.Y <= 0f || !float.IsFinite(worldRadius) || worldRadius < 0f) return default; bool projected = ScreenProjection.TryProjectSphereToScreenRect( worldCenter, worldRadius, view, projection, viewport, out Vector2 rectMin, out Vector2 rectMax, out _, minSidePixels: 0f); // SmartBox::GetObjectBoundingBox @ 0x00452E20 calls // Render::set_default_view before viewconeCheck. The selected object is // therefore tested against the full viewport, not the portal/occlusion // traversal which happened to draw the world. if (projected && rectMax.X >= 0f && rectMin.X <= viewport.X && rectMax.Y >= 0f && rectMin.Y <= viewport.Y) { return new VividTargetProjection( VividTargetProjectionStatus.OnScreen, rectMin, rectMax, 0f); } Vector3 local = Vector3.Transform(worldCenter, view); float length = local.Length(); float angle = 0f; if (float.IsFinite(length) && length > 1e-6f) { // Retail viewer-local axes are x=screen-right, y=forward, // z=screen-up. System.Numerics view space is x=right, y=up, // -z=forward. When the target is behind the viewer, retail // deliberately flattens forward/up to zero so the arrow chooses // the nearest horizontal screen edge. float right = local.X / length; float forward = -local.Z / length; float up = local.Y / length; float radians = forward > 0f ? MathF.Atan2(right, up) : MathF.Atan2(right, 0f); angle = PositiveModulo( 450f - radians * (180f / MathF.PI), 360f); } return new VividTargetProjection( VividTargetProjectionStatus.OffScreen, default, default, angle); } internal static uint SelectOffScreenImageEnum(float angleDegrees) { float angle = PositiveModulo(angleDegrees, 360f); // VividTargetIndicator::OnDraw @ 0x004F62B0 uses eight 45-degree // sectors with integer boundaries centered on the cardinal axes. if (angle >= 338f || angle < 23f) return 6u; if (angle < 68f) return 7u; if (angle < 113f) return 9u; if (angle < 158f) return 12u; if (angle < 203f) return 11u; if (angle < 248f) return 10u; if (angle < 293f) return 8u; return 5u; } internal static Vector2 ComputeOffScreenPosition( float angleDegrees, Vector2 viewport, Vector2 imageSize) { float angleRadians = PositiveModulo(angleDegrees, 360f) * (MathF.PI / 180f); var direction = new Vector2(MathF.Cos(angleRadians), -MathF.Sin(angleRadians)); var halfImage = imageSize * 0.5f; var center = viewport * 0.5f; var minimumCenter = new Vector2(ViewportMargin) + halfImage; var maximumCenter = viewport - new Vector2(ViewportMargin) - halfImage; float horizontalDistance = direction.X switch { > 1e-6f => (maximumCenter.X - center.X) / direction.X, < -1e-6f => (minimumCenter.X - center.X) / direction.X, _ => float.PositiveInfinity, }; float verticalDistance = direction.Y switch { > 1e-6f => (maximumCenter.Y - center.Y) / direction.Y, < -1e-6f => (minimumCenter.Y - center.Y) / direction.Y, _ => float.PositiveInfinity, }; float distance = MathF.Min(horizontalDistance, verticalDistance); if (!float.IsFinite(distance) || distance < 0f) distance = 0f; Vector2 position = center + direction * distance - halfImage; return new Vector2( Math.Clamp(position.X, ViewportMargin, MathF.Max(ViewportMargin, viewport.X - imageSize.X - ViewportMargin)), Math.Clamp(position.Y, ViewportMargin, MathF.Max(ViewportMargin, viewport.Y - imageSize.Y - ViewportMargin))); } private static float PositiveModulo(float value, float modulus) { float result = value % modulus; return result < 0f ? result + modulus : result; } internal static VividTargetLayout ComputeLayout( Vector2 rectMin, Vector2 rectMax, Vector2 viewport, IReadOnlyList<(float Width, float Height)> sizes) { if (sizes.Count != 4) throw new ArgumentException("Retail VividTargetIndicator has exactly four corners.", nameof(sizes)); // VividTargetIndicator::OnDraw @ 0x004F69C8..0x004F6A99 reads // corner[1]'s dimensions, expands the SmartBox rectangle by one // corner on the top/left, then clamps each rectangle edge separately. // Partially off-screen targets therefore shrink the assembled region; // retail does not translate the whole un-clipped box back on-screen. float cornerWidth = sizes[0].Width; float cornerHeight = sizes[0].Height; float outerLeft = rectMin.X - cornerWidth; float outerTop = rectMin.Y - cornerHeight; float outerRight = rectMax.X; float outerBottom = rectMax.Y; if (outerLeft > outerRight) outerLeft = outerRight - 1f; if (outerTop > outerBottom) outerTop = outerBottom - 1f; outerLeft = MathF.Max(outerLeft, ViewportMargin); outerTop = MathF.Max(outerTop, ViewportMargin); outerRight = MathF.Max(outerRight, cornerWidth + ViewportMargin); outerBottom = MathF.Max(outerBottom, cornerHeight + ViewportMargin); float maximumRight = viewport.X - cornerWidth - ViewportMargin; float maximumBottom = viewport.Y - cornerHeight - ViewportMargin; outerLeft = MathF.Min(outerLeft, maximumRight - cornerWidth); outerTop = MathF.Min(outerTop, maximumBottom - cornerHeight); outerRight = MathF.Min(outerRight, maximumRight); outerBottom = MathF.Min(outerBottom, maximumBottom); Vector2 rootSize = new( MathF.Max(1f, outerRight - outerLeft + cornerWidth), MathF.Max(1f, outerBottom - outerTop + cornerHeight)); return new VividTargetLayout( new Vector2(outerLeft, outerTop), rootSize, [ Vector2.Zero, new Vector2(rootSize.X - sizes[1].Width, 0f), new Vector2(rootSize.X - sizes[2].Width, rootSize.Y - sizes[2].Height), new Vector2(0f, rootSize.Y - sizes[3].Height), ]); } } internal readonly record struct VividTargetLayout( Vector2 RootPosition, Vector2 RootSize, IReadOnlyList CornerPositions); internal enum VividTargetProjectionStatus { Invalid, OnScreen, OffScreen, } internal readonly record struct VividTargetProjection( VividTargetProjectionStatus Status, Vector2 RectMin, Vector2 RectMax, float AngleDegrees); internal readonly record struct VividTargetSource( uint Texture, float Width, float Height);