using UnityEngine; using Ashwild.Audio; namespace Ashwild.Player { /// /// Plays the local player's footstep and landing sounds, picking the clip set from the /// surface underfoot. A footstep fires every "stride" meters of horizontal travel, the stride /// being chosen by gait — so the step rate scales with speed (sprinting sounds faster than /// walking) and stays in sync with movement. The surface is sampled lazily and cached. /// [DisallowMultipleComponent] public class PlayerAudio : MonoBehaviour { #region Serialized Fields [Header("Stride (meters of travel between footsteps — smaller = faster cadence)")] [SerializeField, Tooltip("Step rate is speed / stride, so smaller strides give a faster footstep cadence. Tune per gait.")] private float walkStride = 2.75f; [SerializeField] private float sprintStride = 2.8f; [SerializeField] private float crouchStride = 1.5f; [Header("Audio")] [SerializeField] private AudioSource audioSource; [Header("Surface profiles")] [SerializeField] private SurfaceSoundProfile defaultProfile; [SerializeField] private SurfaceSoundProfile[] surfaceProfiles; [SerializeField, Tooltip("Resample dominant terrain layer only after the player moves this many meters since the last sample.")] private float surfaceResampleDistance = 0.5f; [Header("Landing sound")] [SerializeField, Tooltip("Minimum drop in meters required before the landing sound fires. Avoids spam on stairs and terrain bumps where the capsule briefly leaves the ground.")] private float minLandingSoundDrop = 0.5f; #endregion #region State private Vector2 moveInput; private bool isGrounded = true; private bool isSprinting; private bool isCrouching; private PhysicsMaterial groundMaterial; private float distanceTravelled; private bool wasMoving; private Vector3 lastPosition; private int lastClipIndex = -1; private SurfaceSoundProfile cachedSurface; private Vector3 lastSampledPosition; private bool surfaceCacheValid; #endregion #region Unity Lifecycle /// /// Seeds the position trackers so the first frame doesn't report a huge delta. /// private void Start() { lastPosition = transform.position; lastSampledPosition = lastPosition; } /// /// Subscribes to the movement and ground events that drive footstep playback. /// private void OnEnable() { PlayerEvents.MoveInput += OnMoveInput; PlayerEvents.SprintChanged += OnSprintChanged; PlayerEvents.CrouchChanged += OnCrouchChanged; PlayerEvents.GroundedChanged += OnGroundedChanged; PlayerEvents.GroundMaterialChanged += OnGroundMaterialChanged; } /// /// Unsubscribes — must mirror OnEnable exactly. /// private void OnDisable() { PlayerEvents.MoveInput -= OnMoveInput; PlayerEvents.SprintChanged -= OnSprintChanged; PlayerEvents.CrouchChanged -= OnCrouchChanged; PlayerEvents.GroundedChanged -= OnGroundedChanged; PlayerEvents.GroundMaterialChanged -= OnGroundMaterialChanged; } /// /// Accumulates horizontal distance travelled and fires a footstep every time it crosses the /// current stride. Driving steps off real distance (not a timer) is what keeps them in sync: /// the faster the player moves, the sooner the stride is covered, so sprinting produces a /// faster cadence on its own. /// /// The very first step when movement begins is played immediately rather than after a full /// stride, otherwise there is an audible 0.5s+ gap before the first footstep when you start /// walking. The leftover distance is carried into the next step (subtracted, not zeroed) so /// the per-frame overshoot doesn't randomly stretch steps at high speed; the second check /// clamps a single huge delta (e.g. a teleport) so it can't fire a burst of steps. /// /// While airborne the accumulator is frozen (not reset) and lastPosition keeps tracking, so /// a one-frame ground-check flicker on bumps or stairs no longer wipes the accumulated /// distance — that wipe was causing random silent gaps mid-walk. It only resets when the /// player is genuinely stationary. /// private void Update() { if (PlayerEvents.IsDead) return; if (!isGrounded) { lastPosition = transform.position; return; } Vector3 current = transform.position; Vector3 delta = current - lastPosition; delta.y = 0f; float moved = delta.magnitude; lastPosition = current; if (moveInput.sqrMagnitude < 0.01f) { distanceTravelled = 0f; wasMoving = false; return; } if (!wasMoving) { wasMoving = true; distanceTravelled = 0f; PlayFootstep(); return; } distanceTravelled += moved; float stride = GetCurrentStride(); if (distanceTravelled >= stride) { PlayFootstep(); distanceTravelled -= stride; if (distanceTravelled >= stride) distanceTravelled = 0f; } } #endregion #region Bus Handlers /// /// Caches the raw move input; a near-zero magnitude stops footstep accumulation. /// private void OnMoveInput(Vector2 v) => moveInput = v; /// /// Tracks sprint state to select the sprint stride and clip volume. /// private void OnSprintChanged(bool b) => isSprinting = b; /// /// Tracks crouch state to select the crouch stride and clip volume. /// private void OnCrouchChanged(bool b) => isCrouching = b; /// /// Updates grounded state and plays a landing sound on touchdown when the drop is large /// enough. The step accumulator is deliberately left untouched here: Update already freezes /// it while airborne, and wiping it on every un-ground turned brief ground-check flickers /// (stairs, terrain bumps) into random silent gaps in the footstep cadence. /// private void OnGroundedChanged(bool grounded, float dropDistance) { bool wasGrounded = isGrounded; isGrounded = grounded; if (grounded && !wasGrounded && dropDistance >= minLandingSoundDrop) PlayLandingSound(); } /// /// Records the new ground physics material and invalidates the surface cache so the next /// footstep resolves the matching profile. /// private void OnGroundMaterialChanged(PhysicsMaterial mat) { groundMaterial = mat; surfaceCacheValid = false; } #endregion #region Step Cadence /// /// Returns the meters of travel between footsteps for the current gait. Crouch takes /// priority over sprint (you can't sprint while crouched), and walking is the default. /// private float GetCurrentStride() { if (isCrouching) return crouchStride; if (isSprinting) return sprintStride; return walkStride; } #endregion #region Surface Detection /// /// Returns the surface profile underfoot, resampling only after the player has moved past /// the resample distance or the ground material changed — GetAlphamaps is too costly to /// run every frame, so the result is cached between samples. /// private SurfaceSoundProfile GetCurrentSurface() { if (surfaceProfiles == null || surfaceProfiles.Length == 0) return defaultProfile; if (!surfaceCacheValid || (transform.position - lastSampledPosition).sqrMagnitude >= surfaceResampleDistance * surfaceResampleDistance) { cachedSurface = ResolveSurface(); lastSampledPosition = transform.position; surfaceCacheValid = true; } return cachedSurface ?? defaultProfile; } /// /// Resolves the surface profile, preferring the dominant terrain layer and falling back to /// the ground physics material, then to the default profile. /// private SurfaceSoundProfile ResolveSurface() { SurfaceSoundProfile terrain = ResolveTerrainSurface(); if (terrain != null) return terrain; if (groundMaterial != null) { for (int i = 0; i < surfaceProfiles.Length; i++) if (surfaceProfiles[i] != null && surfaceProfiles[i].physicsMaterial == groundMaterial) return surfaceProfiles[i]; } return defaultProfile; } /// /// Samples the active terrain's alphamap at the player position, finds the dominant layer, /// and returns the profile mapped to that layer. Returns null when there's no terrain or no /// profile matches, letting the caller fall back to physics material / default. /// private SurfaceSoundProfile ResolveTerrainSurface() { Terrain terrain = Terrain.activeTerrain; if (terrain == null) return null; TerrainData td = terrain.terrainData; if (td == null || td.terrainLayers == null || td.terrainLayers.Length == 0) return null; Vector3 terrainPos = terrain.transform.position; Vector3 playerPos = transform.position; float nx = Mathf.Clamp01((playerPos.x - terrainPos.x) / td.size.x); float nz = Mathf.Clamp01((playerPos.z - terrainPos.z) / td.size.z); int mapX = Mathf.RoundToInt(nx * (td.alphamapWidth - 1)); int mapZ = Mathf.RoundToInt(nz * (td.alphamapHeight - 1)); float[,,] alpha = td.GetAlphamaps(mapX, mapZ, 1, 1); int dominantIndex = 0; float maxWeight = 0f; int layers = alpha.GetLength(2); for (int i = 0; i < layers; i++) { float w = alpha[0, 0, i]; if (w > maxWeight) { maxWeight = w; dominantIndex = i; } } if (dominantIndex >= td.terrainLayers.Length) return null; TerrainLayer dominant = td.terrainLayers[dominantIndex]; for (int i = 0; i < surfaceProfiles.Length; i++) if (surfaceProfiles[i] != null && surfaceProfiles[i].terrainLayer == dominant) return surfaceProfiles[i]; return null; } #endregion #region Playback /// /// Plays one footstep from the current surface at the gait-appropriate volume and a random /// pitch, then raises the footstep and surface bus events for other listeners (UI, etc.). /// Stops the source first so a previous step that is still ringing out (some clips are /// longer than the stride interval) is cut instead of overlapping into a muddy double-step. /// private void PlayFootstep() { SurfaceSoundProfile profile = GetCurrentSurface(); if (profile == null || profile.footstepClips == null || profile.footstepClips.Length == 0) return; float volume = isCrouching ? profile.crouchVolume : isSprinting ? profile.sprintVolume : profile.walkVolume; float pitch = Random.Range(profile.pitchMin, profile.pitchMax); AudioClip clip = PickRandomClip(profile.footstepClips, ref lastClipIndex); if (audioSource == null) return; audioSource.Stop(); audioSource.pitch = pitch; audioSource.PlayOneShot(clip, volume); PlayerEvents.RaiseFootstepTriggered(profile, volume); PlayerEvents.RaiseSurfaceChanged(profile); } /// /// Plays the landing sound for the current surface at neutral pitch. /// private void PlayLandingSound() { SurfaceSoundProfile profile = GetCurrentSurface(); if (profile == null || profile.landingClips == null || profile.landingClips.Length == 0) return; int idx = -1; AudioClip clip = PickRandomClip(profile.landingClips, ref idx); if (audioSource == null) return; audioSource.pitch = 1f; audioSource.PlayOneShot(clip, profile.landingVolume); } /// /// Returns a random clip from the set, never repeating the previous index so two identical /// steps don't play back to back. Updates lastIndex with the chosen clip. /// private AudioClip PickRandomClip(AudioClip[] clips, ref int lastIndex) { if (clips.Length == 1) return clips[0]; int index; do { index = Random.Range(0, clips.Length); } while (index == lastIndex); lastIndex = index; return clips[index]; } #endregion } }