내비게이션은 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에 저장
}

 

모바일플랫폼에서 게임오브젝트및 프리팹을 동적으로 생성하는 방식은 부하를 증가시킨다. 따라서 미리 만들어 놓고 필요할때 가져다 쓰는 방식을 오브젝트 풀링이라 한다.

동적으로 만들던 몬스터를 게임시작시 미리 만들어 놓고 비활성화해서 하나씩 불러다 쓰고 사망시 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초후 반복적으로 몬스터를 만든다
    }

몬스터 생성은 Instantiate대신 풀에서 비활성환된 몬스터를 골라 리턴 GetMonsterInPool()을 호출 

    void CreateMonster() {  //동적 몬스터 생성
        int idx = Random.Range(0, points.Count);
        //Instantiate(monster, points[idx].position, points[idx].rotation); //몬스트프리팹 동적 생성
        GameObject _monster = GetMonsterInPool();
        _monster?.transform.SetPositionAndRotation(points[idx].position, points[idx].rotation);
        _monster?.SetActive(true);
    }

풀을 순환하며 비활성환된 몬스터를 골라 리턴해주는 GetMonsterInPool() 

    public GameObject GetMonsterInPool() {
        foreach(var _monster in monsterPool) {
            if(_monster.activeSelf == false) {  
                return _monster;  }
        }
        return null;
    }

이제 플레이해보면 10개까지 잘 생성되는데 몬스터를 죽이면 다시 생성되지 않는다 왜냐하면 몬스터가 Destroy되지만 active한 상태가 되기 때문에 풀에서 비활성 몬스터가 없어지기 때문에 Destroy대신 active를 false로 해준다

몬스터의 Hp관리는 MonsterCtrl에서 한다. Start()함수를 Awake()로 이름을 바꾸고

    void Awake()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        bloodEffect = Resources.Load<GameObject>("GoopSpray"); //혈흔효과      
    }

StartCourotine()2개를 OnEnable함수로 옮겨 연결되지 않는 컴포넌트의 참조하는 오류를 막는다

    private void OnEnable() {  //스크립트가 활성활 될때 콜백되는 함수
        PlayerCtrl.OnPlayerDie += this.OnPlayerDie; //교재는 PlayerCtrl.
        StartCoroutine(CheckMonsterState()); //몬스트 상태를 체크하는 코루틴
        StartCoroutine(MonsterAction());  //상태에 따라 행동을 수행하는 코루틴
    }

MonsterAction()의 case State.DIE부분을 수정한다. 책 코드대로 실행해본 결과 죽은 몬스터가 부활할때 다시 죽는 경우가 발생했다. 따라서 state=State.IDLE을 추가해서 해결하였다.(DIE상태로 부활하면 다시 DIE상태로 무한 반복한다)

IEnumerator MonsterAction() {
        while (!isDie) {
            transform.LookAt(playerTr);
            switch (state) {
            //중략
                case State.DIE: //사망상태
                    isDie= true;  
                    agent.isStopped = true;  //추적 정지
                    anim.SetTrigger(hashDie); // 애니메이트 hasDie 트리거발생
                    GetComponent<CapsuleCollider>().enabled = false;  //충돌컴포넌트 비활성화
                    //일정시간 경과후 오브젝트풀링으로 환원
                    yield return new WaitForSeconds(0.3f); //일정시간경과후
                    hp = 10;  //hp를 초기화하고
                    isDie= false; // 상태를 살리고
                    GetComponent<CapsuleCollider>().enabled = true;  //콜라이더를 다시 true
                    this.gameObject.SetActive(false); //몬스터를 비활성화 한다.
                    state = State.IDLE;  //Idel로 안하면 태어나자 마자 죽는다
                    break;
            }
            yield return new WaitForSeconds(0.3f);
        }
    }

GameManager.cs
0.00MB
MonsterCtrl.cs
0.01MB

 

싱글턴 패턴은 메모리상에 오직 하나의 인스턴스만 생성하고 그 인스턴스에 전역적인 접근을 제공하는 방식이다. 

