Projext/Assets/Scripts/Player/Sound/Playerfootstep.cs
2026-02-27 09:44:52 +09:00

420 lines
27 KiB
C#

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System; // 시스템 기능을 사용할거에요 -> Array.IndexOf용
// ============================================================
// PlayerFootstep v3
//
// 방식: 발 본(foot.l / foot.r)의 월드 Y 좌표를 매 프레임 추적
// 발이 아래로 내려오다가 멈추는 순간 = 실제 접지 순간
// → 그 순간에 발소리 + 파티클 재생
//
// 장점:
// - 콜라이더 / 물리 설정 불필요
// - 애니메이션 이벤트 등록 불필요
// - 어떤 애니메이션에도 자동 대응
// - 발바닥이 실제로 멈추는 순간을 정확히 감지
//
// 필요 설정:
// 1. Player 오브젝트에 Add Component → PlayerFootstep
// 2. 발소리 클립 슬롯 채우기
// 3. (선택) 발자국 파티클 프리팹 연결
// ============================================================
[RequireComponent(typeof(AudioSource))] // 컴포넌트를 강제 추가할거에요 -> AudioSource를
public class PlayerFootstep : MonoBehaviour // 클래스를 선언할거에요 -> 플레이어 발소리를
{
// ─────────────────────────────────────────────────────────
// 발 본 설정
// ─────────────────────────────────────────────────────────
[Header("=== 발 본 설정 ===")]
[Tooltip("왼발 본 이름 (Hierarchy에서 확인)")]
[SerializeField] private string leftFootBoneName = "foot.l"; // 변수를 선언할거에요 -> 왼발 본 이름을
[Tooltip("오른발 본 이름 (Hierarchy에서 확인)")]
[SerializeField] private string rightFootBoneName = "foot.r"; // 변수를 선언할거에요 -> 오른발 본 이름을
[Tooltip("접지 판정 Y속도 임계값 (음수)\n발이 내려오다 이 속도 이하가 되면 접지로 판정\n기본 -0.002 / 너무 예민하면 -0.005로 낮춰요")]
[SerializeField] private float landingVelocityThreshold = -0.002f; // 변수를 선언할거에요 -> 접지 판정 Y속도 임계값을
[Tooltip("발 높이 최대값 (루트 기준)\n이 높이 이상에서는 발소리 무시 → 점프 중 오감지 방지\n기본 0.25")]
[SerializeField] private float maxFootHeight = 0.25f; // 변수를 선언할거에요 -> 공중 판정 발 높이를
// ─────────────────────────────────────────────────────────
// 이동 속도 설정
// ─────────────────────────────────────────────────────────
[Header("=== 이동 속도 설정 ===")]
[Tooltip("이 속도 이하면 발소리 재생 안 함 (제자리 애니메이션 방지)")]
[SerializeField] private float minSpeedThreshold = 0.1f; // 변수를 선언할거에요 -> 최소 속도를
[Tooltip("이 속도 이상이면 달리기 발소리 세트 사용")]
[SerializeField] private float runSpeedThreshold = 4.0f; // 변수를 선언할거에요 -> 달리기 판정 속도를
[Tooltip("발소리 재생 쿨타임 (초)\n양발 동시 감지 중복 방지 / 기본 0.1")]
[SerializeField] private float footstepCooldown = 0.1f; // 변수를 선언할거에요 -> 발소리 쿨타임을
// ─────────────────────────────────────────────────────────
// 발소리 — 걷기
// ─────────────────────────────────────────────────────────
[Header("=== 발소리 — 걷기 ===")]
[Tooltip("걷기 기본 발소리 클립 배열 (재질 매칭 실패 시 사용)")]
[SerializeField] private AudioClip[] walkFootsteps; // 배열을 선언할거에요 -> 걷기 발소리들을
[Tooltip("걷기 발소리 볼륨 (기본 0.35)")]
[SerializeField][Range(0f, 1f)] private float walkVolume = 0.35f; // 변수를 선언할거에요 -> 걷기 볼륨을
// ─────────────────────────────────────────────────────────
// 발소리 — 달리기
// ─────────────────────────────────────────────────────────
[Header("=== 발소리 — 달리기 ===")]
[Tooltip("달리기 발소리 클립 배열 (없으면 걷기 발소리 사용)")]
[SerializeField] private AudioClip[] runFootsteps; // 배열을 선언할거에요 -> 달리기 발소리들을
[Tooltip("달리기 발소리 볼륨 (기본 0.5)")]
[SerializeField][Range(0f, 1f)] private float runVolume = 0.5f; // 변수를 선언할거에요 -> 달리기 볼륨을
// ─────────────────────────────────────────────────────────
// 발소리 — 바닥 재질별
// ─────────────────────────────────────────────────────────
[Header("=== 발소리 — 바닥 재질별 ===")]
[Tooltip("바닥 감지 Raycast 거리 (발 아래로 쏘는 레이, 기본 0.3)")]
[SerializeField] private float groundRayDistance = 0.3f; // 변수를 선언할거에요 -> 바닥 감지 거리를
[Tooltip("바닥 감지 레이어 (Ground 레이어 선택)")]
[SerializeField] private LayerMask groundLayerMask = ~0; // 변수를 선언할거에요 -> 바닥 레이어를
[Tooltip("재질별 발소리 배열 (SurfaceFootstep.cs 공유)\nTag 또는 PhysicMaterial 이름으로 자동 매칭")]
[SerializeField] private SurfaceFootstep[] surfaceFootsteps; // 배열을 선언할거에요 -> 재질별 발소리 묶음들을
// ─────────────────────────────────────────────────────────
// Pitch / Volume 랜덤
// ─────────────────────────────────────────────────────────
[Header("=== Pitch / Volume 랜덤 ===")]
[Tooltip("Pitch 최솟값 (기본 0.93 → ±7% 범위)")]
[SerializeField][Range(0.5f, 1.5f)] private float pitchMin = 0.93f; // 변수를 선언할거에요 -> 피치 최솟값을
[Tooltip("Pitch 최댓값 (기본 1.07)")]
[SerializeField][Range(0.5f, 1.5f)] private float pitchMax = 1.07f; // 변수를 선언할거에요 -> 피치 최댓값을
[Tooltip("Volume 랜덤 범위 ± (기본 0.05 → ±5%)")]
[SerializeField][Range(0f, 0.2f)] private float volumeVariation = 0.05f; // 변수를 선언할거에요 -> 볼륨 변동 범위를
// ─────────────────────────────────────────────────────────
// 발자국 파티클
// ─────────────────────────────────────────────────────────
[Header("=== 발자국 파티클 ===")]
[Tooltip("발자국 파티클 프리팹 (없으면 파티클 생략)")]
[SerializeField] private GameObject footstepParticlePrefab; // 변수를 선언할거에요 -> 발자국 파티클 프리팹을
[Tooltip("파티클 자동 제거 시간 (초, 기본 2.0)")]
[SerializeField] private float particleDestroyTime = 2.0f; // 변수를 선언할거에요 -> 파티클 제거 시간을
// ─────────────────────────────────────────────────────────
// 참조
// ─────────────────────────────────────────────────────────
[Header("=== 참조 ===")]
[Tooltip("이동 속도 체크용 (비워두면 자동 캐싱)")]
[SerializeField] private PlayerMovement playerMovement; // 변수를 선언할거에요 -> 이동 속도 체크용을
// ─────────────────────────────────────────────────────────
// 내부 변수
// ─────────────────────────────────────────────────────────
private AudioSource _audioSource; // 변수를 선언할거에요 -> AudioSource를
private float _originalPitch; // 변수를 선언할거에요 -> 원래 Pitch를 (복원용)
private AudioClip _lastFootstepClip; // 변수를 선언할거에요 -> 마지막 재생 클립을 (반복 방지)
private float _lastFootstepTime; // 변수를 선언할거에요 -> 마지막 발소리 시간을 (쿨타임용)
private Transform _leftFootBone; // 변수를 선언할거에요 -> 왼발 본 Transform을
private Transform _rightFootBone; // 변수를 선언할거에요 -> 오른발 본 Transform을
private float _leftFootPrevY; // 변수를 선언할거에요 -> 이전 프레임 왼발 Y를 (Y속도 계산용)
private float _rightFootPrevY; // 변수를 선언할거에요 -> 이전 프레임 오른발 Y를 (Y속도 계산용)
private bool _leftFootGrounded; // 변수를 선언할거에요 -> 왼발 현재 접지 상태를 (연속 감지 방지)
private bool _rightFootGrounded; // 변수를 선언할거에요 -> 오른발 현재 접지 상태를 (연속 감지 방지)
// ─────────────────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────────────────
private void Awake() // 함수를 실행할거에요 -> 초기화를
{
_audioSource = GetComponent<AudioSource>(); // 가져올거에요 -> AudioSource를
_audioSource.playOnAwake = false; // 끌거에요 -> 자동 재생을
_originalPitch = _audioSource.pitch; // 저장할거에요 -> 원래 Pitch를
if (playerMovement == null) // 조건이 맞으면 실행할거에요 -> 연결 안 됐으면
playerMovement = GetComponent<PlayerMovement>(); // 가져올거에요 -> 자동 캐싱
_leftFootBone = FindBoneRecursive(transform, leftFootBoneName); // 찾을거에요 -> 왼발 본을
_rightFootBone = FindBoneRecursive(transform, rightFootBoneName); // 찾을거에요 -> 오른발 본을
if (_leftFootBone == null) // 조건이 맞으면 실행할거에요 -> 못 찾으면
Debug.LogWarning($"[PlayerFootstep] 왼발 본 '{leftFootBoneName}' 을 찾지 못했어요!"); // 경고를 출력할거에요
if (_rightFootBone == null) // 조건이 맞으면 실행할거에요 -> 못 찾으면
Debug.LogWarning($"[PlayerFootstep] 오른발 본 '{rightFootBoneName}' 을 찾지 못했어요!"); // 경고를 출력할거에요
}
private void Start() // 함수를 실행할거에요 -> 시작 시 초기 Y 저장
{
// 첫 프레임 오감지 방지용 초기 Y 저장
if (_leftFootBone != null) _leftFootPrevY = _leftFootBone.position.y; // 저장할거에요 -> 왼발 초기 Y를
if (_rightFootBone != null) _rightFootPrevY = _rightFootBone.position.y; // 저장할거에요 -> 오른발 초기 Y를
}
// ─────────────────────────────────────────────────────────
// 매 프레임 발 Y속도 감지
// ─────────────────────────────────────────────────────────
private void Update() // 함수를 실행할거에요 -> 매 프레임 발 접지 감지를
{
// 이동 속도가 너무 낮으면 감지 스킵 (성능 최적화 + 제자리 방지)
if (playerMovement != null && playerMovement.GetCurrentSpeed() < minSpeedThreshold) // 조건이 맞으면 실행할거에요 -> 거의 안 움직이면
{
_leftFootGrounded = false; // 초기화할거에요 -> 다음 걸음 감지 준비로
_rightFootGrounded = false; // 초기화할거에요 -> 다음 걸음 감지 준비로
return; // 중단할거에요
}
if (_leftFootBone != null) // 조건이 맞으면 실행할거에요 -> 왼발 본 있으면
CheckFootLanding(_leftFootBone, ref _leftFootPrevY, ref _leftFootGrounded); // 확인할거에요 -> 왼발 접지를
if (_rightFootBone != null) // 조건이 맞으면 실행할거에요 -> 오른발 본 있으면
CheckFootLanding(_rightFootBone, ref _rightFootPrevY, ref _rightFootGrounded); // 확인할거에요 -> 오른발 접지를
}
// ─────────────────────────────────────────────────────────
// 발 접지 감지 핵심 로직
// ─────────────────────────────────────────────────────────
private void CheckFootLanding(Transform footBone, ref float prevY, ref bool isGrounded) // 함수를 선언할거에요 -> 발 접지를 감지하는 CheckFootLanding을
{
float currentY = footBone.position.y; // 가져올거에요 -> 현재 발의 월드 Y 좌표를
// 루트 기준 발 높이 계산 → 너무 높으면 공중(점프 중) → 무시
float footHeightAboveRoot = currentY - transform.position.y; // 계산할거에요 -> 루트 기준 발 높이를
if (footHeightAboveRoot > maxFootHeight) // 조건이 맞으면 실행할거에요 -> 발이 너무 높으면 (공중)
{
prevY = currentY; // 갱신할거에요 -> 이전 Y를
isGrounded = false; // 초기화할거에요 -> 접지 상태를
return; // 중단할거에요 -> 공중이므로 무시
}
// Y속도 계산: (현재Y - 이전Y) / deltaTime
// → 양수 = 올라가는 중 / 음수 = 내려오는 중 / 0에 가까움 = 멈춤
float yVelocity = (currentY - prevY) / Time.deltaTime; // 계산할거에요 -> 발의 Y축 속도를
// ⭐ 핵심 감지:
// Y속도가 임계값 이하 (내려오다 멈추는 순간) + 아직 접지 아닐 때
// = 발바닥이 실제로 바닥에 닿는 바로 그 순간
if (yVelocity <= landingVelocityThreshold && !isGrounded) // 조건이 맞으면 실행할거에요 -> 발이 내려오다 멈추는 순간
{
isGrounded = true; // 설정할거에요 -> 접지 상태로
TryPlayFootstep(footBone.position); // 재생할거에요 -> 발소리를
}
// 발이 다시 올라가기 시작하면 접지 해제 → 다음 스텝 감지 준비
else if (yVelocity > 0.001f && isGrounded) // 조건이 맞으면 실행할거에요 -> 발이 다시 올라가면
{
isGrounded = false; // 해제할거에요 -> 접지 상태를
}
prevY = currentY; // 갱신할거에요 -> 이전 Y를 현재 Y로 (다음 프레임 계산용)
}
// ─────────────────────────────────────────────────────────
// 발소리 재생
// ─────────────────────────────────────────────────────────
private void TryPlayFootstep(Vector3 footPosition) // 함수를 선언할거에요 -> 발소리 재생을 시도하는 TryPlayFootstep을
{
// 쿨타임 체크 (양발 동시 접지 시 중복 방지)
if (Time.time - _lastFootstepTime < footstepCooldown) return; // 중단할거에요 -> 쿨타임 중이면
// 현재 상태에 맞는 클립 세트 선택
AudioClip[] clips = GetCurrentClipSet(footPosition); // 가져올거에요 -> 클립 배열을
if (clips == null || clips.Length == 0) return; // 중단할거에요 -> 클립 없으면
// 직전 클립과 다른 클립 선택 (반복 방지)
AudioClip clip = PickNonRepeat(clips); // 뽑을거에요 -> 반복 없는 클립을
if (clip == null) return; // 중단할거에요 -> null이면
// 걷기/달리기에 따른 기본 볼륨 결정
float baseVolume = IsRunning() ? runVolume : walkVolume; // 결정할거에요 -> 볼륨을
// Pitch 랜덤 적용 (매번 다른 느낌)
_audioSource.pitch = UnityEngine.Random.Range(pitchMin, pitchMax); // 설정할거에요 -> 랜덤 Pitch를
// Volume 랜덤 적용
float vol = Mathf.Clamp( // 계산할거에요 -> 0~1 범위 랜덤 볼륨을
baseVolume + UnityEngine.Random.Range(-volumeVariation, volumeVariation),
0f, 1f
);
_audioSource.PlayOneShot(clip, vol); // 재생할거에요 -> 발소리를
_audioSource.pitch = _originalPitch; // 복원할거에요 -> 원래 Pitch로
_lastFootstepClip = clip; // 저장할거에요 -> 마지막 클립을
_lastFootstepTime = Time.time; // 저장할거에요 -> 마지막 재생 시간을
SpawnFootstepParticle(footPosition); // 생성할거에요 -> 발자국 파티클을
}
// ─────────────────────────────────────────────────────────
// 클립 세트 선택 (바닥 재질 + 이동 상태)
// ─────────────────────────────────────────────────────────
private AudioClip[] GetCurrentClipSet(Vector3 footPosition) // 함수를 선언할거에요 -> 현재 상태에 맞는 클립 배열을 반환하는 GetCurrentClipSet을
{
SurfaceType surface = DetectSurface(footPosition); // 감지할거에요 -> 바닥 재질을
// 재질별 클립 우선 반환
if (surfaceFootsteps != null) // 조건이 맞으면 실행할거에요 -> 재질별 설정 있으면
{
foreach (SurfaceFootstep sf in surfaceFootsteps) // 반복할거에요 -> 순회를
{
if (sf.surfaceType == surface && sf.clips != null && sf.clips.Length > 0) // 조건이 맞으면 실행할거에요 -> 일치하면
return sf.clips; // 반환할거에요 -> 재질 클립을
}
}
// 달리기 / 걷기 기본 세트
if (IsRunning() && runFootsteps != null && runFootsteps.Length > 0) // 조건이 맞으면 실행할거에요 -> 달리기 중이고 클립 있으면
return runFootsteps; // 반환할거에요 -> 달리기 클립을
return walkFootsteps; // 반환할거에요 -> 걷기 클립을
}
private SurfaceType DetectSurface(Vector3 position) // 함수를 선언할거에요 -> 바닥 재질을 감지하는 DetectSurface를
{
if (!Physics.Raycast(position + Vector3.up * 0.1f, Vector3.down, out RaycastHit hit, groundRayDistance, groundLayerMask)) // 조건이 맞으면 실행할거에요 -> 바닥 못 찾으면
return SurfaceType.Default; // 반환할거에요 -> 기본 타입을
SurfaceType surface = TagToSurface(hit.collider.tag); // 변환할거에요 -> Tag로 재질을
if (surface == SurfaceType.Default && hit.collider.sharedMaterial != null) // 조건이 맞으면 실행할거에요 -> Tag 실패 시 PhysicMaterial로
surface = NameToSurface(hit.collider.sharedMaterial.name); // 변환할거에요 -> 이름으로 재질을
return surface; // 반환할거에요 -> 감지된 재질을
}
// ─────────────────────────────────────────────────────────
// 발자국 파티클
// ─────────────────────────────────────────────────────────
private void SpawnFootstepParticle(Vector3 position) // 함수를 선언할거에요 -> 발자국 파티클을 생성하는 SpawnFootstepParticle을
{
if (footstepParticlePrefab == null) return; // 중단할거에요 -> 프리팹 없으면
Vector3 spawnPos = position; // 초기화할거에요 -> 생성 위치를
// Raycast로 정확한 바닥 표면 위치 찾기
if (Physics.Raycast(position + Vector3.up * 0.1f, Vector3.down, out RaycastHit hit, groundRayDistance, groundLayerMask)) // 조건이 맞으면 실행할거에요 -> 바닥 찾으면
spawnPos = hit.point; // 설정할거에요 -> 정확한 바닥 표면으로
GameObject particle = Instantiate(footstepParticlePrefab, spawnPos, Quaternion.identity); // 생성할거에요 -> 파티클 오브젝트를
Destroy(particle, particleDestroyTime); // 제거할거에요 -> 지정 시간 후 자동으로
}
// ─────────────────────────────────────────────────────────
// 유틸리티
// ─────────────────────────────────────────────────────────
private bool IsRunning() // 함수를 선언할거에요 -> 달리기 중인지 판별하는 IsRunning을
{
if (playerMovement == null) return false; // 반환할거에요 -> 참조 없으면 false를
return playerMovement.GetCurrentSpeed() >= runSpeedThreshold; // 반환할거에요 -> 속도 기준 이상이면 true를
}
private AudioClip PickNonRepeat(AudioClip[] clips) // 함수를 선언할거에요 -> 직전 클립과 다른 클립을 뽑는 PickNonRepeat를
{
if (clips.Length == 1) return clips[0]; // 반환할거에요 -> 1개뿐이면 그냥
AudioClip picked = clips[UnityEngine.Random.Range(0, clips.Length)]; // 뽑을거에요 -> 랜덤으로
if (picked == _lastFootstepClip) // 조건이 맞으면 실행할거에요 -> 직전과 같으면
{
int idx = Array.IndexOf(clips, picked); // 찾을거에요 -> 현재 인덱스를
picked = clips[(idx + 1) % clips.Length]; // 뽑을거에요 -> 다음 인덱스로
}
return picked; // 반환할거에요 -> 선택된 클립을
}
private Transform FindBoneRecursive(Transform parent, string boneName) // 함수를 선언할거에요 -> 본 이름으로 재귀 탐색하는 FindBoneRecursive를
{
if (parent.name == boneName) return parent; // 반환할거에요 -> 찾았으면 바로
foreach (Transform child in parent) // 반복할거에요 -> 모든 자식을
{
Transform found = FindBoneRecursive(child, boneName); // 탐색할거에요 -> 재귀로
if (found != null) return found; // 반환할거에요 -> 찾았으면
}
return null; // 반환할거에요 -> 없으면 null을
}
private SurfaceType TagToSurface(string tag) // 함수를 선언할거에요 -> Tag를 SurfaceType으로 변환하는 TagToSurface를
{
switch (tag)
{
case "Stone": return SurfaceType.Stone;
case "Dirt": return SurfaceType.Dirt;
case "Wood": return SurfaceType.Wood;
case "Metal": return SurfaceType.Metal;
case "Grass": return SurfaceType.Grass;
case "Water": return SurfaceType.Water;
default: return SurfaceType.Default;
}
}
private SurfaceType NameToSurface(string materialName) // 함수를 선언할거에요 -> PhysicMaterial 이름을 SurfaceType으로 변환하는 NameToSurface를
{
string lower = materialName.ToLower(); // 변환할거에요 -> 소문자로
if (lower.Contains("stone") || lower.Contains("concrete") || lower.Contains("rock")) return SurfaceType.Stone;
if (lower.Contains("dirt") || lower.Contains("sand") || lower.Contains("soil")) return SurfaceType.Dirt;
if (lower.Contains("wood") || lower.Contains("plank")) return SurfaceType.Wood;
if (lower.Contains("metal") || lower.Contains("iron") || lower.Contains("steel")) return SurfaceType.Metal;
if (lower.Contains("grass") || lower.Contains("lawn")) return SurfaceType.Grass;
if (lower.Contains("water") || lower.Contains("puddle")) return SurfaceType.Water;
return SurfaceType.Default;
}
// ─────────────────────────────────────────────────────────
// 에디터 디버그 (씬 뷰 시각화)
// ─────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmos() // 함수를 실행할거에요 -> 씬 뷰 디버그를
{
if (!Application.isPlaying) return; // 중단할거에요 -> 실행 중 아니면
// 접지 상태에 따라 색상 변경 (초록 = 접지, 빨강 = 공중)
if (_leftFootBone != null) // 조건이 맞으면 실행할거에요 -> 왼발 본 있으면
{
Gizmos.color = _leftFootGrounded ? Color.green : Color.red; // 설정할거에요 -> 접지 여부 색상을
Gizmos.DrawWireSphere(_leftFootBone.position, 0.04f); // 그릴거에요 -> 왼발 위치 구를
}
if (_rightFootBone != null) // 조건이 맞으면 실행할거에요 -> 오른발 본 있으면
{
Gizmos.color = _rightFootGrounded ? Color.green : Color.red; // 설정할거에요 -> 접지 여부 색상을
Gizmos.DrawWireSphere(_rightFootBone.position, 0.04f); // 그릴거에요 -> 오른발 위치 구를
}
}
#endif
}