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(true)?.transform; if (cameraTransform != null) cam = cameraTransform.GetComponent(); 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 // ============================================================ } }