using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을 using System.Collections; // 코루틴을 사용할거에요 -> System.Collections를 using System; // 시스템 기능을 사용할거에요 -> System을 // ============================================================ // MonsterBehaviour v2 // // 역할: 몬스터의 사운드 / 이펙트 / 넉백 / 스턴을 // MonsterClass 코드를 건드리지 않고 Inspector에서 조합 // // v2 추가 기능: // ① 바닥 재질(Tag/PhysicMaterial)별 발소리 자동 전환 // ② 발소리 Pitch/Volume 랜덤 → 반복 이질감 제거 // ③ 연속 동일 클립 방지 (마지막 재생 클립 기억) // // 투사체 충돌음은 ProjectileImpactSound 컴포넌트 사용 // (투사체 프리팹에 별도 부착) // ============================================================ // ───────────────────────────────────────────────────────────── // MonsterBehaviour 본체 // SurfaceType, SurfaceFootstep → SurfaceFootstep.cs 참조 // ───────────────────────────────────────────────────────────── public class MonsterBehaviour : MonoBehaviour // 클래스를 선언할거에요 -> MonsterBehaviour를 { // ───────────────────────────────────────────────────────── // 전투 사운드 // ───────────────────────────────────────────────────────── [Header("=== 전투 사운드 ===")] [Tooltip("피격 시 소리 (여러 개 = 랜덤)")] [SerializeField] private AudioClip[] hitSounds; // 배열을 선언할거에요 -> 피격 소리들을 [Tooltip("사망 시 소리")] [SerializeField] private AudioClip deathSound; // 변수를 선언할거에요 -> 사망 소리를 [Tooltip("공격 시 소리 (애니메이션 이벤트 OnAttackSound)")] [SerializeField] private AudioClip[] attackSounds; // 배열을 선언할거에요 -> 공격 소리들을 // ───────────────────────────────────────────────────────── // 발소리 — 기본 // ───────────────────────────────────────────────────────── [Header("=== 발소리 — 기본 ===")] [Tooltip("바닥 재질 매칭 실패 시 사용할 기본 발소리")] [SerializeField] private AudioClip[] defaultFootsteps; // 배열을 선언할거에요 -> 기본 발소리들을 [Tooltip("발소리 기본 볼륨")] [SerializeField][Range(0f, 1f)] private float footstepVolume = 0.5f; // 변수를 선언할거에요 -> 발소리 볼륨을 [Tooltip("Pitch 최솟값 (이 값~최댓값 랜덤 → 반복 이질감 제거)")] [SerializeField][Range(0.5f, 1.5f)] private float pitchMin = 0.9f; // 변수를 선언할거에요 -> 피치 최솟값을 [Tooltip("Pitch 최댓값")] [SerializeField][Range(0.5f, 1.5f)] private float pitchMax = 1.1f; // 변수를 선언할거에요 -> 피치 최댓값을 [Tooltip("Volume 랜덤 범위 (footstepVolume ± 이 값)")] [SerializeField][Range(0f, 0.3f)] private float volumeVariation = 0.1f; // 변수를 선언할거에요 -> 볼륨 변동 범위를 // ───────────────────────────────────────────────────────── // 발소리 — 바닥 재질별 // ───────────────────────────────────────────────────────── [Header("=== 발소리 — 바닥 재질별 ===")] [Tooltip("바닥 감지 Raycast 거리")] [SerializeField] private float groundCheckDistance = 0.3f; // 변수를 선언할거에요 -> 바닥 감지 거리를 [Tooltip("바닥 감지 LayerMask (Ground 레이어 등)")] [SerializeField] private LayerMask groundLayerMask = ~0; // 변수를 선언할거에요 -> 감지 레이어를 (기본 전체) [Tooltip("재질별 발소리 목록\n" + "바닥 오브젝트 Tag 또는 PhysicMaterial 이름으로 자동 매칭\n" + "예: Tag = 'Stone' → SurfaceType.Stone 클립 재생")] [SerializeField] private SurfaceFootstep[] surfaceFootsteps; // 배열을 선언할거에요 -> 재질별 발소리 묶음들을 // ───────────────────────────────────────────────────────── // 이펙트 // ───────────────────────────────────────────────────────── [Header("=== 이펙트 ===")] [Tooltip("피격 시 파티클 (오브젝트에 붙은 ParticleSystem)")] [SerializeField] private ParticleSystem hitEffect; // 변수를 선언할거에요 -> 피격 파티클을 [Tooltip("피격 시 생성할 이펙트 프리팹 (여러 개 = 랜덤)")] [SerializeField] private GameObject[] hitEffectPrefabs; // 배열을 선언할거에요 -> 피격 이펙트 프리팹들을 [Tooltip("사망 시 생성할 이펙트 프리팹")] [SerializeField] private GameObject deathEffectPrefab; // 변수를 선언할거에요 -> 사망 이펙트 프리팹을 [Tooltip("공격/슬래쉬 이펙트 프리팹 (여러 개 = 랜덤)")] [SerializeField] private GameObject[] slashEffectPrefabs; // 배열을 선언할거에요 -> 슬래쉬 이펙트 프리팹들을 [Tooltip("이펙트 생성 위치 (칼 끝, 손 등) — 없으면 몬스터 위치")] [SerializeField] private Transform effectSpawnPoint; // 변수를 선언할거에요 -> 이펙트 생성 위치를 // ───────────────────────────────────────────────────────── // 넉백 // ───────────────────────────────────────────────────────── [Header("=== 넉백 ===")] [Tooltip("넉백 사용 여부")] [SerializeField] private bool enableKnockback = true; // 변수를 선언할거에요 -> 넉백 사용 여부를 [Tooltip("넉백 거리")] [SerializeField] private float knockbackForce = 1.5f; // 변수를 선언할거에요 -> 넉백 힘을 [Tooltip("넉백 지속 시간 (초)")] [SerializeField] private float knockbackDuration = 0.15f; // 변수를 선언할거에요 -> 넉백 지속 시간을 // ───────────────────────────────────────────────────────── // 스턴 // ───────────────────────────────────────────────────────── [Header("=== 스턴 ===")] [Tooltip("피격 시 스턴 사용 여부")] [SerializeField] private bool enableStunOnHit = false; // 변수를 선언할거에요 -> 피격 스턴 여부를 [Tooltip("스턴 지속 시간 (초)")] [SerializeField] private float stunDuration = 0.5f; // 변수를 선언할거에요 -> 스턴 시간을 // ───────────────────────────────────────────────────────── // 내부 변수 // ───────────────────────────────────────────────────────── private MonsterClass _monster; // 변수를 선언할거에요 -> 연결된 MonsterClass를 private AudioSource _audioSource; // 변수를 선언할거에요 -> AudioSource를 private Coroutine _stunCoroutine; // 변수를 선언할거에요 -> 스턴 코루틴 핸들을 private AudioClip _lastFootstepClip; // 변수를 선언할거에요 -> 마지막 발소리 클립을 (연속 동일 방지) private float _originalPitch; // 변수를 선언할거에요 -> 원래 AudioSource Pitch를 (복원용) // ───────────────────────────────────────────────────────── // 초기화 // ───────────────────────────────────────────────────────── private void Awake() // 함수를 실행할거에요 -> 초기화를 { _monster = GetComponent(); // 가져올거에요 -> MonsterClass를 _audioSource = GetComponent(); // 가져올거에요 -> AudioSource를 if (_audioSource != null) // 조건이 맞으면 실행할거에요 -> AudioSource가 있으면 _originalPitch = _audioSource.pitch; // 저장할거에요 -> 원래 Pitch를 if (_monster == null) // 조건이 맞으면 실행할거에요 -> MonsterClass가 없으면 { Debug.LogError($"[MonsterBehaviour] {gameObject.name}에 MonsterClass가 없어요!"); // 오류를 출력할거에요 return; // 중단할거에요 } _monster.OnHitEvent += HandleHit; // 등록할거에요 -> 피격 이벤트를 _monster.OnDeadEvent += HandleDead; // 등록할거에요 -> 사망 이벤트를 } private void OnDestroy() // 함수를 실행할거에요 -> 파괴 시 이벤트 해제를 { if (_monster == null) return; // 중단할거에요 _monster.OnHitEvent -= HandleHit; // 해제할거에요 -> 피격 이벤트 구독을 _monster.OnDeadEvent -= HandleDead; // 해제할거에요 -> 사망 이벤트 구독을 } // ───────────────────────────────────────────────────────── // 이벤트 핸들러 // ───────────────────────────────────────────────────────── private void HandleHit(Vector3 hitDirection) // 함수를 선언할거에요 -> 피격 처리를 { PlayRandomSound(hitSounds); // 재생할거에요 -> 피격 소리를 SpawnHitEffect(); // 생성할거에요 -> 피격 이펙트를 if (hitEffect != null) hitEffect.Play(); // 재생할거에요 -> 피격 파티클을 if (enableKnockback) // 조건이 맞으면 실행할거에요 -> 넉백 켜져있으면 _monster.ApplyKnockback(hitDirection, knockbackForce, knockbackDuration); // 적용할거에요 -> 넉백을 if (enableStunOnHit) // 조건이 맞으면 실행할거에요 -> 스턴 켜져있으면 ApplyStun(stunDuration); // 적용할거에요 -> 스턴을 } private void HandleDead() // 함수를 선언할거에요 -> 사망 처리를 { if (deathSound != null && _audioSource != null) // 조건이 맞으면 실행할거에요 -> 사망 소리 있으면 _audioSource.PlayOneShot(deathSound); // 재생할거에요 -> 사망 소리를 SpawnDeathEffect(); // 생성할거에요 -> 사망 이펙트를 } // ───────────────────────────────────────────────────────── // 애니메이션 이벤트 // ───────────────────────────────────────────────────────── public void OnAttackSound() // 함수를 선언할거에요 -> 공격 소리 재생을 (애니메이션 이벤트) { PlayRandomSound(attackSounds); // 재생할거에요 -> 랜덤 공격 소리를 } public void OnFootstep() // 함수를 선언할거에요 -> 발소리 재생을 (애니메이션 이벤트) { if (_audioSource == null) return; // 중단할거에요 -> AudioSource 없으면 // ① 바닥 재질에 맞는 클립 배열 가져오기 AudioClip[] clips = GetFootstepClips(); // 가져올거에요 -> 현재 바닥 재질 클립 배열을 if (clips == null || clips.Length == 0) return; // 중단할거에요 -> 클립 없으면 // ② 직전 클립과 다른 클립 선택 (연속 동일 방지) AudioClip clip = PickNonRepeat(clips); // 뽑을거에요 -> 직전과 다른 클립을 if (clip == null) return; // 중단할거에요 -> null이면 // ③ Pitch 랜덤 적용 → 같은 소리도 매번 다르게 _audioSource.pitch = UnityEngine.Random.Range(pitchMin, pitchMax); // 설정할거에요 -> 랜덤 Pitch를 // ④ Volume 랜덤 적용 → 약간씩 다른 세기 float vol = Mathf.Clamp( // 계산할거에요 -> 0~1 범위로 제한한 랜덤 볼륨을 footstepVolume + UnityEngine.Random.Range(-volumeVariation, volumeVariation), 0f, 1f ); _audioSource.PlayOneShot(clip, vol); // 재생할거에요 -> 발소리를 // ⑤ Pitch 복원 (다른 소리에 영향 안 주게) _audioSource.pitch = _originalPitch; // 복원할거에요 -> 원래 Pitch로 _lastFootstepClip = clip; // 저장할거에요 -> 마지막 클립을 } public void OnSlashEffect() // 함수를 선언할거에요 -> 슬래쉬 이펙트 생성을 (애니메이션 이벤트) { SpawnEffect(slashEffectPrefabs); // 생성할거에요 -> 슬래쉬 이펙트를 } // ───────────────────────────────────────────────────────── // 바닥 재질 감지 // ───────────────────────────────────────────────────────── private AudioClip[] GetFootstepClips() // 함수를 선언할거에요 -> 바닥 재질에 맞는 클립 배열을 반환하는 GetFootstepClips를 { if (surfaceFootsteps == null || surfaceFootsteps.Length == 0) // 조건이 맞으면 실행할거에요 -> 재질별 설정 없으면 return defaultFootsteps; // 반환할거에요 -> 기본 발소리를 // 발 위치(약간 위)에서 아래로 Raycast Vector3 origin = transform.position + Vector3.up * 0.1f; // 계산할거에요 -> 레이 시작 위치를 if (!Physics.Raycast(origin, Vector3.down, out RaycastHit hit, groundCheckDistance + 0.1f, groundLayerMask)) // 조건이 맞으면 실행할거에요 -> 바닥 못 찾으면 return defaultFootsteps; // 반환할거에요 -> 기본 발소리를 // Tag로 재질 판별 SurfaceType surface = TagToSurface(hit.collider.tag); // 변환할거에요 -> Tag를 SurfaceType으로 // Tag 실패 시 PhysicMaterial 이름으로 판별 if (surface == SurfaceType.Default && hit.collider.sharedMaterial != null) // 조건이 맞으면 실행할거에요 -> Tag 매칭 실패하고 PhysicMaterial 있으면 surface = NameToSurface(hit.collider.sharedMaterial.name); // 변환할거에요 -> PhysicMaterial 이름으로 // 해당 SurfaceType 클립 배열 검색 foreach (SurfaceFootstep sf in surfaceFootsteps) // 반복할거에요 -> 재질별 설정 순회를 { if (sf.surfaceType == surface && sf.clips != null && sf.clips.Length > 0) // 조건이 맞으면 실행할거에요 -> 타입 일치하고 클립 있으면 return sf.clips; // 반환할거에요 -> 해당 재질 클립 배열을 } return defaultFootsteps; // 반환할거에요 -> 매칭 실패 시 기본 발소리를 } private SurfaceType TagToSurface(string tag) // 함수를 선언할거에요 -> Tag를 SurfaceType으로 변환하는 TagToSurface를 { // Unity Tag에 Stone / Dirt / Wood / Metal / Grass / Water 추가해서 사용 switch (tag) // 분기할거에요 -> 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; // 반환할거에요 -> 매칭 실패 시 기본을 } 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; // 반환할거에요 -> 선택된 클립을 } // ───────────────────────────────────────────────────────── // 스턴 // ───────────────────────────────────────────────────────── public void ApplyStun(float duration) // 함수를 선언할거에요 -> 스턴을 적용하는 ApplyStun을 { if (_stunCoroutine != null) StopCoroutine(_stunCoroutine); // 취소할거에요 -> 이전 스턴 코루틴을 _stunCoroutine = StartCoroutine(StunRoutine(duration)); // 시작할거에요 -> 스턴 코루틴을 } private IEnumerator StunRoutine(float duration) // 코루틴을 정의할거에요 -> 스턴 처리를 { yield return new WaitForSeconds(duration); // 기다릴거에요 -> 스턴 시간만큼 _stunCoroutine = null; // 초기화할거에요 -> 핸들을 } // ───────────────────────────────────────────────────────── // 내부 유틸 // ───────────────────────────────────────────────────────── private void PlayRandomSound(AudioClip[] clips) // 함수를 선언할거에요 -> 랜덤 소리 재생을 { if (clips == null || clips.Length == 0 || _audioSource == null) return; // 중단할거에요 -> 소리 없으면 int idx = UnityEngine.Random.Range(0, clips.Length); // 뽑을거에요 -> 랜덤 인덱스를 if (clips[idx] != null) _audioSource.PlayOneShot(clips[idx]); // 재생할거에요 -> 랜덤 소리를 } private void SpawnHitEffect() // 함수를 선언할거에요 -> 피격 이펙트 생성을 { SpawnEffect(hitEffectPrefabs); // 생성할거에요 -> 피격 이펙트를 } private void SpawnDeathEffect() // 함수를 선언할거에요 -> 사망 이펙트 생성을 { if (deathEffectPrefab == null) return; // 중단할거에요 -> 프리팹 없으면 Vector3 pos = effectSpawnPoint != null ? effectSpawnPoint.position : transform.position; // 결정할거에요 -> 생성 위치를 GameObject fx = Instantiate(deathEffectPrefab, pos, transform.rotation); // 생성할거에요 -> 사망 이펙트를 Destroy(fx, 3f); // 예약할거에요 -> 3초 후 제거를 } private void SpawnEffect(GameObject[] prefabs) // 함수를 선언할거에요 -> 이펙트 프리팹 랜덤 생성을 { if (prefabs == null || prefabs.Length == 0) return; // 중단할거에요 -> 프리팹 없으면 int idx = UnityEngine.Random.Range(0, prefabs.Length); // 뽑을거에요 -> 랜덤 인덱스를 if (prefabs[idx] == null) return; // 중단할거에요 -> 비어있으면 Vector3 pos = effectSpawnPoint != null ? effectSpawnPoint.position : transform.position; // 결정할거에요 -> 생성 위치를 Quaternion rot = effectSpawnPoint != null ? effectSpawnPoint.rotation : transform.rotation; // 결정할거에요 -> 생성 방향을 GameObject fx = Instantiate(prefabs[idx], pos, rot); // 생성할거에요 -> 이펙트를 ParticleSystem ps = fx.GetComponent(); // 가져올거에요 -> 파티클 시스템을 float destroyTime = ps != null ? ps.main.duration + ps.main.startLifetime.constantMax : 2f; // 계산할거에요 -> 제거 시간을 Destroy(fx, destroyTime); // 예약할거에요 -> 자동 제거를 } }