Projext/Assets/Scripts/Enemy/AI/MonsterBehaviour.cs

362 lines
23 KiB
C#
Raw Normal View History

2026-02-27 00:44:52 +00:00
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>(); // 가져올거에요 -> MonsterClass를
_audioSource = GetComponent<AudioSource>(); // 가져올거에요 -> 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<ParticleSystem>(); // 가져올거에요 -> 파티클 시스템을
float destroyTime = ps != null ? ps.main.duration + ps.main.startLifetime.constantMax : 2f; // 계산할거에요 -> 제거 시간을
Destroy(fx, destroyTime); // 예약할거에요 -> 자동 제거를
}
}