using UnityEngine; using UnityEngine.UI; using AppleHills.Core; namespace UI.Tracking { /// /// UI pin that displays on screen edges pointing to off-screen targets. /// Consists of a static icon in the center and a rotatable frame that points toward the target. /// Optionally displays distance to target if enabled. /// Updates every frame for smooth tracking. /// public class OffScreenPin : MonoBehaviour { [Header("References")] [Tooltip("The image component that displays the target's icon (static, in center)")] [SerializeField] private Image iconImage; [Tooltip("The image component that rotates to point toward the target (default: pointing downward)")] [SerializeField] private Image frameImage; [Tooltip("Optional: Text component to display distance to target")] [SerializeField] private TMPro.TextMeshProUGUI distanceText; private RectTransform _rectTransform; private TrackableTarget _target; private bool _isInitialized; private float _screenPadding; private bool _trackDistance; private void Awake() { _rectTransform = GetComponent(); } private void Update() { // Update position and rotation every frame for smooth tracking if (_isInitialized && _target != null) { Camera mainCamera = QuickAccess.Instance?.MainCamera; if (mainCamera != null) { UpdatePositionAndRotation(mainCamera, _screenPadding); } // Update distance display if enabled if (_trackDistance && distanceText != null) { UpdateDistanceDisplay(); } } } /// /// Initialize the pin with a target reference and set the icon /// public void Initialize(TrackableTarget target, float screenPadding) { _target = target; _isInitialized = true; _screenPadding = screenPadding; _trackDistance = target.TrackDistance; // Set the icon sprite if available if (iconImage != null && target.Icon != null) { iconImage.sprite = target.Icon; iconImage.enabled = true; } else if (iconImage != null) { iconImage.enabled = false; } // Configure distance text if (distanceText != null) { distanceText.gameObject.SetActive(_trackDistance); } } /// /// Update the pin's position and rotation based on the target's world position. /// Called by the manager each update cycle. /// public void UpdatePositionAndRotation(Camera cam, float screenPadding) { if (!_isInitialized || _target == null) return; // Get target position in screen space Vector3 targetScreenPos = cam.WorldToScreenPoint(_target.WorldPosition); // Calculate direction from screen center to target Vector2 screenCenter = new Vector2(Screen.width / 2f, Screen.height / 2f); Vector2 targetScreenPos2D = new Vector2(targetScreenPos.x, targetScreenPos.y); Vector2 directionToTarget = (targetScreenPos2D - screenCenter).normalized; // Calculate screen bounds with padding (inset from edges) float minX = screenPadding; float maxX = Screen.width - screenPadding; float minY = screenPadding; float maxY = Screen.height - screenPadding; // Find intersection point with screen bounds Vector2 intersectionPoint = CalculateScreenEdgeIntersection( screenCenter, directionToTarget, minX, maxX, minY, maxY ); // Offset the intersection point slightly toward the center to ensure pin is fully visible Vector2 offsetTowardCenter = -directionToTarget * screenPadding * 0.5f; Vector2 finalPosition = intersectionPoint + offsetTowardCenter; // Update pin position _rectTransform.position = finalPosition; // Update frame rotation to point toward target // Frame's default orientation points downward (0 degrees in UI space) // In UI space: 0° = down, 90° = right, 180° = up, 270° = left // Atan2(y, x) gives angle from right (+X axis), so we need to adjust float angle = Mathf.Atan2(directionToTarget.y, directionToTarget.x) * Mathf.Rad2Deg; // Add 90 to convert from "right is 0°" to align with down-pointing sprite angle = angle + 90f; if (frameImage != null) { frameImage.rectTransform.localRotation = Quaternion.Euler(0, 0, angle); } } /// /// Update the distance display text /// private void UpdateDistanceDisplay() { // Get distance source (typically the player) if (TrackingDistanceSource.Instance == null) { distanceText.text = "???"; return; } // Calculate distance between source and target float distance = Vector3.Distance(TrackingDistanceSource.Instance.WorldPosition, _target.WorldPosition); // Format distance nicely (meters with 1 decimal place) distanceText.text = $"{distance:F1}m"; } /// /// Calculate the intersection point of a ray from center in given direction with screen bounds /// private Vector2 CalculateScreenEdgeIntersection( Vector2 center, Vector2 direction, float minX, float maxX, float minY, float maxY) { // Calculate intersection with each edge and find the closest one float tMin = float.MaxValue; Vector2 intersection = center; // Check intersection with right edge (x = maxX) if (direction.x > 0.001f) { float t = (maxX - center.x) / direction.x; float y = center.y + t * direction.y; if (y >= minY && y <= maxY && t < tMin) { tMin = t; intersection = new Vector2(maxX, y); } } // Check intersection with left edge (x = minX) if (direction.x < -0.001f) { float t = (minX - center.x) / direction.x; float y = center.y + t * direction.y; if (y >= minY && y <= maxY && t < tMin) { tMin = t; intersection = new Vector2(minX, y); } } // Check intersection with top edge (y = maxY) if (direction.y > 0.001f) { float t = (maxY - center.y) / direction.y; float x = center.x + t * direction.x; if (x >= minX && x <= maxX && t < tMin) { tMin = t; intersection = new Vector2(x, maxY); } } // Check intersection with bottom edge (y = minY) if (direction.y < -0.001f) { float t = (minY - center.y) / direction.y; float x = center.x + t * direction.x; if (x >= minX && x <= maxX && t < tMin) { tMin = t; intersection = new Vector2(x, minY); } } return intersection; } /// /// Reset the pin for pooling reuse /// public void ResetPin() { _target = null; _isInitialized = false; if (iconImage != null) { iconImage.sprite = null; iconImage.enabled = false; } } /// /// Get the current target (for null checking) /// public TrackableTarget Target => _target; } }