2025-12-07 19:36:57 +00:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using Core;
|
|
|
|
|
using Core.Lifecycle;
|
|
|
|
|
using Unity.Cinemachine;
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
namespace Common.Camera
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Serializable mapping between a camera state and its Cinemachine camera.
|
|
|
|
|
/// Used to assign cameras in the Inspector for each enum state.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Serializable]
|
|
|
|
|
public class CameraStateMapping<TState> where TState : Enum
|
|
|
|
|
{
|
|
|
|
|
[Tooltip("The state this camera represents")]
|
|
|
|
|
public TState state;
|
|
|
|
|
|
|
|
|
|
[Tooltip("The Cinemachine camera for this state")]
|
|
|
|
|
public CinemachineCamera camera;
|
|
|
|
|
|
|
|
|
|
public CameraStateMapping(TState state)
|
|
|
|
|
{
|
|
|
|
|
this.state = state;
|
|
|
|
|
this.camera = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Generic state-based camera controller using Cinemachine.
|
|
|
|
|
/// Manages camera transitions by setting priorities on virtual cameras.
|
|
|
|
|
/// Type parameter TState must be an enum representing camera states.
|
|
|
|
|
/// </summary>
|
|
|
|
|
///
|
|
|
|
|
public abstract class CameraStateManager<TState> : ManagedBehaviour where TState : Enum
|
|
|
|
|
{
|
|
|
|
|
#region Configuration
|
|
|
|
|
|
|
|
|
|
[Header("Camera Mappings")]
|
|
|
|
|
[Tooltip("Assign cameras for each state - list auto-populates from enum")]
|
|
|
|
|
[SerializeField] protected List<CameraStateMapping<TState>> cameraMappings = new List<CameraStateMapping<TState>>();
|
|
|
|
|
|
|
|
|
|
[Header("Camera Priority Settings")]
|
|
|
|
|
[Tooltip("Priority for inactive cameras")]
|
|
|
|
|
[SerializeField] protected int inactivePriority = 10;
|
|
|
|
|
|
|
|
|
|
[Tooltip("Priority for the active camera")]
|
|
|
|
|
[SerializeField] protected int activePriority = 20;
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
[Header("Cinemachine Brain")]
|
|
|
|
|
[Tooltip("CinemachineBrain for blend detection (auto-finds if null)")]
|
|
|
|
|
[SerializeField] protected CinemachineBrain cinemachineBrain;
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
[Header("Debug")]
|
|
|
|
|
[SerializeField] protected bool showDebugLogs = false;
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region State
|
|
|
|
|
|
|
|
|
|
private Dictionary<TState, CinemachineCamera> _cameraMap = new Dictionary<TState, CinemachineCamera>();
|
|
|
|
|
private TState _currentState;
|
|
|
|
|
private bool _isInitialized = false;
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
// Event-driven blend tracking
|
|
|
|
|
private CinemachineCamera _pendingBlendTarget;
|
|
|
|
|
private bool _isBlendComplete;
|
|
|
|
|
private Action _pendingBlendCallback;
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
public TState CurrentState => _currentState;
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Events
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Fired when camera state changes. Parameters: (TState oldState, TState newState)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public event Action<TState, TState> OnStateChanged;
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Fired when camera blend completes after state switch
|
|
|
|
|
/// </summary>
|
|
|
|
|
public event Action OnBlendComplete;
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Lifecycle
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Initialize camera mappings and validate them.
|
|
|
|
|
/// Subclasses should call base.OnManagedAwake() to get automatic initialization.
|
|
|
|
|
/// If custom initialization is needed, override without calling base.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal override void OnManagedAwake()
|
|
|
|
|
{
|
|
|
|
|
base.OnManagedAwake();
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
// Auto-find CinemachineBrain if not assigned
|
|
|
|
|
if (cinemachineBrain == null)
|
|
|
|
|
{
|
|
|
|
|
cinemachineBrain = UnityEngine.Camera.main?.GetComponent<CinemachineBrain>();
|
|
|
|
|
|
|
|
|
|
if (cinemachineBrain == null && showDebugLogs)
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] CinemachineBrain not found. Blend tracking will be unavailable.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
// Initialize cameras from Inspector mappings
|
|
|
|
|
InitializeCameraMap();
|
|
|
|
|
|
|
|
|
|
// Validate all cameras are assigned
|
|
|
|
|
ValidateCameras();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Subscribe to Cinemachine events on enable
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnEnable()
|
|
|
|
|
{
|
|
|
|
|
// Subscribe to Cinemachine global events
|
|
|
|
|
CinemachineCore.BlendFinishedEvent.AddListener(OnBlendFinished);
|
|
|
|
|
CinemachineCore.CameraActivatedEvent.AddListener(OnCameraActivated);
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Debug($"[{GetType().Name}] Subscribed to Cinemachine events");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Unsubscribe from Cinemachine events on disable
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnDisable()
|
|
|
|
|
{
|
|
|
|
|
// Unsubscribe from Cinemachine events to prevent memory leaks
|
|
|
|
|
CinemachineCore.BlendFinishedEvent.RemoveListener(OnBlendFinished);
|
|
|
|
|
CinemachineCore.CameraActivatedEvent.RemoveListener(OnCameraActivated);
|
|
|
|
|
|
|
|
|
|
// Clear any pending callbacks
|
|
|
|
|
_pendingBlendCallback = null;
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Debug($"[{GetType().Name}] Unsubscribed from Cinemachine events");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Initialization
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Initialize camera mappings from Inspector-assigned list.
|
|
|
|
|
/// Call this in OnManagedAwake - no need to manually register cameras!
|
|
|
|
|
/// This is the preferred method for new implementations.
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected void InitializeCameraMap()
|
|
|
|
|
{
|
|
|
|
|
_cameraMap.Clear();
|
|
|
|
|
|
|
|
|
|
// Build dictionary from serialized mappings
|
|
|
|
|
foreach (var mapping in cameraMappings)
|
|
|
|
|
{
|
|
|
|
|
if (mapping.camera == null)
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No camera assigned for state {mapping.state}");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_cameraMap[mapping.state] = mapping.camera;
|
|
|
|
|
mapping.camera.Priority.Value = inactivePriority;
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Debug($"[{GetType().Name}] Registered camera '{mapping.camera.gameObject.name}' for state {mapping.state}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_isInitialized = true;
|
|
|
|
|
|
|
|
|
|
if (_cameraMap.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No cameras registered!");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Initialized with {_cameraMap.Count} cameras");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// DEPRECATED: Use InitializeCameraMap() instead for cleaner code.
|
|
|
|
|
/// Kept for backward compatibility with existing implementations.
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected void RegisterCamera(TState state, CinemachineCamera pCamera)
|
|
|
|
|
{
|
|
|
|
|
if (pCamera == null)
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] Attempted to register null camera for state {state}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_cameraMap.ContainsKey(state))
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] Camera for state {state} already registered, overwriting");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_cameraMap[state] = pCamera;
|
|
|
|
|
pCamera.Priority.Value = inactivePriority;
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Registered camera '{pCamera.gameObject.name}' for state {state}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// DEPRECATED: Use InitializeCameraMap() instead.
|
|
|
|
|
/// Kept for backward compatibility with existing implementations.
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected void FinalizeInitialization()
|
|
|
|
|
{
|
|
|
|
|
_isInitialized = true;
|
|
|
|
|
|
|
|
|
|
if (_cameraMap.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No cameras registered!");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Initialized with {_cameraMap.Count} cameras");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region State Management
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Switch to a specific camera state
|
|
|
|
|
/// </summary>
|
|
|
|
|
public virtual void SwitchToState(TState newState)
|
|
|
|
|
{
|
|
|
|
|
if (!_isInitialized)
|
|
|
|
|
{
|
|
|
|
|
Logging.Error($"[{GetType().Name}] Cannot switch state - not initialized!");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_cameraMap.ContainsKey(newState))
|
|
|
|
|
{
|
|
|
|
|
Logging.Error($"[{GetType().Name}] No camera registered for state {newState}!");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TState oldState = _currentState;
|
|
|
|
|
_currentState = newState;
|
|
|
|
|
|
|
|
|
|
// Set all cameras to inactive priority
|
|
|
|
|
foreach (var kvp in _cameraMap)
|
|
|
|
|
{
|
|
|
|
|
kvp.Value.Priority.Value = inactivePriority;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set target camera to active priority
|
|
|
|
|
_cameraMap[newState].Priority.Value = activePriority;
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs) Logging.Debug($"[{GetType().Name}] Switched from {oldState} to {newState} (camera: {_cameraMap[newState].gameObject.name})");
|
|
|
|
|
|
|
|
|
|
OnStateChanged?.Invoke(oldState, newState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Get the camera for a specific state
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CinemachineCamera GetCamera(TState state)
|
|
|
|
|
{
|
|
|
|
|
if (_cameraMap.TryGetValue(state, out CinemachineCamera pCamera))
|
|
|
|
|
{
|
|
|
|
|
return pCamera;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No camera found for state {state}");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Check if a camera is registered for a state
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool HasCamera(TState state)
|
|
|
|
|
{
|
|
|
|
|
return _cameraMap.ContainsKey(state);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 00:55:47 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Blend to a state and wait asynchronously (coroutine).
|
|
|
|
|
/// Yields until Cinemachine blend event fires. Event-driven, no polling.
|
|
|
|
|
/// Use this when you need to wait for the blend to complete before continuing.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public System.Collections.IEnumerator BlendToStateAsync(TState newState)
|
|
|
|
|
{
|
|
|
|
|
// Reset completion flag
|
|
|
|
|
_isBlendComplete = false;
|
|
|
|
|
|
|
|
|
|
// Set pending target camera
|
|
|
|
|
if (!_cameraMap.TryGetValue(newState, out _pendingBlendTarget))
|
|
|
|
|
{
|
|
|
|
|
Logging.Error($"[{GetType().Name}] No camera for state {newState}");
|
|
|
|
|
yield break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Switch camera state (triggers blend)
|
|
|
|
|
SwitchToState(newState);
|
|
|
|
|
|
|
|
|
|
// Fallback: if no brain, complete immediately
|
|
|
|
|
if (cinemachineBrain == null)
|
|
|
|
|
{
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No brain, completing blend immediately");
|
|
|
|
|
yield break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for event to fire (event handlers set _isBlendComplete = true)
|
|
|
|
|
yield return new WaitUntil(() => _isBlendComplete);
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Debug($"[{GetType().Name}] Blend to {newState} completed (event-driven)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Blend to a state with callback invoked on completion.
|
|
|
|
|
/// Callback fires when Cinemachine blend event fires. Event-driven, no polling.
|
|
|
|
|
/// Use this when you want to perform an action after the blend completes.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void BlendToState(TState newState, Action onComplete)
|
|
|
|
|
{
|
|
|
|
|
// Set pending target camera
|
|
|
|
|
if (!_cameraMap.TryGetValue(newState, out _pendingBlendTarget))
|
|
|
|
|
{
|
|
|
|
|
Logging.Error($"[{GetType().Name}] No camera for state {newState}");
|
|
|
|
|
onComplete?.Invoke(); // Still invoke to prevent hanging
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store callback
|
|
|
|
|
_pendingBlendCallback = onComplete;
|
|
|
|
|
|
|
|
|
|
// Switch camera state (triggers blend)
|
|
|
|
|
SwitchToState(newState);
|
|
|
|
|
|
|
|
|
|
// Fallback: if no brain, invoke callback immediately
|
|
|
|
|
if (cinemachineBrain == null)
|
|
|
|
|
{
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No brain, invoking callback immediately");
|
|
|
|
|
CompleteBlend();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Event handlers will invoke callback when blend finishes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Event Handlers
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Called when Cinemachine finishes a blend (non-zero length blends)
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnBlendFinished(ICinemachineMixer mixer, ICinemachineCamera cam)
|
|
|
|
|
{
|
|
|
|
|
// Filter: only respond to blends from our brain to our expected camera
|
|
|
|
|
if (mixer == cinemachineBrain && cam == _pendingBlendTarget)
|
|
|
|
|
{
|
|
|
|
|
CompleteBlend();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Called when Cinemachine activates a camera (handles instant cuts)
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnCameraActivated(ICinemachineCamera.ActivationEventParams evt)
|
|
|
|
|
{
|
|
|
|
|
// Filter: only respond to cuts from our brain to our expected camera
|
|
|
|
|
if (evt.Origin == cinemachineBrain && evt.IncomingCamera == _pendingBlendTarget && evt.IsCut)
|
|
|
|
|
{
|
|
|
|
|
CompleteBlend();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Mark blend as complete and fire callbacks
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void CompleteBlend()
|
|
|
|
|
{
|
|
|
|
|
_isBlendComplete = true;
|
|
|
|
|
_pendingBlendCallback?.Invoke();
|
|
|
|
|
_pendingBlendCallback = null;
|
|
|
|
|
OnBlendComplete?.Invoke();
|
|
|
|
|
|
|
|
|
|
if (showDebugLogs)
|
|
|
|
|
Logging.Debug($"[{GetType().Name}] Blend completed, callbacks invoked");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:36:57 +00:00
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Validation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Validate that all enum states have cameras registered.
|
|
|
|
|
/// Override to add custom validation (e.g., check for specific components).
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected virtual void ValidateCameras()
|
|
|
|
|
{
|
|
|
|
|
// Check that all enum values have cameras assigned
|
|
|
|
|
foreach (TState state in Enum.GetValues(typeof(TState)))
|
|
|
|
|
{
|
|
|
|
|
if (!_cameraMap.ContainsKey(state))
|
|
|
|
|
{
|
|
|
|
|
Logging.Warning($"[{GetType().Name}] No camera assigned for state {state}");
|
|
|
|
|
}
|
|
|
|
|
else if (_cameraMap[state] == null)
|
|
|
|
|
{
|
|
|
|
|
Logging.Error($"[{GetType().Name}] Camera for state {state} is null!");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Editor Support
|
|
|
|
|
|
|
|
|
|
#if UNITY_EDITOR
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Auto-populate camera mappings list with all enum values.
|
|
|
|
|
/// Called automatically in the Editor when the component is added or values change.
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected virtual void OnValidate()
|
|
|
|
|
{
|
|
|
|
|
// Get all enum values
|
|
|
|
|
TState[] allStates = (TState[])Enum.GetValues(typeof(TState));
|
|
|
|
|
|
|
|
|
|
// Add missing states to the list
|
|
|
|
|
foreach (TState state in allStates)
|
|
|
|
|
{
|
|
|
|
|
bool exists = cameraMappings.Any(m => EqualityComparer<TState>.Default.Equals(m.state, state));
|
|
|
|
|
if (!exists)
|
|
|
|
|
{
|
|
|
|
|
cameraMappings.Add(new CameraStateMapping<TState>(state));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove mappings for states that no longer exist in the enum
|
|
|
|
|
cameraMappings.RemoveAll(m => !System.Array.Exists(allStates, s => EqualityComparer<TState>.Default.Equals(s, m.state)));
|
|
|
|
|
|
|
|
|
|
// Sort by enum order for cleaner Inspector display
|
|
|
|
|
cameraMappings = cameraMappings.OrderBy(m => (int)(object)m.state).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Initialize list when component is first added
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected virtual void Reset()
|
|
|
|
|
{
|
|
|
|
|
OnValidate();
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|