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