Projext/Assets/02_Scripts/Player/Sound/Playerfootstep.cs
hydrozen e989d20668 카툰 쉐이더 추가 + 중복 스크립트 수정 + 전체 업데이트
- ToonPostProcess.shader: 횃불 고딕 스타일 후처리 쉐이더 (Built-in RP)
- ToonCameraEffect.cs: 카메라 자동 부착 후처리 스크립트
- 중복 UI 스크립트 제거 (MenuIntroController, ToggleCustom)
- 씬, 프리팹, 애니메이션 등 전체 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:31:16 +09:00

329 lines
21 KiB
C#

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System; // 시스템 기능을 사용할거에요 -> Array.IndexOf용
// ============================================================
// PlayerFootstep v4
//
// 방식: 속도 기반 타이머
// 이동 중일 때 일정 간격으로 발소리 재생
// 속도가 빠를수록 간격이 짧아짐
//
// 장점:
// - 본 추적 불필요 → 어떤 모델에도 즉시 작동
// - 변수가 적어 안정적
// - 설정이 간단 (클립만 넣으면 끝)
//
// v3 → v4 변경:
// - 발 본 Y좌표 추적 방식 제거
// - 속도 기반 타이머 방식으로 교체
// - 기존 기능 유지: 재질 감지, 피치/볼륨 랜덤, 파티클
// ============================================================
[RequireComponent(typeof(AudioSource))] // 컴포넌트를 강제 추가할거에요 -> AudioSource를
public class PlayerFootstep : MonoBehaviour // 클래스를 선언할거에요 -> 플레이어 발소리를
{
// ─────────────────────────────────────────────────────────
// 이동 속도 설정
// ─────────────────────────────────────────────────────────
[Header("=== 이동 속도 설정 ===")]
[Tooltip("이 속도 이하면 발소리 재생 안 함 (제자리 방지)")]
[SerializeField] private float minSpeedThreshold = 0.1f; // 변수를 선언할거에요 -> 최소 속도를
[Tooltip("이 속도 이상이면 달리기 발소리 세트 사용")]
[SerializeField] private float runSpeedThreshold = 4.0f; // 변수를 선언할거에요 -> 달리기 판정 속도를
// ─────────────────────────────────────────────────────────
// 발소리 간격 설정 (속도 기반 타이머 핵심)
// ─────────────────────────────────────────────────────────
[Header("=== 발소리 간격 설정 ===")]
[Tooltip("걷기 시 발소리 간격 (초)\n낮을수록 발소리가 자주 남\n추천: 0.4~0.6")]
[SerializeField] private float walkStepInterval = 0.5f; // 변수를 선언할거에요 -> 걷기 발소리 간격을
[Tooltip("달리기 시 발소리 간격 (초)\n걷기보다 짧아야 자연스러움\n추천: 0.25~0.35")]
[SerializeField] private float runStepInterval = 0.3f; // 변수를 선언할거에요 -> 달리기 발소리 간격을
// ─────────────────────────────────────────────────────────
// 발소리 — 걷기
// ─────────────────────────────────────────────────────────
[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.5)")]
[SerializeField] private float groundRayDistance = 0.5f; // 변수를 선언할거에요 -> 바닥 감지 거리를
[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 _stepTimer; // 변수를 선언할거에요 -> 다음 발소리까지 남은 시간 타이머를
// ─────────────────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────────────────
private void Awake() // 함수를 실행할거에요 -> 초기화를
{
_audioSource = GetComponent<AudioSource>(); // 가져올거에요 -> AudioSource를
_audioSource.playOnAwake = false; // 끌거에요 -> 자동 재생을
_originalPitch = _audioSource.pitch; // 저장할거에요 -> 원래 Pitch를
if (playerMovement == null) // 조건이 맞으면 실행할거에요 -> 연결 안 됐으면
playerMovement = GetComponent<PlayerMovement>(); // 가져올거에요 -> 같은 오브젝트에서 자동 캐싱
if (playerMovement == null) // 조건이 맞으면 실행할거에요 -> 그래도 없으면
playerMovement = GetComponentInParent<PlayerMovement>(); // 가져올거에요 -> 부모에서 자동 캐싱
if (playerMovement == null) // 조건이 맞으면 실행할거에요 -> 그래도 없으면
Debug.LogError("[PlayerFootstep] PlayerMovement를 찾을 수 없습니다! 인스펙터에서 연결해주세요."); // 에러를 출력할거에요
}
// ─────────────────────────────────────────────────────────
// 매 프레임 — 속도 기반 타이머
// ─────────────────────────────────────────────────────────
private void Update() // 함수를 실행할거에요 -> 매 프레임 발소리 타이머 체크를
{
// --- 1. 이동 속도 가져오기 ---
if (playerMovement == null) return; // 중단할거에요 -> 참조 없으면
float currentSpeed = playerMovement.GetCurrentSpeed(); // 가져올거에요 -> 현재 이동 속도를
// --- 2. 멈춰있으면 타이머 리셋 ---
if (currentSpeed < minSpeedThreshold) // 조건이 맞으면 실행할거에요 -> 거의 안 움직이면
{
_stepTimer = 0f; // 리셋할거에요 -> 타이머를 0으로 (다시 움직이면 바로 소리 나게)
return; // 중단할거에요 -> 멈춰있으니 발소리 안 냄
}
// --- 3. 현재 속도에 맞는 발소리 간격 계산 ---
bool isRunning = currentSpeed >= runSpeedThreshold; // 판별할거에요 -> 달리기 중인지
float currentInterval = isRunning ? runStepInterval : walkStepInterval; // 결정할거에요 -> 현재 발소리 간격을
// --- 4. 타이머 누적 ---
_stepTimer += Time.deltaTime; // 더할거에요 -> 경과 시간을
// --- 5. 간격 도달 시 발소리 재생 ---
if (_stepTimer >= currentInterval) // 조건이 맞으면 실행할거에요 -> 간격에 도달했으면
{
_stepTimer = 0f; // 리셋할거에요 -> 타이머를 0으로 (다음 발소리 대기)
PlayFootstep(); // 재생할거에요 -> 발소리를
}
}
// ─────────────────────────────────────────────────────────
// 발소리 재생
// ─────────────────────────────────────────────────────────
private void PlayFootstep() // 함수를 선언할거에요 -> 발소리를 재생하는 PlayFootstep을
{
// --- 1. 플레이어 발 아래 위치 계산 ---
Vector3 footPosition = transform.position; // 가져올거에요 -> 플레이어 위치를 (발 위치 근사)
// --- 2. 현재 상태에 맞는 클립 세트 선택 ---
AudioClip[] clips = GetCurrentClipSet(footPosition); // 가져올거에요 -> 클립 배열을
if (clips == null || clips.Length == 0) return; // 중단할거에요 -> 클립 없으면
// --- 3. 직전 클립과 다른 클립 선택 (반복 방지) ---
AudioClip clip = PickNonRepeat(clips); // 뽑을거에요 -> 반복 없는 클립을
if (clip == null) return; // 중단할거에요 -> null이면
// --- 4. 걷기/달리기에 따른 기본 볼륨 결정 ---
bool isRunning = playerMovement.GetCurrentSpeed() >= runSpeedThreshold; // 판별할거에요 -> 달리기 여부를
float baseVolume = isRunning ? runVolume : walkVolume; // 결정할거에요 -> 볼륨을
// --- 5. Pitch 랜덤 적용 (매번 다른 느낌) ---
_audioSource.pitch = UnityEngine.Random.Range(pitchMin, pitchMax); // 설정할거에요 -> 랜덤 Pitch를
// --- 6. Volume 랜덤 적용 ---
float vol = Mathf.Clamp( // 계산할거에요 -> 0~1 범위 랜덤 볼륨을
baseVolume + UnityEngine.Random.Range(-volumeVariation, volumeVariation),
0f, 1f
);
// --- 7. 재생 ---
_audioSource.PlayOneShot(clip, vol); // 재생할거에요 -> 발소리를
// --- 8. 복원 및 기록 ---
_audioSource.pitch = _originalPitch; // 복원할거에요 -> 원래 Pitch로
_lastFootstepClip = clip; // 저장할거에요 -> 마지막 클립을
// --- 9. 파티클 ---
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 (playerMovement.GetCurrentSpeed() >= runSpeedThreshold && 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 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 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;
}
}