using System.Collections; using System.Collections.Generic; using UnityEngine; using Core; using Core.Lifecycle; using AppleHills.Core; namespace UI.Tracking { /// /// Singleton manager that tracks off-screen targets and displays directional pins. /// Uses ManagedBehaviour pattern and lazily accesses camera via QuickAccess (like AudioManager). /// 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 _trackedTargets = new Dictionary(); // Pin pooling private List _inactivePins = new List(); // Coroutine tracking private Coroutine _updateCoroutine; // Auto-created canvas for pins private Canvas _pinCanvas; private RectTransform _pinContainer; /// /// Nested class to track per-target state and timers /// 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(); } /// /// Called when any scene finishes loading. Refreshes camera and restarts coroutine. /// private void OnSceneLoadCompleted(string sceneName) { Logging.Debug($"[OffScreenTrackerManager] Scene loaded: {sceneName}, reinitializing camera and coroutine"); InitializeForCurrentScene(); } /// /// Initialize camera reference and start/restart tracking coroutine for current scene /// 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; } } /// /// Create a dedicated canvas for pins with sort order 50 /// 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(); _pinCanvas.renderMode = RenderMode.ScreenSpaceOverlay; _pinCanvas.sortingOrder = 50; // Add CanvasScaler for consistent sizing var scaler = canvasObj.AddComponent(); 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(); // Get RectTransform for pin container _pinContainer = canvasObj.GetComponent(); Logging.Debug("[OffScreenTrackerManager] Created dedicated pin canvas with sort order 50"); } /// /// Register a target for tracking /// 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}"); } /// /// Unregister a target from tracking /// 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}"); } } /// /// Main update coroutine that runs every updateInterval seconds /// 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 targetsToRemove = new List(); 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); } } } /// /// 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. /// 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; } /// /// Spawn a pin for the given target data /// 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}"); } /// /// Despawn a pin and return it to the pool /// 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"}"); } /// /// Get a pin from the pool or instantiate a new one /// 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 /// /// Get or set the screen padding in pixels /// public float ScreenPadding { get => screenPadding; set => screenPadding = Mathf.Max(0f, value); } /// /// Get or set the spawn debounce delay /// public float SpawnDebounceDelay { get => spawnDebounceDelay; set => spawnDebounceDelay = Mathf.Max(0f, value); } /// /// Get or set the despawn debounce delay /// public float DespawnDebounceDelay { get => despawnDebounceDelay; set => despawnDebounceDelay = Mathf.Max(0f, value); } #endregion } }