지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=-mEbCEENpjU&list=PLctzObGsrjfyevwpeEVQ9pxGVwZtS7gZK&index=9
이번 강의에서는 게임의 끝을 만들어본다.
게임이 끝나기 위해 적이 플레이어를 공격해 체력을 깎고, 체력이 모두 깎이면 끝나게 하자.
Enemy, IDamaneable, LivingEntity 를 수정해 공격으로 플레이어의 체력을 깎도록 하자.
| Enemy 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(UnityEngine.AI.NavMeshAgent))]
// 현재 오브젝트에 NavMeshAgent 를 포함
public class Enemy : LivingEntity
{
public enum State { Idle, Cashing, Attacking };
// 적의 상태, { 기본(아무것도 안함), 추적, 공격 }
State currentState; // 현재 상태
UnityEngine.AI.NavMeshAgent pathfinder; // 내비게이션 레퍼런스
Transform target; // 적의 타겟(플레이어) 트랜스폼
LivingEntity targetEntity; // 타겟(플레이어) 레퍼런스
Material skinMatreial; // 현재 오브젝트의 마테리얼
Color originalColor; // 현재 오브젝트의 기존 색상
float attackDistanceThreshold = 0.5f;
// 공격 거리 임계값(사거리). 유니티에서 단위 1은 1 meter 이다..!!
float timeBetweenAttacks = 1;
// 공격 시간 간격
float nextAttackTime;
// 실제 다음 공격 시간
float damage = 1;
// 적의 공격 데미지
float myCollisionRadius; // 자신의 충돌 범위 반지름
float targetConllisionRadius; // 타겟의 충돌 범위 반지름
bool hasTarget; // 타겟이 있는지 확인
protected override void Start()
// override 로 부모 클래스의 메소드를 재정의.
{
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
pathfinder = GetComponent<UnityEngine.AI.NavMeshAgent>();
// NavMeshAgent 레퍼런스 생성
skinMatreial = GetComponent<Renderer>().material; // 현재 오브젝트의 마테리얼 저장.
originalColor = skinMatreial.color; // 현재 색상 저장
if(GameObject.FindGameObjectWithTag("Player") != null)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
currentState = State.Cashing; // 기본 상태를 추적 상태로 설정
hasTarget = true; // 타겟(플레이어) 있음으로 설정
target = GameObject.FindGameObjectWithTag("Player").transform;
// 타겟으로 "Player" 라는 태그를 가진 오브젝트의 트랜스폼을 저장
targetEntity = target.gameObject.GetComponent<LivingEntity>();
// 위에서 저장 한 플레이어 오브젝트의 LivingEntity 컴포넌트를 저장
targetEntity.OnDeath += OnTargetDeath;
// 타겟 사망 메소드 추가.
myCollisionRadius = GetComponent<CapsuleCollider>().radius;
// 자신의 충돌체(collider)의 반지름을 저장
targetConllisionRadius = target.GetComponent<CapsuleCollider>().radius;
// 타겟의 충돌체(collider)의 반지름을 저장
StartCoroutine(UpdatePath());
// 지정된 시간마다 목적지 쪽으로 위치를 갱신하는 코루틴 실행
}
}
void OnTargetDeath() // 타겟(플레이어)이 죽었을 때 호출되는 메소드
{
hasTarget = false; // 타겟 없음으로 설정
currentState = State.Idle; // 현재 상태를 기본(정지)상태로 변경
}
void Update()
{
if (hasTarget) // 타겟(플레이어)가 있는 경우
{
if (Time.time >= nextAttackTime) // 공격 시간이 지나면
{
float sqrDstToTarget = (target.position - transform.position).sqrMagnitude;
/* 자신(적)과 목표(플레이어) 사이의 거리를 저장.
두 오브젝트 사이의 거리를 측정하기 위해 Vector3 의 Distace 메소드를
쓰는 방법도 있지만 벡터에 제곱 연산이 들어가다 보니 연산 수가 많다.
두 오브젝트 사이의 실제 거리가 필요한 것이 아닌 비교값이 필요 한
것이므로 위와 같이 연산을 줄여 볼 수 있다. */
if (sqrDstToTarget <= Mathf.Pow(attackDistanceThreshold + targetConllisionRadius + myCollisionRadius, 2))
// 거리의 제곱과 임계 거리의 제곱을 비교하여 공격 범위 내인지 확인
{
nextAttackTime = Time.time + timeBetweenAttacks;
// 다음 공격 시간 지정
StartCoroutine(Attack());
// 공격 코루틴 실행
}
}
}
}
IEnumerator Attack() // 적의 공격 코루틴
{
currentState = State.Attacking; // 공격 상태로 변경
pathfinder.enabled = false; // 공격 중 플레이어 추적 중지
Vector3 originalPosition = transform.position; // 자신(적)의 위치
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 attackPosition = target.position - dirToTarget * (myCollisionRadius);
// 타겟(플레이어)의 위치
float attackSpeed = 3; // 공격 속도
float percent = 0;
skinMatreial.color = Color.magenta; // 공격 시 색상 지정
bool hasAppliedDamage = false; // 데미지를 적용 중인지 확인
while(percent <= 1) // 찌르는 거리가 1 이하일 때 루프
{
if(percent >= .5f && hasAppliedDamage == false)
// 적이 공격 지점에 도달했고 데미지를 적용중이지 않은 경우
{
hasAppliedDamage = true; // 데미지 적용 중으로 설정
targetEntity.TakeDamage(damage); // 타겟(플레이어)에게 데미지 적용
}
percent += Time.deltaTime * attackSpeed;
float interpolation = (-Mathf.Pow(percent, 2) + percent) * 4;
/* 대칭 함수를 사용
원지점->공격지점 이동에서 보간 값을 참조한다. */
transform.position = Vector3.Lerp(originalPosition, attackPosition, interpolation);
/* Lerp 메소드는 내분점을 반환한다.
원점, 공격지점, 보간 값을 참조값으로 전달한다
보간 값이 0이면 원점에, 1이면 공격지점에 있게 된다. */
yield return null;
/* while 루프의 처리 사이에서 프레임을 스킵합니다.
이 지점에서 작업이 멈추고 Update 메소드 작업이 끝나고
다음 프레임으로 넘어가면 이 아래의 코드나 루프가 실행 */
}
skinMatreial.color = originalColor; // 공격이 끝나면 기존 색상으로 변경
currentState = State.Cashing; // 공격이 끝나면 추적 상태로 변경
pathfinder.enabled = true; // 공격이 끝나면 다시 플레이어 추적
}
IEnumerator UpdatePath()
// 해당 코루틴 실행 시 지정한 시간마다 루프문 내부 코드 반복
{
float refreshRate = .25f; // 루프 시간
while(hasTarget) // 타겟이 있을 때 반복
{
if(currentState == State.Cashing)
// 상태가 추적 상태인 경우
{
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 targetPosition = target.position - dirToTarget * (myCollisionRadius + targetConllisionRadius + attackDistanceThreshold / 2);
/* 자신의 위치에서 자신과 타겟의 반지름 길이에 공격 사거리의 절반을 더하고, 방향 벡터를 곱한 값을 뺀다.
즉, 타겟(플레이어) 공격 가능한 위치를 타겟 위치로 정한다. */
if (!dead) // 죽지 않은 경우
{
pathfinder.SetDestination(targetPosition);
// 내비게이션의 목적지를 타겟(플레이어)의 위치로 설정
}
}
yield return new WaitForSeconds(refreshRate);
// refreshRate 시간만큼 기다림
}
}
}
| IDamaneable 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface IDamaneable // 인터페이스 생성.
{
void TakeHit(float damage, RaycastHit hit); // 데미지를 받는 메소드
void TakeDamage(float damage); // 데미지를 받는 메소드(간략화)
}
| LivingEntity 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LivingEntity : MonoBehaviour, IDamaneable // 인터페이스 상속
{
public float startingHealth; // 시작 체력
protected float health; // 체력
/* protected 를 통해 상속 관계가 없는 클래스에서 사용할 수 없게 한다.
인스펙터에서도 보이지 않음. */
protected bool dead; // 캐릭터가 죽었는지
public event System.Action OnDeath;
/* System.Action 은 델리게이트 메소드이다.
여기서 델리게이트란 C++ 의 함수 포인터와 유사하게
메소드의 위치를 가르키고 불러올 수 있는 타입이다.
void 를 리턴하고 입력을 받지 않는다. */
protected virtual void Start()
// 상속 받는 클래스의 같은 메소드를 재정의 할 수 있도록 virtual 로 선언.
{
health = startingHealth; // 시작 체력 설정
}
public void TakeHit(float damage, RaycastHit hit) // 데미지 받는 메소드 구현
{
TakeDamage(damage); // 데미지 받는 메소드 호출
}
public void TakeDamage(float damage) // 데미지 받는 메소드 구현
{
health -= damage; // 체력에서 데미지만큼 감소
if (health <= 0 && !dead) // 체력이 0 이하고 죽은 경우
{
Die(); // 죽음 메소드 호출
}
}
protected void Die() // 죽음 메소드
{
dead = true; // 죽은 판정으로 변경.
if(OnDeath != null) // 이벤트가 있는 경우
{
OnDeath(); // 메소드를 호출
}
GameObject.Destroy(gameObject); // 현재 오브젝트 파괴
}
}
위 스크립트에서 TakeDamage 를 보고 TakeHit 은 있을 필요가 없지 않나? 라고 생각할 수 있다.
하지만 레이캐스트 변수를 전달받으면 총을 맞췄을 때 맞은 곳의 좌표 등을 가져와
해당 위치에 피격 파티클 등을 생성할 수 있다.
이러한 기능을 나중에 TakeHit 메소드에서 구현하기 위해 남겨두자.
플레이어 오브젝트가 사라지면 CameraController 스크립트에서도 오류가 떠서 살짝 수정했다.
| CameraController 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
public GameObject Player; // 플레이어 오브젝트
public Vector3 offset; // 플레이어와 카메라 간격
private void Start() {
offset = this.transform.position - Player.transform.position; // 간격 설정
}
private void Update() {
if (Player != null) { // 플레이어 오브젝트가 있는 경우
this.transform.position = Player.transform.position + offset;
// 현재 오브젝트(카메라) 위치를 플레이어 위치 + 간격 으로 설정
}
}
}
이제 플레이어 체력을 좀 작게 설정해보고 실행해보자.
이제 플레이어가 공격을 받아 체력이 다 깎이면 죽게된다.
다음으로는 총알(발사체)에서 몇가지를 수정해보자.
먼저, 지금은 발사한 총알이 적에게 맞을 때 까지 끝없이 날아가게 된다.
총알이 일정 시간 후 사라지도록 하자.
그리고 문제점이 또 있는데,
만약 발사체가 적의 몸 안쪽에서 생성되는 경우(적과 딱 붙은 상태로 쏘는 경우 이렇게 된다)
발사체의 레이캐스트가 충돌을 인식하지 못하고 그냥 적을 뚫고 지나간다.
그리고 총알을 발사하여 총알이 이동할 때, 적 또한 플레이어 쪽으로 이동해온다.
여기서 한 프레임 후의 총알의 이동 위치와 적의 이동 위치가 겹치면서 총알의 레이캐스트가
적의 안에서부터 시작하여 충돌이 감지되지 않는 경우가 발생한다.
이 또한 해결해보자.
| Projectile 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Projectile : MonoBehaviour
{
public LayerMask collisionMask;
// 어떤 오브젝트, 레이어가 발사체(총알)과 충돌할지 저장.
float speed = 10.0f; // 기본 속도
float damage = 1; // 데미지
float lifeTime = 3; // 총알 유지 시간
float skinWidth = .1f; // 레이 거리 보정값
void Start()
{
Destroy(gameObject, lifeTime); // 총알 유지 시간이 지났다면 현재 오브젝트(총알)을 제거.
Collider[] initialCollisions = Physics.OverlapSphere(transform.position, 0.1f, collisionMask);
// 현재 오브젝트(총알, 발사체)와 충돌중인 충돌체들의 배열 생성
if(initialCollisions.Length > 0)
// 위 배열의 길이가 0 보다 큰 경우, 즉 충돌체가 하나라도 있는 경우
{
OnHitObject(initialCollisions[0]);
// 충돌 처리 메소드를 호출하고 첫 번째 충돌체를 전달.
}
}
public void SetSpeed(float newSpeed) // 속도 설정 메소드
{
speed = newSpeed; // 입력받은 속도로 설정
}
void Update()
{
float moveDistance = speed * Time.deltaTime; // 총알의 이동 거리
CheckCollision(moveDistance); // 총알 충돌 확인 메소드 호출
transform.Translate(Vector3.forward * Time.deltaTime * speed);
// 총알 이동
}
void CheckCollision(float moveDistance) // 총알 충돌 확인 메소드
{
Ray ray = new Ray(transform.position, transform.forward);
// 레이 생성, 발사체(총알)위치와 정면 방향을 전달.
RaycastHit hit; // 충돌 오브젝트 반환 정보
if (Physics.Raycast(ray, out hit, moveDistance + skinWidth, collisionMask, QueryTriggerInteraction.Collide))
/* 레이캐스트로 발사체가 오브젝트나 레이어에 닿았는지 확인.
QueryTriggerInteraction 을 Collide 로 하여 트리거 콜라이더와 충돌 하게 설정. */
{
OnHitObject(hit); // 충돌 처리 메소드 호출
}
}
void OnHitObject(RaycastHit hit) // 충돌 시 처리 메소드 (레이캐스트 사용)
{
IDamaneable damageableObject = hit.collider.GetComponent<IDamaneable>();
// 인터페이스 변수 생성, 발사체에 맞은 오브젝트의 인터페이스를 가져와 저장.
if(damageableObject != null)
/* 위 변수가 null 이 아닐 때,
즉 맞은 오브젝트에 인터페이스가 있는, 데미지를 받는 오브젝트인 경우. */
{
damageableObject.TakeHit(damage, hit);
// 해당 오브젝트 인터페이스의 데미지 받기 메소드를 호출
}
GameObject.Destroy(gameObject); // 현재 오브젝트(발사체) 제거
}
void OnHitObject(Collider c) // 충돌 시 처리 메소드 (레이캐스트 사용 X)
{
IDamaneable damageableObject = c.GetComponent<IDamaneable>();
// 인터페이스 변수 생성, 발사체에 맞은 오브젝트의 인터페이스를 가져와 저장.
if (damageableObject != null)
/* 위 변수가 null 이 아닐 때,
즉 맞은 오브젝트에 인터페이스가 있는, 데미지를 받는 오브젝트인 경우. */
{
damageableObject.TakeDamage(damage);
// 해당 오브젝트 인터페이스의 데미지 받기 메소드를 호출
}
GameObject.Destroy(gameObject); // 현재 오브젝트(발사체) 제거
}
}
이제 실행해보자
계층 구조 탭을 보면 생성된 총알 오브젝트가 충돌하지 않으면 몇 초 뒤 사라지는 것을 볼 수 있다.
또한 총알 충돌 판정이 좀 더 확실해졌다.
다음 강의에서는 레벨 자동생성을 구현해보자.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 9 (0) | 2023.11.29 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 8 (0) | 2023.11.29 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 6 (0) | 2023.11.24 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 5 (1) | 2023.11.23 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 4 (0) | 2023.11.22 |