Outsourcing of the camera pan system

This commit is contained in:
2026-02-04 16:58:43 +01:00
parent 71c44eb39e
commit 85bf743cba
21 changed files with 2860 additions and 212 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b41805f922f09994ebf9845415dab39b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,355 @@
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
using Unheimlich.Data.CameraSettings;
namespace Unheimlich.Gameplay.CameraControl
{
/// <summary>
/// Handles camera panning and player/camera alignment to targets.
/// Uses CameraPanSettings ScriptableObject for configuration.
/// </summary>
public class CameraPanController : MonoBehaviour
{
[Header("References")]
[SerializeField] private UnityEngine.Camera _camera;
[SerializeField] private Transform _playerTransform;
[Header("Settings")]
[SerializeField] private CameraPanSettings _settings;
[Header("Input Actions")]
[SerializeField] private InputActionReference _lookAction;
private Vector2 _originalCameraRotation;
private Vector3 _originalCameraLocalPosition;
private Vector3 _originalPlayerPosition;
private Vector3 _originalPlayerRotation;
private Vector3 _originalCameraLocalRotation;
private Coroutine _alignmentCoroutine;
private Coroutine _returnCoroutine;
private float _timeSinceLastInput;
private const float INPUT_IDLE_THRESHOLD = 0.1f;
public bool IsPanning { get; private set; }
public CameraPanSettings Settings
{
get => _settings;
set => _settings = value;
}
private void Awake()
{
if (_camera == null)
_camera = UnityEngine.Camera.main;
if (_playerTransform == null)
_playerTransform = transform;
if (_camera != null)
{
_originalCameraLocalPosition = _camera.transform.localPosition;
_originalCameraLocalRotation = _camera.transform.localEulerAngles;
}
_originalPlayerPosition = _playerTransform.position;
_originalPlayerRotation = _playerTransform.eulerAngles;
}
/// <summary>
/// Starts camera pan mode.
/// </summary>
public void StartPan()
{
if (_settings == null)
{
Debug.LogError("[CameraPanController] Settings not assigned!");
return;
}
IsPanning = true;
_timeSinceLastInput = 0f;
float currentPitch = _camera.transform.localEulerAngles.x;
if (currentPitch > 180f)
currentPitch -= 360f;
_originalCameraRotation = new Vector2(_playerTransform.eulerAngles.y, currentPitch);
}
/// <summary>
/// Stops camera pan mode and returns to original state.
/// </summary>
public void StopPan()
{
IsPanning = false;
}
/// <summary>
/// Updates camera pan based on input (gamepad or mouse).
/// Call this from Update when pan is active.
/// </summary>
public void UpdatePan()
{
if (!IsPanning || _camera == null || _settings == null)
return;
float panX = 0f;
float panY = 0f;
bool hasInput = false;
if (_lookAction != null && _lookAction.action != null)
{
Vector2 lookInput = _lookAction.action.ReadValue<Vector2>();
if (lookInput.sqrMagnitude > 0.01f)
{
panX = lookInput.x;
panY = lookInput.y;
hasInput = true;
}
}
if (!hasInput && Mouse.current != null)
{
Vector2 mousePos = Mouse.current.position.ReadValue();
float screenWidth = Screen.width;
float screenHeight = Screen.height;
float borderPixels = _settings.PanBorderSize * Mathf.Min(screenWidth, screenHeight);
if (mousePos.x < borderPixels)
{
panX = -(borderPixels - mousePos.x) / borderPixels;
hasInput = true;
}
else if (mousePos.x > screenWidth - borderPixels)
{
panX = (mousePos.x - (screenWidth - borderPixels)) / borderPixels;
hasInput = true;
}
if (mousePos.y < borderPixels)
{
panY = -(borderPixels - mousePos.y) / borderPixels;
hasInput = true;
}
else if (mousePos.y > screenHeight - borderPixels)
{
panY = (mousePos.y - (screenHeight - borderPixels)) / borderPixels;
hasInput = true;
}
}
if (hasInput)
{
_timeSinceLastInput = 0f;
}
else
{
_timeSinceLastInput += Time.deltaTime;
}
float targetYaw;
float targetPitch;
if (hasInput || _timeSinceLastInput < INPUT_IDLE_THRESHOLD)
{
targetYaw = _originalCameraRotation.x + panX * _settings.MaxPanAngle;
targetPitch = _originalCameraRotation.y - panY * _settings.MaxPanAngle;
}
else
{
targetYaw = _originalCameraRotation.x;
targetPitch = _originalCameraRotation.y;
}
targetYaw = Mathf.Clamp(targetYaw, _originalCameraRotation.x - _settings.MaxPanAngle, _originalCameraRotation.x + _settings.MaxPanAngle);
targetPitch = Mathf.Clamp(targetPitch, _originalCameraRotation.y - _settings.MaxPanAngle, _originalCameraRotation.y + _settings.MaxPanAngle);
float currentYaw = _playerTransform.eulerAngles.y;
float currentPitch = _camera.transform.localEulerAngles.x;
if (currentPitch > 180f)
currentPitch -= 360f;
float lerpSpeed = hasInput ? _settings.PanSpeed : _settings.PanSpeed * 2f;
float newYaw = Mathf.LerpAngle(currentYaw, targetYaw, Time.deltaTime * lerpSpeed);
float newPitch = Mathf.LerpAngle(currentPitch, targetPitch, Time.deltaTime * lerpSpeed);
_playerTransform.eulerAngles = new Vector3(0f, newYaw, 0f);
_camera.transform.localEulerAngles = new Vector3(newPitch, 0f, 0f);
}
/// <summary>
/// Aligns the player and camera to the target object.
/// - Player moves along target's local X axis to align with target center
/// - Player rotates to face the target
/// - Camera pitch adjusts to look at target center
/// </summary>
public void AlignToTarget(Transform target, System.Action onComplete = null)
{
if (_alignmentCoroutine != null)
StopCoroutine(_alignmentCoroutine);
_alignmentCoroutine = StartCoroutine(AlignCameraToTargetCoroutine(target, onComplete));
}
/// <summary>
/// Returns player and camera to original position and rotation.
/// </summary>
public void ReturnToOriginalPosition(System.Action onComplete = null)
{
if (_returnCoroutine != null)
StopCoroutine(_returnCoroutine);
_returnCoroutine = StartCoroutine(ReturnToOriginalPositionCoroutine(onComplete));
}
/// <summary>
/// Saves current player and camera state as the original position.
/// Call this before starting alignment.
/// </summary>
public void SaveOriginalState()
{
if (_camera != null)
{
_originalCameraLocalPosition = _camera.transform.localPosition;
_originalCameraLocalRotation = _camera.transform.localEulerAngles;
}
_originalPlayerPosition = _playerTransform.position;
_originalPlayerRotation = _playerTransform.eulerAngles;
}
private IEnumerator AlignCameraToTargetCoroutine(Transform target, System.Action onComplete)
{
if (_camera == null || target == null || _settings == null)
{
onComplete?.Invoke();
yield break;
}
SaveOriginalState();
Vector3 targetCenter = target.position;
Renderer targetRenderer = target.GetComponent<Renderer>();
if (targetRenderer != null)
{
targetCenter = targetRenderer.bounds.center;
}
else
{
Collider targetCollider = target.GetComponent<Collider>();
if (targetCollider != null)
targetCenter = targetCollider.bounds.center;
}
Vector3 startPlayerPos = _playerTransform.position;
Vector3 startPlayerRotation = _playerTransform.eulerAngles;
Vector3 startCameraLocalRotation = _camera.transform.localEulerAngles;
float startPitch = startCameraLocalRotation.x;
if (startPitch > 180f)
startPitch -= 360f;
float elapsed = 0f;
float duration = 1f / _settings.AlignmentSpeed;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = GetInterpolationValue(elapsed / duration);
Vector3 currentCameraPos = _camera.transform.position;
Vector3 toTarget = targetCenter - currentCameraPos;
Vector3 directionToTarget = toTarget.normalized;
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
float targetYaw = targetRotation.eulerAngles.y;
float targetPitch = -Mathf.Asin(directionToTarget.y) * Mathf.Rad2Deg;
Vector3 targetRightAxis = target.right;
Vector3 playerToTargetCenter = targetCenter - _playerTransform.position;
float offsetAlongTargetRight = Vector3.Dot(playerToTargetCenter, targetRightAxis);
Vector3 targetPlayerPos = _playerTransform.position + targetRightAxis * offsetAlongTargetRight;
Vector3 currentPlayerPos = Vector3.Lerp(startPlayerPos, targetPlayerPos, t);
_playerTransform.position = currentPlayerPos;
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, targetYaw, t);
_playerTransform.eulerAngles = new Vector3(0f, currentYaw, 0f);
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
_camera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
yield return null;
}
_alignmentCoroutine = null;
onComplete?.Invoke();
}
private IEnumerator ReturnToOriginalPositionCoroutine(System.Action onComplete)
{
if (_camera == null || _settings == null)
{
onComplete?.Invoke();
yield break;
}
Vector3 startPlayerPos = _playerTransform.position;
Vector3 startPlayerRotation = _playerTransform.eulerAngles;
Vector3 startCameraLocalPos = _camera.transform.localPosition;
Vector3 startCameraLocalRotation = _camera.transform.localEulerAngles;
float startPitch = startCameraLocalRotation.x;
if (startPitch > 180f)
startPitch -= 360f;
float targetPitch = _originalCameraLocalRotation.x;
if (targetPitch > 180f)
targetPitch -= 360f;
float elapsed = 0f;
float duration = 1f / _settings.AlignmentSpeed;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = GetInterpolationValue(elapsed / duration);
_playerTransform.position = Vector3.Lerp(startPlayerPos, _originalPlayerPosition, t);
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, _originalPlayerRotation.y, t);
_playerTransform.eulerAngles = new Vector3(0f, currentYaw, 0f);
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
_camera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
_camera.transform.localPosition = Vector3.Lerp(startCameraLocalPos, _originalCameraLocalPosition, t);
yield return null;
}
_playerTransform.position = _originalPlayerPosition;
_playerTransform.eulerAngles = _originalPlayerRotation;
_camera.transform.localPosition = _originalCameraLocalPosition;
_camera.transform.localEulerAngles = _originalCameraLocalRotation;
_returnCoroutine = null;
onComplete?.Invoke();
}
private float GetInterpolationValue(float t)
{
if (_settings.AlignmentCurve != null && _settings.AlignmentCurve.length > 0)
return _settings.AlignmentCurve.Evaluate(t);
if (_settings.UseSmoothStep)
return Mathf.SmoothStep(0f, 1f, t);
return t;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69e8150bab500424eb366f0ac8761995
@@ -5,6 +5,8 @@ using Unheimlich.Audio;
using Unheimlich.Core;
using Unheimlich.Utility;
using Unheimlich.Gameplay.Player;
using Unheimlich.Gameplay.CameraControl;
using Unheimlich.Data.CameraSettings;
using InputManager = Unheimlich.Managers.PlayerInputManager;
namespace Unheimlich.Gameplay.Player
@@ -14,7 +16,7 @@ namespace Unheimlich.Gameplay.Player
/// Handles:
/// - Equipping/unequipping the diapason with tween animations
/// - Concentration phase (breathing sound and duration)
/// - Listening mode with camera pan control
/// - Listening mode with camera pan control (via CameraPanController)
/// - Tapping the diapason on listenable objects
/// - Visual feedback (vignette, cursor, particles)
/// - Preventing multiple taps on the same emitter
@@ -27,7 +29,8 @@ namespace Unheimlich.Gameplay.Player
[Header("References")]
[SerializeField] private GameObject _diapasonObject;
[SerializeField] private Animator _diapasonAnimator;
[SerializeField] private Camera _playerCamera;
[SerializeField] private UnityEngine.Camera _playerCamera;
[SerializeField] private CameraPanController _cameraPanController;
[Header("Listening Settings")]
[SerializeField] private float _listeningRange = 5f;
@@ -41,13 +44,6 @@ namespace Unheimlich.Gameplay.Player
[SerializeField] private AnimationCurve _diapasonTweenCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
[SerializeField] private float _vibrationDuration = 15f;
[Header("Camera Settings")]
[SerializeField] private float _cameraPanSpeed = 2f;
[SerializeField] private float _cameraPanBorderSize = 0.1f;
[SerializeField] private float _maxCameraPanAngle = 15f;
[SerializeField] private float _cameraAlignmentSpeed = 3f;
[SerializeField] private float _cameraAlignmentThreshold = 0.5f;
[Header("Visual Feedback")]
[SerializeField] private float _vignetteIntensity = 0.5f;
[SerializeField] private ListeningVignetteEffect _vignetteEffect;
@@ -66,7 +62,6 @@ namespace Unheimlich.Gameplay.Player
[Header("Input Action")]
[SerializeField] private InputActionReference _diapasonAction;
[SerializeField] private InputActionReference _lookAction;
[SerializeField] private InputActionReference _fireAction;
public bool IsListening { get; private set; }
@@ -78,17 +73,11 @@ namespace Unheimlich.Gameplay.Player
private int _diapasonTweenId = -1;
private int _audioListenerFadeTweenId = -1;
private int _playerAudioFadeTweenId = -1;
private Vector2 _originalCameraRotation;
private float _originalAmbientVolume;
private Vector3 _diapasonEquippedPosition;
private Vector3 _diapasonHiddenPosition;
private Vector3 _originalCameraLocalPosition;
private Vector3 _originalPlayerPosition;
private Vector3 _originalPlayerRotation;
private Vector3 _originalCameraLocalRotation;
private bool _canUseDiapason;
private bool _isFadingOutConcentration;
private Coroutine _returnCoroutine;
private Coroutine _vibrationCoroutine;
protected override void OnAwake()
@@ -97,15 +86,16 @@ namespace Unheimlich.Gameplay.Player
if (_playerAudioSource == null)
_playerAudioSource = gameObject.AddComponent<AudioSource>();
if (_playerCamera != null)
if (_cameraPanController == null)
{
_originalCameraLocalPosition = _playerCamera.transform.localPosition;
_originalCameraLocalRotation = _playerCamera.transform.localEulerAngles;
_cameraPanController = GetComponent<CameraPanController>();
if (_cameraPanController == null)
{
Debug.LogWarning("[Diapason] CameraPanController not found! Adding one automatically.");
_cameraPanController = gameObject.AddComponent<CameraPanController>();
}
}
_originalPlayerPosition = transform.position;
_originalPlayerRotation = transform.eulerAngles;
if (_diapasonObject != null)
{
_diapasonEquippedPosition = _diapasonObject.transform.localPosition;
@@ -154,9 +144,9 @@ namespace Unheimlich.Gameplay.Player
}
}
if (IsListening)
if (IsListening && _cameraPanController != null)
{
UpdateCursorAndCameraPan();
_cameraPanController.UpdatePan();
CheckForListenableObject();
}
}
@@ -252,11 +242,12 @@ namespace Unheimlich.Gameplay.Player
IsConcentrating = true;
InputManager.SetContext(InputManager.InputContext.Listening);
if (_currentTargetEmitter != null)
if (_currentTargetEmitter != null && _cameraPanController != null)
{
_currentTargetEmitter.SetConcentrating(true);
yield return StartCoroutine(AlignCameraToTarget(_currentTargetEmitter.transform));
_cameraPanController.AlignToTarget(_currentTargetEmitter.transform);
yield return new WaitForSeconds(1f / 3f);
}
if (_diapasonObject != null)
@@ -288,11 +279,10 @@ namespace Unheimlich.Gameplay.Player
EnableVignette(true);
float currentPitch = _playerCamera.transform.localEulerAngles.x;
if (currentPitch > 180f)
currentPitch -= 360f;
_originalCameraRotation = new Vector2(transform.eulerAngles.y, currentPitch);
if (_cameraPanController != null)
{
_cameraPanController.StartPan();
}
Cursor.lockState = CursorLockMode.Confined;
Cursor.visible = false;
@@ -325,11 +315,11 @@ namespace Unheimlich.Gameplay.Player
IsListening = false;
IsConcentrating = false;
if (_returnCoroutine != null)
if (_cameraPanController != null)
{
StopCoroutine(_returnCoroutine);
_cameraPanController.StopPan();
_cameraPanController.ReturnToOriginalPosition();
}
_returnCoroutine = StartCoroutine(ReturnToOriginalPosition());
if (_playerAudioSource != null && _playerAudioSource.isPlaying)
{
@@ -403,182 +393,6 @@ namespace Unheimlich.Gameplay.Player
_isFadingOutConcentration = false;
}
/// <summary>
/// Aligns the player and camera to the target object:
/// - Player moves along target's local X axis to align with target center
/// - Player rotates to face the target
/// - Camera pitch adjusts to look at target center
/// </summary>
private IEnumerator AlignCameraToTarget(Transform target)
{
if (_playerCamera == null || target == null)
yield break;
_originalCameraLocalPosition = _playerCamera.transform.localPosition;
_originalCameraLocalRotation = _playerCamera.transform.localEulerAngles;
_originalPlayerPosition = transform.position;
_originalPlayerRotation = transform.eulerAngles;
Vector3 targetCenter = target.position;
Renderer targetRenderer = target.GetComponent<Renderer>();
if (targetRenderer != null)
{
targetCenter = targetRenderer.bounds.center;
}
else
{
Collider targetCollider = target.GetComponent<Collider>();
if (targetCollider != null)
targetCenter = targetCollider.bounds.center;
}
Vector3 startPlayerPos = transform.position;
Vector3 startPlayerRotation = transform.eulerAngles;
Vector3 startCameraLocalRotation = _playerCamera.transform.localEulerAngles;
float startPitch = startCameraLocalRotation.x;
if (startPitch > 180f)
startPitch -= 360f;
float elapsed = 0f;
float duration = 1f / _cameraAlignmentSpeed;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.SmoothStep(0f, 1f, elapsed / duration);
Vector3 currentCameraPos = _playerCamera.transform.position;
Vector3 toTarget = targetCenter - currentCameraPos;
Vector3 directionToTarget = toTarget.normalized;
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
float targetYaw = targetRotation.eulerAngles.y;
float targetPitch = -Mathf.Asin(directionToTarget.y) * Mathf.Rad2Deg;
Vector3 targetRightAxis = target.right;
Vector3 playerToTargetCenter = targetCenter - transform.position;
float offsetAlongTargetRight = Vector3.Dot(playerToTargetCenter, targetRightAxis);
Vector3 targetPlayerPos = transform.position + targetRightAxis * offsetAlongTargetRight;
Vector3 currentPlayerPos = Vector3.Lerp(startPlayerPos, targetPlayerPos, t);
transform.position = currentPlayerPos;
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, targetYaw, t);
transform.eulerAngles = new Vector3(0f, currentYaw, 0f);
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
_playerCamera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
yield return null;
}
}
/// <summary>
/// Returns player and camera to original position and rotation with smooth lerp.
/// </summary>
private IEnumerator ReturnToOriginalPosition()
{
if (_playerCamera == null)
yield break;
Vector3 startPlayerPos = transform.position;
Vector3 startPlayerRotation = transform.eulerAngles;
Vector3 startCameraLocalPos = _playerCamera.transform.localPosition;
Vector3 startCameraLocalRotation = _playerCamera.transform.localEulerAngles;
float startPitch = startCameraLocalRotation.x;
if (startPitch > 180f)
startPitch -= 360f;
float targetPitch = _originalCameraLocalRotation.x;
if (targetPitch > 180f)
targetPitch -= 360f;
float elapsed = 0f;
float duration = 1f / _cameraAlignmentSpeed;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.SmoothStep(0f, 1f, elapsed / duration);
transform.position = Vector3.Lerp(startPlayerPos, _originalPlayerPosition, t);
float currentYaw = Mathf.LerpAngle(startPlayerRotation.y, _originalPlayerRotation.y, t);
transform.eulerAngles = new Vector3(0f, currentYaw, 0f);
float currentPitch = Mathf.LerpAngle(startPitch, targetPitch, t);
_playerCamera.transform.localEulerAngles = new Vector3(currentPitch, 0f, 0f);
_playerCamera.transform.localPosition = Vector3.Lerp(startCameraLocalPos, _originalCameraLocalPosition, t);
yield return null;
}
transform.position = _originalPlayerPosition;
transform.eulerAngles = _originalPlayerRotation;
_playerCamera.transform.localPosition = _originalCameraLocalPosition;
_playerCamera.transform.localEulerAngles = _originalCameraLocalRotation;
_returnCoroutine = null;
}
/// <summary>
/// Updates cursor position and camera panning during listening mode.
/// Supports both gamepad (look input) and mouse (screen edges).
/// </summary>
private void UpdateCursorAndCameraPan()
{
if (_playerCamera == null)
return;
float panX = 0f;
float panY = 0f;
if (_lookAction != null && _lookAction.action != null)
{
Vector2 lookInput = _lookAction.action.ReadValue<Vector2>();
if (lookInput.sqrMagnitude > 0.01f)
{
panX = lookInput.x;
panY = lookInput.y;
}
}
if (panX == 0f && panY == 0f && Mouse.current != null)
{
Vector2 viewportPos = _playerCamera.ScreenToViewportPoint(Mouse.current.position.ReadValue());
if (viewportPos.x < _cameraPanBorderSize)
panX = -1f;
else if (viewportPos.x > 1f - _cameraPanBorderSize)
panX = 1f;
if (viewportPos.y < _cameraPanBorderSize)
panY = -1f;
else if (viewportPos.y > 1f - _cameraPanBorderSize)
panY = 1f;
}
float targetYaw = _originalCameraRotation.x + (panX * _maxCameraPanAngle);
float targetPitch = _originalCameraRotation.y - (panY * _maxCameraPanAngle);
targetYaw = Mathf.Clamp(targetYaw, _originalCameraRotation.x - _maxCameraPanAngle, _originalCameraRotation.x + _maxCameraPanAngle);
targetPitch = Mathf.Clamp(targetPitch, _originalCameraRotation.y - _maxCameraPanAngle, _originalCameraRotation.y + _maxCameraPanAngle);
targetPitch = Mathf.Clamp(targetPitch, -89f, 89f);
Vector3 currentRotation = transform.eulerAngles;
currentRotation.y = Mathf.LerpAngle(currentRotation.y, targetYaw, Time.deltaTime * _cameraPanSpeed);
transform.eulerAngles = currentRotation;
Vector3 cameraRotation = _playerCamera.transform.localEulerAngles;
if (cameraRotation.x > 180f)
cameraRotation.x -= 360f;
cameraRotation.x = Mathf.LerpAngle(cameraRotation.x, targetPitch, Time.deltaTime * _cameraPanSpeed);
_playerCamera.transform.localEulerAngles = cameraRotation;
}
/// <summary>
/// Raycasts from camera center to detect listenable objects.
/// Handles tap input and prevents tapping the same emitter multiple times.