Unity Chapter 11-16. 좀비 TPS 게임 만들기 : Enemy
카테고리: Unity Lesson 1
태그: Unity Game Engine
인프런에 있는 이제민님의 레트로의 유니티 C# 게임 프로그래밍 에센스 강의를 듣고 정리한 필기입니다. 😀
🌜 [레트로의 유니티 C# 게임 프로그래밍 에센스] 강의 들으러 가기!
Chapter 11. 좀비 TPS 게임 만들기
📜Enemy.cs
Zombie
에 붙여준다.
- 좀비 캐릭터의 생명체로서의 동작을 담당
- LivingEntity를 상속 받아 상속 받은 생명체로서의 기본 동작 그 위에 좀비만의 동작을 구현할 것이다.
- LivingEntity 위에서 확장만 하면 됨
- LivingEntity를 상속 받아 상속 받은 생명체로서의 기본 동작 그 위에 좀비만의 동작을 구현할 것이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class Enemy : LivingEntity
{
private enum State
{
Patrol,
Tracking,
AttackBegin,
Attacking
}
private State state;
private NavMeshAgent agent; // 경로계산 AI 에이전트
private Animator animator; // 애니메이터 컴포넌트
public Transform attackRoot;
public Transform eyeTransform;
private AudioSource audioPlayer; // 오디오 소스 컴포넌트
public AudioClip hitClip; // 피격시 재생할 소리
public AudioClip deathClip; // 사망시 재생할 소리
private Renderer skinRenderer; // 렌더러 컴포넌트
public float runSpeed = 10f;
[Range(0.01f, 2f)] public float turnSmoothTime = 0.1f;
private float turnSmoothVelocity;
public float damage = 30f;
public float attackRadius = 2f;
private float attackDistance;
public float fieldOfView = 50f;
public float viewDistance = 10f;
public float patrolSpeed = 3f;
[HideInInspector] public LivingEntity targetEntity; // 추적할 대상
public LayerMask whatIsTarget; // 추적 대상 레이어
private RaycastHit[] hits = new RaycastHit[10];
private List<LivingEntity> lastAttackedTargets = new List<LivingEntity>();
private bool hasTarget => targetEntity != null && !targetEntity.dead;
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (attackRoot != null)
{
Gizmos.color = new Color(1f, 0f, 0f, 0.5f);
Gizmos.DrawSphere(attackRoot.position, attackRadius);
}
var leftRayRotation = Quaternion.AngleAxis(-fieldOfView * 0.5f, Vector3.up);
var leftRayDirection = leftRayRotation * transform.forward;
Handles.color = new Color(1f, 1f, 1f, 0.2f);
Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
}
#endif
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
animator = GetComponent<Animator>();
audioPlayer = GetComponent<AudioSource>();
skinRenderer = GetComponentInChildren<Renderer>();
attackDistance = Vector3.Distance(transform.position,
new Vector3(attackRoot.position.x, transform.position.y, attackRoot.position.z)) +
attackRadius;
attackDistance += agent.radius;
agent.stoppingDistance = attackDistance;
agent.speed = patrolSpeed;
}
// 적 AI의 초기 스펙을 결정하는 셋업 메서드
public void Setup(float health, float damage,
float runSpeed, float patrolSpeed, Color skinColor)
{
// 체력 설정
this.startingHealth = health;
this.health = health;
// 내비메쉬 에이전트의 이동 속도 설정
this.runSpeed = runSpeed;
this.patrolSpeed = patrolSpeed;
this.damage = damage;
// 렌더러가 사용중인 머테리얼의 컬러를 변경, 외형 색이 변함
skinRenderer.material.color = skinColor;
agent.speed = patrolSpeed;
}
private void Start()
{
// 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
StartCoroutine(UpdatePath());
}
private void Update()
{
if (dead) return;
if (state == State.Tracking &&
Vector3.Distance(targetEntity.transform.position, transform.position) <= attackDistance)
{
BeginAttack();
}
// 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
animator.SetFloat("Speed", agent.desiredVelocity.magnitude);
}
private void FixedUpdate()
{
if (dead) return;
if (state == State.AttackBegin || state == State.Attacking)
{
var lookRotation =
Quaternion.LookRotation(targetEntity.transform.position - transform.position);
var targetAngleY = lookRotation.eulerAngles.y;
transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY,
ref turnSmoothVelocity, turnSmoothTime);
}
if (state == State.Attacking)
{
var direction = transform.forward;
var deltaDistance = agent.velocity.magnitude * Time.deltaTime;
var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance,
whatIsTarget);
for (var i = 0; i < size; i++)
{
var attackTargetEntity = hits[i].collider.GetComponent<LivingEntity>();
if (attackTargetEntity != null && !lastAttackedTargets.Contains(attackTargetEntity))
{
var message = new DamageMessage();
message.amount = damage;
message.damager = gameObject;
if(hits[i].distance <= 0f)
{
message.hitPoint = attackRoot.position;
}
else
{
message.hitPoint = hits[i].point;
}
message.hitNormal = hits[i].normal;
attackTargetEntity.ApplyDamage(message);
lastAttackedTargets.Add(attackTargetEntity);
break;
}
}
}
}
// 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신
private IEnumerator UpdatePath()
{
// 살아있는 동안 무한 루프
while (!dead)
{
if (hasTarget)
{
if (state == State.Patrol)
{
state = State.Tracking;
agent.speed = runSpeed;
}
// 추적 대상 존재 : 경로를 갱신하고 AI 이동을 계속 진행
agent.SetDestination(targetEntity.transform.position);
}
else
{
if (targetEntity != null) targetEntity = null;
if (state != State.Patrol)
{
state = State.Patrol;
agent.speed = patrolSpeed;
}
if (agent.remainingDistance <= 1f)
{
var patrolPosition = Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);
agent.SetDestination(patrolPosition);
}
// 20 유닛의 반지름을 가진 가상의 구를 그렸을때, 구와 겹치는 모든 콜라이더를 가져옴
// 단, whatIsTarget 레이어를 가진 콜라이더만 가져오도록 필터링
var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);
// 모든 콜라이더들을 순회하면서, 살아있는 LivingEntity 찾기
foreach (var collider in colliders)
{
if (!IsTargetOnSight(collider.transform)) continue;
var livingEntity = collider.GetComponent<LivingEntity>();
// LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면,
if (livingEntity != null && !livingEntity.dead)
{
// 추적 대상을 해당 LivingEntity로 설정
targetEntity = livingEntity;
// for문 루프 즉시 정지
break;
}
}
}
// 0.05 초 주기로 처리 반복
yield return new WaitForSeconds(0.05f);
}
}
// 데미지를 입었을때 실행할 처리
public override bool ApplyDamage(DamageMessage damageMessage)
{
if (!base.ApplyDamage(damageMessage)) return false;
if (targetEntity == null)
{
targetEntity = damageMessage.damager.GetComponent<LivingEntity>();
}
EffectManager.Instance.PlayHitEffect(damageMessage.hitPoint, damageMessage.hitNormal, transform, EffectManager.EffectType.Flesh);
audioPlayer.PlayOneShot(hitClip);
return true;
}
public void BeginAttack()
{
state = State.AttackBegin;
agent.isStopped = true;
animator.SetTrigger("Attack");
}
public void EnableAttack()
{
state = State.Attacking;
lastAttackedTargets.Clear();
}
public void DisableAttack()
{
if(hasTarget)
{
state = State.Tracking;
}
else
{
state = State.Patrol;
}
agent.isStopped = false;
}
private bool IsTargetOnSight(Transform target)
{
RaycastHit hit;
var direction = target.position - eyeTransform.position;
direction.y = eyeTransform.forward.y;
if (Vector3.Angle(direction, eyeTransform.forward) > fieldOfView * 0.5f)
{
return false;
}
direction = target.position - eyeTransform.position;
if (Physics.Raycast(eyeTransform.position, direction, out hit, viewDistance, whatIsTarget))
{
if (hit.transform == target) return true;
}
return false;
}
// 사망 처리
public override void Die()
{
// LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
base.Die();
// 다른 AI들을 방해하지 않도록 자신의 모든 콜라이더들을 비활성화
GetComponent<Collider>().enabled = false;
// AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
agent.enabled = false;
// 사망 애니메이션 재생
animator.applyRootMotion = true;
animator.SetTrigger("Die");
// 사망 효과음 재생
if (deathClip != null) audioPlayer.PlayOneShot(deathClip);
}
}
시작하기 앞서
UnityEngine.AI
using UnityEngine.AI
- 네비게이션 시스템을 사용하기 위해선
UnityEngine.AI
을 인클루딩 해줘야 한다.
전처리기
#if UNITY_EDITOR
using UnityEditor;
#endif
UnityEditor 의 기능들을 사용할 때는
using UnityEditor
만 해주면 안되고 반드시 전처리기#if UNITY_EDITOR
,#endif
안에 넣어서 선언해주어야 한다.
- 오직 유니티 에디터에서만
UnityEditor
네임스페이스를 사용하겠다고 선언.- 유니티 에디터 내에서만 동작할 뿐
UnityEditor
네임스페이스가 빌드 되지는 않는다.- 게임 개발이 완성된 후 나중에 윈도우용, 맥OS, 안드로이드 등등 이런 다양한 플랫폼으로서 빌드 할 때 이 부분은 빌드에서 빠지게 된다. 유니티 에디터에서만 되니까!
- 오로지 유니티 에디터에서만 좀비가 플레이어를 인식할 수 있는 영역을 시각적으로 보여주기 위해서 이에 대한 기능을 제공하는
UnityEditor
네임스페이스를 사용하되 오직 유니티 에디터에서만 사용!
- 유니티 에디터 내에서만 동작할 뿐
전처리기 👉 특정 상황에 따라 스크립트를 컴파일할지 말지 결정할 수 있다.
- 유니티는 여러 플랫폼을 빌드할 수 있다. iOS, 안드로이드, 윈도우, 맥OS 등등..
- 플랫폼에 따른 각각의 코드들을 만들 때 특정 플랫폼에만 컴파일 되는 전처리기를 만들 수 있다.
- 예를들어 iOS 전처리기 부분은 게임 개발이 완성된 후 iOS로 빌드될 때만 포함된다. 안드로이드로 빌드 될 때는 이 부분의 코드가 빌드에 포함되지 않는다.
#if UNITY_EDITOR
Debug.Log("Unity Editor"); // 유니티 에디터에서만 나오는 로그
#endif
#if UNITY_IOS
Debug.Log("Iphone"); // iOS 에서만 나오는 로그. iOS로 빌드할 때만 빌드 된다.
#endif
#if UNITY_STANDALONE_OSX
Debug.Log("Stand Alone OSX"); // 맥 OS 에서만 나오는 로그. 맥 OS로 빌드할 때만 빌드 된다.
#endif
#if UNITY_STANDALONE_WIN
Debug.Log("Stand Alone Windows"); // 윈도우에서만 나오는 로그. 윈도우로 빌드할 때만 빌드 된다.
#endif
멤버 변수/프로퍼티
LivingEntity를 상속 받았으므로 LivingEntity의 멤버 변수들도 가지고 있다는 것 잊지 말기!
private enum State // 좀비 상태
{
Patrol, // 돌아다니는 상태
Tracking, // 플레이어를 추격하는 상태
AttackBegin, // 공격 시작
Attacking // 공격
}
private State state; // 좀비 상태
private NavMeshAgent agent; // NavMeshAgent 경로계산 AI 에이전트
private Animator animator; // 좀비 애니메이션을 표현할 애니메이터 컴포넌트
public Transform attackRoot;
public Transform eyeTransform;
private AudioSource audioPlayer; // 오디오 소스 컴포넌트. 소리 재생기
public AudioClip hitClip; // 피격시 재생할 소리
public AudioClip deathClip; // 사망시 재생할 소리
private Renderer skinRenderer; // 렌더러 컴포넌트
public float runSpeed = 10f; // 좀비 이동 속도
[Range(0.01f, 2f)] public float turnSmoothTime = 0.1f; // 좀비가 방향을 스무스하게 회전할 때 사용할 지연시간. smoothDamp 에 사용할것.
private float turnSmoothVelocity; // smoothDamp 에 사용할것. 스무스하게 회전하는 실시간 변화량
public float damage = 30f; // 공격령
public float attackRadius = 2f; // 공격 반경(반지름)
private float attackDistance; // 공격을 시도하는 거리
public float fieldOfView = 50f; // 좀비의 시야 각
public float viewDistance = 10f; // 좀비가 볼 수 있는 거리
public float patrolSpeed = 3f; // 좀비가 돌아다니는 거리(Patrol 상태일 때)
[HideInInspector] public LivingEntity targetEntity; // 추적할 대상.
public LayerMask whatIsTarget; // 추적 대상 레이어
private RaycastHit[] hits = new RaycastHit[10];
private List<LivingEntity> lastAttackedTargets = new List<LivingEntity>();
private bool hasTarget => targetEntity != null && !targetEntity.dead;
- 필기 안한건 주석 참고
attackRoot
- Transform
- 좀비 오브젝트가 공격을 하는 Pivot 포인트.
- 이
attackRoot
을 중심으로 반지름을 지정해서 이 반경 내에 있는 플레이어가 공격을 당하도록 할 것이다.
eyeTransform
- Transform
- 시야의 기준점. ‘눈의 위치’가 될 어떤 게임 오브젝트의 Trnasform
- 이
eyeTransform
을 기준으로 어떤 영역을 지정해서 플레이어나 적 AI를 감지할 수 있게 할 것이다.
skinRenderer
- Renderer
- 좀비의 피부색에 따라서 공격력을 다르게 해줄 것
- 그때 사용할 피부색!
targetEntity
- LivingEntity
- 좀비가 추적할 대상.
- 플레이어 캐릭터 오브젝트가 이 곳에 할당 될 것!
- LivingEntity 타입이라면 어떤 것이든지 이 곳에 할당 가능.
[HideInInspector]
라서 유니티 인스펙터 창에선 보이지 않음. public인데도 불구하고!- 코드로 할당 할 것이라서 숨겼다.
whatIsTarget
- LayerMask
- 적을 감지할 때 사용할 레이어 필터
hits
- 10 사이즈의 RaycastHit 배열이다.
- 배열을 사용한 이유
- 좀비의 공격을 범위 기반의 공격으로 구현할 것이라서 범위 기반으로 하면 여러개의 Ray 충돌 지점이 생기기 때문.
- 배열을 사용한 이유
- 10 사이즈의 RaycastHit 배열이다.
lastAttackedTargets
- LivingEntity 타입의 원소들이 들어가 리스트
- 공격을 시작할 때마다 초기화 될 리스트
- 공격 도중에 직전 프레임까지 공격이 적용된 대상들을 모아둘 리스트
- 공격은 시간을 들여서 진행 되는데, 공격이 똑같은 대상에게 두번 이상 적용되지 않도록 하기 위하여 이 리스트에 포함된 오브젝트들은 공격 대상에서 제외할 것이다.
- 공격이 끝나고 나면 리스트를 비움
- 공격은 시간을 들여서 진행 되는데, 공격이 똑같은 대상에게 두번 이상 적용되지 않도록 하기 위하여 이 리스트에 포함된 오브젝트들은 공격 대상에서 제외할 것이다.
- LivingEntity 타입의 원소들이 들어가 리스트
hasTarget
- 추적할 대상이 존재하는지의 여부
- 람다 함수로 정의된 프로퍼티
- targetEntity != null && !targetEntity.dead
- 추적할 상대방이 존재하고 추적할 상대방이 죽은 상태가 아니라면
- targetEntity != null && !targetEntity.dead
멤버 함수
private void OnDrawGizmosSelected()
Zombie
오브젝트의 시야와 공격 범위를 유디터 에디터 내에서만, 씬 상에서만 그리는 역할을 한다.
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (attackRoot != null)
{
Gizmos.color = new Color(1f, 0f, 0f, 0.5f);
Gizmos.DrawSphere(attackRoot.position, attackRadius);
}
var leftRayRotation = Quaternion.AngleAxis(-fieldOfView * 0.5f, Vector3.up);
var leftRayDirection = leftRayRotation * transform.forward;
Handles.color = new Color(1f, 1f, 1f, 0.2f);
Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
}
#endif
- 전처리기
#if
#endif
로 묶여있다.UNITY_EDITOR
유니티 에디터 내에서만 이 코드가 존재하게 되며 최종적인 실제 게임 빌드에서는 빠지게 된다.
- OnDrawGizmosSelected() 는 이벤트 함수다.
OnDrawGizmos()
👉 이 함수가 소속된 스크립트가 컴포넌트로서 붙은 오브젝트가 Scene 화면 상에서 항상 보이도록 하는 이벤트 함수.OnDrawGizmosSeleced()
👉 이 함수가 소속된 스크립트가 컴포넌트로서 붙은 오브젝트가 선택됐을때만 보이도록 하는 이벤트 함수.- 씬상에 존재하기는 하나 아직 보이지는 않는 그런 오브젝트들을 기즈모를 통해 보여줌으로서 위치 조정을 쉽게 해주는 등등 개발자 편의를 돕는다.
Zombie
의 빨간색 공격 반경 그리기 🔈 씬 상에서만 그려진다.- if (attackRoot != null)
attackRoot
공격의 기준점이 존재할 때만 공격 반경을 그릴 수 있으므로.- 기즈모의 색깔을 빨간색으로 변경해준다.
- 레드를
1.0f
100 % 로.
- 레드를
- 공격 반경을 구로 그려준다.
- 중심점
attackRoot.position
- 반지름
attackRadius
- 중심점
- if (attackRoot != null)
Zombie
의 빨간색 시야 범위 그리기 🔈 씬 상에서만 그려진다.- 피자 모양의 ‘호’(Arc) 형태로 그릴 것이다.
-
- 갈색 피자모양이 좀비 시야각을 표현한 것이다. 이것을 씬상에서 그릴 것!
- 만약
fieldOfView
즉, 좀비의 시야각이 60 도라면-fieldOfView * 0.5f
은 -30 도가 되므로transform.forward
축을 기준으로 왼쪽으로 30도 만큼 회전한 상태를 시작점으로 해서 오른쪽으로 60도만큼 원을 그려(피자모양) 시야각을 표현할 것이다.
- 만약
- 갈색 피자모양이 좀비 시야각을 표현한 것이다. 이것을 씬상에서 그릴 것!
leftRayRotation
왼쪽 끝지점으로 향하는 회전값(쿼터니언)Vector3.up
위쪽 방향(y축)을 중심으로 하여fieldOfView * 0.5f
각도만큼 왼쪽으로-
회전시킨 것 (최종적으로fieldOfView * 0.5f
) 을 쿼터니언으로 표현한 것.- 필기 그림 상에서 노란 각도 부분
leftRayDirection
왼쪽 끝지점으로 향하는 방향leftRayRotation * transform.forward
- 위에서 구한 쿼터니언인
leftRayRotation
만큼 앞쪽 방향으로 회전한 처리가 된다. leftRayRotation
으로부터lefRayDirection
을 구함!- 필기 그림 상에서 노란 각도를 forward(z축)를 기준으로 한 과정
- 위에서 구한 쿼터니언인
- 이렇게 피자모양의 호(Arc)를 그릴 수 있는 기능은
Gizmos
에는 없어서 여러가지 그리기 요소가 담겨있는unityEditor
의Handles
클래스의 함수 DrawSolidArc 를 통해 그릴 것이다.- 색깔은 조금 투명한 하얀색 new Color(1f, 1f, 1f, 0.2f);
- 필기 그림 상에서 핫핑크 색 (
leftRayDirection
)을 시작점 으로 오른쪽으로fieldOfview
각도(노란색의 2 배) 만큼의 부채꼴 Arc를 그린다. - Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
- 시야 각이 그려지는 위치인
eyeTransform.position
에서 그려지며 Vector3.up
축을 기준으로 회전한 부채꼴leftRayDirection
을 부채꼴 시작점으로 (from)- 오른쪽으로
fieldOfview
각도만큼 회전한 - 중심으로부터 호가 그려지는 길이는, 즉 반지름이
viewDistance
(좀비가 볼 수 있는 거리)인 Arc를 그린다.
- 시야 각이 그려지는 위치인
Eye, AttackRoot 추가
- 빈 게임 오브젝트인
Eye
를Zombie
의 자식으로 추가해준다.- 좀비의 눈의 위치가 될 것이다.
- Trnasform 값은 (0, 1.6, 0.12) 로 해준다.
- 📜Enemy.cs 의 `eyeTransform` 슬롯에 드래그 앤 드롭 해준다.
- 빈 게임 오브젝트인
Attack Root
를Zombie
의 자식으로 추가해준다.- 좀비의 공격 반경이 된다.
-
- 좀비의 앞쪽에 위치한 것을 볼 수 있다. 좀비와 이
Attack Root
거리가 공격 반경 구의 반지름이 될 거이다.
- 좀비의 앞쪽에 위치한 것을 볼 수 있다. 좀비와 이
- Trnasform 값은 (0, 1.2, 0.5) 로 해준다.
- 📜Enemy.cs 의 `attackRoot` 슬롯에 드래그 앤 드롭 해준다.
-
Zombie
를 클릭했을 때 Scene 상에서 다음과 같이 빨간 공격 반경 구와 시야 범위가 Arc로 그려지는 것을 확인할 수 있다.
private void Awake()
필요한 다른 컴포넌트들을 가져와서 멤버 변수에 할당
private void Awake()
{
// 컴포넌트들 가져오기
agent = GetComponent<NavMeshAgent>(); // Zombie의 NavMeshAgent 컴포넌트 가져오기
animator = GetComponent<Animator>(); // Zombie의 Animator 컴포넌트 가져오기
audioPlayer = GetComponent<AudioSource>(); // Zombie의 AudioSource 컴포넌트 가져오기
skinRenderer = GetComponentInChildren<Renderer>(); // Zombie의 자식 오브젝트들 중에서 Rederer 컴포넌트를 가진 오브젝트를 Reneerer 타입으로 가져오기
//
attackDistance = Vector3.Distance(transform.position, new Vector3(attackRoot.position.x, transform.position.y, attackRoot.position.z)) + attackRadius;
attackDistance += agent.radius;
agent.stoppingDistance = attackDistance;
agent.speed = patrolSpeed;
}
skinRenderer
- Zombie의 자식 오브젝트들 중에서 Rederer 컴포넌트를 가진 오브젝트를 Reneerer 타입으로 가져오기
- 좀비의 색상(Renderer)는 바로
Zombie
의 컴포넌트로 추가되는게 아니라Zombie
의 자식 오브젝트인LowMan
에 Skinned Mesh Renderer로 붙어 있기 때문에 GetComponentInChildren 함수로 가져오는 것이다.
attackDistance
공격을 시도하는 거리를 나타내는 멤버 변수. 플레이어와 적 사이의 거리가attackDistance
보다 작거나 같다면 적이 플레이어에게 공격을 시도.transform.position
적(좀비인 자기 자신)의 위치로부터- 공격 반경 중심점이 되는
attackRoot
사이의 거리에다가- 적의 Trnasform은 적의 발을 기준으로 하기 때문에
attackRoot
가transform.position
보다 더 높이 위치한다. - 수평 방향으로만 따질거라서 바로
transform.position
와attackRoot
사이의 거리를 바로 구하는게 아니라 x, z 위치값은attackRoot
를 따르고 y 위치값은transform.position
와 일치한 벡터를 더해준다.
- 적의 Trnasform은 적의 발을 기준으로 하기 때문에
- 이 거리에 공격 반경(
attackRadius
)을 더해주면attackDistance
완성
stoppingDistance
- 👉 NavMeshAgent의 변수로 AI Agent가 도착지로부터 이
stoppingDistance
값의 거리내에 들면 속도를 감속하여 서서히 멈춘다. 즉 AI가 목표를 추적하다가 목표 위치에 가까워졌을시 정지하는 근접 거리. - 좀비가 플레이어를 막 추적하다가 공격 사정거리(
attackDistance
) 안에 들면 플레이어를 공격해야 한다. 이 때 플레이어에게 공격 행동을 하기 위해선 좀비가 멈춰야 한다. 좀비가 멈추고 공격을 해야 함. 따라서stoppingDistance
을attackDistance
로 초기화 해둠.
- 👉 NavMeshAgent의 변수로 AI Agent가 도착지로부터 이
speed
- 👉 NavMeshAgent의 변수로 AI Agent가 목표(플레이어)를 추적하는 속도
- 처음엔 순찰 속도로 초기화 agent.speed = patrolSpeed
public void Setup(float health, float damage, float runSpeed, float patrolSpeed, Color skinColor)
Zombie
가 생성될 때,Zombie
의 스펙(체력, 공격력, 뛰는 속도, 정찰 속도, 색깔)을 결정해준다.
- 인수는 나중에 스포너에서 결정할 것이다.
- 이 함수의 역할은 들어온 인수로
Zombie
의 스펙을 설정하는 역할만 수행.
// 적 AI의 초기 스펙을 결정하는 셋업 메서드
public void Setup(float health, float damage,
float runSpeed, float patrolSpeed, Color skinColor)
{
// 체력 설정
this.startingHealth = health; // 초기 시작 체력
this.health = health; // 체력
// 내비메쉬 에이전트의 이동 속도 설정
this.runSpeed = runSpeed;
this.patrolSpeed = patrolSpeed;
this.damage = damage; // 공격력
// 렌더러가 사용중인 머테리얼의 컬러를 변경, 외형 색이 변함
skinRenderer.material.color = skinColor;
agent.speed = patrolSpeed; // 위에서 변경된 patrolSpeed로 다시 적용
}
private void Start()
private void Start()
{
// 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
StartCoroutine(UpdatePath());
}
- 게임이 시작되자마자 private IEnumerator UpdatePath() 함수를 코루틴으로 실행시키고 있다.
private void Update()
매 프레임마다 추적 대상과의 거리를 따져서 공격을 실행할지 검사하고 현재 상태에 따라 재생할 애니메이션을 결정한다.
private void Update()
{
if (dead) return;
// 추적 대상과의 거리를 따져서 공격을 실행할지 검사
if (state == State.Tracking &&
Vector3.Distance(targetEntity.transform.position, transform.position) <= attackDistance)
{
BeginAttack();
}
// 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
animator.SetFloat("Speed", agent.desiredVelocity.magnitude);
}
Zombie
가 죽었다면 Update 함수 종료- 매 프레임마다 추적 대상과의 거리를 따져서 공격을 실행할지 검사
- 추적 상태 + 추적 대상과 나 사이의 거리가 공격 사정거리 이하라면
- 👉 공격하기 !
BeginAttack()
공격을 시작하는 메소드- 총과 다르게 직접 공격을 하는 모션은 시작과 끝이 존재하기 때문에 공격을 시작하는 메소드, 끝내는 메소드 따로 지정해야 한다.
- 👉 공격하기 !
- 추적 상태 + 추적 대상과 나 사이의 거리가 공격 사정거리 이하라면
- 매 프레임마다 현재 상태에 따라 재생할 애니메이션을 결정
- 애니메이션에 넘길 속도 파라미터(“Speed”) 값으로는
agent.desiredVelocity.magnitude
- NavMeshAgent(좀비)의
desiredVelocity
은 음 목적지로 향하는 목표 속도를 나타낸다. 실제 속도는 아님! 현재 속도로 설정하고 싶은 원하는 속도 값.desiredVelocity
속도로 움직이게 하고 싶지만 실제론 관성이나 어떤 장애물에 의해 실제 속도와는 차이가 날 수 있다.- 예를 들어 우리가 원하는 속도가 50인데 현재 캐릭터가 장애물에 막혀 제자리에서 뛰고 있다면 속도는 실제로는 0 이다.
- 이동하려고 하는 속도가 아니라 실제 이동 속도를 적용하고 싶다면 그냥
agent.velocity
- 그 속도의
magnitude
스칼라 크기.
- NavMeshAgent(좀비)의
- 애니메이션에 넘길 속도 파라미터(“Speed”) 값으로는
private void FixedUpdate()
- Fixedupdate()
- Update()처럼 매번 반복 실행되나 프레임에 기반하지 않고 어떤 고정적이고 동일한 시간에 기반하여 실행된다.
- 기본적으로 0.02초마다 실행됨.
- 프레임 환경에 상관없이 무조건 실행 횟수를 지킨다.
- Update()처럼 매번 반복 실행되나 프레임에 기반하지 않고 어떤 고정적이고 동일한 시간에 기반하여 실행된다.
private void FixedUpdate()
{
if (dead) return;
if (state == State.AttackBegin || state == State.Attacking)
{
var lookRotation =
Quaternion.LookRotation(targetEntity.transform.position - transform.position);
var targetAngleY = lookRotation.eulerAngles.y;
transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY,
ref turnSmoothVelocity, turnSmoothTime);
}
if (state == State.Attacking)
{
var direction = transform.forward;
var deltaDistance = agent.velocity.magnitude * Time.deltaTime;
var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance,
whatIsTarget);
for (var i = 0; i < size; i++)
{
var attackTargetEntity = hits[i].collider.GetComponent<LivingEntity>();
if (attackTargetEntity != null && !lastAttackedTargets.Contains(attackTargetEntity))
{
var message = new DamageMessage();
message.amount = damage; // 공격양
message.damager = gameObject; // 공격을 가하는 사람은 좀비 자기 자신
// 공격이 들어간 지점
if(hits[i].distance <= 0f)
{
message.hitPoint = attackRoot.position;
}
else
{
message.hitPoint = hits[i].point;
}
// 공격이 들어가는 방향
message.hitNormal = hits[i].normal;
attackTargetEntity.ApplyDamage(message);
// 이미 공격을 가한 상대방이라는 뜻에서
lastAttackedTargets.Add(attackTargetEntity);
break; // 공격 대상을 찾았으니 for문 종료
}
}
}
}
현재 상태에 따라서 공격 범위에 겹친 상대방의 Collider를 통해 상대방을 감지하고 데미지를 주는 처리를 할 것이다.
Zombie
가 죽었다면 FixedUpdate 함수 종료- 이제 막 공격을 시작하는 상태(
AttackBegin
)이거나 공격이 한창 이루어지는 중(Attacking
)에는 나(좀비) 자신이 회전값을 타겟을 향한 방향으로 변경한다. 즉, 좀비가 공격하는 대상인 타겟을 향하여 보도록lookRotation
👉 현재Zombie
가 바라보고 있는 방향- Quaternion.LookRotation(targetEntity.transform.position - transform.position, Vector3.up);
- 인수로 넣은 Vector3 의 방향을 바라보게끔 회전한 값
- 인수 👉 (타겟 위치 - 자기 자신의 위치 = 타겟을 바라보는 방향)
- Quaternion.LookRotation(targetEntity.transform.position - transform.position, Vector3.up);
targetAngleY
👉 현재Zombie
가 바라보고 있는 방향으로 LookRotation 할 때 y 축 회전 방향.- targetAngleY = lookRotation.eulerAngles.y;
eulerAngles
는 쿼터니언(lookRotation
)을 오일러각으로 변환시킨다. 즉 Vector3로 변환한다.- x축, z축 즉 평면의 회전 값만 변하는 y 축 중심의 회전 방향만 고려할 것
- targetAngleY = lookRotation.eulerAngles.y;
- 내 Vector3 회전 값(
transform.eulerAngles
)을Vector3.up * targeAngleY
로 설정해주면 되는데, 이를 스무스 하게 설정하자.transform.eulerAngles.y
값이targetAngleY
로 스무스 하게 변할 수 있도록.- Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY, ref turnSmoothVelocity, turnSmoothTime);
- 공격이 실행되자마자 바로 공격 처리가 들어가게 하지 않고 공격 애니메이션이 끝나갈때, 즉 좀비가 공격하기 위해 팔을 뻗는 애니메이션을 충분히 다 취하고난 후에 공격 처리를 할 것이다.
- 따라서
AttackBegin
상태에는 공격 애니메이션을 재생하고 한창 공격 애니메이션이 충분히 재생되고 난 후인Attacking
상태일 때 공격 처리(감지하고 데미지 주기 등등)를 할 것이다.- if (state ==
State.Attacking
) 공격 처리 시작- 1️⃣
attackRoot
중심으로 구를 그려서 해당 구에 겹치는 Collider들을 감지.- ⭐ 단, Physics.OverlapSphere 함수로 Collider를 감지 하지 않고 Physics.SphereCastNonAlloc 함수를 사용할 것이다.
- Physics.OverlapSphere 함수
- 실행과 실행 그 사이(다음 FixedUpdate())의 순간에서 좀비나 공격 대상이 너무 빠르게 움직여버려서 감지가 됐어야 맞는데 그림처럼 감지가 안되는 상황이 생길 수 있다.
Collider []
배열을 리턴한다.
- Physics.SphereCastNonAlloc 함수
- 위와 같은 상황을 방지하고자 Physics.SphereCastNonAlloc 함수를 쓸 것이다.
- 이 함수는 인수로 방향과 거리를 넘겨주면 구가 해당 방향과 거리로 이동한 ✨궤적✨에 겹치는 Collider가 있는지를 검사한다. 이게 바로 Cast계열 함수들의 특성
Collider
가 들어있는 것이 아닌Raycast
에 걸린 원소들이 들어 있는hit
배열의 크기를 리턴한다.
- 위와 같은 상황을 방지하고자 Physics.SphereCastNonAlloc 함수를 쓸 것이다.
- Physics.OverlapSphere 함수
direction
👉 좀비가 보는 앞쪽 방향transform.forward
deltaDistance
👉 좀비(AI agent)가 다음 FixedUpdate() 실행까지의 사잇 시간인Time.fixedDeltaTime
동안 이동한 거리(스칼라)가 된다. (속도 크기를 곱해주면 됨!)- 그런데
Time.fixedDeltaTime
가 아닌Time.deltaTime
을 쓴 이유는 밑에 참고
- 그런데
size
👉 Physics.SphereCastNonAlloc 함수의 리턴값으로hit
배열의 크기. 즉, 구 궤적에 걸린 모든 RaycastHit 들이 몇개인지를 나타낸다.- 어차피 감지될 플레이어는 한명인데
size
가 1 보다 클 수 있는 이유. 즉RaycastHi
결과 충돌한 Collider들이 여러개일 수 있는 이유- 플레이어는 한명이지만
Ray
충돌 포인트는 다수가 존재할 수 있다. - 플레이어는 하나라도 플레이어에게 할당된 콜라이더는 여러개가 존재할 수 있으며 또한 플레이어랑 부딪치는 물체도 여러개가 존재할 수 있다.
- 플레이어는 한명이지만
- Physics.SphereCastNonAlloc 함수에 대한 자세한 설명은 바로 아래에 필기함
- 어차피 감지될 플레이어는 한명인데
- ⭐ 단, Physics.OverlapSphere 함수로 Collider를 감지 하지 않고 Physics.SphereCastNonAlloc 함수를 사용할 것이다.
- 2️⃣ 감지된 것들이 모여있는
hit
배열에서Player Character
인 것(whatIsTarget
레이어에다가LivingEntity
를 가지고 있는 것)을 for문으로 순회하며 찾아 공격을 처리해준다. 찾으면 순회 끝냄 breakhit.size()
만큼 돌면 안되고size
만큼만 돌아야 한다. 만약 충돌체들이 3 개였다면hit
배열 크기는 10이므로 (멤버 변수 선언 때 10 크기로 선언했었다) 3 개의 충돌체와 함께 그 뒤의 7 개는 이전 프레임의 충돌체 정보를 담고 있게 된다. 따라서 10 만큼 돌면 안되고 딱 3 만큼으로 반복문을 돌아야 함!- 감지된 것들 중 `LivingEntity`이며 lastAttackedTargets 리스트에 소속되는 것이 아닌 경우(공격 도중에 또 공격이 들어가면 안되므로)에만 공격 처리를 해야 한다.
- 공격량, 공격행위자(자기자신), 공격거리, 공격방향 등을
message
에 설정한 후 공격을 개시한다. lastAttackedTargets
리스트에 공격대상을 추가해준다.
- 공격량, 공격행위자(자기자신), 공격거리, 공격방향 등을
- 1️⃣
- if (state ==
- 따라서
Physics의 Cast계열 함수의 특징
- 유니티에서 Collider를 찾아내는 방법은 크게 4 가지가 있다.
Ray
,Box
,Sphere
,Capsule
등등 이런 형태의 Collider 컴포넌트를 오브젝트에 달아서 OnTrigger나 OnCollision 같은 이벤트를 사용하여 해당 형태에 겹치는 Collider를 찾아내기도 한다.- 그러나 이런 방법의 경우 매 프레임 실행되는 것이기 때문에 순간적으로 딱 한번만 Collider를 찾아내려고 하는 경우에는 성능상 부적절할 수 있다. 매 프레임 실행되므로 적당히 모양을 유지하는 경우에는 사용하기 괜찮지만 그 순간의 겹치는 Collider를 잡아내야 하는 경우에는 힘들기 때문!
- Physics의 Cast계열 함수 들은 움직이려는 궤적에 충돌하는지를 검사하기 때문에 위와같이 프레임간 사이에서 빠르게 변화하여 감지되지 못할 수 있는 것들도 감지할 수 있도록 해준다.
- 종류
Cast
- 👉 찾아낸 충돌체 하나만을 구조체로 반환한다. 가장 처음에 충돌한 물체만 반환한다.
CastAll
- 👉 찾아낸 충돌체 전부를 리턴한다.
RaycastHit []
배열로 리턴한다.
- 👉 찾아낸 충돌체 전부를 리턴한다.
CastNonAlloc
- 👉 충돌체들을 리턴이 아닌 인수로 넘긴
RaycastHit
배열에 담아준다. 따라서CastAll
보다는 성능이 더 좋을 수 있다. 다만 찾아낸 충돌체들의 수가 인수로 넘긴 배열의 사이즈보다 적을 수도 많을 수도 있다는 것에 주의하여 사용해야 한다.- 직전 프레임까지 어떤
RaycastHit
정보가 있었는지를 알 수 있다.
- 직전 프레임까지 어떤
- 리턴은
int
로 인수로 넘긴 배열이 채워진 사이즈, 즉 충돌체들의 개수를 리턴한다.
- 👉 충돌체들을 리턴이 아닌 인수로 넘긴
- 움직이려고 하자마자, 즉 궤적을 그리기도 전에 바로 Collider가 걸린 상태라면 첫번째로 감지된 이 Collider의 point는 제로 포인트다.
- 가상의 구 (SphereCastNonAlloc을 예로 들자면) 가 움직이기도 전에 처음부터 겹쳐있었던 것(
hits[0]
)이 있다면 그 Collider의point
(충돌 위치)는 제로 포인트가 된다.hits[0].point
는 (0, 0, 0)- 따라서 이런 경우엔 distance도 0 으로 나오게 된다.
- 가상의 구 (SphereCastNonAlloc을 예로 들자면) 가 움직이기도 전에 처음부터 겹쳐있었던 것(
- 종류
var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance, whatIsTarget);
- SphereCastNonAlloc 함수
attackRadius
반경을 가진 구가attackRoot.position
에 위치로부터direction
방향으로deltaDistance
거리만큼 이동하면서 생긴 궤적(연속선상)에 겹치는 Collider들 중에서whatIsTarget
LayerMask를 가진 Collider가 있다면 그것을hits
배열에 담고 그 배열의 크기를 리턴한다.out hits
혹은ref hits
이렇게 레퍼런스로 넘기지 않은 이유는hits
배열 이름이 그 자체로 레퍼런스가 되기 때문이다. 따라서 그냥hits
로 인수 넘기면 됨.- 배열이 아니라 그냥
RaycastHit
자체를 넘기는 것이였으면 value이므로out hit
이런식으로 넘겨야 한다.
- 배열이 아니라 그냥
Time.fixedDeltaTime
- FixedUpdate() 함수가 실행되는 그 사이의 시간. 다음 FixedUpdate() 함수가 실행되기까지의 시간.
- FixedUpdate() 함수는 디폴트로 1/50초인 0.02초를 주기로 실행되기 때문에 디폴트론
Time.fixedDeltaTime
값은0.02
이다. - FixedUpdate() 함수 안에서 사용할 때 Time.fixedDeltaTime가 아닌 그냥 Time.deltaTime 를 사용할 것을 권장한다.
- 자동으로
Time.deltaTime
이 FixedUpdate() 함수 안에서 쓰이면 알아서Time.fixedDeltaTime
으로 리턴하기 때문이다.Time.deltaTime
은 알아서 Update() 함수에서는 프레임간의 사잇 시간으로서 동작하고 FixedUpdate() 함수 안에서 쓰이면 알아서 고정된 프레임인Time.fixedDeltaTime
(0.02초) 으로 쓰인다.
- 자동으로
Time.timeScale
을 통해 시간이 흘러가는 속도를 변경한다면Time.fixedDeltaTime
도 그에 맞게 변경해줄 것을 권장한다.Time.fixedDeltaTime = 0.02f * Time.timeScale
Time.timeScale
이 2.0f이면 디폴트에 비해 두배로 시간이 빨리 흘러간다는 의미고 0.0f면 시간이 아예 흐르지 않고 정지 상태라는 것을 의미한다.
private IEnumerator UpdatePath()
코루틴 함수
좀비 AI 가 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신
- 좀비가 살아있는 동안 계속 반복한다. 무한 루프
private IEnumerator UpdatePath()
{
// 살아있는 동안 무한 루프
while (!dead)
{
if (hasTarget)
{
if (state == State.Patrol)
{
state = State.Tracking;
agent.speed = runSpeed;
}
// 추적 대상 존재 : 경로를 갱신하고 AI 이동을 계속 진행
agent.SetDestination(targetEntity.transform.position);
}
else
{
if (targetEntity != null) targetEntity = null;
// 정찰 상태가 아니였다면 이제 다시 정찰 상태로 변경
if (state != State.Patrol)
{
state = State.Patrol;
agent.speed = patrolSpeed;
}
// 일단 시야를 통해 감지하기 전에 NavMesh 위의 어떤 임의의 지점으로 이동하게 한다.
if (agent.remainingDistance <= 1f)
{
var patrolPosition = Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);
agent.SetDestination(patrolPosition);
}
// 20 유닛의 반지름을 가진 가상의 구를 그렸을때, 구와 겹치는 모든 콜라이더를 가져옴
// 단, whatIsTarget 레이어를 가진 콜라이더만 가져오도록 필터링
var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);
// 모든 콜라이더들을 순회하면서, 살아있는 LivingEntity 찾기
foreach (var collider in colliders)
{
if (!IsTargetOnSight(collider.transform)) continue;
var livingEntity = collider.GetComponent<LivingEntity>();
// LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면,
if (livingEntity != null && !livingEntity.dead)
{
// 추적 대상을 해당 LivingEntity로 설정
targetEntity = livingEntity;
// for문 루프 즉시 정지
break;
}
}
}
// 0.05 초 시간 간격을 두면서 살아 있는 동안 무한 루프 반복 처리
yield return new WaitForSeconds(0.05f);
}
}
- while (!dead) 살아있는 동안 무한 루프로 추적 대상의 위치를 찾아 경로를 갱신한다.
- if (hasTarget) 추적 대상이 존재한다면
agent.SetDestination(targetEntity.transform.position);
- 추적 대상의 위치를 agent의 목표 위치로 삼고 그에 따라 경로를 갱신하고 AI 이동을 계속 진행
SetDestination
- 👉 NavMeshAgent 컴포넌트의 함수로 목적지를 인수로 넘겨 해당 목적지로 매 프레임마다 agent로 하여금 이동하게 하는 함수다.
- 추적 대상이 존재하는데 지금 정찰 상태였다면
if (state == State.Patrol)
- 이제 더 이상 정찰하지 않고 추적 대상을 향해 뛰어가도록 상태를
Tracking
으로 바꿔 주고 - 속도도
runSpeed
로 바꿔준다.
- 이제 더 이상 정찰하지 않고 추적 대상을 향해 뛰어가도록 상태를
- else 추적 대상이 존재하지 않는다면 (플에이어가 죽었거나 없는 상태)
- 주변을 정찰하면서 플레이어를 찾아야 함.
- 1️⃣
targetEntity
가 null이 아닌데hasTarget
이 null인 경우는 플레이어가 사망한 상태이다. 나중에 만약 이 게임을 온라인 게임으로 확장해서 사망한 플레이어 말고 이제 좀비들이 다른 플레이어를 추적하게끔 하려고 한다면targetEntity
를 null로 변경해주어야 한다. - 2️⃣ 정찰 상태가 아니였다면 이제 다시 정찰 상태로 변경해주어야 한다. 추적 대상이 없어진거니까.
- 상태와 속도를
Patrol
에 맞게 변경
- 상태와 속도를
- 3️⃣ 새로운 정찰 시작 위치 결정하기
- 시야를 통해서 감지하기 전에 먼저
Zombie
에게 NavMesh 위의 임의의 위치로 찍어줘서 그 위치로 이동하게끔 해준다.- 이 무한루프는 0.2초마다 실행이 되고 있으므로 아직 그 위치로 이동을 완료 하지도 않았는데 매번 새로운 정찰 위치를 결정하면 이상하게 보일 것이다. 따라서 새로운 정찰 위치인
patrolPosition
에 거의 다다른 다음에, 즉 거의 이동을 완료할 때쯤에, 찾으려는 대상이 없으면 그제서야 새로운 정찰 위치를 설정할 수 있도록 한다.- if (agent.
remainingDistance
<= 1f)- 인공지능 agent가 목표지점까지의 남은 거리가 1 이하일 때만. 즉, 거의 다 왔을 때만 실행하도록.
- if (agent.
- var
patrolPosition
= Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);- 📜Utility.cs에 구현되어 있는 GetRandomPointOnNavMesh 함수를 사용하여 NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치 리턴
- 현재 위치에서 20 만큼의 반경 내의
NavMesh.AllAreas
에서 랜덤한 위치 찍음.
- agent.SetDestination(patrolPosition);
- 그 위치로 이동시킴
- 이 무한루프는 0.2초마다 실행이 되고 있으므로 아직 그 위치로 이동을 완료 하지도 않았는데 매번 새로운 정찰 위치를 결정하면 이상하게 보일 것이다. 따라서 새로운 정찰 위치인
- 시야를 통해서 감지하기 전에 먼저
- 4️⃣ 시야를 통해 적을 감지
-
- 시야 각 내에 있는 모든 Collider들을 배열에 담는다.
- var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);
- 좀비의 눈의 위치
eyeTransform.position
를 기준으로 시야 사정거리viewDistance
을 구의 반경으로 삼았을 때whatIsTarget
layMask에 해당하는 Collider 들을colliders
에 리턴한다.colliders
은 Collider [] 배열
- 좀비의 눈의 위치
- var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);
- 모든 Collider들을 순회하면서 살아있는 Living Entity 찾기.
- 단, 시야 각 내에 있는 모든 것들에게
Zombie
의 눈에서 Raycast를 쐈을 때 상대방에게 무사히 도착 했는지를 기준으로 걸러낼 것. 시야각 내에 있더라도 장애물 뒤에 있는 대상이라면 감지 할 수 없는 대상으로서 걸러내야 한다. 좀비가 볼 수 있는 것들에 대해서만 감지해야 한다- if (!IsTargetOnSight(collider.transform)) continue;
- 좀비가 볼 수 없는 대상이라면 스킵! 다음 collider 검사하러 가기.
IsTargetOnSight
함수- 👉 밑에서 설명 되어있는 함수.
- 인수로 넘긴 collider의 위치가 좀비가 볼 수 있는 위치인지를 Raycast로 검사하여 True, False로 리턴하는 함수다.
- if (!IsTargetOnSight(collider.transform)) continue;
- LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면
- 추적 대상을 해당 LivingEntity로 설정
- 찾았으니 더 이상 collider는 순회하지 않고 for문을 빠져나온다.
- 단, 시야 각 내에 있는 모든 것들에게
- 시야 각 내에 있는 모든 Collider들을 배열에 담는다.
-
- 1️⃣
- 주변을 정찰하면서 플레이어를 찾아야 함.
- 위 과정을 0.05 초 시간 간격을 두면서 살아 있는 동안 무한 반복.
- if (hasTarget) 추적 대상이 존재한다면
📜Utility.cs
using UnityEngine;
using UnityEngine.AI;
public static class Utility
{
// NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치 리턴
public static Vector3 GetRandomPointOnNavMesh(Vector3 center, float distance, int areaMask) // 인수 👉 중심 위치, 반경 거리, 검색할 Area (내부적으로 int)
{
var randomPos = Random.insideUnitSphere * distance + center; // center를 중점으로 하여 반지름(반경) distance 내에 랜덤한 위치 리턴. *Random.insideUnitSphere*은 반지름 1 짜리의 구 내에서 랜덤한 위치를 리턴해주는 프로퍼티다.
NavMeshHit hit; // NavMesh 샘플링의 결과를 담을 컨테이너. Raycast hit 과 비슷
NavMesh.SamplePosition(randomPos, out hit, distance, areaMask); // areaMask에 해당하는 NavMesh 중에서 randomPos로부터 distance 반경 내에서 randomPos에 *가장 가까운* 위치를 하나 찾아서 그 결과를 hit에 담음.
return hit.position; // 샘플링 결과 위치인 hit.position 리턴
}
// 이전 포스트에서 한번 사용한적 있음!
public static float GetRandomNormalDistribution(float mean, float standard)
{
var x1 = Random.Range(0f, 1f);
var x2 = Random.Range(0f, 1f);
return mean + standard * (Mathf.Sqrt(-2.0f * Mathf.Log(x1)) * Mathf.Sin(2.0f * Mathf.PI * x2));
}
}
- 랜덤한 위치
randomPos
생성- var randomPos = Random.insideUnitSphere * distance + center;
- center를 중점으로 하여 반지름(반경) distance 내에 랜덤한 위치 리턴.
- Random.insideUnitSphere은 반지름 1 짜리의 구 내에서 랜덤한 위치를 리턴해주는 프로퍼티다.
- var randomPos = Random.insideUnitSphere * distance + center;
- NavMesh인
areaMask
이면서 +distance
내에서,randomPos
에 가장 가까운 위치 생성- NavMesh.SamplePosition(randomPos, out hit, distance, areaMask);
- areaMask에 해당하는 NavMesh 중에서 randomPos로부터 distance 반경 내에서 randomPos에 가장 가까운 위치를 하나 찾아서 그 결과를 hit에 담음.
- NavMesh.SamplePosition(randomPos, out hit, distance, areaMask);
hit.posiion
이 최종 결과가 됨.- NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치
public override bool ApplyDamage(DamageMessage damageMessage)
📜LivingEntity.cs 의 ApplyDamage 오버라이딩
// 좀비가 총 맞아서 데미지를 입었을때 실행할 처리
public override bool ApplyDamage(DamageMessage damageMessage)
{
if (!base.ApplyDamage(damageMessage)) return false; // 데미지 처리
if (targetEntity == null)
{
targetEntity = damageMessage.damager.GetComponent<LivingEntity>();
}
EffectManager.Instance.PlayHitEffect(damageMessage.hitPoint, damageMessage.hitNormal, transform, EffectManager.EffectType.Flesh); // 타격 파티클효과 실행
audioPlayer.PlayOneShot(hitClip); // 오디오 재생
return true;
}
좀비가 맞았을 때
- if (!base.ApplyDamage(damageMessage)) return false;
- 📜LivingEntity.cs 의 ApplyDamage을 먼저 실행하고
base.ApplyDamage(damageMessage)
- 이때 성공적으로 데미지 처리가 이루어질 수 없어 False가 리턴 되었다면 📜Enemy.cs의 이 ApplyDamage 함수도
return false
한다. - 데미지 처리는 부모 클래스인 📜LivingEntity.cs의 ApplyDamage 함수에서 이루어진다.
- 즉, 이 if문에서 데미지 처리가 수행된다.
- LivingEntity의 자식인 📜PlayerHealth.cs 스크립트나 📜Enemy.cs 스크립트나, 데미지를 입는 로직은 동일하기 때문에
base.ApplyDamage(damageMessage)
로 처리 하는 것이다.- 공격을 당하고 난 후에 수행할 행동이라던가 파티클 효과 같은 것은 좀비랑 플레이어 캐릭터가 서로 다르게 일어나므로 이 부분만 오버라이딩된 ApplyDamage 함수에 덧붙여 주면 되는 것이다.
- 📜LivingEntity.cs 의 ApplyDamage을 먼저 실행하고
- if (targetEntity == null)
- 아직 추적할 대상을 못 찾았는데 공격을 당했다면 자신할 공격한 사람을
targetEntity
에 할당한다. - 공격 당하는 순간에 공격 한 사람을 알아보도록.
- 아직 추적할 대상을 못 찾았는데 공격을 당했다면 자신할 공격한 사람을
- 파티클 효과 재생
- 재생 위치, 방향 등등 인수로 넘겨준다.
- 피가 튀는 효과
- 효과음 재생
public void BeginAttack()
코드상에서(이 함수는 Update 함수 내에서 실행됨) 명시적으로 공격을 시작할 때 사용.
- 공격 애니메이션이 시작되지만 데미지를 입히는 시점은 아님
public void BeginAttack()
{
state = State.AttackBegin;
agent.isStopped = true;
animator.SetTrigger("Attack");
}
- 상태를
State.AttackBegin
으로 설정. - AI agent 잠시 추적을 정지.
- agent.isStopped = true;
- 공격 애니메이션 시작
- animator.SetTrigger(“Attack”);
public void EnableAttack()
실제로 데미지가 들어가기 시작하는 지점 (
State.Attacking
상태가 되어 FixedUpdate 함수에서 데미지 처리 실행)
- 코드 상에서 실행되는 메소드가 아닌애니메이션 이벤트를 통해 실행할 메소드다.
- 공격 애니메이션의 재생 중간 쯤에 실행할 것이라서
public void EnableAttack()
{
state = State.Attacking;
lastAttackedTargets.Clear();
}
- 상태를
State.Attacking
으로 변경. - 이전까지 공격한 대상들이 담겨있는
lastAttackedTargets
리스트 비움
public void DisableAttack()
공격이 끝나는 지점. 데미지를 입히는 처리가 끝났을 때.
- 코드 상에서 실행되는 메소드가 아닌 애니메이션 이벤트를 통해 실행할 메소드다.
- 공격 애니메이션이 얼추 끝나갈 때 실행할 것이라서
public void DisableAttack()
{
if(hasTarget)
{
state = State.Tracking;
}
else
{
state = State.Patrol;
}
agent.isStopped = false;
}
- 타겟이 여전히 남아있다면 상태를
State.Tracking
로 되돌림. - 타겟이 이제 없다면 상태를
State.Patrol
로 되돌림.- 위와 같이 두개의 케이스로 나누어주지 않고
State.Tracking
를 해버리면 나중에targetEntity
는 null이 되버리므로targetEntity
와의 거리를 재려는 처리를 할 때 런타임 에러가 발생한다.
- 위와 같이 두개의 케이스로 나누어주지 않고
- AI Agent가 다시 움직이도록 agent.isStopped = false
애니메이션 이벤트 함수
📢 이 애니메이션이 적용되는 오브젝트에 붙어있는 또 다른 스크립트 혹은 컴포넌트에 해당 함수가 존재해야 한다.
Zombie
의 Animator 의 애니메이터 컨트롤러에 보면 상태도에Bite
라는 이름의 애니메이션 상태가 있다.Bite
는 공격 애니메이션을 취하는 상태이다.Bite
상태에 할당되어 있는 애니메이션 클립인 📂Assets/Animations/Zombie Clips 에 위치한ZombieBite
라는 애니메이션 클립을 더블 클릭 해보자.ZombieBite
애니메이션 클립의 재생 흐름인데 상단을 보면 중간에 하얀 네모가 두개가 있는 것을 볼 수 있다. 이것들이 하나는 EnableAttack() 함수가 실행되는, 다른 하나는 DisableAttack() 함수가 실행되는 이벤트임을 알 수 있다.- 무기를 휘두루는 시점에서 데미지가 들어가는 것이 아닌 얼추 휘두르고 난 애니메이션 재생 중간 시점부터 데미지가 들어가게 하기 위해 애니메이션 클립 중간 즈음에 두 함수가 실행된는 것을 볼 수 있다.
애니메이션 클립에 함수를 이벤트로 등록하는 방법
write
권한이 있는 애니메이션 클립에만 적용할 수 있다.
3D 모델 파일 내부에 포함되어 있는 애니메이션 클립같은 경우 유니티 엔진에서 write
쓰기 권한이 없기 때문에 이런 클립들엔 함수를 이벤트로 등록할 수가 없다. 📂Assets 폴더에 별개의 애니메이션 클립으로서 독립적으로 존재하는 그런 애니메이션 클립들에만 적용이 가능하다.
- 애니메이션 클립을 더블 클릭해서 연다.
- 이벤트 함수를 추가할 시점을 선택(하얀 직선이 생긴다)하여 클릭한 후
- 하얀 네모 추가 버튼, 즉 이벤트 추가버튼을 눌러주면 해당 시점에 하얀 네모가 추가된다.
- Inspector 창을 보면 이 시점에 추가할 이벤트를 작성할 수 있는 폼이 뜬다. 해당 시점에 원하는 함수의 이름을 써주면 됨.
- 팁! 애니메이션 클립 창의 아래에 있는 가로 스크롤바의 양 옆에 커서를 놓으면 가로 화살표가 뜨는데 이를 드래그 하여 줌 앤 줌아웃 할 수 있다. 마우스 휠 스크롤로도 가능하다.
private bool IsTargetOnSight(Transform target)
인수로 넘긴 위치가 좀비가 볼 수 있는 위치인지를 Raycast로 검사하여 True, False로 리턴하는 함수다.
private bool IsTargetOnSight(Transform target)
{
RaycastHit hit;
var direction = target.position - eyeTransform.position;
direction.y = eyeTransform.forward.y;
// 1️⃣ 그 광선이 시야각을 벗어나선 안되며
if (Vector3.Angle(direction, eyeTransform.forward) > fieldOfView * 0.5f)
{
return false;
}
direction = target.position - eyeTransform.position;
// 2️⃣ 시야각 내에 존재 하더라도 광선이 장애물에 부딪치지 않고 목표에 잘 닿아야 함
if (Physics.Raycast(eyeTransform.position, direction, out hit, viewDistance, whatIsTarget))
{
if (hit.transform == target) return true;
}
return false;
}
hit
- Raycast 결과 정보가 담길 컨테이너
direction
- 눈의 위치(시작)로부터 타겟 위치(목표)로 향하는 방향
- 방향 = 눈의 위치 - 타겟 위치
- 다만, 수평적인 각도만 따질 것이므로 1️⃣ 조건을 따질 때 수직 방향은 고려하지 않기 위해서 y 값은 눈의 위치와 똑같게.
- direction.y = eyeTransform.forward.y;
- 눈의 위치(시작)로부터 타겟 위치(목표)로 향하는 방향
- 좀비의 눈의 위치에서 Raycast 광선을 쐈을 때
- 1️⃣ 그 광선이 시야각을 벗어나지 않고
- 좀비 눈에서 타겟 위치로 향하는 방향과 눈의 앞쪽 방향의 사이각이 좀비가 볼 수 있는 시야 각의 절반 보다 크다면 볼 수 없는 타겟임.
- 좀비 눈에서 타겟 위치로 향하는 방향과 눈의 앞쪽 방향의 사이각이 좀비가 볼 수 있는 시야 각의 절반 보다 크다면 볼 수 없는 타겟임.
- 2️⃣ 광선이 장애물에 부딪치지 않고 목표에 잘 닿아야 함
eyeTransform.position
눈에서direction
방향으로 광선 쏴서viewDistance
거리 내에whatIsTarget
레이마스크인 것과 충돌하는 것이 있다면hit
에 담는다.- if (hit.transform == target) return true;
- 원래
target
과 Raycast의 결과와 같다면 장애물에 부딪치는게 없었던 것이니 true 리턴
- 원래
- if (hit.transform == target) return true;
- direction = target.position - eyeTransform.position;
- 2️⃣ 하기전에 이렇게 다시 초기화 해준 이유는 Raycast를 실행하기 전 y방향을 원래 방향대로 되돌려놓기 위해서
- 1️⃣ 그 광선이 시야각을 벗어나지 않고
- 1️⃣2️⃣ 를 만족하지 않으면 좀비가 볼 수 없는 것이니 False 리턴
- 둘 다 만족하면 True 리턴
public override void Die()
📜LivingEntity.cs 의 ApplyDamage 오버라이딩
// 사망 처리
public override void Die()
{
// LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
base.Die();
// 다른 AI들을 방해하지 않도록 자신의 모든 콜라이더들을 비활성화
GetComponent<Collider>().enabled = false;
// AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
agent.enabled = false;
// 사망 애니메이션 재생
animator.applyRootMotion = true;
animator.SetTrigger("Die");
// 사망 효과음 재생
if (deathClip != null) audioPlayer.PlayOneShot(deathClip);
}
}
- 📜LivingEntity.cs 의 Die()을 먼저 실행하여 생명체로서의 기본 사망 처리를 한다.
base.Die()
- 기본적인 사망 처리는 부모 클래스인 📜LivingEntity.cs의 Die() 함수에서 이루어진다.
- LivingEntity의 자식인 📜PlayerHealth.cs 스크립트나 📜Enemy.cs 스크립트나, 공통적인 생명체로서의 사망 로직은 동일하기 때문에
base.Die()
로 처리 하는 것이다.- 사망 하고 난 후에 수행할 행동이라던가 애니메이션 같은 것은 좀비랑 플레이어 캐릭터가 서로 다르게 일어나므로 이 부분만 오버라이딩된 Die 함수에 덧붙여 주면 되는 것이다.
- LivingEntity의 자식인 📜PlayerHealth.cs 스크립트나 📜Enemy.cs 스크립트나, 공통적인 생명체로서의 사망 로직은 동일하기 때문에
- 다른 AI들을 방해하지 않도록, 예를 들어 길막하지 않도록 자신의 모든 콜라이더들을 비활성화
GetComponent<Collider>().enabled = false;
- 자기 자신의 Collider 컴포넌트를 끈다.
- AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
agent.isStopped = true
하는 방법도 있는데 다만 이 방법은 본인의 추적을 멈추는 것일 뿐 비활성화된 Agent는 아니다. Nav Mesh Agent들끼리는 내비게이션 추적에 있어 서로를 장애물로 인식하고 피하고 다니기 때문에 아예 비활성화 해주는 것이다.- 즉, 활성화 되있다면 나중에 좀비가 많이 죽었을 때 쓸데없이 크게 돌아와야 하고 그렇기 때문 😢
- 사망한 좀비는 밟고 다닐 수 있게끔 해주자.
- 사망 애니메이션 재생
- Animator 컴포넌트의
applyRootMotion
를 True로 하여 루트 모션을 켜준다.- 사망 애니메이션 위치 같은 것을 애니메이션에 의해 통제되게 하는 것이 더 자연스럽기 때문.
- 어차피 죽었기 때문에 코드로 통제하는것 보다는 애니메이션으로 통제하는 것이 더 자연스럽다.
- Die 애니메이션 재생
- Animator 컴포넌트의
- 사망 효과음 재생
🔔 컴포넌트 설정
- 사망, 데미지 애니메이션 클립을 위와 같이 할당해준다.
attackRadius
값을 0.5로 조금 줄였다!- 원래
targetEntity
는[HideInInspector]
속성이여서 슬롯이 안열리고 안보이는게 맞는데, 일단 코드 상에서[HideInInspector]
를 지워 잠시 열어두었다. 👉 이러면 좀비는 타겟이 누군지 알기 때문에 게임 시작하면 플레이어에게 그냥 바로 멀리서도 달려온다! 이것을 본 후 다시 원래대로[HideInInspector]
을 붙여 수정했다.targetEntity
는Player Character
오브젝트가 좀비의 시야각에 들어올 때 SphereCastNonAlloc 함수를 통해 감지되고 할당된다.Player Character
오브젝트를 할당한다.- 📜LivingEntity.cs 를 상속 하는 📜PlayerHealth.cs 스크립트를 가지고 있기에
LivingEntity
타입인targetEntity
에 할당이 가능하다.
- 📜LivingEntity.cs 를 상속 하는 📜PlayerHealth.cs 스크립트를 가지고 있기에
whatIsTarget
레이어마스크는Player
로 설정해준다.
프리팹
PlayerCharacter
의 변경 사항을 모두 Overrides 하여 Apply all 해준다.Zombie
또한 변경 사항을 모두 Overrides 하여 Apply all 해준다.
🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄
댓글남기기