using ActionRPG.Input; using UnityEngine; namespace ActionRPG.Camera { /// /// Third-person camera controller. Place this script on an empty pivot GameObject. /// Add a child GameObject for the physical camera and assign it to _cameraBoom. /// public class PlayerCameraController : MonoBehaviour { // ── References ───────────────────────────────────────────────── [Header("References")] [SerializeField] private PlayerInputReader _input; [SerializeField] private Transform _followTarget; [SerializeField] private UnityEngine.Camera _camera; [SerializeField] private Transform _cameraBoom; // ── Sensitivity & Inversion ──────────────────────────────────── [Header("Sensitivity")] [SerializeField, Min(0.1f)] private float _mouseSensitivity = 5f; [SerializeField, Min(0.1f)] private float _gamepadSensitivity = 120f; [SerializeField] private bool _invertY = false; // ── Camera Position ──────────────────────────────────────────── [Header("Position")] [SerializeField] private float _cameraDistance = 5f; [SerializeField] private float _heightOffset = 1.5f; [SerializeField] private float _horizontalOffset = 0.5f; [SerializeField] private float _tiltOffset = 0f; [SerializeField] private Vector2 _tiltBounds = new Vector2(-20f, 60f); // ── Lag ──────────────────────────────────────────────────────── [Header("Lag")] [SerializeField, Min(0.01f)] private float _positionalLag = 0.05f; [SerializeField, Min(0.01f)] private float _rotationalLag = 0.05f; // ── Shoulder Switch ──────────────────────────────────────────── [Header("Shoulder Switch")] [SerializeField] private bool _allowShoulderSwitch = true; [SerializeField] private float _shoulderSwitchSpeed = 8f; private float _currentHorizontalOffset; private bool _isRightShoulder = true; // ── Collision ────────────────────────────────────────────────── [Header("Collision")] [SerializeField] private bool _enableCollision = true; [SerializeField] private float _collisionRadius = 0.2f; [SerializeField] private LayerMask _collisionLayers; // ── Camera Shake ─────────────────────────────────────────────── [Header("Camera Shake")] [SerializeField, Min(0f)] private float _shakeDecaySpeed = 3f; private float _shakeMagnitude; private float _shakeTimer; // ── Lock-On ──────────────────────────────────────────────────── [Header("Lock-On")] [SerializeField] private bool _isLockedOn; [SerializeField] private Transform _lockOnTarget; [SerializeField, Min(0.1f)] private float _lockOnRotationSpeed = 8f; // ── Cursor ───────────────────────────────────────────────────── [Header("Cursor")] [SerializeField] private bool _hideCursor = true; // ── Internal state ───────────────────────────────────────────── private Vector3 _currentPosition; private float _yaw; private float _pitch; private float _currentYaw; private float _currentPitch; private void Start() { if (_hideCursor) { Cursor.visible = false; Cursor.lockState = CursorLockMode.Locked; } if (_followTarget != null) { transform.position = _followTarget.position; _yaw = transform.eulerAngles.y; _currentYaw = _yaw; } _currentHorizontalOffset = _horizontalOffset; ApplyBoomPosition(); } private void LateUpdate() { if (_followTarget == null) return; HandleRotation(); HandlePositionalFollow(); HandleShoulderOffset(); HandleCollision(); HandleShake(); ApplyBoomPosition(); } // ── Rotation ─────────────────────────────────────────────────── private void HandleRotation() { if (_isLockedOn && _lockOnTarget != null) { // Smoothly rotate to face the lock-on target Vector3 toTarget = _lockOnTarget.position - transform.position; toTarget.y = 0f; if (toTarget != Vector3.zero) { float targetYaw = Quaternion.LookRotation(toTarget).eulerAngles.y; _yaw = Mathf.LerpAngle(_currentYaw, targetYaw, _lockOnRotationSpeed * Time.deltaTime); } // Keep pitch level when locked on _pitch = Mathf.Lerp(_currentPitch, 0f, _lockOnRotationSpeed * Time.deltaTime); } else { Vector2 look = _input.LookInput; float invertSign = _invertY ? 1f : -1f; // Detect gamepad (larger raw values) vs mouse bool isGamepad = look.magnitude > 1f; float sensitivity = isGamepad ? _gamepadSensitivity * Time.deltaTime : _mouseSensitivity; _yaw += look.x * sensitivity; _pitch += look.y * sensitivity * invertSign; _pitch = Mathf.Clamp(_pitch, _tiltBounds.x, _tiltBounds.y); } _currentYaw = Mathf.LerpAngle(_currentYaw, _yaw, _rotationalLag > 0 ? Time.deltaTime / _rotationalLag : 1f); _currentPitch = Mathf.LerpAngle(_currentPitch, _pitch, _rotationalLag > 0 ? Time.deltaTime / _rotationalLag : 1f); transform.rotation = Quaternion.Euler(_currentPitch + _tiltOffset, _currentYaw, 0f); } // ── Positional Follow ────────────────────────────────────────── private void HandlePositionalFollow() { Vector3 targetPos = _followTarget.position + Vector3.up * _heightOffset; _currentPosition = Vector3.Lerp(_currentPosition == Vector3.zero ? targetPos : _currentPosition, targetPos, _positionalLag > 0 ? Time.deltaTime / _positionalLag : 1f); transform.position = _currentPosition; } // ── Shoulder Switch ──────────────────────────────────────────── private void HandleShoulderOffset() { float targetOffset = _isRightShoulder ? _horizontalOffset : -_horizontalOffset; _currentHorizontalOffset = Mathf.Lerp(_currentHorizontalOffset, targetOffset, _shoulderSwitchSpeed * Time.deltaTime); } // ── Collision ────────────────────────────────────────────────── private void HandleCollision() { if (!_enableCollision || _cameraBoom == null) return; Vector3 origin = transform.position; Vector3 dir = _cameraBoom.position - origin; float maxDist = _cameraDistance; if (Physics.SphereCast(origin, _collisionRadius, dir.normalized, out RaycastHit hit, maxDist, _collisionLayers)) { float adjustedDistance = Mathf.Clamp(hit.distance - _collisionRadius, 0.5f, maxDist); _cameraBoom.localPosition = new Vector3(_currentHorizontalOffset, 0f, -adjustedDistance); } } // ── Camera Shake ─────────────────────────────────────────────── private void HandleShake() { if (_shakeTimer > 0f) { _shakeTimer -= Time.deltaTime * _shakeDecaySpeed; float shake = Mathf.Lerp(0f, _shakeMagnitude, _shakeTimer); _cameraBoom.localPosition += (Vector3)Random.insideUnitCircle * shake; } else { _shakeTimer = 0f; } } // ── Boom Position ────────────────────────────────────────────── private void ApplyBoomPosition() { if (_cameraBoom == null) return; _cameraBoom.localPosition = new Vector3(_currentHorizontalOffset, 0f, -_cameraDistance); _cameraBoom.localEulerAngles = Vector3.zero; } // ── Public API ───────────────────────────────────────────────── /// Enables or disables lock-on to a target. public void SetLockOn(bool enabled, Transform target = null) { _isLockedOn = enabled; _lockOnTarget = target != null ? target : _lockOnTarget; } /// Switches the camera between left and right shoulder. public void SwitchShoulder() { if (!_allowShoulderSwitch) return; _isRightShoulder = !_isRightShoulder; } /// Triggers a camera shake effect. /// Strength of the shake. /// Duration in seconds (controls decay speed). public void Shake(float magnitude, float duration = 1f) { _shakeMagnitude = magnitude; _shakeTimer = duration; } /// Returns the camera's forward vector with Y zeroed and normalised. Use for movement direction. public Vector3 CameraForwardFlat => new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized; /// Returns the camera's right vector with Y zeroed and normalised. public Vector3 CameraRightFlat => new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized; /// Returns the camera's current yaw in world space. public float CameraYaw => _currentYaw; private void OnDrawGizmosSelected() { if (_cameraBoom == null) return; Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(_cameraBoom.position, _collisionRadius); } } }