스크립트에서 제일먼저 실행되는 Awake()함수에서 GameManager를 static키워드로 정적 메모리영역에 올려두고 다른 스크립트에서 바로 접근할 수 있게 구현하는 방식이다.

그리고 자신이 아니라면 지우고 다른씬으로 넘어가도 삭제되지 않게 DontDestroyOnLoad()명령을 실행해 놓는다.

public static GameManager instance = null;
private void Awake() {
    if (instance == null) {
        instance = this; //싱글턴지정
    } else if (instance != this){  //싱글턴검사
        Destroy(this.gameObject);
    }
    DontDestroyOnLoad(this.gameObject);  //다른씬으로 넘어가도 삭제하지 않고 유지함
}

이제  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;  //적 생산 멈춤
        GameManager.instance.IsGameOver= true; // IsGameOver 프라퍼티로 바로 사용가능
    }

 

이번에는 게임 개발시 자주 사용하는 싱글턴 디자인 패턴을 활용해 게임 매니저를 제작하고 오브젝트 폴링을 활용해 성능을 높이는 기법에 대해 알아본다.

 

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;  //적 생산 멈춤
    }

 

지금까지 작업했던 UI를 Main씬을 저장하고 01.Scenes/Play 씬을 더블클릭해 연다.

Canvas를 하나 만들고 다음과 같이 설정한다.

Canvas를 선택하고 + 버튼을 클릭한 후 UI>Panel을 추가하다. "Panel-Hpbar"로 이름을 바꾼다. Panel의 이미지를 04.Image>Menu폴더의 Button-Tab으로 선택한다.

Panel아래 TextMeshPro를 추가하고 HP로 내용을 바꾼다.

Panel아래 Image를 추가하고 이름을 Image-Hpbar로 수정후 Source Image에 04.Images/Menu폴더에 있는 HpBar를 연결한다.Sprite로 변경후 연결해야한다.

Fill Amount의 슬라이드 바를 조정하면 좌우로 채워지는 걸 확인할 수 있다. TAG를 HP_BAR를 만들어 지정해준다.

PlayerCtrl 스크립트를 다음과 같이 수정한다.

UI에 접근하기 위해 using UnityEngine.UI;를 선언한다.

HP_BAR TAG를 이용해 오브젝트를 hpBar와 연결해준다. ?는 null이 아니면 뒤쪽 GetComponent()을 실행한다는 뜻이다.

hpBar= GameObject.FindGameObjectWithTag("HP_BAR")?.GetComponent<Image>();

트리거가 발생하면 HP를 10빼주고 HP바를 갱신할 DisplayHealth()를 실행한다.

void OnTriggerEnter(Collider coll) {
    if(curHP >= 0.0f && coll.CompareTag("PUNCH")) {
        curHP -= 10.0f;
        DisplayHealth();
        //Debug.Log($"Player hp = { curHP / initHP }");
        if (curHP <= 0.0f) {
            PlayerDie();
        }
    }
}

fillAmount의 값을 조정해 바의 상태를 갱신한다. 0~1f의값을 갖는다

    void DisplayHealth() {
        hpBar.fillAmount = curHP / initHP;
    }

