지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=yoIPKI_iK5E&list=PLFt_AvWsXl0ctd4dgE1F8g3uec4zKNRV0&index=22
이번 강의에서는 총기를 추가하고 수정하며 게임의 UI 를 추가해본다.
시작해보자.
기본 총을 수정해보자.
총 프리팹을 씬으로 가져와 수정한다.
모양, 총구 위치 등을 수정해 총기 형태를 원하는 기본 형태로 변경하자.
권총 형태로 변경하고 이름을 Gun 01 로 바꾼 뒤 발사 모드와 정보를 설정해주었다.
프리팹 폴더에 Weapons 폴더를 만들어 그곳에 프리팹으로 저장한다.
이제 복사하여 다른 총들도 만들어주자.
위와 같이 여러 종류의 총기를 만들었다.
이제 프리팹 폴더에서 기존에 사용하던 Gun 프리팹은 삭제해주자.
위에서 만든 여러가지 총을 스테이지별로 사용할 수 있도록 해보자.
GunController, Player 스크립트를 수정한다.
| GunController 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GunController : MonoBehaviour {
public Transform weaponHold; // 플레이어 손(총)위치
public Gun[] allGuns; // 총기 배열
Gun equippedGun; // 착용하는 총
private void Start() {
}
// ■ 총 착용 메소드
public void EquipGun(Gun gunToEquip) {
if (equippedGun != null) { // 착용중인 총이 있다면
Destroy(equippedGun.gameObject); // 착용중인 총(오브젝트)를 제거(파괴)
}
equippedGun = Instantiate(gunToEquip, weaponHold.position, weaponHold.rotation) as Gun;
// 착용중인 총에 전달받은 총 오브젝트를 인스턴스화하여 생성
equippedGun.transform.parent = weaponHold;
// 착용중인 총의 위치를 설정, 부모 오브젝트를 설정하여 위치를 부모 오브젝트의 위치로.
}
// ■ 총 착용 메소드
public void EquipGun(int weaponIndex) {
EquipGun(allGuns[weaponIndex]); // 총 장착
}
// ■ 방아쇠 당김 메소드
public void OnTriggerHold()
{
if(equippedGun != null) // 착용중인 총이 있다면
{
equippedGun.OnTriggerHold(); // 방아쇠 당김
}
}
// ■ 방아쇠 놓음 메소드
public void OnTriggerRelease()
{
if (equippedGun != null) // 착용중인 총이 있다면
{
equippedGun.OnTriggerRelease(); // 방아쇠 놓음
}
}
// ■ 무기 높이 반환 메소드
public float GunHeight {
get {
return weaponHold.position.y; // 무기의 y좌표(높이)반환
}
}
// ■ 에임 보정 메소드
public void Aim(Vector3 aimPoint) {
if (equippedGun != null) { // 착용중인 총이 있다면
equippedGun.Aim(aimPoint); // 착용한 총의 에임 보정 메소드 호출
}
}
// ■ 재장전 메소드
public void Reload() {
if (equippedGun != null) { // 착용중인 총이 있다면
equippedGun.Reload(); // 착용한 총의 재장전 메소드 호출
}
}
}
| Player 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(GunController))] // GunController 스크립트를 포함
[RequireComponent(typeof(PlayerController))] // PlayerController 스크립트를 포함
public class Player : LivingEntity
{
public float moveSpeed = 5.0f; // 플레이어 이동속도
public Crosshair crosshairs; // 조준점 레퍼런스
Camera viewCamera; // 카메라
PlayerController controller; // 플레이어 컨트롤러
GunController gunController; // 총 컨트롤러
protected override void Start() { // override 로 부모 클래스의 메소드를 재정의.
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
}
void Awake() {
controller = GetComponent<PlayerController>();
// 현재 오브젝트의 플레이어 컨트롤러를 가져옴
gunController = GetComponent<GunController>();
// 현재 오브젝트의 총 컨트롤러를 가져옴
viewCamera = Camera.main;
// 카메라
FindObjectOfType<Spawner>().OnNewWave += OnNewWave;
// Spawner 의 새 웨이브 시작 이벤트 구독
}
// ■ 새 웨이브 시작 시 메소드
void OnNewWave(int waveNumber) {
health = startingHealth; // 체력 초기화
gunController.EquipGun(waveNumber - 1); // 장착할 총 설정
}
void Update() {
// 이동 입력
Vector3 moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxis("Vertical"));
// GetAxisRaw 로 스무딩 X
Vector3 moveVelocity = moveInput.normalized * moveSpeed;
// normalized 로 단위벡터로 변환, 이동 속도를 계산
controller.Move(moveVelocity);
// 이동 속도를 이동 메소드로 전달
// 방향 입력
Ray ray = viewCamera.ScreenPointToRay(Input.mousePosition);
// 카메라 -> 마우스 커서 위치로 레이 발사.
Plane groundPlane = new Plane(Vector3.up, Vector3.up * gunController.GunHeight);
// 평면의 법선벡터 생성.
float rayDistance;
// 위에서 쏜 레이와 평면의 교차지점까지 거리
if (groundPlane.Raycast(ray, out rayDistance)) {
// 레이가 평면과 교차하는 경우 교차지점까지의 거리를 rayDistance 에 저장.
Vector3 point = ray.GetPoint(rayDistance);
// GetPoint 와 교차지점까지의 거리로 교차지점의 위치를 저장.
// 카메라부터 교차지점까지 선으로 표시 : Debug.DrawLine(ray.origin, point, Color.red);
controller.LookAt(point);
// 플레이어 컨트롤러의 방향 전환 메소드에 교차 지점(방향) 전달.
crosshairs.transform.position = point;
// 조준점 위치 설정
crosshairs.DetectTarget(ray);
// 조준점 레이캐스트
if((new Vector2(point.x, point.z) - new Vector2(transform.position.x, transform.position.z)).sqrMagnitude > 1) {
// 조준점(커서)와 플레이어 사이 거리가 1 이상일 때
gunController.Aim(point);
// 총 에임 보정 위치 전달
}
}
// 무기 조작 입력
if (Input.GetMouseButton(0)) // 마우스 왼쪽 버튼 클릭 시
{
gunController.OnTriggerHold(); // 방아쇠 당김 메소드 호출
}
if (Input.GetMouseButtonUp(0)) // 마우스 왼쪽 버튼 클릭 후 손을 뗄 시
{
gunController.OnTriggerRelease(); // 방아쇠 놓음 메소드 호출
}
if (Input.GetKeyDown(KeyCode.R)) // 키보드 R 버튼 누를 경우
{
gunController.Reload(); // 재장전 메소드 호출
}
}
}
이제 Player 오브젝트의 스크립트에서 All Guns 배열에 총들을 할당해주자.
그리고 적을 처치 할 때 나오는 파티클의 색상이 변경되지 않는 것을 확인,
Enemy 스크립트에서 적의 기본 정보를 셋팅할 때 적의 머터리얼 레퍼런스 설정에서
머터리얼을 셰어드 머터리얼로 설정해 해결했다.
그리고, 이렇게 고치고 나면 적이 공격 시 모든 적의 색상이 공격 색상으로 바뀌게 된다.
따라서 공격 색상 설정 코드 위에서 머터리얼 레퍼런스를 셰어드 머터리얼이 아닌 머터리얼로 설정하자.
| 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; // 현재 상태
public ParticleSystem deathEffect; // 사망 이펙트 레퍼런스
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; // 타겟이 있는지 확인
void Awake()
{
pathfinder = GetComponent<UnityEngine.AI.NavMeshAgent>();
// NavMeshAgent 레퍼런스 생성
if (GameObject.FindGameObjectWithTag("Player") != null)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
hasTarget = true; // 타겟(플레이어) 있음으로 설정
target = GameObject.FindGameObjectWithTag("Player").transform;
// 타겟으로 "Player" 라는 태그를 가진 오브젝트의 트랜스폼을 저장
targetEntity = target.gameObject.GetComponent<LivingEntity>();
// 위에서 저장 한 플레이어 오브젝트의 LivingEntity 컴포넌트를 저장
myCollisionRadius = GetComponent<CapsuleCollider>().radius;
// 자신의 충돌체(collider)의 반지름을 저장
targetConllisionRadius = target.GetComponent<CapsuleCollider>().radius;
// 타겟의 충돌체(collider)의 반지름을 저장
}
}
protected override void Start()
// override 로 부모 클래스의 메소드를 재정의.
{
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
if(hasTarget)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
currentState = State.Cashing; // 기본 상태를 추적 상태로 설정
targetEntity.OnDeath += OnTargetDeath;
// 타겟 사망 메소드 추가.
StartCoroutine(UpdatePath());
// 지정된 시간마다 목적지 쪽으로 위치를 갱신하는 코루틴 실행
}
}
// ■ 정보 수정 메소드
public void SetCharactoristics(float moveSpeed, int hitsToKillPlayer, float enemyHealth, Color skinColour) {
pathfinder.speed = moveSpeed; // 적의 이동속도 설정
if (hasTarget) { // 목표 (플레이어)가 있다면
damage = Mathf.Ceil(targetEntity.startingHealth / hitsToKillPlayer); // 적의 공격력 설정
}
startingHealth = enemyHealth; // 적의 체력 설정
skinMatreial = GetComponent<Renderer>().sharedMaterial; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = skinColour; // 적의 색상 설정
originalColor = skinMatreial.color; // 현재 색상 저장
}
// ■ 타격 메소드 오버라이딩
public override void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
if(damage >= health) { // 데미지가 현재 체력 이상인 경우
Destroy(Instantiate(deathEffect.gameObject, hitPoint, Quaternion.FromToRotation(Vector3.forward, hitDirection)) as GameObject, deathEffect.startLifetime);
// 이펙트(파티클)을 인스턴스화 하여 생성(FromToRotation 으로 방향 설정), 설정한 시간경과 후 파괴
}
base.TakeHit(damage, hitPoint, hitDirection);
// 부모 클래스의 기존 TakeHit 메소드 호출
}
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 = GetComponent<Renderer>().material; // 현재 오브젝트의 마테리얼 저장.
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 시간만큼 기다림
}
}
}
이제 실행해보면
웨이브 별로 다른 총을 들고 나오는 것을 볼 수 있다.
이제 게임 UI 를 추가해보자.
웨이브가 시작할 때 정보를 표시 할 배너를 만들어보자.
씬의 Canvas 에 New Wave Banner 오브젝트(빈 오브젝트)를 만들고 하위 오브젝트로 이미지를 생성한다.
그리고 텍스트도 추가해 웨이브 정보를 간단하게 적어주자.
이제 GameUI 스크립트로 가서 배너를 띄울 수 있도록 수정하자.
| GameUI 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class GameUI : MonoBehaviour {
public Image fadePlane; // 페이드 이미지(UI 배경) 오브젝트 레퍼런스
public GameObject gameOverUI; // 게임 오버 텍스트, 버튼 오브젝트 레퍼런스
public RectTransform newWaveBanner; // 배너 UI 레퍼런스
public TextMeshProUGUI newWaveTitle; // 새 웨이브 타이틀 텍스트 레퍼런스
public TextMeshProUGUI newWaveEnemyCount; // 새 웨이브 적 카운트 텍스트 레퍼런스
Spawner spawner; // 적 스폰기 레퍼런스
void Start() {
FindObjectOfType<Player>().OnDeath += OnGameOver; // 플레이어 사망 이벤트 구독
}
void Awake() {
spawner = FindObjectOfType<Spawner>(); // 스포너 레퍼런스에 스포너 오브젝트 찾아서 할당
spawner.OnNewWave += OnNewWave; // 새 웨이브 시작 이벤트 구독
}
// ■ 새 웨이브 시작 메소드
void OnNewWave(int waveNumber) {
string[] numbers = { "One", "Two", "Three", "Four", "Five" }; // 숫자 텍스트 문자열
newWaveTitle.text = "- Wave " + numbers[waveNumber - 1] + " -"; // 새 웨이브 배너 타이틀 텍스트 설정
string enemyCountString = ((spawner.waves[waveNumber - 1].infinite) ? "Infinite" : spawner.waves[waveNumber - 1].enemyCount + "");
// 해당 웨이브가 무한 모드인지 판단, 맞다면 Infinite, 아니면 적 수를 출력한다
newWaveEnemyCount.text = "Enemy : " + enemyCountString; // 새 웨이브 적 카운트 텍스트 설정
StopCoroutine("AnimateNewWaveBanner"); // 새 웨이브 배너 애니메이션 코루틴 중지
StartCoroutine("AnimateNewWaveBanner"); // 새 웨이브 배너 애니메이션 코루틴
}
// ■ 플레이어 사망(게임 오버) 시 UI 처리 메소드
void OnGameOver() {
Cursor.visible = true; // 마우스 커서 보이도록 설정
StartCoroutine(Fade(Color.clear, Color.black, 1)); // 배경 이미지 페이드 인 효과 코루틴 시작
gameOverUI.SetActive(true); // 게임 오버 텍스트, 버튼 오브젝트 활성화
}
// ■ 새 웨이브 배너 애니메이션 코루틴
IEnumerator AnimateNewWaveBanner() {
float delayTime = 1f; // 배너 대기 시간
float speed = 2.5f; // 배너 움직이는 시간
float animatePercent = 0; // 애니메이션 실행 퍼센트 설정
int dir = 1; // 배너 이동 방향, 양수면 위, 음수면 아래쪽
float endDelayTime = Time.time + 1 / speed + delayTime; // 배너 나타난 후 대기시간 설정
while(animatePercent >= 0) { // 애니메이션 실행
animatePercent += Time.deltaTime * speed * dir; // 애니메이션 실행 퍼센트 설정
if (animatePercent >= 1) { // 애니메이션 실행 퍼센트가 1 이상인 경우
animatePercent = 1; // 1로 설정
if(Time.time > endDelayTime) { // 현재 시간이 배너가 나타나고 대기시간까지 지난 경우
dir = -1; // 방향을 음수로 설정, 아래로 이동하도록 함
}
}
newWaveBanner.anchoredPosition = Vector2.up * Mathf.Lerp(-190, 45, animatePercent); // 배너의 위치를 설정
yield return null; // 다음 프레임으로 스킵
}
}
// ■ 이미지 페이드 효과 코루틴
IEnumerator Fade(Color from, Color to, float time) {
float speed = 1 / time; // 페이드 효과 속도
float percent = 0; // 이미지 색상(투명도) 변화 퍼센트
while(percent < 1) { // 색상(투명도) 변화가 1(100%)미만일 때
percent += Time.deltaTime * speed; // 색상(투명도) 변화율 계산
fadePlane.color = Color.Lerp(from, to, percent); // 색상(투명도) 지정
yield return null; // 다음 프레임으로 건너뜀
}
}
// UI Input 부분
// ■ 새 게임 시작 메소드
public void startNewGame() {
Application.LoadLevel("Game"); // 게임 씬, 게임을 다시 로드
}
}
Canvas 오브젝트의 인스펙터에서 배너와 텍스트 오브젝트들을 할당해주자.
* 텍스트 메쉬 프로 (TMP) 할당하기
강의영상에서는 Text 오브젝트를 사용해서 스크립트에서도 Text 오브젝트 레퍼런스를 선언해서 사용했다.
하지만 지금 사용중인 버전(2022.3.13f1)에서는 UI 에서 텍스트를 보여주기 위해 텍스트 메시 프로(TMP) 를 사용한다.
(UI의 레거시에서 텍스트 오브젝트를 사용할 수 있는 것 같긴 한데..비추천하는 것 같다.)
TMP 를 사용해서인지 인스펙터로 드래그해도 할당이 되지 않았다.
TMP 를 사용할 수 있도록 using TMPro; 를 추가하고,
Text 가 아닌 TextMeshProUGUI 로 선언해줬다.
이제 다시 드래그하니 할당이 잘 된다!
할당을 했으면 이제 실행해보자
만든 UI 배너가 웨이브가 시작할 때 올라왔다가 다시 내려간다.
다음 강의에서는 사운드를 추가해본다.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 23 (0) | 2023.12.15 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 22 (0) | 2023.12.14 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 20 (1) | 2023.12.12 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 19 (1) | 2023.12.11 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 18 (0) | 2023.12.11 |