지금까지 구현한 총알 발사 로직은 실제 Bullet모델이 날아가서 몬스터와 충돌을 일으키는 Projectile방식으로 돼 있다. 대부분의 FPS게임에서는 실제 총알이 날라가지 않고 발사와 동시에 적에 명중해서 혈흔 효과와 같은 이벡트를 연출하고 적이 사망한다. 유니티에서 이러한 방식을 구현할수 있는 Raycast를 제공한다.
눈에 보이지 않는 광선을 발사해서 광선에 맞은 물체를 판단해서 후처리를 하는 방식이다. 다음과 같이 광선의 발사원점과 발사각, 거리등의 인자로 광선을 발사 할 수 있다.
이 기능은 게임스테이지에서 마우스 포인트 위치로 레이캐스트해서 3차원좌푯값을 읽어온후 해당 좌표로 이동시킬때도 사용한다.
DrawRay
레이캐스트는 씬 뷰에서 시각적으로 표시되지 않기 때문에 개발할 때는 DrawRay 함수를 이용해 시각적으로 표시하면서 개발을 해야한다. FireCtrl 스크립트의 Update()에서 Debug.DrawRay()를 추가한다.
void Update()
{ //마우스 왼쪽 버튼을 클릭했을때 Fire 함수 호출
Debug.DrawRay(firePos.position, firePos.forward * 10.0f, Color.green);
if(Input.GetMouseButtonDown(0)) {
Fire(); //발사처리
}
}
Play후 씬뷰탭을 눌러 보면 Ray가 잘 그려진다. 사실 좀 불편하다.
LineRenderer
게임뷰에서도 보일수 있게 하이라키뷰에서 Player를 선택하고 LineRendere 컴포넌트를 추가한다.
FireCtrl 스크립트 전역변수에 lr을 선언하고
private LineRenderer lr;
Start()에서 LineRenderer 컴포넌트를 연결해주고 선의 두께, 색을 지정해준다.
private void Start() {
//중략
lr = GetComponent<LineRenderer>();
lr.startWidth = 0.01f; //라인 시작 두께
lr.endWidth = 0.1f; //라인 시작 두께
lr.startColor = Color.red; //라인 시작 색깔
lr.endColor = Color.blue; //라인 종료 색깔
}
이제 Update()에서 시작점과 종료점을 지정해주면 실시간으로 선을 그려준다.
void Update()
{ //마우스 왼쪽 버튼을 클릭했을때 Fire 함수 호출
lr.SetPosition(0, firePos.position);
lr.SetPosition(1,firePos.position + firePos.forward * 20f - firePos.up);
Raycast, RaycastHit
레이캐스트가 객체를 검출하기 위해서는 그 객체는 Collider 컴포넌트를 갖고 있어야 한다. 특정 레이어만 감지하게 할 수도 있다. 몬스터는 6번째 MONSTER_BODY 레이어로 지정되어 있어 LayerMask(1<<6)를 사용 필터링 할 수 있다.
이제 물리적 총알은 시각적이고 Ray를 맞았을때 데미지를 입도록 수정해보자.
총알생성로직은 FireCtrl 스크립를 수정한다.
전역변수로 hit를 추가한다. Raycast발사후 충돌객체의 정보를 돌려준다.
private RaycastHit hit; // Raycast 결과값을 저장하기 위한 구조체
Variables
barycentricCoordinate
충돌한 triangle의 무게중심 좌표
collider
충돌한 collider
distance
Ray의 origin으로부터 충돌 지점까지의 거리
lightmapCoord
충돌 지점의 uv lightmap 좌표
normal
Ray가 충돌한 surface의 normal
point
Ray가 충돌한 Collider의 충돌 지점 (world 좌표 사용)
rigidbody
충돌한 collider의 rigidbody (rigidbody가 없는 경우 null 반환)
textureCoord
충돌 위치에서의 uv texture 좌표
textureCoord2
충돌 위치에서의 2차 uv texture 좌표
transform
충돌한 Transform의 Rigidbody 또는 Collider
triangleIndex
충돌한 triangle의 index
이제 Physics.Raycast()를 이용해 눈에 보이지 않는 광선을 쏴보자 사용법은 여러가지가 있으나 다음 방법을 사용했다
Physics.Raycast(발사원점, 발사방향벡터, 결과값, 발사거리, 레이어마스크)
총구의 위치가 약간 위를 바라보고 있어 방향 layDir 을 약간 아래로 수정했다. 1<<6은 몬스터 레이어가 6번째이무로 시프트연산자를 사용해 오른쪽에서 6번째 비트를 켠것이다. 비트연산자를 사용해 복수의 레이어를 선택할 수도 있다.
void Update()
{ //마우스 왼쪽 버튼을 클릭했을때 Fire 함수 호출
//중략
Vector3 layDir = firePos.forward * 20.0f - firePos.up;
if (Physics.Raycast(firePos.position, layDir, out hit, 20.0f, 1 << 6)) {
}
교재에서는 Instantiate(bullet)를 마크했으나 필요에 따라 사용할수 있게 매개변수bool shot로 끄고 켤수 있게 했다. Update()안에서 Fire(false)로 잠시 총알 발사를 못하게 할 수 있다.
Fire(false)로 이제 총알이 나가지 않는다 Raycast()는 OnCollisionEnter나 OnTriggerEnter Event를 발생시키지 않는다. 따라서 다음과 같이 MonsterCtrl의 OncollisionEnter()의 피격효과및 HP관리를 지우고 OnDamage함수로 처리를 옮겨준다.
private void OnCollisionEnter(Collision coll) {
if (coll.collider.CompareTag("BULLET")) {
Destroy(coll.gameObject); //충돌한 총알을 삭제
}
}
public void OnDamage(Vector3 pos, Vector3 normal) {
anim.SetTrigger(hashHit); //피격 리액셔 애니메이션 실행
Quaternion rot = Quaternion.LookRotation(normal); //충돌지점 법선벡트
ShowBloodEffect(pos, rot); //혈흔효과를 생성하는 함수호출
hp -= 20;
Debug.Log(hp);
if (hp <= 0) {
state = State.DIE;
GameManager.instance.DisplayScore(50);
}
}
OnDamage를 유니티의 표준 콜백함수가 아니므로 FireCtrl 스크립트내 Update()안의 RaycastHit hit변수를 보고 충돌이 있었을때 충돌체의 MonsterCtrl스크립트 컴포넌트내의 OnDamage()함수를 호출한다.
if (Input.GetMouseButtonDown(0)) {
Fire(false); //true=총알발사처리, false=총소리만
Vector3 layDir = firePos.forward * 20.0f - firePos.up;
if (Physics.Raycast(firePos.position, layDir, out hit, 20.0f, 1 << 6)) {
//Debug.Log($"Hit={hit.transform.name}");
hit.transform.GetComponent<MonsterCtrl>()?.OnDamage(hit.point, hit.normal);
}
}
이제 물리적 총알 발사없이 타격효과및 Hp 관리가 문제없이 된다면 다시 위 코드에서 Fire(true)로 바꿔 물리적 총알 발사를 활성화한다.
if (Input.GetMouseButtonDown(0)) {
Fire(true); //true=총알발사처리, false=총소리만
#Debug.DrawRay #LineRenderer #RayCast #RayCastHit 3가지를 배우느라 좀 힘들수 있다. 실제 이 3가지를 정리하느라 몇칠 걸린듯 하니 천천히 이해해주시기 바랍니다.
테스트 하다보면 적이 어디서 나타났는지 찾기 어렵다. 마우스 우클릭을 하면 적을 보게 하겠다. 적을 고를 수는 없다.
첫번째 방법은 씬뷰상의 MONSTER TAG를 이용해 적을 찾은후 LookAt하는 방법이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RightClick : MonoBehaviour
{
// Start is called before the first frame update
public GameObject[] monster; //컴포넌트의 캐시를 처리할 변수
public int idx = 0;
// Update is called once per frame
void Update()
{
monster = GameObject.FindGameObjectsWithTag("MONSTER");
if (Input.GetMouseButtonDown(1)) {
idx = monster.Length-1;
while (idx>=0 && monster[idx].GetComponent<MonsterCtrl>().state == MonsterCtrl.State.DIE) idx--;
transform.LookAt(monster[idx].transform);
Debug.Log(monster.Length);
}
}
}
2번째 방법은 GameManager MonsterPool을 이용해 active한걸 찾아 LookAt하는 방법이다
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RightClick : MonoBehaviour
{
// Update is called once per frame
void Update()
{
if (Input.GetMouseButtonDown(1)) {
GameObject _monster = FindMonster();
if (_monster != null) {
transform.LookAt(_monster.transform);
}
}
}
GameObject FindMonster() {
foreach (var _monster in GameManager.instance.monsterPool) {
if (_monster.activeSelf == true) {
return _monster;
}
}
return null;
}
}
실행후 우클릭을 하면 적으로 자동 향합니다. 천천히 움직이지는 않습니다.
모든 Active한 Monster의 거리를 비교해 가장 가까운 몬스터를 향하게 할수도 있을듯.
생명게이지 UI아래 스코어 UI를 구현해보자. 이로직을 지금 만드는 이유는 게임매니저에 점수 계산 로직을 추가하기 위해서다.
Play씬을 로딩한다
하이라키 Panel-Hpbar를 Ctrl+D로 카피하고 이름을 Panel-Score로 변경한다. 위치는 밑으로 이동시킨다. Image를 삭제하고 Text(TMP) Text Input에 다음과 같이 입력한다. 스코어는 녹색 점수를 표시하는 점수는 빨간색으로 마크업태그를 지정한다.
모바일플랫폼에서 게임오브젝트및 프리팹을 동적으로 생성하는 방식은 부하를 증가시킨다. 따라서 미리 만들어 놓고 필요할때 가져다 쓰는 방식을 오브젝트 풀링이라 한다.
동적으로 만들던 몬스터를 게임시작시 미리 만들어 놓고 비활성화해서 하나씩 불러다 쓰고 사망시 Destroy하지 않고 오브젝트풀에서 재사용할 수 있게 변경한다. 다음과 같이 GameManager.cs 를 수정한다.
몬스터 풀로 사용할 List<>와 최대생성수를 정의한다.
public List<GameObject> monsterPool = new List<GameObject>(); // 몬스트풀 리스트
public int maxMonsters = 10; //몬스터 최대 생성 수
CreateMonsterPool()을 호출 몬스터프리팹을이용 Instantiate()로 몬스터를 생성하고 이름을 Monster_00방식으로 변경하고 비활성화후 풀 List에 add해준다.
void CreateMonsterPool() { //몬스트풀 생성 함수
for(int i=0; i < maxMonsters; i++) {
var _monster = Instantiate<GameObject>(monster); //몬스터 생성
_monster.name = $"Monster_{i:00}"; //몬스터이름 생성
_monster.SetActive(false); //몬스터 비활성화
monsterPool.Add(_monster); //생성된 몬스트를 풀에 저장
}
}
풀생성후 InvokeRepeating()함수를 통해 몬스터가 주기적으로 자동생성된다.
void Start() {
CreateMonsterPool(); // 몬스터풀 생성 함수
InvokeRepeating("CreateMonster", 2.0f, createTime); //2초후 반복적으로 몬스터를 만든다
}
이번에는 게임 개발시 자주 사용하는 싱글턴 디자인 패턴을 활용해 게임 매니저를 제작하고 오브젝트 폴링을 활용해 성능을 높이는 기법에 대해 알아본다.
SpawnPointGroup 생성
몬스터가 출현할 불규칙한 위치 정보는 게임 매니저가 알고 있어야 한다. 먼저 빈 게임오브젝트를 생성하고 SpawnPointGroup으로 지정한다. Position은 (0,0,0)이다 SpawnPointGroup를 선택하고 하위에 빈게임오브젝트를 생성하고 Point라고 이름을 바꾼다. 시각적으로 표현하기 위해 MyGizmo.cs를 작성해서 연결한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MyGizmo : MonoBehaviour
{
public Color _color = Color.yellow;
public float _radius = 0.1f;
private void OnDrawGizmos() {
Gizmos.color = _color;
Gizmos.DrawSphere(transform.position, _radius);
}
}
게임 매니저는 전반적으로 게임을 제어 관리하는 기능을 모아 놓은 기능이다.
이제 몬스터를 일정시간 간격으로 여러 Spawn Point중에서 랜덤으로 발생시킬것이다. 씬뷰의 모든 몬스터는 삭제할것이지만 지우기전에 프리팹으로 전환되었는지 확인 바랍니다.
빈오브젝트를 하나 만들고 이름을 GameManager로 한다. 같은 이름으로 스크립트를 추가한다.
포인트를 Ctr-D로 복사해서 여러군대 배치한다.
Game Manager 객체 생성
빈 오브젝트를 생성하고 GameMgr로 이름짓는다. GameManager라는 스크립트를 작성한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
// Start is called before the first frame update
public Transform[] points;
void Start() {
//null이 아니면 뒤를 실행
Transform spawnPointGroup = GameObject.Find("SpawnPointGroup")?.transform;
points = spawnPointGroup?.GetComponentsInChildren<Transform>();
}
}
배열은 삭제가 어렵기 때문에 동적으로 삭제가 가능한 List를 이용해 본다. List를 참조를 받아 오는게 아니라 참조를 전달한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour {
// Start is called before the first frame update
public List<Transform> points = new List<Transform>();
void Start() {
//null이 아니면 뒤를 실행
Transform spawnPointGroup = GameObject.Find("SpawnPointGroup")?.transform;
spawnPointGroup?.GetComponentsInChildren<Transform>(points); //points를 전달
}
}
GetComponentsInChildren<Transform>(points);이 편하긴 하지만 Child뿐만이 아니라 Parent까지 추출한다. 인덱스0이 Parent이므로 1부터 쓰면되지만 다음과 같이 차일드만 추출하기도 한다..
Transform spawnPointGroup = GameObject.Find("SpawnPointGroup")?.transform;
//spawnPointGroup?.GetComponentsInChildren<Transform>(points); //points를 전달
foreach(Transform point in spawnPointGroup) {
points.Add(point);
}
Invoke, InvokeRepeate 함수
일정한 시간 간격으로 몬스터를 불규칙한 위치에 생성하는 스크립트이다.
몬스터가 출연할 장소를 public List<Transform> points를 선언한다. spawnPointGroup에 Transform을 동적으로 연결하고 차일드Transform을 points에 add해준다. points는 몬스터를 만들때 위치벡터로만 사용된다.
몬스터를 연결하기 위해 public GameObject monster를 선언하고 인스펙터에서 연결한다.
CreateMonster()는 Instantiate()를 이용해 monster를 points[RandomIdx]위치에 찍어내는 함수다.
InvokeRepeate()에서 주기적으로 CrateMonster()를 불러 Monster를 만들게 한다.
public class GameManager : MonoBehaviour
{
public List<Transform> points = new List<Transform>();
public GameObject monster;
public float createTime = 5.0f;
private bool isGameOver; //게임종료 상태
// 게임종료 프로퍼티ㄹ 체크용 메써드
public bool IsGameOver {
get { return isGameOver; }
set {
isGameOver = value;
if (isGameOver) {
CancelInvoke("CreateMonster"); //몬스터 생산을 멈춘다.
}
}
}
// Start is called before the first frame update
void Start() {
//null이 아니면 뒤를 실행
Transform spawnPointGroup = GameObject.Find("SpawnPointGroup")?.transform;
//spawnPointGroup?.GetComponentsInChildren<Transform>(points); //points를 전달
foreach(Transform point in spawnPointGroup) {
points.Add(point);
}
InvokeRepeating("CreateMonster", 2.0f, createTime); //2초후 반복적으로 몬스터를 만든다
}
// 불규칙한 위치에 몬스트를 만든다
void CreateMonster() {
int idx = Random.Range(0, points.Count);
Instantiate(monster, points[idx].position, points[idx].rotation);
}
}
IsGameOver() 함수는 Player를 죽었을때 상태를 변경해주고 몬스터의 반복 생성을 종료한다. 이 함수는 PlayerCtrl 스크립터에서 불리어진다. 참조시 GameManager 스크립터이름을 컴포넌트이름처럼 사용하여 접근한것이 재미있다.
void PlayerDie() {
//MONSTER 태그를 가진 모든 게임오브젝트를 찾아옴
/*
GameObject[] monsters = GameObject.FindGameObjectsWithTag("MONSTER");
foreach(GameObject monster in monsters) { //모든 오브젝트를 순차적으로 불러옴
monster.SendMessage("OnPlayerDie", SendMessageOptions.DontRequireReceiver);
}
*/
OnPlayerDie(); //주인공 사망 이벤트 호출(발생)
GameObject.Find("GameMgr").GetComponent<GameManager>().IsGameOver = true; //적 생산 멈춤
}