PlayerCtrl 전체코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PlayerCtrl : MonoBehaviour {
    // Start is called before the first frame update
    

    Transform tr;
    private Animation anim;

    private float moveSpeed = 10f; //이동속도
    private float turnSpeed = 80f;  //회전속도

    private readonly float initHP = 100.0f;  //초기 생명값
    public float curHP; //현재생명값
    public Image hpBar;

    public delegate void PlayerDieHandler();  //델리게이트타입 선언
    public static event PlayerDieHandler OnPlayerDie; //델리게이트 변수선언
    public
    IEnumerator  Start() {  //start()함수는 코루틴으로 실행할 수 있다.
        //Hpbar연결
        hpBar= GameObject.FindGameObjectWithTag("HP_BAR")?.GetComponent<Image>();
        curHP = initHP;
        DisplayHealth();
        tr = GetComponent<Transform>();
        anim = GetComponent<Animation>();  //추가된 코드
        anim.Play("Idle");
        turnSpeed = 0.0f;  //프로그램 기동시 가비지 마우스값을 막기위해
        yield return new WaitForSeconds(0.3f);  //0.3초 기다린후
        turnSpeed = 300f;  //변수값을 설정
    }

    // Update is called once per frame
    void Update() {
        float h = Input.GetAxis("Horizontal");  //AD 입력 좌우
        float v = Input.GetAxis("Vertical");  //WS 입력 전후
        if (Mathf.Abs(h) > float.Epsilon || Mathf.Abs(v) > float.Epsilon) {  //움직임이 없다면 불필요한 동작을 안한다.
            Vector3 dir = Vector3.right * h + Vector3.forward * v;
            tr.Translate(dir.normalized * moveSpeed * Time.deltaTime);
            PlayerAnim(h, v);
        }
        float r = Input.GetAxis("Mouse X");  //마우스 x축 입력 
        tr.Rotate(Vector3.up * turnSpeed * Time.deltaTime * r);
    }
    void PlayerAnim(float h, float v) {
        if (v >= 0.1f) {  //앞으로 달림
            anim.CrossFade("RunF",0.25f);
        } else if(v <= -0.1f) { //뒤로 움직임
            anim.CrossFade("RunB", 0.25f);
        } else if(h >= 0.1f) { //오른쪽으로 움직임
            anim.CrossFade("RunR", 0.25f);
        } else if (h <= -0.1f) { //왼쪽으로 움직임
            anim.CrossFade("RunL", 0.25f);
        } else { //제자리대기
            anim.CrossFade("Idle", 0.25f);
        }
    }
    void OnTriggerEnter(Collider coll) {
        if(curHP >= 0.0f && coll.CompareTag("PUNCH")) {
            curHP -= 10.0f;
            DisplayHealth();
            //Debug.Log($"Player hp = { curHP / initHP }");
            if (curHP <= 0.0f) {
                PlayerDie();
            }
        }
    }
    void PlayerDie() {
        //MONSTER 태그를 가진 모든 게임오브젝트를 찾아옴
        /*
        GameObject[] monsters = GameObject.FindGameObjectsWithTag("MONSTER");
        foreach(GameObject monster in monsters) {  //모든 오브젝트를 순차적으로 불러옴
            monster.SendMessage("OnPlayerDie", SendMessageOptions.DontRequireReceiver);
        }
        */
        OnPlayerDie();  //주인공 사망 이벤트 호출(발생)
    }
    void DisplayHealth() {
        hpBar.fillAmount = curHP / initHP;
    }
}

TextMeshPro는 유료에셋이었던 모양인데 현재는 설치안해도 사용할 수 있지만 SDF포맷의 폰트에셋을 직접 생성해야 한다.

TextMesh Pro는 Legacy Text보다 다양한 속성을 제공한다.

Menu-UI-Text-Text Mesh Pro를 추가한다.

TextMeshPro-Text(UI)뷰에 텍스트를 입력한다.

<font="LiberationSans SDF"><color=#ff0000><b>S</b></color>pace
Shooter</font>
스페이스슈터

입력하면 한글은 깨져나올것이다. 다음 사이트 http://hangeul.naver.com  에서 나눔체를 다운받아 NanumGothic.ttf폰트를 프로젝트뷰의 "TexMesh Pro/Fonts"폴더로 드래그해 임포트 한다. 

메뉴 Window>TextMeshPro>FontAssetCreator를 선택해 Font Asset Creator뷰를 연다. Source Font File에 생성할 폰트를 연결한다. 브라우저 버튼을 클릭해 나눔고딕을 선택한다.

Character Set을 Custom Range로 해서 특정범위를 지정할 수도 있지만 Custom Characters로 지정해 필요한 폰트만 적을 수도 있다.  설정후 Atlas Resolution을 적절히 조정하고 

Generate Font Atlas를 누르면 인코딩이 시작하고 저장할 수 있다. 

Main Setting에서 폰트를 선택한다.

KSX1001.txt파일을 카피해서 KS X 1001규격의 한글 2350자 만들수도 있다.

 

+ Recent posts