350 lines
14 KiB
C#
350 lines
14 KiB
C#
using UnityEngine;
|
|
using Ashwild.Audio;
|
|
|
|
namespace Ashwild.Player
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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
|
|
|
|
/// <summary>
|
|
/// Seeds the position trackers so the first frame doesn't report a huge delta.
|
|
/// </summary>
|
|
private void Start()
|
|
{
|
|
lastPosition = transform.position;
|
|
lastSampledPosition = lastPosition;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes to the movement and ground events that drive footstep playback.
|
|
/// </summary>
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.MoveInput += OnMoveInput;
|
|
PlayerEvents.SprintChanged += OnSprintChanged;
|
|
PlayerEvents.CrouchChanged += OnCrouchChanged;
|
|
PlayerEvents.GroundedChanged += OnGroundedChanged;
|
|
PlayerEvents.GroundMaterialChanged += OnGroundMaterialChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes — must mirror OnEnable exactly.
|
|
/// </summary>
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.MoveInput -= OnMoveInput;
|
|
PlayerEvents.SprintChanged -= OnSprintChanged;
|
|
PlayerEvents.CrouchChanged -= OnCrouchChanged;
|
|
PlayerEvents.GroundedChanged -= OnGroundedChanged;
|
|
PlayerEvents.GroundMaterialChanged -= OnGroundMaterialChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Caches the raw move input; a near-zero magnitude stops footstep accumulation.
|
|
/// </summary>
|
|
private void OnMoveInput(Vector2 v) => moveInput = v;
|
|
|
|
/// <summary>
|
|
/// Tracks sprint state to select the sprint stride and clip volume.
|
|
/// </summary>
|
|
private void OnSprintChanged(bool b) => isSprinting = b;
|
|
|
|
/// <summary>
|
|
/// Tracks crouch state to select the crouch stride and clip volume.
|
|
/// </summary>
|
|
private void OnCrouchChanged(bool b) => isCrouching = b;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void OnGroundedChanged(bool grounded, float dropDistance)
|
|
{
|
|
bool wasGrounded = isGrounded;
|
|
isGrounded = grounded;
|
|
if (grounded && !wasGrounded && dropDistance >= minLandingSoundDrop) PlayLandingSound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the new ground physics material and invalidates the surface cache so the next
|
|
/// footstep resolves the matching profile.
|
|
/// </summary>
|
|
private void OnGroundMaterialChanged(PhysicsMaterial mat)
|
|
{
|
|
groundMaterial = mat;
|
|
surfaceCacheValid = false;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Step Cadence
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private float GetCurrentStride()
|
|
{
|
|
if (isCrouching) return crouchStride;
|
|
if (isSprinting) return sprintStride;
|
|
return walkStride;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Surface Detection
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the surface profile, preferring the dominant terrain layer and falling back to
|
|
/// the ground physics material, then to the default profile.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plays the landing sound for the current surface at neutral pitch.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|