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.playOnAwake = false; // 끌거에요 -> 자동 재생을 _originalPitch = _audioSource.pitch; // 저장할거에요 -> 원래 Pitch를 if (playerMovement == null) // 조건이 맞으면 실행할거에요 -> 연결 안 됐으면 playerMovement = GetComponent(); // 가져올거에요 -> 자동 캐싱 _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 }