Files
AppleHillsProduction/Assets/Scripts/UI/Tracking/OffScreenTrackerManager.cs

495 lines
18 KiB
C#
Raw Normal View History

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
}
}