247 lines
11 KiB
C#
247 lines
11 KiB
C#
|
|
using ActionRPG.Input;
|
||
|
|
using UnityEngine;
|
||
|
|
|
||
|
|
namespace ActionRPG.Camera
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// 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.
|
||
|
|
/// </summary>
|
||
|
|
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 ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// <summary>Enables or disables lock-on to a target.</summary>
|
||
|
|
public void SetLockOn(bool enabled, Transform target = null)
|
||
|
|
{
|
||
|
|
_isLockedOn = enabled;
|
||
|
|
_lockOnTarget = target != null ? target : _lockOnTarget;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Switches the camera between left and right shoulder.</summary>
|
||
|
|
public void SwitchShoulder()
|
||
|
|
{
|
||
|
|
if (!_allowShoulderSwitch) return;
|
||
|
|
_isRightShoulder = !_isRightShoulder;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Triggers a camera shake effect.</summary>
|
||
|
|
/// <param name="magnitude">Strength of the shake.</param>
|
||
|
|
/// <param name="duration">Duration in seconds (controls decay speed).</param>
|
||
|
|
public void Shake(float magnitude, float duration = 1f)
|
||
|
|
{
|
||
|
|
_shakeMagnitude = magnitude;
|
||
|
|
_shakeTimer = duration;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Returns the camera's forward vector with Y zeroed and normalised. Use for movement direction.</summary>
|
||
|
|
public Vector3 CameraForwardFlat => new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized;
|
||
|
|
|
||
|
|
/// <summary>Returns the camera's right vector with Y zeroed and normalised.</summary>
|
||
|
|
public Vector3 CameraRightFlat => new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized;
|
||
|
|
|
||
|
|
/// <summary>Returns the camera's current yaw in world space.</summary>
|
||
|
|
public float CameraYaw => _currentYaw;
|
||
|
|
|
||
|
|
private void OnDrawGizmosSelected()
|
||
|
|
{
|
||
|
|
if (_cameraBoom == null) return;
|
||
|
|
Gizmos.color = Color.cyan;
|
||
|
|
Gizmos.DrawWireSphere(_cameraBoom.position, _collisionRadius);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|