내비게이션은 3D메시의 정보를 기반으로 네비메시를 생성한다. 따라서 분리된 메시는 추적할 수 없다. 이때 Off Mesh Link를 통해 서로 분리된 메시를 연결할 수 있다. 즉, 추적이 가능하게 연결고리를 생성할 수 있다.

다운로드한 Resources/Models 폴더에 있는 Stair패키지를 설치한다.

프로젝트뷰에 임포트된  Stair폴더는 05.Models로 옮긴다.

Stair 폴더 하위에 있는 Stair모델을 씬뷰로 드래그해서 적절한 위치에 배치한다. 아래부분이 바닥에 묻히게 조절한다.

추가한 Stair는 _STAGE하위로 차일드화 한다. Stair를 선택후 인스펙터뷰에서 Navigation Static과 Off Mesh Link를 선택한다. 인스펙터뷰 Navigation탭에서도 선택할 수 있다.

Navigation Bake TAB을 클릭후 Generated Off Mesh Links-Drop Height을 5로 설정한다. 이는 Off Mesh Link가 생성되는 최대 높이를 지정하는 것이다.

 Bake를 눌러 내비메시를 생성하면 다음과 같이 연결선이 보인다.

계단에 Collider를 추가해 올라갈수 있게 해준다. 연산부하를 줄이기 위해 Mesh Collider컴포넌트의 Convex속성을 체크해 폴리곤수를 낮춘다.

Navigation뷰의 Bake탭의 Agent Radius를 0.2로 변경후 Bake하면 좀더 촘촘하게 Off Mesh Link가 생성된다.

이제 실행해서 계단을 올라가면 몬스터들이 따라온다.

처음에 Player가 계단을 못올라 가서 헤맸는데 다음과 같이  Player collider의 Trigger를 꺼주고 해결하였다. 그리고 교재와 Rigidbody 속성이 달라 수정하였다

드럼통 폭발이후에도 적 몬스터는 그 지점을 장애물로 인식하는 문제점이 있다. 이는 내비게이션 메시가 정적으로 Bake되어 졌기 때문이다. 이 문제는 NavMeshObstacle 컴포넌트로 해결할 수 있다.

Barrel Prefab을 선택후 이름 우측 Static을 Off한후 children에 반영시킨다.

메뉴>window>AI>Navigation을 선택해 뷰에서 Bake탭을 선택후 아래 베이크 버튼을 클릭한다.

씬뷰에서 보면 Floor바닥 전체가 내비메시로 베이크된다.

배럴 프리팹 원본을 선택후 인스펙터뷰 이름옆 Open을 눌러 에디트 모드로 전환된다. NavMeshObstacle을 추가한다.

 NavMeshObstacle 속성을 다음과 같이 변경한다.  Auto save기능이 있기 때문에 하이라키뷰"<"나 씬뷰[Scenes]을 클릭해 돌아가도 자동으로 저장된다.

Carve옵션을 체크하면 실시간으로 내비메시가 변경되지만 부하를 줄이기 위해 최적의 옵션값을 설정해야한다. 

Move Threshold : 갱신할 최소거리

Time To Stationary : 갱신한 최소 정지시간

Carve Only Stationary : 정지 상태에서만 내비메시를 갱신한다.

 

 

지금까지 구현한 총알 발사 로직은 실제 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)로 잠시 총알 발사를 못하게 할 수 있다.

void Fire(bool shot) {
        if(shot) {
            GameObject b = Instantiate(bullet, firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
            b.transform.Rotate(Vector3.right * 3f);  //총탄의 각도가 높아서 낮추었음
        }
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생\
        StartCoroutine(ShowMuzzleFlash()); //총구화염효과 코루틴함수 호출
    }

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에 다음과 같이 입력한다. 스코어는 녹색 점수를 표시하는 점수는 빨간색으로 마크업태그를 지정한다.

<color=#00ff00>SCORE :</color><color=#ff0000>0000</color>

몬스터를 죽였을때 스코어를 50점씩 증가시키는 로직은 게임매니저 스크립트에서 구현한다. 다음같이 수정한다.

 

TextMeshPro를 사용하기 위해  using TMPro;를 선언한다.

인스펙터에서 스코어텍스트를 연결할 public TMP_Text scoreText를 선언해준다.

누적점수도 선언해준다. private int totScore = 0;

using TMPro;  // TTextMeshPro를 사용하기 위한 선언
public TMP_Text scoreText;  //인스펙터에서 연결해야함.
private int totScore = 0;

UI 점수 갱신을 담당할 함수를 만든다

public void DisplayScore(int score) {
    totScore += score;
    scoreText.text = $"<color=#00ff00>SCORE :</color><color=#ff0000>{totScore:#,##0}</color>";
}

처음 게임이 시작하면 점수를 0으로 표시하기 위해 DisplayScore(0);을 Start()에 추가한다.

    void Start() {
        CreateMonsterPool();  // 몬스터풀 생성 함수
		//중략
        InvokeRepeating("CreateMonster", 2.0f, createTime);  //2초후 반복적으로 몬스터를 만든다
        DisplayScore(0);
    }

스크립트를 저장한후 하이라키의 Text(TMP)를 끌어다 GameManager 스크립트컴포넌트의 ScoreText에 연결한다.

이제 마지막으로 몬스터에 총알이 맞았을때 점수가 올라가게 할것이다 MonsterCtrl.cs - OncollisionEnter부분을 수정한다.

    private void OnCollisionEnter(Collision coll) {
        if (coll.collider.CompareTag("BULLET")) {
			//중략
            if (hp <= 0) {
                state = State.DIE;
                GameManager.instance.DisplayScore(50);
            }
        }
    }

PlayerPrefs를 활용한 스코어저장

게임을 시장하면 스코어는 초기화된다 유니티에서 제공하는 PlayerPrefs기능을 활용 저장해보자.

PlayerPrefs는 다양한 변수를 저장하고 로드하는 기능을 제공한다.

Start()함수에서 PlayerPrefs에서 로딩한다.

GameManager.cs에 다음과 같이 점수 로딩 저장을 구현한다.

void Start() {
//중략
    InvokeRepeating("CreateMonster", 2.0f, createTime);  //2초후 반복적으로 몬스터를 만든다
    totScore = PlayerPrefs.GetInt("TOT_SCORE", 0);  //Prefs 로딩
    DisplayScore(0);
}

스코어가 변경되면 Prefs에 저장한다.

public void DisplayScore(int score) {
    totScore += score;
    scoreText.text = $"<color=#00ff00>SCORE :</color><color=#ff0000>{totScore:#,##0}</color>";
    PlayerPrefs.SetInt("TOT_SCORE", totScore);  //Prefs에 저장
}

 

+ Recent posts