Files
Emberwild/Assets/GAME/Script/Player/Camera/PlayerCamera.cs
T
2026-06-22 16:18:34 +02:00

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
// ============================================================
}
}