Projext/Assets/Scripts/Enemy/AI/Range Monster.cs
2026-02-13 00:23:25 +09:00

208 lines
17 KiB
C#

using UnityEngine; // 유니티 엔진의 기본 기능을 불러올거에요 -> UnityEngine을
using UnityEngine.AI; // 길찾기(내비게이션) 기능을 불러올거에요 -> UnityEngine.AI를
using System.Collections; // 코루틴 기능을 사용할거에요 -> System.Collections를
/// <summary>
/// 통합 원거리 몬스터 (키 작은 플레이어 머리 조준 기능 추가)
/// 파일 이름을 반드시 [ UniversalRangedMonster.cs ] 로 맞춰주세요!
/// </summary>
public class UniversalRangedMonster : MonsterClass // 클래스를 선언할거에요 -> MonsterClass를 상속받는 UniversalRangedMonster를
{
public enum AttackStyle { Straight, Lob } // 열거형을 정의할거에요 -> 공격 방식(직선, 곡사)을 정의하는 AttackStyle을
[Header("=== 🏹 공격 스타일 선택 ===")] // 인스펙터 창에 제목을 표시할거에요 -> === 🏹 공격 스타일 선택 === 을
[SerializeField] private AttackStyle attackStyle = AttackStyle.Straight; // 변수를 선언할거에요 -> 공격 스타일(기본 직선)을 attackStyle에
[Header("공통 설정")] // 인스펙터 창에 제목을 표시할거에요 -> 공통 설정 을
[SerializeField] private GameObject projectilePrefab; // 변수를 선언할거에요 -> 발사체 프리팹을 projectilePrefab에
[SerializeField] private Transform firePoint; // 변수를 선언할거에요 -> 발사 위치를 firePoint에
[SerializeField] private float attackRange = 10f; // 변수를 선언할거에요 -> 공격 사거리(10.0)를 attackRange에
[SerializeField] private float attackDelay = 2f; // 변수를 선언할거에요 -> 공격 딜레이(2.0초)를 attackDelay에
[SerializeField] private float detectRange = 15f; // 변수를 선언할거에요 -> 인식 거리(15.0)를 detectRange에
[Header("🔹 직선 발사 설정 (활/총)")] // 인스펙터 창에 제목을 표시할거에요 -> 🔹 직선 발사 설정 (활/총) 을
[SerializeField] private float projectileSpeed = 20f; // 변수를 선언할거에요 -> 투사체 속도(20.0)를 projectileSpeed에
[SerializeField] private float minDistance = 5f; // 변수를 선언할거에요 -> 최소 거리(도망가는 거리)를 minDistance에
[Header("🔸 곡사 투척 설정 (돌/폭탄)")] // 인스펙터 창에 제목을 표시할거에요 -> 🔸 곡사 투척 설정 (돌/폭탄) 을
[Tooltip("체크하면 거리/높이를 계산해서 정확히 맞춥니다.")] // 마우스를 올리면 설명을 보여줄거에요 -> 툴팁 내용을
[SerializeField] private bool usePreciseLob = true; // 변수를 선언할거에요 -> 정밀 곡사 사용 여부를 usePreciseLob에
[Tooltip("던지는 각도 (45도가 최대 사거리)")] // 마우스를 올리면 설명을 보여줄거에요 -> 툴팁 내용을
[Range(15f, 75f)][SerializeField] private float launchAngle = 45f; // 변수를 선언할거에요 -> 발사 각도(45도)를 launchAngle에
// ⭐ [추가됨] 조준 높이 보정 (키 작은 플레이어 해결용)
[Tooltip("0이면 발가락, 1.0이면 명치, 1.5면 머리를 노립니다.")] // 마우스를 올리면 설명을 보여줄거에요 -> 툴팁 내용을
[SerializeField] private float aimHeight = 1.2f; // 변수를 선언할거에요 -> 조준 높이 오프셋(1.2)을 aimHeight에
[Header("🏃‍♂️ 도망 설정")] // 인스펙터 창에 제목을 표시할거에요 -> 🏃‍♂️ 도망 설정 을
[SerializeField] private float fleeDistance = 5f; // 변수를 선언할거에요 -> 도망가는 거리(5.0)를 fleeDistance에
[Header("애니메이션 & 기타")] // 인스펙터 창에 제목을 표시할거에요 -> 애니메이션 & 기타 를
[SerializeField] private float throwForce = 15f; // 변수를 선언할거에요 -> 기본 투척 힘을 throwForce에
[SerializeField] private float throwUpward = 5f; // 변수를 선언할거에요 -> 기본 상향 힘을 throwUpward에
[SerializeField] private float reloadTime = 2.0f; // 변수를 선언할거에요 -> 장전 시간(2.0초)을 reloadTime에
[SerializeField] private GameObject handModel; // 변수를 선언할거에요 -> 손에 든 무기 모델을 handModel에
[SerializeField] private bool aimAtPlayer = true; // 변수를 선언할거에요 -> 플레이어 조준 여부를 aimAtPlayer에
[SerializeField] private string attackAnim = "Monster_Attack"; // 변수를 선언할거에요 -> 공격 애니메이션 이름을 attackAnim에
[SerializeField] private string walkAnim = "Monster_Walk"; // 변수를 선언할거에요 -> 걷기 애니메이션 이름을 walkAnim에
[SerializeField] private float patrolRadius = 5f; // 변수를 선언할거에요 -> 순찰 반경(5.0)을 patrolRadius에
[SerializeField] private float patrolInterval = 3f; // 변수를 선언할거에요 -> 순찰 간격(3.0초)을 patrolInterval에
private float lastAttackTime; // 변수를 선언할거에요 -> 마지막 공격 시간을 lastAttackTime에
private float nextPatrolTime; // 변수를 선언할거에요 -> 다음 순찰 시간을 nextPatrolTime에
private bool isReloading = false; // 변수를 선언할거에요 -> 장전 중 여부를 isReloading에
protected override void Init() // 함수를 덮어씌워 실행할거에요 -> 초기화 로직인 Init을
{
if (agent != null) { agent.stoppingDistance = attackRange * 0.8f; agent.speed = 3.5f; } // 조건이 맞으면 설정할거에요 -> 정지 거리를 사거리의 80%로, 속도를 3.5로
if (animator != null) animator.applyRootMotion = false; // 조건이 맞으면 설정할거에요 -> 애니메이션 이동을 끄기로
}
protected override void ExecuteAILogic() // 함수를 덮어씌워 실행할거에요 -> AI 행동 로직인 ExecuteAILogic을
{
if (isHit || isAttacking || isResting || isReloading) return; // 조건이 맞으면 중단할거에요 -> 피격, 공격, 휴식, 장전 중이라면
if (isAttacking && animator.GetCurrentAnimatorStateInfo(0).IsName(Monster_Idle)) OnAttackEnd(); // 조건이 맞으면 실행할거에요 -> 공격 중인데 모션이 대기라면 강제로 공격 종료를
float dist = Vector3.Distance(transform.position, playerTransform.position); // 거리를 계산할거에요 -> 플레이어와의 거리를
if (dist <= detectRange) HandleCombat(dist); // 조건이 맞으면 실행할거에요 -> 감지 거리 이내라면 전투 처리(HandleCombat)를
else // 조건이 틀리면 실행할거에요 -> 멀리 있다면
{
if (agent.hasPath && agent.destination == playerTransform.position) { agent.ResetPath(); nextPatrolTime = 0; } // 조건이 맞으면 실행할거에요 -> 추격 중이었다면 경로를 취소하고 순찰을 준비하기를
Patrol(); // 함수를 실행할거에요 -> 순찰을 도는 Patrol을
UpdateMovementAnimation(); // 함수를 실행할거에요 -> 애니메이션 갱신을
}
}
void HandleCombat(float dist) // 함수를 선언할거에요 -> 거리별 전투 행동을 정하는 HandleCombat을
{
if (attackStyle == AttackStyle.Straight && dist < minDistance) RetreatFromPlayer(); // 조건이 맞으면 실행할거에요 -> 직선 공격 타입이고 너무 가까우면 도망가기를
else if (dist <= attackRange) TryAttack(); // 조건이 맞으면 실행할거에요 -> 사거리 안이라면 공격 시도를
else { if (agent.isOnNavMesh) { agent.isStopped = false; agent.SetDestination(playerTransform.position); UpdateMovementAnimation(); } } // 그 외엔 실행할거에요 -> 플레이어를 향해 추격하기를
}
void TryAttack() // 함수를 선언할거에요 -> 공격을 수행하는 TryAttack을
{
if (Time.time < lastAttackTime + attackDelay) return; // 조건이 맞으면 중단할거에요 -> 쿨타임이 안 지났다면
lastAttackTime = Time.time; // 값을 저장할거에요 -> 현재 시간을 마지막 공격 시간으로
isAttacking = true; // 상태를 바꿀거에요 -> 공격 중 상태를 참(true)으로
if (agent.isOnNavMesh) { agent.isStopped = true; agent.velocity = Vector3.zero; } // 조건이 맞으면 실행할거에요 -> 이동을 멈추고 제자리에 서기를
Vector3 lookDir = (playerTransform.position - transform.position).normalized; // 벡터를 계산할거에요 -> 플레이어를 바라보는 방향을
lookDir.y = 0; // 값을 바꿀거에요 -> 높이 차이를 무시하게
if (lookDir != Vector3.zero) transform.rotation = Quaternion.LookRotation(lookDir); // 회전시킬거에요 -> 플레이어 쪽을 보도록
animator.Play(attackAnim); // 재생할거에요 -> 공격 애니메이션을
}
public override void OnAttackStart() // 함수를 덮어씌워 실행할거에요 -> 공격 타이밍(애니메이션 이벤트)에 호출될 OnAttackStart를
{
if (!projectilePrefab || !firePoint) return; // 조건이 맞으면 중단할거에요 -> 프리팹이나 발사 위치가 없다면
GameObject obj = Instantiate(projectilePrefab, firePoint.position, transform.rotation); // 생성할거에요 -> 투사체를 발사 위치에
if (obj.TryGetComponent<Projectile>(out var proj)) // 조건이 맞으면 실행할거에요 -> 투사체 스크립트가 있다면
{
float speed = (attackStyle == AttackStyle.Straight) ? projectileSpeed : 0f; // 값을 결정할거에요 -> 직선이면 속도를, 곡사면 0(물리)을
proj.Initialize(transform.forward, speed, attackDamage); // 초기화할거에요 -> 방향, 속도, 데미지 정보를 전달해서
}
if (attackStyle == AttackStyle.Lob) // 조건이 맞으면 실행할거에요 -> 곡사 공격 타입이라면
{
Rigidbody rb = obj.GetComponent<Rigidbody>(); // 컴포넌트를 가져올거에요 -> 투사체의 리지드바디를
if (rb) // 조건이 맞으면 실행할거에요 -> 리지드바디가 있다면
{
rb.useGravity = true; // 기능을 켤거에요 -> 중력 영향을 받도록
// ⭐ 목표 지점 설정 (플레이어 위치 + 높이 보정)
Vector3 targetPos = playerTransform.position + Vector3.up * aimHeight; // 위치를 계산할거에요 -> 플레이어 위치에 조준 높이를 더해서
if (usePreciseLob) // 조건이 맞으면 실행할거에요 -> 정밀 곡사를 사용한다면
{
// 보정된 위치(targetPos)를 기준으로 탄도 계산
Vector3 velocity = CalculateLobVelocity(firePoint.position, targetPos, launchAngle); // 계산할거에요 -> 목표에 도달하기 위한 물리 속도를
if (!float.IsNaN(velocity.x)) // 조건이 맞으면 실행할거에요 -> 계산 결과가 유효하다면
{
rb.velocity = velocity; // 값을 넣을거에요 -> 계산된 속도를 리지드바디에
}
else // 조건이 틀리면 실행할거에요 -> 계산 실패(도달 불가)라면
{
// 계산 실패 시 백업
rb.AddForce(transform.forward * throwForce + Vector3.up * throwUpward, ForceMode.Impulse); // 힘을 가할거에요 -> 기본 힘으로 던지기를
}
}
else // 조건이 틀리면 실행할거에요 -> 정밀 곡사가 아니라면
{
Vector3 dir = aimAtPlayer ? (targetPos - firePoint.position).normalized : transform.forward; // 방향을 결정할거에요 -> 타겟 방향 혹은 정면으로
rb.AddForce(dir * throwForce + Vector3.up * throwUpward, ForceMode.Impulse); // 힘을 가할거에요 -> 방향과 힘을 적용해서 던지기를
}
}
if (handModel != null) { handModel.SetActive(false); StartCoroutine(ReloadRoutine()); } // 조건이 맞으면 실행할거에요 -> 손에 있는 무기를 숨기고 장전 코루틴을 시작하기를
}
}
Vector3 CalculateLobVelocity(Vector3 origin, Vector3 target, float angle) // 함수를 선언할거에요 -> 곡사 탄도 속도를 계산하는 CalculateLobVelocity를
{
Vector3 dir = target - origin; // 벡터를 계산할거에요 -> 목표까지의 거리 벡터를
float height = dir.y; // 값을 저장할거에요 -> 높이 차이를
dir.y = 0; // 값을 바꿀거에요 -> 수평 거리 계산을 위해 y를 0으로
float dist = dir.magnitude; // 값을 저장할거에요 -> 수평 거리를
float a = angle * Mathf.Deg2Rad; // 값을 변환할거에요 -> 각도를 라디안으로
dir.y = dist * Mathf.Tan(a); // 값을 계산할거에요 -> 탄젠트를 이용해 높이를
dist += height / Mathf.Tan(a); // 값을 보정할거에요 -> 높이 차이에 따른 거리 보정을
float gravity = Physics.gravity.magnitude; // 값을 가져올거에요 -> 중력 가속도를
// 물리 공식: v^2 = (g * x^2) / (2 * cos^2(a) * (x * tan(a) - y))
float velocitySq = (gravity * dist * dist) / (2 * Mathf.Pow(Mathf.Cos(a), 2) * (dist * Mathf.Tan(a) - height)); // 값을 계산할거에요 -> 필요한 속도의 제곱을
if (velocitySq <= 0) return Vector3.zero; // 조건이 맞으면 반환할거에요 -> 계산 불가라면 0 벡터를
float velocity = Mathf.Sqrt(velocitySq); // 값을 계산할거에요 -> 제곱근을 구해서 실제 속도를
return dir.normalized * velocity; // 반환할거에요 -> 방향 벡터에 속도를 곱해서
}
void RetreatFromPlayer() // 함수를 선언할거에요 -> 플레이어로부터 도망가는 RetreatFromPlayer를
{
if (agent.pathPending || (agent.remainingDistance > 0.5f && agent.velocity.magnitude > 0.1f)) { UpdateMovementAnimation(); return; } // 조건이 맞으면 중단할거에요 -> 이미 이동 중이라면
Vector3 dir = (transform.position - playerTransform.position).normalized; // 벡터를 계산할거에요 -> 플레이어 반대 방향을
Vector3 pos = transform.position + dir * fleeDistance; // 위치를 계산할거에요 -> 도망갈 목표 지점을
if (NavMesh.SamplePosition(pos, out NavMeshHit hit, 5f, NavMesh.AllAreas)) { if (agent.isOnNavMesh) { agent.isStopped = false; agent.SetDestination(hit.position); } } // 조건이 맞으면 실행할거에요 -> 유효한 위치라면 그곳으로 이동하기를
UpdateMovementAnimation(); // 함수를 실행할거에요 -> 이동 애니메이션 갱신을
}
IEnumerator ReloadRoutine() // 코루틴 함수를 선언할거에요 -> 무기 장전(재생성)을 처리하는 ReloadRoutine을
{
isReloading = true; // 상태를 바꿀거에요 -> 장전 중 상태를 참으로
yield return new WaitForSeconds(reloadTime); // 기다릴거에요 -> 장전 시간만큼
if (handModel != null) handModel.SetActive(true); // 조건이 맞으면 실행할거에요 -> 손에 있는 무기를 다시 보이게
isReloading = false; // 상태를 바꿀거에요 -> 장전 중 상태를 거짓으로
}
public override void OnAttackEnd() // 함수를 덮어씌워 실행할거에요 -> 공격 종료 처리를 하는 OnAttackEnd를
{
isAttacking = false; // 상태를 바꿀거에요 -> 공격 중 상태를 거짓으로
if (animator != null) animator.Play(Monster_Idle); // 조건이 맞으면 실행할거에요 -> 대기 애니메이션으로 복귀를
if (!isDead && !isHit) StartCoroutine(RestAfterAttack()); // 조건이 맞으면 실행할거에요 -> 살아있다면 휴식 코루틴 시작을
}
void Patrol() // 함수를 선언할거에요 -> 순찰 로직 Patrol을
{
if (Time.time < nextPatrolTime) { if (agent.velocity.magnitude < 0.1f) animator.Play(Monster_Idle); return; } // 조건이 맞으면 중단할거에요 -> 대기 시간이라면 대기 모션 재생 후 리턴
Vector3 randomPoint = transform.position + new Vector3(Random.Range(-patrolRadius, patrolRadius), 0, Random.Range(-patrolRadius, patrolRadius)); // 벡터를 계산할거에요 -> 랜덤 순찰 지점을
if (NavMesh.SamplePosition(randomPoint, out NavMeshHit hit, 3f, NavMesh.AllAreas)) { if (agent.isOnNavMesh) { agent.isStopped = false; agent.SetDestination(hit.position); } } // 조건이 맞으면 실행할거에요 -> 유효한 위치라면 이동하기를
nextPatrolTime = Time.time + patrolInterval; // 값을 갱신할거에요 -> 다음 순찰 시간을
}
void UpdateMovementAnimation() // 함수를 선언할거에요 -> 이동 애니메이션 갱신 함수를
{
if (isAttacking || isHit || isResting) return; // 조건이 맞으면 중단할거에요 -> 다른 행동 중이라면
if (agent.velocity.magnitude > 0.1f) animator.Play(walkAnim); else animator.Play(Monster_Idle); // 조건에 따라 재생할거에요 -> 걷기 또는 대기 애니메이션을
}
}