358 lines
14 KiB
C#
358 lines
14 KiB
C#
using UnityEngine;
|
|
using Ashwild.Settings;
|
|
|
|
namespace Ashwild.Player
|
|
{
|
|
[DisallowMultipleComponent]
|
|
public class PlayerCamera : MonoBehaviour
|
|
{
|
|
// ============================================================
|
|
// Tunables
|
|
// ============================================================
|
|
|
|
[Header("References")]
|
|
[SerializeField] private Transform cameraTransform;
|
|
|
|
[Header("Look")]
|
|
[SerializeField, Tooltip("Used if SettingsManager is not present.")]
|
|
private float mouseSensitivity = 0.15f;
|
|
[SerializeField] private bool invertY = false;
|
|
[SerializeField, Range(0f, 89f)] private float verticalClampAngle = 85f;
|
|
|
|
[Header("Dynamic FOV (base FOV comes from SettingsManager)")]
|
|
[SerializeField] private bool enableDynamicFOV = true;
|
|
[SerializeField, Tooltip("Fallback base FOV if SettingsManager is not present.")]
|
|
private float fallbackBaseFOV = 75f;
|
|
[SerializeField] private float sprintFovOffset = 10f;
|
|
[SerializeField] private float crouchFovOffset = -5f;
|
|
[SerializeField] private float fovSmooth = 6f;
|
|
|
|
[Header("Head bob")]
|
|
[SerializeField] private bool enableHeadBob = true;
|
|
[SerializeField] private float headBobFrequency = 8f;
|
|
[SerializeField] private float headBobAmplitudeY = 0.02f;
|
|
[SerializeField] private float headBobAmplitudeX = 0.01f;
|
|
[SerializeField] private float sprintBobMultiplier = 1.4f;
|
|
[SerializeField] private float crouchBobMultiplier = 0.5f;
|
|
|
|
[Header("Idle breathing")]
|
|
[SerializeField] private bool enableIdleBreathing = true;
|
|
[SerializeField] private float idleBreathFrequency = 0.6f;
|
|
[SerializeField] private float idleBreathAmplitude = 0.002f;
|
|
|
|
[Header("Landing spring")]
|
|
[SerializeField] private bool enableLandingFeel = true;
|
|
[SerializeField] private float landingOffsetAmount = 0.15f;
|
|
[SerializeField] private float landingPitchMin = 3f;
|
|
[SerializeField] private float landingPitchMax = 12f;
|
|
[SerializeField, Tooltip("Drop in meters below which no landing kick fires.")]
|
|
private float landingDropMin = 1f;
|
|
[SerializeField, Tooltip("Drop in meters at which the landing kick reaches max intensity.")]
|
|
private float landingDropMax = 10f;
|
|
[SerializeField] private float landingSpringStiffness = 120f;
|
|
[SerializeField] private float landingSpringDamping = 8f;
|
|
|
|
[Header("Strafe tilt")]
|
|
[SerializeField] private bool enableStrafeTilt = true;
|
|
[SerializeField] private float tiltAmount = 2f;
|
|
[SerializeField] private float tiltSmooth = 6f;
|
|
|
|
[Header("Death pose")]
|
|
[SerializeField] private float deathCameraHeight = -0.3f;
|
|
[SerializeField] private float deathPitch = 20f;
|
|
[SerializeField] private float deathRoll = 35f;
|
|
[SerializeField] private float deathFallSpeed = 4f;
|
|
|
|
// ============================================================
|
|
// Wiring
|
|
// ============================================================
|
|
|
|
private Camera cam;
|
|
|
|
// ============================================================
|
|
// Runtime
|
|
// ============================================================
|
|
|
|
private Vector2 lookInput;
|
|
private float yaw;
|
|
private float pitch;
|
|
|
|
private Vector2 strafeFromMoveInput;
|
|
private float currentSpeed;
|
|
private bool isSprinting;
|
|
private bool isCrouching;
|
|
private bool isGrounded = true;
|
|
private bool wasGroundedLastFrame = true;
|
|
|
|
private float baseCameraY = 0.85f;
|
|
private float bobTimer;
|
|
private float idleTimer;
|
|
private float currentBobIntensity;
|
|
private float currentTilt;
|
|
|
|
// Landing spring
|
|
private float pitchSpringPos, pitchSpringVel;
|
|
private float offsetSpringPos, offsetSpringVel;
|
|
|
|
// Death
|
|
private float deathTimer;
|
|
private float deathStartY;
|
|
private float currentDeathHeight;
|
|
private float currentDeathPitch;
|
|
private float currentDeathRoll;
|
|
|
|
public float Yaw => yaw;
|
|
|
|
// ============================================================
|
|
// Lifecycle
|
|
// ============================================================
|
|
|
|
private void Awake()
|
|
{
|
|
if (cameraTransform == null) cameraTransform = GetComponentInChildren<Camera>(true)?.transform;
|
|
if (cameraTransform != null) cam = cameraTransform.GetComponent<Camera>();
|
|
|
|
baseCameraY = cameraTransform != null ? cameraTransform.localPosition.y : 0.85f;
|
|
|
|
yaw = transform.eulerAngles.y;
|
|
pitch = 0f;
|
|
|
|
Cursor.lockState = CursorLockMode.Locked;
|
|
Cursor.visible = false;
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.LookInput += OnLookInput;
|
|
PlayerEvents.MoveInput += OnMoveInputBus;
|
|
PlayerEvents.VelocityChanged += OnVelocityChanged;
|
|
PlayerEvents.SprintChanged += OnSprintChanged;
|
|
PlayerEvents.CrouchChanged += OnCrouchChanged;
|
|
PlayerEvents.GroundedChanged += OnGroundedChanged;
|
|
PlayerEvents.CameraHeightChanged += OnCameraHeightChanged;
|
|
PlayerEvents.Died += BeginDeath;
|
|
PlayerEvents.Respawned += EndDeath;
|
|
|
|
if (SettingsManager.Instance != null)
|
|
{
|
|
SettingsManager.Instance.onSettingsChanged.AddListener(ApplySettings);
|
|
ApplySettings();
|
|
}
|
|
else if (cam != null)
|
|
{
|
|
cam.fieldOfView = fallbackBaseFOV;
|
|
}
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.LookInput -= OnLookInput;
|
|
PlayerEvents.MoveInput -= OnMoveInputBus;
|
|
PlayerEvents.VelocityChanged -= OnVelocityChanged;
|
|
PlayerEvents.SprintChanged -= OnSprintChanged;
|
|
PlayerEvents.CrouchChanged -= OnCrouchChanged;
|
|
PlayerEvents.GroundedChanged -= OnGroundedChanged;
|
|
PlayerEvents.CameraHeightChanged -= OnCameraHeightChanged;
|
|
PlayerEvents.Died -= BeginDeath;
|
|
PlayerEvents.Respawned -= EndDeath;
|
|
if (SettingsManager.Instance != null)
|
|
SettingsManager.Instance.onSettingsChanged.RemoveListener(ApplySettings);
|
|
}
|
|
|
|
private void ApplySettings()
|
|
{
|
|
var s = SettingsManager.Instance;
|
|
if (s == null) return;
|
|
mouseSensitivity = s.Sensitivity;
|
|
invertY = s.InvertY;
|
|
if (cam != null) cam.fieldOfView = s.Fov;
|
|
}
|
|
|
|
// ============================================================
|
|
// Event handlers
|
|
// ============================================================
|
|
|
|
private void OnLookInput(Vector2 v) => lookInput = v;
|
|
private void OnMoveInputBus(Vector2 v) => strafeFromMoveInput = v;
|
|
private void OnVelocityChanged(Vector3 v) => currentSpeed = v.magnitude;
|
|
private void OnSprintChanged(bool b) => isSprinting = b;
|
|
private void OnCrouchChanged(bool b) => isCrouching = b;
|
|
private void OnGroundedChanged(bool grounded, float dropDistance)
|
|
{
|
|
wasGroundedLastFrame = isGrounded;
|
|
isGrounded = grounded;
|
|
if (grounded && !wasGroundedLastFrame && enableLandingFeel && dropDistance > landingDropMin)
|
|
{
|
|
float fallIntensity = Mathf.InverseLerp(landingDropMin, landingDropMax, dropDistance);
|
|
float pitchKick = Mathf.Lerp(landingPitchMin, landingPitchMax, fallIntensity);
|
|
float offsetKick = -landingOffsetAmount * Mathf.Lerp(0.3f, 1f, fallIntensity);
|
|
pitchSpringVel += pitchKick * landingSpringStiffness * 0.1f;
|
|
offsetSpringVel += offsetKick * landingSpringStiffness * 0.1f;
|
|
}
|
|
}
|
|
private void OnCameraHeightChanged(float y) => baseCameraY = y;
|
|
|
|
// ============================================================
|
|
// Main loop
|
|
// ============================================================
|
|
|
|
private void LateUpdate()
|
|
{
|
|
bool blocked = PlayerEvents.InputLocked;
|
|
ApplyLookRotation(blocked);
|
|
|
|
if (cameraTransform == null) return;
|
|
|
|
// Build camera local position: base height + bob + breathing + spring
|
|
Vector3 localPos = new Vector3(0f, baseCameraY, 0f);
|
|
|
|
if (!blocked)
|
|
{
|
|
UpdateBobTimers();
|
|
localPos += ComputeBobOffset();
|
|
localPos += ComputeBreathingOffset();
|
|
}
|
|
else
|
|
{
|
|
currentBobIntensity = 0f;
|
|
}
|
|
|
|
AdvanceSpring();
|
|
localPos.y += offsetSpringPos;
|
|
|
|
// Death drop
|
|
if (PlayerEvents.IsDead)
|
|
{
|
|
deathTimer += Time.deltaTime;
|
|
float t = 1f - Mathf.Exp(-deathFallSpeed * deathTimer);
|
|
currentDeathHeight = Mathf.Lerp(deathStartY, deathCameraHeight, t);
|
|
currentDeathPitch = Mathf.Lerp(currentDeathPitch, deathPitch, t);
|
|
currentDeathRoll = Mathf.Lerp(currentDeathRoll, deathRoll, t);
|
|
localPos.y = currentDeathHeight + offsetSpringPos;
|
|
}
|
|
|
|
cameraTransform.localPosition = localPos;
|
|
|
|
// Strafe tilt
|
|
float targetTilt = enableStrafeTilt ? -strafeFromMoveInput.x * tiltAmount : 0f;
|
|
currentTilt = Mathf.Lerp(currentTilt, targetTilt, Time.deltaTime * tiltSmooth);
|
|
|
|
// Final rotation: pitch on X, yaw on Y, roll on Z — all on the camera child so the
|
|
// player root (Rigidbody) never rotates. Avoids interpolation jitter from physics.
|
|
cameraTransform.localRotation = Quaternion.Euler(pitch + pitchSpringPos + currentDeathPitch, yaw, currentTilt + currentDeathRoll);
|
|
|
|
UpdateDynamicFov();
|
|
}
|
|
|
|
// ============================================================
|
|
// Look
|
|
// ============================================================
|
|
|
|
private void ApplyLookRotation(bool blocked)
|
|
{
|
|
float mx = blocked ? 0f : lookInput.x * mouseSensitivity;
|
|
float my = blocked ? 0f : lookInput.y * mouseSensitivity;
|
|
yaw += mx;
|
|
pitch += invertY ? my : -my;
|
|
pitch = Mathf.Clamp(pitch, -verticalClampAngle, verticalClampAngle);
|
|
PlayerEvents.RaiseYawChanged(yaw);
|
|
}
|
|
|
|
// ============================================================
|
|
// Bob + breathing
|
|
// ============================================================
|
|
|
|
private void UpdateBobTimers()
|
|
{
|
|
float dt = Time.deltaTime;
|
|
float target = (isGrounded && currentSpeed > 0.1f) ? 1f : 0f;
|
|
currentBobIntensity = Mathf.Lerp(currentBobIntensity, target, dt * 4f);
|
|
|
|
if (isGrounded && currentSpeed > 0.1f)
|
|
bobTimer += dt * headBobFrequency * (currentSpeed / 5f);
|
|
|
|
if (currentBobIntensity < 0.05f) idleTimer += dt;
|
|
else idleTimer = 0f;
|
|
}
|
|
|
|
private Vector3 ComputeBobOffset()
|
|
{
|
|
if (!enableHeadBob) return Vector3.zero;
|
|
float mult = isCrouching ? crouchBobMultiplier : (isSprinting ? sprintBobMultiplier : 1f);
|
|
return new Vector3(
|
|
Mathf.Cos(bobTimer * 0.5f) * headBobAmplitudeX * mult * currentBobIntensity,
|
|
Mathf.Sin(bobTimer) * headBobAmplitudeY * mult * currentBobIntensity,
|
|
0f);
|
|
}
|
|
|
|
private Vector3 ComputeBreathingOffset()
|
|
{
|
|
if (!enableIdleBreathing) return Vector3.zero;
|
|
float weight = 1f - currentBobIntensity;
|
|
float phase = idleTimer * idleBreathFrequency * Mathf.PI * 2f;
|
|
return new Vector3(
|
|
Mathf.Cos(phase * 0.7f) * idleBreathAmplitude * 0.5f * weight,
|
|
Mathf.Sin(phase) * idleBreathAmplitude * weight,
|
|
Mathf.Sin(phase * 0.4f) * idleBreathAmplitude * 0.3f * weight);
|
|
}
|
|
|
|
// ============================================================
|
|
// Landing spring (F = -kx - bv)
|
|
// ============================================================
|
|
|
|
private void AdvanceSpring()
|
|
{
|
|
float dt = Time.deltaTime;
|
|
|
|
float pitchForce = -landingSpringStiffness * pitchSpringPos - landingSpringDamping * pitchSpringVel;
|
|
pitchSpringVel += pitchForce * dt;
|
|
pitchSpringPos += pitchSpringVel * dt;
|
|
|
|
float offsetForce = -landingSpringStiffness * offsetSpringPos - landingSpringDamping * offsetSpringVel;
|
|
offsetSpringVel += offsetForce * dt;
|
|
offsetSpringPos += offsetSpringVel * dt;
|
|
}
|
|
|
|
// ============================================================
|
|
// FOV
|
|
// ============================================================
|
|
|
|
private void UpdateDynamicFov()
|
|
{
|
|
if (cam == null || !enableDynamicFOV) return;
|
|
float baseFov = SettingsManager.Instance != null ? SettingsManager.Instance.Fov : fallbackBaseFOV;
|
|
float target = baseFov;
|
|
bool dead = PlayerEvents.IsDead;
|
|
if (!dead)
|
|
{
|
|
if (isCrouching) target = baseFov + crouchFovOffset;
|
|
else if (isSprinting && currentSpeed > 0.5f) target = baseFov + sprintFovOffset;
|
|
}
|
|
cam.fieldOfView = Mathf.Lerp(cam.fieldOfView, target, Time.deltaTime * fovSmooth);
|
|
}
|
|
|
|
// ============================================================
|
|
// Death
|
|
// ============================================================
|
|
|
|
private void BeginDeath()
|
|
{
|
|
deathTimer = 0f;
|
|
deathStartY = baseCameraY;
|
|
}
|
|
|
|
private void EndDeath()
|
|
{
|
|
deathTimer = 0f;
|
|
currentDeathHeight = 0f;
|
|
currentDeathPitch = 0f;
|
|
currentDeathRoll = 0f;
|
|
}
|
|
|
|
// ============================================================
|
|
// Misc
|
|
// ============================================================
|
|
|
|
}
|
|
}
|