using UnityEngine; using Unheimlich.Core.Interaction; using Unheimlich.World.CameraSystem; namespace Unheimlich.Features.Player { /// /// Simulates camera head bobbing motion during player movement and idle states. /// Smoothly transitions between idle, walk and run bob parameters. /// Adds a subtle Z-axis roll to the bob and a strafe tilt applied in LateUpdate. /// public class CameraHeadBob : MonoBehaviour { private float _timer = 0f; private Vector3 _startLocalPos; // Smoothly interpolated bob parameters to avoid brutal state switches. private float _currentBobSpeed = 0f; private float _currentBobAmplitude = 0f; // Strafe tilt (Z roll applied in LateUpdate, after OctoPlayer sets camera pitch). private float _currentTilt = 0f; private HeadBobMode _overrideMode = HeadBobMode.Normal; private bool _hasOverride = false; private Coroutine _resetCoroutine; private void Start() { _startLocalPos = transform.localPosition; } private void Update() { if (PlayerController.Instance == null || PlayerController.Instance.CharController == null) return; var settings = PlayerController.Instance.CameraEffects?.Settings; if (settings == null) return; HeadBobMode currentMode = _hasOverride ? _overrideMode : HeadBobMode.Normal; if (currentMode == HeadBobMode.Disabled) { SetBobDirect(settings.idleBobSpeed, 0f); ApplySmoothedBob(settings.bobRollAmplitude); return; } bool forceIdle = currentMode == HeadBobMode.Idle || (PlayerInspectionHandler.Instance != null && PlayerInspectionHandler.Instance.IsInspecting); if (forceIdle) { SetBobDirect(settings.idleBobSpeed, settings.idleBobAmplitude); ApplySmoothedBob(settings.bobRollAmplitude); return; } bool isGrounded = PlayerController.Instance.CharController.isGrounded; if (!isGrounded) { SetBobDirect(settings.idleBobSpeed, 0f); ApplySmoothedBob(settings.bobRollAmplitude); return; } // Drive bob parameters directly from the actual physics velocity. // This is naturally in sync with OctoPlayer's inertia — no extra lerp needed. float horizontalSpeed = new Vector3( PlayerController.Instance.CharController.velocity.x, 0f, PlayerController.Instance.CharController.velocity.z).magnitude; bool isRunning = PlayerController.Instance.IsRunning; // Normalise: 0 = stopped, 1 = full walk/run speed (m/s converted from km/h ÷ 3.6). const float WalkSpeedMs = 10f / 3.6f; const float RunSpeedMs = 20f / 3.6f; float maxSpeed = isRunning ? RunSpeedMs : WalkSpeedMs; float t = Mathf.Clamp01(horizontalSpeed / maxSpeed); float targetSpeed = Mathf.Lerp(settings.idleBobSpeed, isRunning ? settings.runBobSpeed : settings.walkBobSpeed, t); float targetAmplitude = Mathf.Lerp(settings.idleBobAmplitude, isRunning ? settings.runBobAmplitude : settings.walkBobAmplitude, t); SetBobDirect(targetSpeed, targetAmplitude); ApplySmoothedBob(settings.bobRollAmplitude); } /// /// Applies strafe tilt in LateUpdate so it composites correctly over OctoPlayer's pitch rotation. /// private void LateUpdate() { if (PlayerController.Instance == null || PlayerController.Instance.CharController == null) return; var settings = PlayerController.Instance.CameraEffects?.Settings; if (settings == null) return; // Strafe direction in local space using actual character velocity. Vector3 velocity = PlayerController.Instance.CharController.velocity; float strafeAmount = Vector3.Dot(velocity, PlayerController.Instance.transform.right); // Normalise by run speed (km/h → m/s: / 3.6) to get a -1..1 factor. float runSpeedMs = PlayerController.Instance.Movement != null ? 20f / 3.6f : 5.5f; float strafeFactor = Mathf.Clamp(strafeAmount / runSpeedMs, -1f, 1f); float targetTilt = -strafeFactor * settings.strafeTiltAngle; _currentTilt = Mathf.Lerp(_currentTilt, targetTilt, Time.deltaTime * settings.strafeTiltSpeed); // Compose with the existing local rotation (OctoPlayer already wrote pitch into it). Quaternion baseRot = transform.localRotation; transform.localRotation = baseRot * Quaternion.Euler(0f, 0f, _currentTilt); } // ── Public API ──────────────────────────────────────────────────────────── /// Overrides the head bob mode (Idle, Normal, Disabled). public void SetMode(HeadBobMode mode) { _overrideMode = mode; _hasOverride = true; } /// Clears any active mode override and returns to Normal. public void ClearOverride() { _hasOverride = false; if (_resetCoroutine != null) { StopCoroutine(_resetCoroutine); _resetCoroutine = null; } } /// Lerps the camera local position back to origin over seconds. public void LerpToZero(float duration) { if (_resetCoroutine != null) StopCoroutine(_resetCoroutine); _resetCoroutine = StartCoroutine(LerpToZeroCoroutine(duration)); } // ── Private helpers ─────────────────────────────────────────────────────── /// Sets bob speed and amplitude directly without lerp — velocity already carries the inertia. private void SetBobDirect(float speed, float amplitude) { _currentBobSpeed = speed; _currentBobAmplitude = amplitude; } /// /// Advances the bob timer and writes the resulting local position offset. /// Includes a subtle Z roll (Lissajous third axis) for organic feel. /// private void ApplySmoothedBob(float rollAmplitude) { _timer += Time.deltaTime * _currentBobSpeed; float x = Mathf.Sin(_timer * 0.5f) * _currentBobAmplitude * 0.6f; float y = Mathf.Cos(_timer) * _currentBobAmplitude; // Micro Z roll: half frequency of Y so it drifts independently. float roll = Mathf.Sin(_timer * 0.5f + Mathf.PI * 0.25f) * rollAmplitude * (_currentBobAmplitude / Mathf.Max(0.001f, 0.03f)); Vector3 newPos = _startLocalPos; newPos.x += x; newPos.y += y; transform.localPosition = newPos; // Store roll so LateUpdate can composite it with the strafe tilt. // We write only the bob-driven roll here; LateUpdate adds strafe tilt on top. Quaternion bobRot = Quaternion.Euler(0f, 0f, roll); transform.localRotation = bobRot; } private System.Collections.IEnumerator LerpToZeroCoroutine(float duration) { Vector3 startPos = transform.localPosition; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / duration); transform.localPosition = Vector3.Lerp(startPos, _startLocalPos, t); yield return null; } transform.localPosition = _startLocalPos; _resetCoroutine = null; } } }