Add tracking pins for offscreen targets
This commit is contained in:
8
Assets/Scripts/UI/Tracking.meta
Normal file
8
Assets/Scripts/UI/Tracking.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ea2c8cb26e7d7a44b21c052322ab6ba
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
237
Assets/Scripts/UI/Tracking/OffScreenPin.cs
Normal file
237
Assets/Scripts/UI/Tracking/OffScreenPin.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using AppleHills.Core;
|
||||
|
||||
namespace UI.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<RectTransform>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the pin with a target reference and set the icon
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the pin's position and rotation based on the target's world position.
|
||||
/// Called by the manager each update cycle.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the distance display text
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the intersection point of a ray from center in given direction with screen bounds
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the pin for pooling reuse
|
||||
/// </summary>
|
||||
public void ResetPin()
|
||||
{
|
||||
_target = null;
|
||||
_isInitialized = false;
|
||||
|
||||
if (iconImage != null)
|
||||
{
|
||||
iconImage.sprite = null;
|
||||
iconImage.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current target (for null checking)
|
||||
/// </summary>
|
||||
public TrackableTarget Target => _target;
|
||||
}
|
||||
}
|
||||
|
||||
3
Assets/Scripts/UI/Tracking/OffScreenPin.cs.meta
Normal file
3
Assets/Scripts/UI/Tracking/OffScreenPin.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 548d13ffdad349b6939e2b873a39b54e
|
||||
timeCreated: 1766074498
|
||||
494
Assets/Scripts/UI/Tracking/OffScreenTrackerManager.cs
Normal file
494
Assets/Scripts/UI/Tracking/OffScreenTrackerManager.cs
Normal file
@@ -0,0 +1,494 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Core;
|
||||
using Core.Lifecycle;
|
||||
using AppleHills.Core;
|
||||
|
||||
namespace UI.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton manager that tracks off-screen targets and displays directional pins.
|
||||
/// Uses ManagedBehaviour pattern and lazily accesses camera via QuickAccess (like AudioManager).
|
||||
/// </summary>
|
||||
public class OffScreenTrackerManager : ManagedBehaviour
|
||||
{
|
||||
[Header("Configuration")]
|
||||
[Tooltip("Prefab for the off-screen tracking pin")]
|
||||
[SerializeField] private OffScreenPin pinPrefab;
|
||||
|
||||
[Tooltip("Pixel padding from screen edges (pins appear this many pixels from edge)")]
|
||||
[SerializeField] private float screenPadding = 50f;
|
||||
|
||||
[Tooltip("Buffer zone in pixels to prevent spawn/despawn flickering (spawn is stricter than despawn)")]
|
||||
[SerializeField] private float bufferZone = 100f;
|
||||
|
||||
[Tooltip("Time in seconds a target must be off-screen before pin appears")]
|
||||
[SerializeField] private float spawnDebounceDelay = 0.3f;
|
||||
|
||||
[Tooltip("Time in seconds a target must be on-screen before pin disappears")]
|
||||
[SerializeField] private float despawnDebounceDelay = 0.2f;
|
||||
|
||||
[Tooltip("Update interval in seconds for checking target visibility")]
|
||||
[SerializeField] private float updateInterval = 0.1f;
|
||||
|
||||
// Singleton instance
|
||||
private static OffScreenTrackerManager _instance;
|
||||
public static OffScreenTrackerManager Instance => _instance;
|
||||
|
||||
// Tracking data
|
||||
private Dictionary<TrackableTarget, TargetTrackingData> _trackedTargets = new Dictionary<TrackableTarget, TargetTrackingData>();
|
||||
|
||||
// Pin pooling
|
||||
private List<OffScreenPin> _inactivePins = new List<OffScreenPin>();
|
||||
|
||||
// Coroutine tracking
|
||||
private Coroutine _updateCoroutine;
|
||||
|
||||
// Auto-created canvas for pins
|
||||
private Canvas _pinCanvas;
|
||||
private RectTransform _pinContainer;
|
||||
|
||||
/// <summary>
|
||||
/// Nested class to track per-target state and timers
|
||||
/// </summary>
|
||||
private class TargetTrackingData
|
||||
{
|
||||
public TrackableTarget Target;
|
||||
public OffScreenPin ActivePin;
|
||||
public float OffScreenTimer;
|
||||
public float OnScreenTimer;
|
||||
public bool IsCurrentlyOffScreen;
|
||||
|
||||
public TargetTrackingData(TrackableTarget target)
|
||||
{
|
||||
Target = target;
|
||||
ActivePin = null;
|
||||
OffScreenTimer = 0f;
|
||||
OnScreenTimer = 0f;
|
||||
IsCurrentlyOffScreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal override void OnManagedAwake()
|
||||
{
|
||||
// Set singleton instance
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
internal override void OnManagedStart()
|
||||
{
|
||||
// Validate configuration
|
||||
if (pinPrefab == null)
|
||||
{
|
||||
Logging.Error("[OffScreenTrackerManager] Pin prefab not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dedicated canvas for pins
|
||||
CreatePinCanvas();
|
||||
|
||||
// Subscribe to scene load events from SceneManagerService (like InputManager does)
|
||||
// This must happen in ManagedStart because SceneManagerService instance needs to be set first
|
||||
if (SceneManagerService.Instance != null)
|
||||
{
|
||||
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
|
||||
Logging.Debug("[OffScreenTrackerManager] Subscribed to SceneLoadCompleted events");
|
||||
}
|
||||
|
||||
// Initialize for current scene and start coroutine
|
||||
InitializeForCurrentScene();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when any scene finishes loading. Refreshes camera and restarts coroutine.
|
||||
/// </summary>
|
||||
private void OnSceneLoadCompleted(string sceneName)
|
||||
{
|
||||
Logging.Debug($"[OffScreenTrackerManager] Scene loaded: {sceneName}, reinitializing camera and coroutine");
|
||||
InitializeForCurrentScene();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize camera reference and start/restart tracking coroutine for current scene
|
||||
/// </summary>
|
||||
private void InitializeForCurrentScene()
|
||||
{
|
||||
// Stop existing coroutine if running
|
||||
if (_updateCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_updateCoroutine);
|
||||
_updateCoroutine = null;
|
||||
Logging.Debug("[OffScreenTrackerManager] Stopped previous coroutine");
|
||||
}
|
||||
|
||||
// Start the tracking coroutine (camera accessed lazily via QuickAccess)
|
||||
_updateCoroutine = StartCoroutine(UpdateTrackingCoroutine());
|
||||
Logging.Debug("[OffScreenTrackerManager] Started tracking coroutine");
|
||||
}
|
||||
|
||||
internal override void OnManagedDestroy()
|
||||
{
|
||||
// Unsubscribe from SceneManagerService events (like InputManager does)
|
||||
if (SceneManagerService.Instance != null)
|
||||
{
|
||||
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// Stop coroutine
|
||||
if (_updateCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_updateCoroutine);
|
||||
}
|
||||
|
||||
// Clean up pooled pins
|
||||
foreach (var pin in _inactivePins)
|
||||
{
|
||||
if (pin != null)
|
||||
{
|
||||
Destroy(pin.gameObject);
|
||||
}
|
||||
}
|
||||
_inactivePins.Clear();
|
||||
|
||||
// Clean up active pins
|
||||
foreach (var data in _trackedTargets.Values)
|
||||
{
|
||||
if (data.ActivePin != null)
|
||||
{
|
||||
Destroy(data.ActivePin.gameObject);
|
||||
}
|
||||
}
|
||||
_trackedTargets.Clear();
|
||||
|
||||
// Clean up canvas
|
||||
if (_pinCanvas != null)
|
||||
{
|
||||
Destroy(_pinCanvas.gameObject);
|
||||
}
|
||||
|
||||
// Clear singleton
|
||||
if (_instance == this)
|
||||
{
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a dedicated canvas for pins with sort order 50
|
||||
/// </summary>
|
||||
private void CreatePinCanvas()
|
||||
{
|
||||
// Create a new GameObject for the canvas
|
||||
GameObject canvasObj = new GameObject("OffScreenPinCanvas");
|
||||
canvasObj.transform.SetParent(transform, false);
|
||||
|
||||
// Add and configure Canvas
|
||||
_pinCanvas = canvasObj.AddComponent<Canvas>();
|
||||
_pinCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
_pinCanvas.sortingOrder = 50;
|
||||
|
||||
// Add CanvasScaler for consistent sizing
|
||||
var scaler = canvasObj.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
scaler.screenMatchMode = UnityEngine.UI.CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
|
||||
// Add GraphicRaycaster (required for UI)
|
||||
canvasObj.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
|
||||
// Get RectTransform for pin container
|
||||
_pinContainer = canvasObj.GetComponent<RectTransform>();
|
||||
|
||||
Logging.Debug("[OffScreenTrackerManager] Created dedicated pin canvas with sort order 50");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a target for tracking
|
||||
/// </summary>
|
||||
public void RegisterTarget(TrackableTarget target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
if (_trackedTargets.ContainsKey(target))
|
||||
{
|
||||
Logging.Warning($"[OffScreenTrackerManager] Target {target.name} is already registered");
|
||||
return;
|
||||
}
|
||||
|
||||
_trackedTargets.Add(target, new TargetTrackingData(target));
|
||||
Logging.Debug($"[OffScreenTrackerManager] Registered target: {target.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a target from tracking
|
||||
/// </summary>
|
||||
public void UnregisterTarget(TrackableTarget target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
|
||||
if (_trackedTargets.TryGetValue(target, out TargetTrackingData data))
|
||||
{
|
||||
// Despawn pin if active
|
||||
if (data.ActivePin != null)
|
||||
{
|
||||
DespawnPin(data);
|
||||
}
|
||||
|
||||
_trackedTargets.Remove(target);
|
||||
Logging.Debug($"[OffScreenTrackerManager] Unregistered target: {target.name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main update coroutine that runs every updateInterval seconds
|
||||
/// </summary>
|
||||
private IEnumerator UpdateTrackingCoroutine()
|
||||
{
|
||||
Logging.Debug("[OffScreenTrackerManager] Tracking coroutine started");
|
||||
WaitForSeconds wait = new WaitForSeconds(updateInterval);
|
||||
|
||||
while (true)
|
||||
{
|
||||
yield return wait;
|
||||
|
||||
// Get camera lazily via QuickAccess (like AudioManager does)
|
||||
Camera mainCamera = QuickAccess.Instance?.MainCamera;
|
||||
|
||||
if (mainCamera == null)
|
||||
{
|
||||
// Camera not available yet (early in boot or scene transition)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create list of targets to remove (for null cleanup)
|
||||
List<TrackableTarget> targetsToRemove = new List<TrackableTarget>();
|
||||
|
||||
foreach (var kvp in _trackedTargets)
|
||||
{
|
||||
TrackableTarget target = kvp.Key;
|
||||
TargetTrackingData data = kvp.Value;
|
||||
|
||||
// Check if target was destroyed
|
||||
if (target == null)
|
||||
{
|
||||
targetsToRemove.Add(target);
|
||||
if (data.ActivePin != null)
|
||||
{
|
||||
DespawnPin(data);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if target is off-screen
|
||||
bool isOffScreen = IsTargetOffScreen(target, mainCamera);
|
||||
|
||||
// Update timers and state
|
||||
if (isOffScreen)
|
||||
{
|
||||
// Target is off-screen
|
||||
data.OffScreenTimer += updateInterval;
|
||||
data.OnScreenTimer = 0f;
|
||||
|
||||
// Check if we should spawn a pin
|
||||
if (!data.IsCurrentlyOffScreen && data.OffScreenTimer >= spawnDebounceDelay)
|
||||
{
|
||||
data.IsCurrentlyOffScreen = true;
|
||||
SpawnPin(data);
|
||||
}
|
||||
// Pin updates itself every frame, no need to call UpdatePositionAndRotation here
|
||||
}
|
||||
else
|
||||
{
|
||||
// Target is on-screen
|
||||
data.OnScreenTimer += updateInterval;
|
||||
data.OffScreenTimer = 0f;
|
||||
|
||||
// Check if we should despawn the pin
|
||||
if (data.IsCurrentlyOffScreen && data.OnScreenTimer >= despawnDebounceDelay)
|
||||
{
|
||||
data.IsCurrentlyOffScreen = false;
|
||||
if (data.ActivePin != null)
|
||||
{
|
||||
DespawnPin(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up null targets
|
||||
foreach (var target in targetsToRemove)
|
||||
{
|
||||
_trackedTargets.Remove(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a target is off-screen by checking its actual bounds.
|
||||
/// - For SPAWNING: Entire object must be off-screen (all corners outside viewport)
|
||||
/// - For DESPAWNING: Any part of object on-screen triggers despawn (any corner inside viewport)
|
||||
/// Uses bufferZone to prevent flickering at boundaries.
|
||||
/// </summary>
|
||||
private bool IsTargetOffScreen(TrackableTarget target, Camera cam)
|
||||
{
|
||||
// Get the world bounds of the target
|
||||
Bounds worldBounds = target.GetWorldBounds();
|
||||
|
||||
// Get the 8 corners of the bounds (we only need the min/max points in 2D)
|
||||
Vector3 min = worldBounds.min;
|
||||
Vector3 max = worldBounds.max;
|
||||
|
||||
// Convert corners to screen space
|
||||
Vector3 minScreen = cam.WorldToScreenPoint(new Vector3(min.x, min.y, worldBounds.center.z));
|
||||
Vector3 maxScreen = cam.WorldToScreenPoint(new Vector3(max.x, max.y, worldBounds.center.z));
|
||||
|
||||
// Check if behind camera
|
||||
if (minScreen.z < 0 && maxScreen.z < 0)
|
||||
return true;
|
||||
|
||||
// Shrink detection zones to 80% of screen (10% inset on each side)
|
||||
// This makes spawn/despawn more conservative
|
||||
float insetPercent = 0.1f; // 10% on each side = 80% total
|
||||
float horizontalInset = Screen.width * insetPercent;
|
||||
float verticalInset = Screen.height * insetPercent;
|
||||
|
||||
float screenLeft = horizontalInset;
|
||||
float screenRight = Screen.width - horizontalInset;
|
||||
float screenBottom = verticalInset;
|
||||
float screenTop = Screen.height - verticalInset;
|
||||
|
||||
// Check if ENTIRELY off-screen (all corners outside viewport)
|
||||
// This is when we should spawn the pin
|
||||
bool entirelyOffScreenLeft = maxScreen.x < screenLeft;
|
||||
bool entirelyOffScreenRight = minScreen.x > screenRight;
|
||||
bool entirelyOffScreenBottom = maxScreen.y < screenBottom;
|
||||
bool entirelyOffScreenTop = minScreen.y > screenTop;
|
||||
|
||||
bool entirelyOffScreen = entirelyOffScreenLeft || entirelyOffScreenRight ||
|
||||
entirelyOffScreenBottom || entirelyOffScreenTop;
|
||||
|
||||
// Apply buffer zone to prevent flickering
|
||||
// If already off-screen, require target to move bufferZone pixels on-screen before considering it "on-screen"
|
||||
// This creates hysteresis to prevent rapid spawn/despawn cycles
|
||||
if (entirelyOffScreen)
|
||||
{
|
||||
return true; // Definitely off-screen
|
||||
}
|
||||
|
||||
// Check if ANY part is on-screen (for despawn logic)
|
||||
// We add bufferZone to make despawn slightly more eager
|
||||
bool anyPartOnScreen = !(minScreen.x > screenRight - bufferZone ||
|
||||
maxScreen.x < screenLeft + bufferZone ||
|
||||
minScreen.y > screenTop - bufferZone ||
|
||||
maxScreen.y < screenBottom + bufferZone);
|
||||
|
||||
// If any part is on-screen (with buffer), consider it "on-screen" (pin should despawn)
|
||||
return !anyPartOnScreen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn a pin for the given target data
|
||||
/// </summary>
|
||||
private void SpawnPin(TargetTrackingData data)
|
||||
{
|
||||
// Try to get pin from pool
|
||||
OffScreenPin pin = GetPinFromPool();
|
||||
|
||||
// Initialize the pin (this also caches the target and settings)
|
||||
pin.Initialize(data.Target, screenPadding);
|
||||
|
||||
// CRITICAL: Update position BEFORE activating to prevent flicker
|
||||
// Get camera for immediate position update
|
||||
Camera mainCamera = QuickAccess.Instance?.MainCamera;
|
||||
if (mainCamera != null)
|
||||
{
|
||||
pin.UpdatePositionAndRotation(mainCamera, screenPadding);
|
||||
}
|
||||
|
||||
// Now activate the pin at the correct position
|
||||
pin.gameObject.SetActive(true);
|
||||
|
||||
|
||||
data.ActivePin = pin;
|
||||
|
||||
Logging.Debug($"[OffScreenTrackerManager] Spawned pin for target: {data.Target.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Despawn a pin and return it to the pool
|
||||
/// </summary>
|
||||
private void DespawnPin(TargetTrackingData data)
|
||||
{
|
||||
if (data.ActivePin == null)
|
||||
return;
|
||||
|
||||
OffScreenPin pin = data.ActivePin;
|
||||
data.ActivePin = null;
|
||||
|
||||
// Reset and return to pool
|
||||
pin.ResetPin();
|
||||
pin.gameObject.SetActive(false);
|
||||
_inactivePins.Add(pin);
|
||||
|
||||
Logging.Debug($"[OffScreenTrackerManager] Despawned pin for target: {data.Target?.name ?? "null"}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a pin from the pool or instantiate a new one
|
||||
/// </summary>
|
||||
private OffScreenPin GetPinFromPool()
|
||||
{
|
||||
// Try to reuse an inactive pin
|
||||
if (_inactivePins.Count > 0)
|
||||
{
|
||||
OffScreenPin pin = _inactivePins[_inactivePins.Count - 1];
|
||||
_inactivePins.RemoveAt(_inactivePins.Count - 1);
|
||||
return pin;
|
||||
}
|
||||
|
||||
// Create a new pin
|
||||
OffScreenPin newPin = Instantiate(pinPrefab, _pinContainer);
|
||||
newPin.gameObject.SetActive(false);
|
||||
return newPin;
|
||||
}
|
||||
|
||||
#region Public Configuration Accessors
|
||||
|
||||
/// <summary>
|
||||
/// Get or set the screen padding in pixels
|
||||
/// </summary>
|
||||
public float ScreenPadding
|
||||
{
|
||||
get => screenPadding;
|
||||
set => screenPadding = Mathf.Max(0f, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or set the spawn debounce delay
|
||||
/// </summary>
|
||||
public float SpawnDebounceDelay
|
||||
{
|
||||
get => spawnDebounceDelay;
|
||||
set => spawnDebounceDelay = Mathf.Max(0f, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or set the despawn debounce delay
|
||||
/// </summary>
|
||||
public float DespawnDebounceDelay
|
||||
{
|
||||
get => despawnDebounceDelay;
|
||||
set => despawnDebounceDelay = Mathf.Max(0f, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8599140fd23e492fa7f14cb7633209fe
|
||||
timeCreated: 1766074538
|
||||
84
Assets/Scripts/UI/Tracking/TrackableTarget.cs
Normal file
84
Assets/Scripts/UI/Tracking/TrackableTarget.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Component that marks a GameObject as trackable by the OffScreenTrackerManager.
|
||||
/// Automatically registers/unregisters with the manager when enabled/disabled.
|
||||
/// </summary>
|
||||
public class TrackableTarget : MonoBehaviour
|
||||
{
|
||||
[Header("Configuration")]
|
||||
[Tooltip("Icon to display in the off-screen tracking pin")]
|
||||
[SerializeField] private Sprite icon;
|
||||
|
||||
[Tooltip("Should this target display distance to the TrackingDistanceSource (e.g., player)?")]
|
||||
[SerializeField] private bool trackDistance = false;
|
||||
|
||||
/// <summary>
|
||||
/// The icon to display in the tracking pin
|
||||
/// </summary>
|
||||
public Sprite Icon => icon;
|
||||
|
||||
/// <summary>
|
||||
/// Should this target track and display distance?
|
||||
/// </summary>
|
||||
public bool TrackDistance => trackDistance;
|
||||
|
||||
/// <summary>
|
||||
/// The world position of this target
|
||||
/// </summary>
|
||||
public Vector3 WorldPosition => transform.position;
|
||||
|
||||
/// <summary>
|
||||
/// Get the screen-space bounds of this target's visual representation.
|
||||
/// Checks for Renderer or Collider2D to determine size.
|
||||
/// </summary>
|
||||
public Bounds GetWorldBounds()
|
||||
{
|
||||
// Try to get bounds from Renderer first (most accurate for visuals)
|
||||
Renderer renderer = GetComponentInChildren<Renderer>();
|
||||
if (renderer != null)
|
||||
{
|
||||
return renderer.bounds;
|
||||
}
|
||||
|
||||
// Fallback to Collider2D
|
||||
Collider2D collider = GetComponent<Collider2D>();
|
||||
if (collider != null)
|
||||
{
|
||||
return collider.bounds;
|
||||
}
|
||||
|
||||
// Last resort: just return position with minimal bounds
|
||||
return new Bounds(transform.position, Vector3.one * 0.1f);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Register with the manager when enabled
|
||||
if (OffScreenTrackerManager.Instance != null)
|
||||
{
|
||||
OffScreenTrackerManager.Instance.RegisterTarget(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// Unregister from the manager when disabled
|
||||
if (OffScreenTrackerManager.Instance != null)
|
||||
{
|
||||
OffScreenTrackerManager.Instance.UnregisterTarget(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allow runtime icon changes
|
||||
/// </summary>
|
||||
public void SetIcon(Sprite newIcon)
|
||||
{
|
||||
icon = newIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
Assets/Scripts/UI/Tracking/TrackableTarget.cs.meta
Normal file
2
Assets/Scripts/UI/Tracking/TrackableTarget.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e41f200c954677b4b8bde8cafa01d5f1
|
||||
43
Assets/Scripts/UI/Tracking/TrackingDistanceSource.cs
Normal file
43
Assets/Scripts/UI/Tracking/TrackingDistanceSource.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a GameObject as the source point for distance calculations in the tracking system.
|
||||
/// Typically attached to the player or camera. Only one should be active at a time.
|
||||
/// </summary>
|
||||
public class TrackingDistanceSource : MonoBehaviour
|
||||
{
|
||||
private static TrackingDistanceSource _instance;
|
||||
|
||||
/// <summary>
|
||||
/// The currently active distance source (typically the player)
|
||||
/// </summary>
|
||||
public static TrackingDistanceSource Instance => _instance;
|
||||
|
||||
/// <summary>
|
||||
/// The world position of this distance source
|
||||
/// </summary>
|
||||
public Vector3 WorldPosition => transform.position;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Set as the active instance
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Debug.LogWarning($"[TrackingDistanceSource] Multiple distance sources detected. Overwriting previous instance ({_instance.name}) with {name}");
|
||||
}
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// Clear instance if this was the active one
|
||||
if (_instance == this)
|
||||
{
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1788298f42bd40f6b077ca3719861752
|
||||
timeCreated: 1766077591
|
||||
Reference in New Issue
Block a user