몬스터가 플레이어와 일정 거리 이내로 좁혀지면 공격 애니메이션을 수행한다. 이때 플레이어의 생명력이 줄어드는 코드를 짜보자.

방법은 collider를 통해 처리하는 방법과 주기적으로 데미지를 주는 방법이 있다. 몬스터는 공격 상태가 되면 양손으로 타격하는 애니메이션을 실행하므로 전자의 방식으로 구현해보자.

하이라키뷰에서 L_wrist, R_wrist를 선택한후 다음 속성을 변경하면 동시에 적용된다.

물리적 충격을 주가위해 몬스터의 양속에 Collider와  Rigidbody컴포넌트를 추가한다. 

몬스터본체 Capsule Collider와의 간섭을 없애기 위해 Collider의 Is Trigger check, 움직임은 애니메이션에서 컨트롤 하므로 물리엔진인 Rigidbody의 Is Kinematic check한다.

Tag에서 PUNCH를 만들고 다시 L_wrist, R_wrist를 선택한후 TAG를 PUNCH로 바꿔준다.

주인공 Player에도 Capsule Collider를 추가해 다음과 같이 Center (0,1,0), Height:2을 설정한다. 

Rigidbody를 추가하고 FreezeRotation x,z를 check한다. 이러면 넘어지지 않는다

보통 FPS나 TPS게임을 개발할때 주인공은 character Controller컴포넌트를 사용한다. collider+rigidbody에 물리엔진을 disable해서 떨림현상이나 미림현상을 방지할 수 있다

 

OnTriggerEnter 콜백 함수

몬스터 양손에 Sphere Collider에서 IsTrigger를 체크했기때문에 OnTrigger계열의 콜백함수가 호출된다.

 

PlayerCtrl에 OnTriggerEnter()를 추가한다.

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

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

    Transform tr;
    private Animation anim;

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

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

    IEnumerator  Start() {  //start()함수는 코루틴으로 실행할 수 있다.
        curHP = initHP;
        tr = GetComponent<Transform>();
        anim = GetComponent<Animation>();  //추가된 코드
        anim.Play("Idle");
        turnSpeed = 0.0f;  //프로그램 기동시 가비지 마우스값을 막기위해
        yield return new WaitForSeconds(0.3f);  //0.3초 기다린후
        turnSpeed = 100f;  //변수값을 설정
    }

    // 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) {
        GetComponent<NavCtrl>().SetNavStop();
        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;
            Debug.Log($"Player hp = { curHP / initHP }");
            if (curHP <= 0.0f) {
                PlayerDie();
            }
        }
    }
    void PlayerDie() {
        Debug.Log("Player Die !");
    }
}

실행하다보니 플레이어를 마우스로 좌우로 회전 컨트롤 하는게 잘 안된다. 

처음에는 마우스로 우클릭을 하면 그 지점을 바라보는 걸 할려고 했다.

물론 스크린좌표를 월드좌표로 변환해서 레이를 쏴서 그 오브젝트를 바라보게해도 되는데 간단하게 LookAt()을 이용해보겠다.

스크립트를 하나 만들고  RightClick으로 이름 짓는다. 스크립트를 Player에 적용한다.

Monster 인스펙터  TAG가 MONSTER로 되어 있어야 한다.

FindwidhtTag()로 간단히 찾아 monsterTR에 저장하고 LookAt()을 이용해 방향을 바꾼다. 

다만. 순간적으로 바꿔지기 때문에 부드럽게 바꾸려면 다른 함수를 써야 한다. 

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

public class RightClick : MonoBehaviour
{
    // Start is called before the first frame update
    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    void Start()
    {
        monsterTr = GameObject.FindWithTag("MONSTER").GetComponent<Transform>();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(1)) {
            transform.LookAt(monsterTr);
        }
    }
}

다른 방법은  어차피 NavMesh가 적에 적용되어 있으니 그걸 응용해보기로 했다. 목적은 자동추적보다는 플레이어의 자동 회전에 가깝다. 일단 하이리키 Player에 NavMesh Agent를 추가한다.

교재의 프로젝트에 영향을 주지 않게 하기 위해 따로 스크립트도 추가하고 NavCtrl로 이름짓는다.

코드는 간단하다. 마우스 우클릭을 하면 Player가 자동으로 적을 따라가고 다시 우클릭을 하면 멈춘다.

다른 스크립트에도 Navigation을 멈추게 하기 위해  SetNavStop()을 추가한다.

    public void SetNavStop() {
        agent.isStopped = true;
    }

추가된 NavCtrl 전체코드, 대부분 MonsterCtrl에서 NavMesh에 관련된 코드를 복붙했다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;  //for Navigation
public class NavCtrl : MonoBehaviour
{
    // Start is called before the first frame update
    private NavMeshAgent agent;
    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수

    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.isStopped = true; //추적을 중지
        monsterTr = GameObject.FindWithTag("MONSTER").GetComponent<Transform>();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(1)) {
            if (agent.isStopped == true) {
                agent.SetDestination(monsterTr.position);  //목표설정
                agent.isStopped = false; //추적을 개시
            } else {
                agent.isStopped = true; //추적을 중지
            }
        }
    }
    public void SetNavStop() {
        agent.isStopped = true;
    }
}

WASD키를 눌렀을때 Navigation을 멈추기 위해 다음 코드를 PlayerCtrl스크립트 PlayerAnim()  맨윗에 추가한다.

 GetComponent<NavCtrl>().SetNavStop();로 NavCtrl의 SetNavStop에 접근할 수 있다. 스크립트 이름이 class이름이고 이걸 GetComponet<T>의 인수로 바로 사용할 수 있다.

void PlayerAnim(float h, float v) {
        GetComponent<NavCtrl>().SetNavStop();

좌클릭을 하면 총이 나갈때도 네비게이션을 멈추게 해야하는데 다행이 멈춘다 . 이유는 각자 찾아보세요. ㅎㅎ 아마 애니메이터의 State가 변하면서 뭔가 바뀌는듯.

주인공 캐릭터(Player)가 쏜 총알이 몬스터에 명중하면 몬스터가 피를 흘리는 혈흔 효과(Blood Effect)를 추가해보자. 혈은 효과는 파티클로 구현하며 프로젝트뷰의 03.Prefabs/EffecExamples/Blood/Prefabs폴더에 있는 BloodSprayeffect를 사용한다.  버전의 차이인지 발견하지 못해서 아래 EffectExamples > Goop Effects > Prefabs> GoopSpray를 사용하였다.

Resources 폴더

지금까지는 프리팹을 사용하기 위해 public GameObject xxx를 선언하고 인스펙터에서 연결해놓고 사용하였다. 이번에는 스크립트에서 런모드(동적)으로 로드하는 방법을 사용한다. 이방법을 쓰기 위해서는 프리팹이나 에셋이 Resouces 폴더안에 있어야 한다. 

 Assets root에 Resources 폴더를 하나 만들자. BloodSprayeffect나 GoopSpray를 끌어다 놓는다. 이 Resources폴더는 빌드할때 포함되므로 필요한 것들만 놓기 바랍니다.

MonsterCtrl 스크립트에 다음과 같이 추가한다.  Goopspray효과는 피튀는게 좀 작아서 localScale을 변경해 크게했다

  • blood.transform.localScale = new Vector3(5, 5, 5);  //효과가 작아서 확대했다.

효과의 인스턴스를 만드는데 인수가 4개이다 마지막 monsterTr의 Child로 만들겠다는 뜻이다.

  • GameObject blood = Instantiate<GameObject>(bloodEffect, pos, rot, monsterTr);

이렇게 child로 만들면 Monster가 총알을 맞은뒤 이동해도 혈흔이  따라다닌다.

private GameObject bloodEffect;
void Start(){
	bloodEffect = Resources.Load<GameObject>("GoopSpray");
}
private void OnCollisionEnter(Collision coll) {
    if (coll.collider.CompareTag("BULLET")) {
        Destroy(coll.gameObject);             //충돌한 총알을 삭제
        anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
        Vector3 pos = coll.GetContact(0).point;
        Quaternion rot = Quaternion.LookRotation(-coll.GetContact(0).normal);
        ShowBloodEffect(pos, rot);  
    }
}
void ShowBloodEffect(Vector3 pos, Quaternion rot) {
    GameObject blood = Instantiate<GameObject>(bloodEffect, pos, rot, monsterTr);
    blood.transform.localScale = new Vector3(5, 5, 5);  //효과가 작아서 확대했다.
    Destroy(blood, 1.0f);
}

MonsterCtrl 전체코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;  //for Navigation


public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE ,DANCE
    }

    public State state = State.IDLE;  //몬스트 현재상태
    public float traceDist = 10.0f;  //추적 사정거리
    public float attackDist = 2.0f; //공격 사정거리
    public bool isDie = false;  //몬스터사망여부

    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    private Transform playerTr;  
    private NavMeshAgent agent;
    private Animator anim;

    public readonly int hashTrace = Animator.StringToHash("IsTrace");
    public readonly int hashAttack = Animator.StringToHash("IsAttack");
    private readonly int hashHit = Animator.StringToHash("Hit");

    private GameObject bloodEffect;

    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        bloodEffect = Resources.Load<GameObject>("GoopSpray");
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
        
    }

    //일정한 간격으로 몬스터의 행동 상태를 체크
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    //몬스터의 상태에 따라 몬스터의 동작을 수행
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool(hashTrace, false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool(hashTrace, true);
                    anim.SetBool(hashAttack, false); //공격파라메터 변경
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool(hashAttack, true);  //공격파라메터 변경
                    break;
                case State.DIE: //사망상태
                    break;

            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnCollisionEnter(Collision coll) {
        if (coll.collider.CompareTag("BULLET")) {
            Destroy(coll.gameObject);             //충돌한 총알을 삭제
            anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
            Vector3 pos = coll.GetContact(0).point;
            Quaternion rot = Quaternion.LookRotation(-coll.GetContact(0).normal);
            ShowBloodEffect(pos, rot);  
        }
    }
    void ShowBloodEffect(Vector3 pos, Quaternion rot) {
        GameObject blood = Instantiate<GameObject>(bloodEffect, pos, rot, monsterTr);
        blood.transform.localScale = new Vector3(5, 5, 5);  //효과가 작아서 확대했다.
        Destroy(blood, 1.0f);
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {

            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

아래와 같은 유한 상태 머신 구현을 위해 상태를 주기적으로 업데이트하는데 매 프레임 하는 건 부담을 주므로 0.2~0.3초간격으로 해도 된다면 코루틴을 적극 활용하겠다.

적 캐릭터의 상태체크

0.3초 마다 상태를 체크한다. Player와 Monster의 거리를 측정해 추적 사정거리와 공격 사정거리를 판단한 다음 상태를 변경한다. MonsterCtrl 스크립트를 다음과 같이 수정한다.

Enum을 사용하면 인스펙터뷰에 리스트박스로 표시되면 가독성을 높혀준다.

Start()함수를 비롯 2개의 코루틴 CheckMonsterState() MonsterActionState()가 있다.

Start()는 참조조기화및 코루틴을 기동시킨다.

코루틴들은 while(!IsDie) 순환문으로 살아 있는 동안만 감시를 하다가 몬스터가 죽으면 isDie=true로 순환문을 빠져나와 종료한다.

 CheckMonsterState() 에서 float distance = Vector3.Distance(playerTr.position, monsterTr.position);거리를 측정해 state를 IDLE, ATTACK, TRACE로 바꾸준다.

MonsterActionState()에서는 state가 Trace라면 agent.SetDestination(playerTr.position);로 목표물을 지정해주고,  agent.isStopped = false로  추적을 시작한다.; 다른 state에 대해서도 적절한 처리를 한다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;  //for Navigation

public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE
    }
    public State state = State.IDLE;
    public float traceDist = 10.0f;
    public float attackDist = 2.0f;
    public bool isDie = false;

    private Transform monsterTr;
    private Transform playerTr;
    private NavMeshAgent agent;

    public Vector3 pPos, mPos;
    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
    }
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            pPos = playerTr.position;
            mPos = monsterTr.position;
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true;
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);
                    agent.isStopped = false;
                    break;
                case State.ATTACK:
                    break;
                case State.DIE:
                    break;
            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {
            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

실행하면서 Monster의 인스펙터의 MonsterCtrl 스크립트뷰를 보면 거리가 좁혀지면 IDEL이 TRACE로 바뀌면 Monster가 추적을 시작하고 더 가가워지면 ATTACK로 바뀐다. 막 도망가서 거리를 벌리면 다시 IDLE상태로 된다.

마지막으로  OnDrawGizmos()는 씬뷰에서만 보인다. 디버그용이다.

트레이스 범위에서  범위에 해당하는 파란색 구를 그려주고 공격범위에서는 범위 크기의 빨간색 구를 그려준다.

애니메이션 동기화

몬스터는 유령처럼 움직임없이 따라오는데 애니메이션을 수행시켜 움직여보자. 애니메이터뷰에 IsTrace 변수를 설정해 놓았다. MonsterCtrl 스크립터에서 Monster의 Animator 컴포넌트에 접근하기 위한 변수를 설정및 할당하고 MonsterAction 함수에 Animator에서 생성한 변숫값을 변경한 변숫값을 변경하는 로직을 추가한다.

switch(state)문안에  case State.IDLE과 State.TRACE 때 IsTrace의 파라메터를false, true로 바꿔주는 anim.SetBool()이 추가되었다. 변경된  IsTrace의 파라메터는 애니메이터의 transition을 일어나게 한다.

IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    break;

MonsterCtrl 전체코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;  //for Navigation


public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE ,DANCE
    }

    public State state = State.IDLE;  //몬스트 현재상태
    public float traceDist = 10.0f;  //추적 사정거리
    public float attackDist = 2.0f; //공격 사정거리
    public bool isDie = false;  //몬스터사망여부

    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    private Transform playerTr;  
    private NavMeshAgent agent;
    private Animator anim;

    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
        
    }

    //일정한 간격으로 몬스터의 행동 상태를 체크
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    //몬스터의 상태에 따라 몬스터의 동작을 수행
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    break;
                case State.ATTACK: //공격상태
                    break;
                case State.DIE: //사망상태
                    break;

            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {

            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

 

몬스터 공격 루틴

몬스터에 공격 기능을 부여하자. 먼저 프로젝트 뷰의 MonsterAnim을 더블클릭해 애니메이터 뷰를 열고  attack 애니메이션 클립을 드래그해 배치한다. attack와 walk 스테이트간의 Transition 2개를 배치한다. 

walk->attack transition은 HasExitTime을 언체크하고 Conditions+를 누루고 IsAttack를 선택하고 true로

attack->walk transition은 HasExitTime을 언체크하고 Conditions+를 누루고 IsAttack를 선택하고 false로

MonsterCtrl 스크립터의 코루틴  MonsterAction() 내부의 Trace와 Attack case문에 IsAttack 파라메터 부분을 넣어준다

    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    anim.SetBool("IsAttack", false);
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool("IsAttack", true);
                    break;

SetBool()은 파라메테명의 오자가 있으면 오류도 발생하고 호출될때 마다 해시테이블을 검색하므로 파라메터 해시값을 미리 추출해 전달하는 방식이 바람직하다. 우선 다음같이 스트링을 해시값으로 미리 바꿔 놓는다 "Hit"는 몬스터가 피격받을때 상태인데 미리 넣어두겠다

    public readonly int hashTrace = Animator.StringToHash("IsTrace");
    public readonly int hashAttack = Animator.StringToHash("IsAttack");
    private readonly int hashHit = Animator.StringToHash("Hit");

코루팅 switch()문안의 스트링을 해시값으로 변경한다.

IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool(hashTrace, false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool(hashTrace, true);
                    anim.SetBool(hashAttack, false); //공격파라메터 변경
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool(hashAttack, true);  //공격파라메터 변경
                    break;

실행해 보면 이제 몬스터가 걷고 공격한다. 이제부터는 몬스터가 피격받을때 취할 리액션을 붙여 넣겠다.

게임뷰탭옆 Ani탭을 누른후 Hit Trigger 파라메터를 하나 만든후 몬스터 모델에서 gothit을 끌어다 놓고 다음과 transition을 만든다.

Any->gothit transition은 HasExitTime 언체크, condition +를 누르고 Hit로 설정한다

gothit에서 idle 로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 isTrace false로 한다.

gothit에서 walk 로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 condition + IsTrace true, IsAttack false로 한다.

gothit에서 attack로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 condition + IsAttack true로 한다.

몬스터와 총알이 충돌이 나면 총알을 없애주고 hasHit을 Trigger시켜준다(setTrigger) 그러면 Any->gotHit transition이 일어난다.

private readonly int hashHit = Animator.StringToHash("Hit");

private void OnCollisionEnter(Collision coll) {
    if (coll.collider.CompareTag("BULLET")) {
        Destroy(coll.gameObject);             //충돌한 총알을 삭제
        anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
    }
}

현재까지의 최종코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;  //for Navigation


public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE ,DANCE
    }

    public State state = State.IDLE;  //몬스트 현재상태
    public float traceDist = 10.0f;  //추적 사정거리
    public float attackDist = 2.0f; //공격 사정거리
    public bool isDie = false;  //몬스터사망여부

    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    private Transform playerTr;  
    private NavMeshAgent agent;
    private Animator anim;

    public readonly int hashTrace = Animator.StringToHash("IsTrace");
    public readonly int hashAttack = Animator.StringToHash("IsAttack");
    private readonly int hashHit = Animator.StringToHash("Hit");

    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
        
    }

    //일정한 간격으로 몬스터의 행동 상태를 체크
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    //몬스터의 상태에 따라 몬스터의 동작을 수행
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool(hashTrace, false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool(hashTrace, true);
                    anim.SetBool(hashAttack, false); //공격파라메터 변경
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool(hashAttack, true);  //공격파라메터 변경
                    break;
                case State.DIE: //사망상태
                    break;

            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnCollisionEnter(Collision coll) {
        if (coll.collider.CompareTag("BULLET")) {
            Destroy(coll.gameObject);             //충돌한 총알을 삭제
            anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {

            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

적 캐릭터가 장애물을 피해서 가는 걸 구현한다면 PathFinding같은 알고리즘이 필요하다. 유니티가 3D모델을 분석해 추적할 수 있는  내비게이션 기능을 제공하기 이전 많이 사용되었다. 유니티의 내비게이션 기능을 이용해 주인공을 추적하는 로직을 구현해 보자. 추적을 시작하면 walk애니메이션으로 변경하고 플레이어에 근접했을때 attack 애니메이션으로 변경되도록 메카님을 구성해본다.

네비게이션은 스테이즈를 구성하고 있는 3D메시(지형:Geometry)를 분석해 네비메시(NavMesh) 데이터를 미리 생성하는 방식이다. 추적할 수 있는 영역과 지나갈수 없는 영역을 미리 메시로 만들어 동작을 연출할 수 있다.

 

네비게이션 설정 - Navigation Static Flag

네비게이션을 만들(Bake)려면 그 대상이 무엇인지 지정해야 한다. 우선 바닥으로 사용되는 Floor의 static 옵션을 설정한다.  Floor 인스펙터의 이름 오른쪽의  Static옆 삼각형을 눌러 Navigation Static만 체크한다.

프리팹폴더의 Barrel도 Wall도 Static중 Navigation Static만 체크하고 다이얼로그가 나오면 children까지 변경해준다.

Monster를 선택하고. window>AI>Navigation을 선택하면 Navigation뷰가 나오고 Bake를 누르고 마치면 파란색 영역이 만들어진다. 드럼통 주위는 파란색 메시로 채워지지 않고 구멍이 나있다. 이 영영읍 지나갈 수 없는 영억으로 인식해 추적한 대상이 장애물로 판단한다. Bake는 씬뷰에 플레이전 있는 것들만 인식한다. 동적으로 생성되는 것은 예측할수 없는 것 같다. 따라서 스테이지에 적용된  Barrel 스크립트를 제거한고 프리팹 Barrel을 여기저기 배치하고 Bake를 한번 더 누른다.

플레이어와 몬스터를 떨어트려 놓과 사이에 배치해주기 바란다. 몬스터가 피해가는지

NavMeshAgent 컴포넌트

NavMeshAgent 컴포넌트는 목표를 향해 움직일 때 서로를 피해가는 캐릭터 생성에 유용합니다. 에이전트는 내비메시를 이용하여 게임 월드에 대해 추론하고 서로 또는 기타 움직이는 장애물을 피할 방법을 이해하고 있습니다. 내비메시 에이전트의 스크립팅 API를 이용하여 경로를 찾거나 공간을 추론할 수 있습니다.

 

하이라키에서 Monster를 선택하고 메뉴의 컴포넌트>Navigation>Nav Mesh Agent를 선택한다.

NaveMeshAgent의 각 속성은 다음과 같다.

프로퍼티 기능
에이전트 크기
Radius 에이전트의 반경은 장애물과 다른 에이전트 간의 충돌 계산하기 위해 사용됩니다.
Height 에이전트가 장애물 밑으로 지나갈 있는 높이 간격입니다.
Base offset 트랜스폼 피봇 포인트와 관련한 충돌 실린더의 오프셋입니다.
스티어링
Speed 최대 이동 속도(초당 월드 단위로)
Angular Speed 최대 회전 속도(초당 각도)
Acceleration 최대 가속(제곱 초당 월드 단위로)
Stopping distance 에이전트는 목표 위치에 가까워졌을 정지합니다.
Auto Braking 활성화 에이전트는 목적지에 다다를 속도를 줄입니다. 에이전트가 멀티플 포인트 사이에서 부드럽게 움직여야 하는 순찰과 같은 동작을 때에는 반드시 비활성화 시켜야 합니다.
장애물 회피
Quality 장애물 회피 품질입니다. 에이전트의 수가 많다면 장애물 회피 품질을 줄임으로써 CPU 시간을 절약할 있습니다. 회피를 없음으로 설정할 경우 충돌만 해결할 있을 다른 에이전트와 장애물에 대한 적극적인 회피는 하지 않습니다.
Priority 낮은 우선 순위의 에이전트는 에이전트의 회피 대상에서 제외됩니다. 값은 0에서 99사이에서 설정되어야 하며 낮은 숫자가 높은 우선 순위임을 의미합니다.
경로 찾기
Auto Traverse OffMesh Link 자동적으로 오프 메시 링크를 횡단하려면 트루로 설정해야 합니다. 애니메이션을 사용하거나 오프메시 링크를 횡단하는 특정한 방법을 사용하고 싶다면 반드시 이를 꺼놔야 합니다.
Auto Repath 활성화 에이전트가 경로 일부분의 끝에 도달하면 경로를 재탐색 합니다. 목적지까지 경로가 없다면 목적지에서 제일 가깝게 도달할 있는 위치까지 부분적인 경로가 생성됩니다.
Area Mask 영역 마스크는 에이전트가 경로 탐색에 어떠한 영역 타입 고려할 것인지를 설명합니다. 내비메시 베이킹를 위해 메시를 준비할 각각의 메시 영역 타입을 설정할 있습니다. 예를 들어 계단을 특별한 영역 타입으로 표시하고 몇몇 캐릭터 타입의 계단 이용을 금지할 있습니다.

세부 정보

에이전트는 수직으로 서 있는 실린더에 의해 정의되며 실린더의 크기는 Radius  Height 프로퍼티에 의해 특정됩니다. 실린더는 오브젝트와 함께 움직이지만 오브젝트가 회전한다 해도 계속 수직으로 서 있습니다. 실린더의 모양은 다른 에이전트와 장애물간의 충돌 감지와 대응에 사용됩니다. 게임 오브젝트의 앵커 포인트가 실린더의 베이스에 없을 때 높이의 차이를 메우기 위해 베이스 오프셋 프로퍼티를 사용할 수 있습니다.

실린더의 높이와 반경은 실제로 두 개 의 다른 장소에 특정됩니다. 내비메시 베이크 설정과 각 에이전트의 프로퍼티 입니다.

내비메시 베이크 설정 은 내비메시 에이전트가 어떻게 정적인 월드 지오메트리와 충돌하고 또는 어떻게 회피하는지를 설명합니다. 메모리 여유량을 유지하고 CPU 로드를 지속적으로 체크하기 위해 오직 하나의 크기만이 베이크 설정에서 특정될 수 있습니다. -내비메시 에이전트 프로퍼티 값은 에이전트가 움직이는 장애물 및 다른 에이전트와 어떻게 충돌하는지를 설명합니다.

대부분의 경우 두 장소 모두에 에이전트 크기를 똑같이 설정합니다. 하지만 예를 들어 크기가 큰 에이전트의 반경이 더 넓다면 다른 에이전트는 그 에이전트 주변에 공간을 더 많이 남겨둡니다. 하지만 그렇지 않다면 크기가 큰 에이전트도 마찬가지로 환경을 무시합니다.

 

하이라키뷰에서 Player를 선택하고  TAG에서 PLAYER를 생성하고 적용한다

Nave Mesh Agent는 인스펙터에서 추적대상을 연결할 수 없고 스크립트를 생성해야한다.

MonsterCtrl스크립트를 생성하자.  Monster에 적용한다.

코드는 간단한다 다음한줄로 자동으로 추적한다. 다만 명령을 실행할 당시의 위치만이다. 명령실행후 플레이어가 움직이면 자동 갱신되지 않는다.

agent.destination = playerTr.position;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;  //for Navigation

public class MonsterCtrl : MonoBehaviour
{
    private Transform monsterTr;
    private Transform playerTr;
    private NavMeshAgent agent;
    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        agent.destination = playerTr.position;  //목표를 플레이어로 설정
    }
}

잘 된다. 처음에는 PLAYER TAG를 Monster에도 설정했다가 뺑글뺑글 도는 걸 보고 풀어줬더니 잘되었다. PLAYER TAG는 Player에만

 

Monster는 NavMeshAgent가 미리 베이크된 NavMesh에 붙어서 이동하기 때문에 살짝 떠 있다. 방법은 NavMesh를 Bake할대 낮춰주던지 Monster NavMeshAgent의 BaseOffset을 -0.1로 낮춰주는 거다

오른쪽 BaseOffset이 -0.1 로 적용되어 있음
Monster가 NavMesh밑으로 가라앉어있다.

 

적 캐릭터는 NPC(Non Playable Character)의 일종으로 게임에 참여한 플레이어가 조작할 수 없는 캐릭터를 말한다.

 

유한 상태 머신의 정의

NPC의 인공지능을 구현하는 방식에는 여러가지가 있다. 그중 하나가 유한상태머신(FSM:Finite State Machine)으로 적캐릭터가 스스로 알아서 주변 환경에 적응하거나 들어오는 반응에 적절하게 반작용하도록 구현한 것을 말한다. 

적 캐릭터가 아래와 같이 Spwan되고 일정 범위를 순찰하다가 주인공을 추적하고 사정거리 이내에 근접하면 공격하는 것과 같은 구조를 상태머신이라고 한다. 또한 일정 데미지가 누적되며 사망하고 소멸되는 구조이기에 상태가 유한하여 유한상태머신 이라고 부른다.

상태가 많아 질수록 유지보수가 어려워진다. 이런점을 개선하기 위해 모듈화하고 계층적으로 분류하는 계층적 유한상태머신과 행동트리방식도 개발에 많이 적용한다.

 

메카님

FSM의 상태에 따라  적절한 애니메이션 동작을 취할 수 있게 메카님을 사용해 유연한 애니메이션을 제어해보자.

메카님은 리타게팅 기능을 제공해 Humanoid 모델의 필수 본 구조와 일치하면 다른 모델의 애니메이션을 바로 적용할 수 있다.

 

적캐릭터 3D 모델 임포트

다운한 Resources/Models폴더에서 Monster피키지를 임포트해 프로젝트의 05.Models폴더로 옮긴다.

Monster아이콘 삼각형을 눌러 펼쳐보면 삼각형아이콘이 애니메이션이다. idel, walk, attack,die, fall, gothit등 기본적인 애니메이션이 포함되어 있다.

메카님 애니메이션으로 전화

인스펙터뷰의 Rig탭을 눌러보면 Animation Type이 Generic으로 설정되어 있다.

애니메이션은 레거시, 제너릭, 휴머노이드 3가지 유형이 있으며,  각 타입특성은 다음과 같다.

레거시 : 유니티 4.0이전 버전

제너릭 : 메카님 애니메이션(비인간형모델), 리타게팅 불가

휴머노이드 : 메카님 애니메이션(인간형모델), 리타게팅 가능

 

Monster는 2족 보행(Biped) 모델로 휴머노이드로 설정해 구현할 수 있다. 애니메이션 타입을 Humanoid로 변경후 하단의 Apply후 자동 매핑이 되고 Configure버튼이 활성화 된다. 실패하면 x표시가 나타나고 수동으로 본을 연결해야 한다.

 

Config 버튼을 누르면 Bone구조의 매핑 정보를 확인할 수 있는 Avatar Confg화면이 나타난다. Avatar  Asset은 모델+본구조매핑정보로 본 구조가 동일하다면 재사용이 가능하다.

실선으로 돼 있는 원은 필수로 15개가 있으며 점선은 추가적인 본으로 연결하지 않아도 상관없다. 

 

인스펙트뷰의 좌하 Head탭을 누르면 머리의 본 연결 정보를 볼수 있고 Left Hand를 누르면 손가락 관절의 정보를 볼 수도 있다.

Monster Avatar상단의 Muscles&Settings 탭을 선택하면 각 관절의 정상적인 동작 여부를 슬라이더로 조절하며 시각적으로 확인할 수 있다. Pre-Muscle Settings에서  각 관절의 회전 범위를 설정할 수 있다.

맨밑의 Revert를 눌러 만질걸 복구시키고 Done을 눌러 변경을 마친다. 다시  인스펙터창이 보이고 Animation탭을 선택한다. idel, walk,attack 애니메이션만 Loop Time을 체크한후 Apply버튼을 누른다

애니메이션 리타게킹

맥사모 www.maxamo.com  사이트에 가입후 무료 에니메이션을 가져오자.

사이트에서 gangnam으로 검색후 싸이의 강남스타일 애니메이션을 가져오자.

지금 작업중인 적 캐릭터 모델파일(Monster.fbx)를 업로드해 애니메이션을 테스트도 할 수 있다.

Resources/Animations 폴더에 미리 다운받은 monster@Gangnam Style.fbx파일이 있으니 이걸 프로젝트뷰의 07.Animation폴더로 끌어온다.

메카님 리타게팅을 사용하려면 적용하려는 모델과 애니메이션 클립의 Type이 둘다 Humanoid로 설정해야 한다. 따라서 monster@Gangnam Style.fbx도 Rig탭에서 타입을 Humanoid로 바꾸고 apply한다. Animation탭을 누루고  Loop Time도 체크후 Apply해 준다.

같은 Animation탭 맨밑의 프리뷰창에 05.Model/Monster폴더의 Monster를 끌어다 놓고 Play시켜보면 춤을 추는걸 확인할수 있다.

Animator 컴포넌트

Monster모델을 씬뷰또는 하이라키로 끌어다 Player옆에 놓는다. 

씬뷰에 추가된  Monster는 기본적으로 Animator가 추가되어 있다. 3D모델의 애니메이션  타입을 메카님으로 설정하면 Animator컴포넌트가 기본적으로 추가된다.  앞서 제작한 Player모델은 애니메이션 타입을 레거시로 설정했기 때문에 Animation 컴포넌트가 추가된것이다. Animator는 Animation을 컨트롤 하는 컴포넌트로 레가시는 Animation컴포넌트가 그 역활을 한다.

Animation컴포넌트의 각 속성의 기능은 다음과 같다.

프로퍼티: 기능:
Controller 캐릭터에 부여된 애니메이터 컨트롤러.
Avatar 캐릭터의 Avatar입니다(애니메이터가 휴머노이드 캐릭터를 애니메이션화하기 위해 사용되는 경우).
Apply Root Motion 캐릭터의 포지션과 회전을 애니메이션 자체에서 제어할지 또는 스크립트에서 제어할지 선택합니다.
Update Mode 애니메이터 업데이트를 언제 할지, 어떤 타임스케일을 사용할지 선택할 있게 해줍니다.
  Normal 애니메이터는 업데이트 호출과 싱크되어 업데이트되며, 애니메이터의 속도는 현재의 타임스케일과 일치합니다. 타임스케일이 느려지면 애니메이션도 따라서 느려집니다.
  Animate Physics 애니메이터는 FixedUpdate 호출에 따라(, 물리 시스템과 동시에) 업데이트됩니다. 리지드바디 오브젝트를 밀어 움직일 있는 캐릭터와 같이 물리 상호작용을 하는 오브젝트의 모션을 애니메이션화하는 경우에 모드를 사용해야 합니다.
  스케일되지 않은 시간(Unscaled Time) 애니메이터는 업데이트 호출에 따라 업데이트되지만 애니메이터의 속도는 현재의 타임스케일을 무시하고 항상 100% 속도로 애니메이션화합니다. 게임플레이를 일시정지하거나 특수한 효과를 위해 수정된 타임스케일을 사용하면서 GUI 시스템을 노멀 속도로 애니메이션화하는 경우에 유용합니다.
Culling Mode 애니메이션에 사용할 있는 컬링 모드.
  항상 애니메이션화(Always Animate) 항상 애니메이션화하고 오프스크린일 경우에도 컬링을 하지 않습니다.
  업데이트 트랜스폼 컬링(Cull Update Transforms) 렌더러가 보이지 않을 리타겟, IK 트랜스폼의 쓰기를 비활성화합니다.
  완전히 컬링(Cull Completely) 렌더러가 보이지 않을 애니메이션을 완전히 비활성화합니다.

 

애니메이터 컨트롤러

애니메이터 컨트롤러를 사용하여 캐릭터나 오브젝트의 애니메이션 클립 세트와 관련 애니메이션 전환을 정렬하고 관리할 수 있습니다. 대부분의 경우, 여러 애니메이션을 이용하여 게임 내에서 특정 상황이 일어났을 때 에셋을 바꿔가면서 사용합니다. 예를 들어 스페이스바를 누를 때마다 걷기 애니메이션 클립에서 점프 애니메이션 클립으로 전환할 수 있습니다. 하지만 애니메이션 클립이 하나만 있을 때에도 이 클립을 게임 오브젝트에 사용하기 위해서는 애니메이터 컨트롤러에 배치해야 합니다.

애니메이터 컨트롤러는 그 안에서 사용되는 애니메이션 클립에 대한 레퍼런스를 포함하고 있으며, 애니메이션 클립과 전환의 순서도 또는 Unity 에디터 내에서 비주얼 프로그래밍 언어로 작성된 간단한 프로그램이라고 생각할 수 있는 상태 머신 을 사용하여 다양한 애니메이션 클립과 각 클립 사이의 전환을 관리합니다. 상태 머신에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

프로젝트뷰의 07.Animation폴더 하위에 Controllers라는 서브폴더를 생성하고 Assts>Create>Animator Controller를 선택해 이름을 MonsterAnim으로 지정한다.

생성한 MonsterAnim을 더블클릭하면 다음 같은 애니메이터뷰가 열린다.

BaseLayer에 3개의 State가 있다.

Any State : 어떤 상태라도 조건이 맞으면 다른 스테이트로 분기시킬 수 있다.

Entry : 시작 스테이트 최초 진입점

Exit : 종료 스테이트 모든 스테이트가 종료되는 마지막 스테이트

Models>Monster폴더의 Monster를 눌러 삼각형 아이콘의 Idle 을 끌어다  애니메이터뷰에 끌어다 놓으면

다음 처럼된다 추가된 Idle은 Default를 의미하는 주황색을 띤다. 다른 스테이트도 우클릭으로 Default State로 변경가능.

하이라키뷰의 Monster를 선택하고 Animation>Controller폴더의 MonsterAnim를 Animator컴포넌트의 Controller에 끌어다 놓는다.

실행해보면 Idel에 파란색 진행 상태가바 채워지는 모습을 확인할 수 있다. 한번만 실행된다면 Loop를 체크해야한다.

이제 Walk도 끌어다 놔보자 아이들을 우클릭후 Maker Transition선택하면 화살표가 나오는데 이걸 walk에 연결한다. 이선을 transition이라고 한다.

애니메이터의 좌상 Parameters 탭을 선택하고 +를 눌러 Bool을 선택하고 이름을 IsTrace로 지정한다.

새로만든 IsTrace변수가 true면 walk, false면 Idel로 되돌아오게 해보겠다.

Idel과 walk사이의 transition을 선택하고 인스펙터뷰의 Conditions속성에 +를 눌러 조건을 추가하면 IsTrue가 true로 되어있다.   HasExitTime은 언체크한다. 이건 현재실행중인 애니메이션을 즉시 멈추고 실행한다는 뜻이다.

Idle>Walk, Walk>Idle transition 설정

이번에는 walk를 우클릭한후 transition을Idel에 연결해주고 transition을 눌러 IsTrace 를 fasle로 한다. 역시 HasExitTime은 언체크한다.

Play를 누르고 게임뷰에서 Animator탭으로 전환하고 왼쪽의 IsTrace를 체크/언체크해보면  스테이지가 변화한다.

 

적 캐릭터는 NPC(Non Playable Character)의 일종으로 게임에 참여한 플레이어가 조작할 수 없는 캐릭터를 말한다.

 

유한 상태 머신의 정의

NPC의 인공지능을 구현하는 방식에는 여러가지가 있다. 그중 하나가 유한상태머신(FSM:Finite State Machine)으로 적캐릭터가 스스로 알아서 주변 환경에 적응하거나 들어오는 반응에 적절하게 반작용하도록 구현한 것을 말한다. 

적 캐릭터가 아래와 같이 Spwan되고 일정 범위를 순찰하다가 주인공을 추적하고 사정거리 이내에 근접하면 공격하는 것과 같은 구조를 상태머신이라고 한다. 또한 일정 데미지가 누적되며 사망하고 소멸되는 구조이기에 상태가 유한하여 유한상태머신 이라고 부른다.

상태가 많아 질수록 유지보수가 어려워진다. 이런점을 개선하기 위해 모듈화하고 계층적으로 분류하는 계층적 유한상태머신과 행동트리방식도 개발에 많이 적용한다.

 

메카님

FSM의 상태에 따라  적절한 애니메이션 동작을 취할 수 있게 메카님을 사용해 유연한 애니메이션을 제어해보자.

메카님은 리타게팅 기능을 제공해 Humanoid 모델의 필수 본 구조와 일치하면 다른 모델의 애니메이션을 바로 적용할 수 있다.

 

적캐릭터 3D 모델 임포트

다운한 Resources/Models폴더에서 Monster피키지를 임포트해 프로젝트의 05.Models폴더로 옮긴다.

Monster아이콘 삼각형을 눌러 펼쳐보면 삼각형아이콘이 애니메이션이다. idel, walk, attack,die, fall, gothit등 기본적인 애니메이션이 포함되어 있다.

메카님 애니메이션으로 전화

인스펙터뷰의 Rig탭을 눌러보면 Animation Type이 Generic으로 설정되어 있다.

애니메이션은 레거시, 제너릭, 휴머노이드 3가지 유형이 있으며,  각 타입특성은 다음과 같다.

레거시 : 유니티 4.0이전 버전

제너릭 : 메카님 애니메이션(비인간형모델), 리타게팅 불가

휴머노이드 : 메카님 애니메이션(인간형모델), 리타게팅 가능

 

Monster는 2족 보행(Biped) 모델로 휴머노이드로 설정해 구현할 수 있다. 애니메이션 타입을 Humanoid로 변경후 하단의 Apply후 자동 매핑이 되고 Configure버튼이 활성화 된다. 실패하면 x표시가 나타나고 수동으로 본을 연결해야 한다.

 

Config 버튼을 누르면 Bone구조의 매핑 정보를 확인할 수 있는 Avatar Confg화면이 나타난다. Avatar  Asset은 모델+본구조매핑정보로 본 구조가 동일하다면 재사용이 가능하다.

실선으로 돼 있는 원은 필수로 15개가 있으며 점선은 추가적인 본으로 연결하지 않아도 상관없다. 

 

인스펙트뷰의 좌하 Head탭을 누르면 머리의 본 연결 정보를 볼수 있고 Left Hand를 누르면 손가락 관절의 정보를 볼 수도 있다.

Monster Avatar상단의 Muscles&Settings 탭을 선택하면 각 관절의 정상적인 동작 여부를 슬라이더로 조절하며 시각적으로 확인할 수 있다. Pre-Muscle Settings에서  각 관절의 회전 범위를 설정할 수 있다.

맨밑의 Revert를 눌러 만질걸 복구시키고 Done을 눌러 변경을 마친다. 다시  인스펙터창이 보이고 Animation탭을 선택한다. idel, walk,attack 애니메이션만 Loop Time을 체크한후 Apply버튼을 누른다

애니메이션 리타게킹

맥사모 www.maxamo.com  사이트에 가입후 무료 에니메이션을 가져오자.

사이트에서 gangnam으로 검색후 싸이의 강남스타일 애니메이션을 가져오자.

지금 작업중인 적 캐릭터 모델파일(Monster.fbx)를 업로드해 애니메이션을 테스트도 할 수 있다.

Resources/Animations 폴더에 미리 다운받은 monster@Gangnam Style.fbx파일이 있으니 이걸 프로젝트뷰의 07.Animation폴더로 끌어온다.

메카님 리타게팅을 사용하려면 적용하려는 모델과 애니메이션 클립의 Type이 둘다 Humanoid로 설정해야 한다. 따라서 monster@Gangnam Style.fbx도 Rig탭에서 타입을 Humanoid로 바꾸고 apply한다. Animation탭을 누루고  Loop Time도 체크후 Apply해 준다.

같은 Animation탭 맨밑의 프리뷰창에 05.Model/Monster폴더의 Monster를 끌어다 놓고 Play시켜보면 춤을 추는걸 확인할수 있다.

Animator 컴포넌트

Monster모델을 씬뷰또는 하이라키로 끌어다 Player옆에 놓는다. 

씬뷰에 추가된  Monster는 기본적으로 Animator가 추가되어 있다. 3D모델의 애니메이션  타입을 메카님으로 설정하면 Animator컴포넌트가 기본적으로 추가된다.  앞서 제작한 Player모델은 애니메이션 타입을 레거시로 설정했기 때문에 Animation 컴포넌트가 추가된것이다. Animator는 Animation을 컨트롤 하는 컴포넌트로 레가시는 Animation컴포넌트가 그 역활을 한다.

Animation컴포넌트의 각 속성의 기능은 다음과 같다.

프로퍼티: 기능:
Controller 캐릭터에 부여된 애니메이터 컨트롤러.
Avatar 캐릭터의 Avatar입니다(애니메이터가 휴머노이드 캐릭터를 애니메이션화하기 위해 사용되는 경우).
Apply Root Motion 캐릭터의 포지션과 회전을 애니메이션 자체에서 제어할지 또는 스크립트에서 제어할지 선택합니다.
Update Mode 애니메이터 업데이트를 언제 할지, 어떤 타임스케일을 사용할지 선택할 있게 해줍니다.
  Normal 애니메이터는 업데이트 호출과 싱크되어 업데이트되며, 애니메이터의 속도는 현재의 타임스케일과 일치합니다. 타임스케일이 느려지면 애니메이션도 따라서 느려집니다.
  Animate Physics 애니메이터는 FixedUpdate 호출에 따라(, 물리 시스템과 동시에) 업데이트됩니다. 리지드바디 오브젝트를 밀어 움직일 있는 캐릭터와 같이 물리 상호작용을 하는 오브젝트의 모션을 애니메이션화하는 경우에 모드를 사용해야 합니다.
  스케일되지 않은 시간(Unscaled Time) 애니메이터는 업데이트 호출에 따라 업데이트되지만 애니메이터의 속도는 현재의 타임스케일을 무시하고 항상 100% 속도로 애니메이션화합니다. 게임플레이를 일시정지하거나 특수한 효과를 위해 수정된 타임스케일을 사용하면서 GUI 시스템을 노멀 속도로 애니메이션화하는 경우에 유용합니다.
Culling Mode 애니메이션에 사용할 있는 컬링 모드.
  항상 애니메이션화(Always Animate) 항상 애니메이션화하고 오프스크린일 경우에도 컬링을 하지 않습니다.
  업데이트 트랜스폼 컬링(Cull Update Transforms) 렌더러가 보이지 않을 리타겟, IK 트랜스폼의 쓰기를 비활성화합니다.
  완전히 컬링(Cull Completely) 렌더러가 보이지 않을 애니메이션을 완전히 비활성화합니다.

 

애니메이터 컨트롤러

애니메이터 컨트롤러를 사용하여 캐릭터나 오브젝트의 애니메이션 클립 세트와 관련 애니메이션 전환을 정렬하고 관리할 수 있습니다. 대부분의 경우, 여러 애니메이션을 이용하여 게임 내에서 특정 상황이 일어났을 때 에셋을 바꿔가면서 사용합니다. 예를 들어 스페이스바를 누를 때마다 걷기 애니메이션 클립에서 점프 애니메이션 클립으로 전환할 수 있습니다. 하지만 애니메이션 클립이 하나만 있을 때에도 이 클립을 게임 오브젝트에 사용하기 위해서는 애니메이터 컨트롤러에 배치해야 합니다.

애니메이터 컨트롤러는 그 안에서 사용되는 애니메이션 클립에 대한 레퍼런스를 포함하고 있으며, 애니메이션 클립과 전환의 순서도 또는 Unity 에디터 내에서 비주얼 프로그래밍 언어로 작성된 간단한 프로그램이라고 생각할 수 있는 상태 머신 을 사용하여 다양한 애니메이션 클립과 각 클립 사이의 전환을 관리합니다. 상태 머신에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

프로젝트뷰의 07.Animation폴더 하위에 Controllers라는 서브폴더를 생성하고 Assts>Create>Animator Controller를 선택해 이름을 MonsterAnim으로 지정한다.

생성한 MonsterAnim을 더블클릭하면 다음 같은 애니메이터뷰가 열린다.

BaseLayer에 3개의 State가 있다.

Any State : 어떤 상태라도 조건이 맞으면 다른 스테이트로 분기시킬 수 있다.

Entry : 시작 스테이트 최초 진입점

Exit : 종료 스테이트 모든 스테이트가 종료되는 마지막 스테이트

Models>Monster폴더의 Monster를 눌러 삼각형 아이콘의 Idle 을 끌어다  애니메이터뷰에 끌어다 놓으면

다음 처럼된다 추가된 Idle은 Default를 의미하는 주황색을 띤다. 다른 스테이트도 우클릭으로 Default State로 변경가능.

하이라키뷰의 Monster를 선택하고 Animation>Controller폴더의 MonsterAnim를 Animator컴포넌트의 Controller에 끌어다 놓는다.

실행해보면 Idel에 파란색 진행 상태가바 채워지는 모습을 확인할 수 있다. 한번만 실행된다면 Loop를 체크해야한다.

이제 Walk도 끌어다 놔보자 아이들을 우클릭후 Maker Transition선택하면 화살표가 나오는데 이걸 walk에 연결한다. 이선을 transition이라고 한다.

애니메이터의 좌상 Parameters 탭을 선택하고 +를 눌러 Bool을 선택하고 이름을 IsTrace로 지정한다.

새로만든 IsTrace변수가 true면 walk, false면 Idel로 되돌아오게 해보겠다.

Idel과 walk사이의 transition을 선택하고 인스펙터뷰의 Conditions속성에 +를 눌러 조건을 추가하면 IsTrue가 true로 되어있다.   HasExitTime은 언체크한다. 이건 현재실행중인 애니메이션을 즉시 멈추고 실행한다는 뜻이다.

Idle>Walk, Walk>Idle transition 설정

이번에는 walk를 우클릭한후 transition을Idel에 연결해주고 transition을 눌러 IsTrace 를 fasle로 한다. 역시 HasExitTime은 언체크한다.

Play를 누르고 게임뷰에서 Animator탭으로 전환하고 왼쪽의 IsTrace를 체크/언체크해보면  스테이지가 변화한다.

 

총구 화염 효과는 파티클 효과나 성능을 고려해 가벼운 메시를 사용할 수 있다. 

하이라키뷰의 Player하위에 있는 FirePos를 선택하고 우클릭후 팝업 메뉴에서 Create>3D Object>Quad를 선택한다. 이름을 MuzzleFlash로 수정한다. 발사되는 총알의 충돌을 막기위해 Mesh Collider 컴포넌트는 삭제한다.

총구 화염은 실시간 그림자가 필요없으므로 Mesh Renderer의 Cast Shdows를 Off로 설정라고 조명과 관련된 Receive Shadows를 Uncheck한다. 내경우는 Disable되어 있어 끌수가 없었다. 이상함..

다운받았던 Resources>Texture>Wepons폴더의 MuzzleFlash.png파일을 끌어다 05.Images 폴더로 드래그해 임포트한다.

이 이미지는 4개의 화염이미지가 들어 있지만 4등분해서 랜덤하게 하나만 사용한다.

MuzzleFlash.png를 끌어다 MuzzleFlash에 적용한고 Shader를 Mobile/Particles/Additive로 하면 검은부분이 투명하게 바낀다.

Shader 왼쪽 삼각형을 눌러 메뉴를 펴서 Particle Texture Tiling을 0.5, 0.5로 하면 4장으로 자르게 된다.

밑의 offet을 변경해 4장중 한장을 고를 수 있다.

주인공 캐릭터 하위의 MuzzleFlash에 포한된 MeshRenderer 컴포넌트를 추출하는 로직을 FireCtrl스크립트에 추가한다. MuzzleFlash의 Offset값을 변경하려면 머터리얼 정보를 담고 있는 MeshRenderer컴포넌트에 접근해야 한다. FireCtrl에 이미 있는 firePos변수를 이용해서 접근한후 비활성화 한다.  총을 발사할때문 보이게 할것이다.

muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();

muzzleFlash.enabled = false;

실행해보면 MuzzleFlash의 MeshRenderer컴포넌트가 비활성활되어 있다.

Play옆 포즈를 누르고 씬뷰탭을 선택후 ALT+좌클릭후 마우스를 움직여 씬뷰를 회전시키면 총구의 화염이 없는 것도 확인할수 있다

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof (AudioSource))]  //삭제되는것을 방지하는 어트리뷰트
public class FireCtrl : MonoBehaviour
{
    public GameObject bullet;  //총알프리팹
    public Transform firePos;  // 총알 발사 좌표
    public AudioClip fireStx;  //총소리에 사용할 음원
    private new AudioSource audio;  //오디오 소스 컴포넌트를 저장할 변수
    private MeshRenderer muzzleFlash;  //머즐이펙트 참조
    // Update is called once per frame
    private void Start() {
        audio = GetComponent<AudioSource>();  //오디오소스를 얻어온다
        muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();  //자식 컴포넌트를 얻어온다
        muzzleFlash.enabled = false;  //안보이게한다
    }
    void Update()
    {  //마우스 왼쪽 버튼을 클릭했을때  Fire 함수 호출
        if(Input.GetMouseButtonDown(0)) {
            Fire();  //발사처리
        }
    }
    void Fire() {
        Instantiate(bullet,firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생
    }
}

코루틴함수

유니티의 모든 스크립트는 메시지루트가 동작한다. 다양한 이벤트함수가 정해진 순서대로 실행되는 순환구조이다. 일반 함수를 호출하면 해당 함수 안의 로직이 다 수행해야만 실행이 끝난다. 

함수안에서 로직이 10초 걸린다고 하면10초동안 메시지루프의 다른 로직을 기다리게 하는데 이걸 Blocking이라고 한다. 

이를 개선하려면 함수를 병렬로 호출해야한다. 이걸 Multi thread라고 한다. 

유니티는 코루틴이라는 병렬처리기능을 제공한다. 

만일 Fade()라는 함수는 for문을 통해 알파값을 감소시켜 투명처리하는 함수인데 순식간에 처리하므로 효과를 확인할수 없어 갑자기 꺼지게 될것이다.

void Fade() {
    for(float f = 1f; f>=0; f-= 0.1f) {
       Color c = GetComponent<Renderer>().material.color;
       c.a = f;  //알파값을 천천히 감소시킨다
       GetComponent<Renderer>().material.color = c;
    }
}

코루틴의 아이디어는 yield return null; 키워드를 만나면 제어 권한을 유니티시스템의 메시지루프로 양보하는 방식이다.

코루틴 함수는 열거자 IEnumerator 타입으로 선언해야 하고 함수내에 하나이상의 yeild 키워드를 사용해야 한다.

Ienumerate void Fade() {
    for(float f = 1f; f>=0; f-= 0.1f) {
       Color c = GetComponent<Renderer>().material.color;
       c.a = f;  //알파값을 천천히 감소시킨다
       GetComponent<Renderer>().material.color = c;
       yield return null;  //메시지루프로 제어권을 넘긴다.
    }
}

yield return null;키워드는 함수내에 여러개 삽입될 수도 있다.

코루틴 함수는 호출시 StartCoroutine()함수를 이용하여 호출한다. 전달인자는 함수의 원형(포인터)를 권장한다. "함수명"으로 호출하는건 가비지컬렉션을 발생시키 개별적으로 정지시킬수 없기 때문이다.

StartCoroutine(Fade());

yield return null대신 yield return WaitForSecond(0.3f)같이 하면 자기 자신을 0.3초동안 멈추게 할 수 있다. 대신 로직을 잘짜 무한 루프에 안 빠지게 해야한다. 다음은 적이 죽지 않았다면 0.3초간 기다려주는 로직이다. 만일 yield return new WaitForSeconds(0.3f)가 없다면 정말 무한루프에 빠질것이다.

bool isDie;
IEnumerator CheckState() {
    while(!isDie)  {  //죽지않았다면
        yield return new waitForSecond(0.3f); //3초 대기하기
    }
}

MuzzleFlash의 블링크 효과

총알알 발사할때 MuzzleFlash가 깜빡거리는 Blink효과를 구현해보자.  다음과 같이 하면 순식간에 일어나 효과가 없다

void ShowMuzzleFlash() {  //일반함수
    muzzleFlash.enabled = true;  //MuzzleFlash활성화
    muzzleFlash.enable = false; //MuzzleFlash비활성화
}

Blink효과는 점멸을 천천히 반복해야 하므로 코루틴을 사용해야한다.

Ienumerator void ShowMuzzleFlash() { //코루틴
    muzzleFlash.enabled = true; //MuzzleFlash활성화
    yield return new WaitForSecond(0.2f);
    muzzleFlash.enable = false;  //MuzzleFlash비활성화
}

MuzzleFlash의 텍스처 오프셋 변경

MuzzleFlash에 연결된 텍스처의 오프셋을 불규칙하게 변경해보자 또한 각도및 스케일도 조절해보자.

텍스처 오프셋값은 SetTextureOffset함수의 mainTextureOffset속성으로 설정할 수 있다.

처음 tiling걊을 (0.5,0.5)로 했기때문에 오프셋값은 다음 4개중 하나이다. (0,0), (0,0.5), (0.5,0), (0.5,0.5)

Randome.Range(0,2)가 리턴하는 숫자는 0, 1이므로 이값에 0.5f를 곱하여 이용하면 된다.

Vector2 offset = New Vector2(Randome.Range(0,2), Randome.Range(0,2))*0.5f;
muzzleFlash.material.mainTextureOffset = offset;
//muzzleFlash.material.setTextureOffset("_MainTxt",offset);  //이렇게도 가능하다

MuzzleFlash는 다른 게임오브젝트(FirePos)아래에 차일드화된 게임오브젝트로서, 좌표, 회전, 각도, 스케일을 수정하려면 반드시 localPosition, localRotation, localScale속성을 사용해야 한다.

MuzzleFlash를 회전시키기 위한 localRotation속성은 Quaternion 타입이므로 오일러각을 변화시키는 Quaternion.Euler(x,y,x)함수를 사용한다. 0~360도의 난수를 발생시켜 회전시켜본다. 유니티 원시모델 Quad로 만들어진 MuzzleFlash는 x축으로 -90도 회전된 모델로 z축을 기준으로 회전시켜야 하므로

float angle = Random.Range(0,360);
muzzleFlash.transfor.localRotation = Quaternion.Euler(0,0,angle);

다음과 같이 단위벡터를 사용할수도 있다.  z축은 Vector3.forward를 사용해야한다.

float angle = Random.Range(0,360);
muzzleFlash.transfor.localRotation = Quaternion.Euler(Vector3.forward*angle);

localScale을 이용해 사이즈를 조절한다. Vector3.one은 Vector3(1,1,1)을 나타내는 상수이다.

 

float scale = Random.Range(1.0f,2.0f);
muzzleFlash.transfor.localScale = Vector3.one * scale;

다음과 같이 FireCtrl을 변경하고 실행해보면 MuzzleFlash의 텍스처 크기 회전이 불규칙하게 변하는 것을 확인할 수 있다.

각도 때문에 게임뷰에서 확인이 어렵다면 게임뷰와 씬뷰를 동시에 보면서 할수 있다. 씬뷰는 게임중에도 위치와  회전을 할 수 있다.

왼쪽:씬뷰 어른쪽:게임뷰

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof (AudioSource))]  //삭제되는것을 방지하는 어트리뷰트
public class FireCtrl : MonoBehaviour
{
    public GameObject bullet;  //총알프리팹
    public Transform firePos;  // 총알 발사 좌표
    public AudioClip fireStx;  //총소리에 사용할 음원
    private new AudioSource audio;  //오디오 소스 컴포넌트를 저장할 변수
    private MeshRenderer muzzleFlash;  //머즐이펙트 참조
    // Update is called once per frame
    private void Start() {
        audio = GetComponent<AudioSource>();  //오디오소스를 얻어온다
        muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();  //자식 컴포넌트를 얻어온다
        muzzleFlash.enabled = false;  //안보이게한다
    }
    void Update()
    {  //마우스 왼쪽 버튼을 클릭했을때  Fire 함수 호출
        if(Input.GetMouseButtonDown(0)) {
            Fire();  //발사처리
        }
    }
    void Fire() {
        Instantiate(bullet,firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생\
        StartCoroutine(ShowMuzzleFlash()); //총구화염효과 코루틴함수 호출
     
    }
    IEnumerator ShowMuzzleFlash() {  //코루틴
        Vector2 offset = new Vector2(Random.Range(0, 2), Random.Range(0, 2)) * 0.5f;
        muzzleFlash.material.mainTextureOffset = offset;  //4장중 한장을 선택
        float angle = Random.Range(0, 360);
        muzzleFlash.transform.localRotation = Quaternion.Euler(0,0, angle);  //로컬회전, 쿼터니언으로 변경할것
        float scale = Random.Range(1.0f, 2.0f);
        muzzleFlash.transform.localScale = Vector3.one * scale;  //로컬스케일변경
        muzzleFlash.enabled=true; //muzzleFlash 활성화
        yield return new WaitForSeconds(0.2f);  // new를 붙여줘야함. 권한을 0.2초 넘겨줌
        muzzleFlash.enabled=false;  //muzzleFlash 비활성화
    }
}

코루틴의 응용 -  임계치

게임을 실행하면 Player의 방향이 불규칙하다. 이건 마우스의 좌우 이동값이 불규칙하게 넘어오기 때문이다. Start함수를 코루틴으로 변경시켜 해결해보자. Player스크립트를 다음과 같이 수정한다. 교재는 PlayerCtrl이다.

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEditor;
using UnityEngine;

public class Player : MonoBehaviour {
    // Start is called before the first frame update
    Transform tr;
    private float moveSpeed=10f;
    private float turnSpeed=0f;
    private Animation anim;

    IEnumerator  Start() {  //start()함수는 코루틴으로 실행할 수 있다.
        tr = GetComponent<Transform>();
        anim = GetComponent<Animation>();  //추가된 코드
        anim.Play("Idel");
        yield return new WaitForSeconds(0.3f);
        turnSpeed = 500f;
    }

    // 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) {
        Debug.Log("Move");
        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);
        }
    }
}

게임에 음악을 추가하려면 AudioSource필요하다.

MainCamera에는 AudioListener가 기본적으로 추가되어있고 설정할 것은 없는데 카메라가 여러대일경우 하나만 남겨놓고 지워야 한다.

Resouces/Sounds폴더에서 WeaponSFX 패키지를 임포트하고 프로젝트뷰의 06.Sounds 폴더로 드래그&드롭해 위치를 옮긴다. 이패키지는 Rifle과 Shotgun 두 가지의 총소리와 폭발음이 포함돼 있다. Rifle폴더에 있는 p_m4_1사운드파일을 사용한다. 임포트한 오디오 클립은 Force To Mono옵션으 체크해서 가볍게 하고 사이즈도 줄일수 있다.

 

오디오 임포트 옵션 - Load Type

Decompress On Load : 로드시 압축을 해제하므로 사이즈가 큰 파일은 오버헤드를 발생시킨다. 작은 사이즈의 오디오에 적합하고 압축후에는 CPU자원을 덜 소비한다.

Compressed in Memory : 압축된 상태로 메모리에 상주한다. 큰 사이즈의 오디오에 적합하다.

Streaming : HDD에서 부터 스트리밍 하듯이 재생한다. 메모리가 필요없다.

 

오디오 임포트 옵션 - Compression Format

PCM : 비압축

ADPCM : 압축율 3.5배로 노이즈가 포함돼어 있는 음원에 적합

Voris / MP3 : 70%정도의 압축률

 

총소리 구현

 p_mp_1.wav을 선택해 인스펙터에서 다음과 같이 설정한다. 총소리는 발사할 때마다 총소리를 발생시키므로 Decompress On Load로 Compression Format은 ADPCM정도로 한다.

Bullet프리팹을 선택해 AudioSoruce컴포넌트를 추가한다. 속성은

Audio Clip : p_mp_1.wav , 발생시킬 음원 파일

Play On Awake : 체크, 해당 컴포넌트가 활성화될 때 자동 재생 여부

Min Distance : 5 , 볼륨 100% 값으로 음원이 들리는 영역범위

Max Distance : 10 , 음원이 들리는 최대범위

하이라키의 Bullet을 수정했기 때문에 Override>Apply All버튼을 클릭해 원본 Bullet프리팹에 저장한다. 다른 방법은 Audio Source 컴포넌트에서 우클릭후 팝업된 메뉴에서 Added Component>Apply to Prefab "Bullet"을 선택한는 것이다.

 

이렇게 Bullet Prefab에서 구현한 총소리는 충돌하자 마자 삭제되므로 사운드가 끊어지는 현상이 발생한다. 따라서 스크립트로 처리해 보겠다. Bullet Prefab에서 AudioSource 컴포넌트를 제거한다.

하이라키뷰의 Player를 선택하고 AudioSource컴포넌트를 추가한다. 수정할 필요는 없다. 다음과 같이 스크립트를 추가하고 스크립트에 오디오클립을 연결해준다.

FireCtrl 스크립 코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof (AudioSource))]  //삭제되는것을 방지하는 어트리뷰트
public class FireCtrl : MonoBehaviour
{
    public GameObject bullet;  //총알프리팹
    public Transform firePos;  // 총알 발사 좌표
    public AudioClip fireStx;  //총소리에 사용할 음원
    private new AudioSource audio;  //오디오 소스 컴포넌트를 저장할 변수
    // Update is called once per frame
    private void Start() {
        audio = GetComponent<AudioSource>();
    }
    void Update()
    {  //마우스 왼쪽 버튼을 클릭했을때  Fire 함수 호출
        if(Input.GetMouseButtonDown(0)) {
            fire();
        }
    }
    void fire() {
        Instantiate(bullet,firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생
    }
}

 

여러개의 드럼통의 같은 텍스처로 적용되여 있다. 시작과 동시에 다양하게 바꿔보자. 텍스처의 적용은 Mesh Rendere 컴포넌트에 연결된  Material에서 지정한다. 여기에 사용한 Barrel모델의 Mesh Renderer컴포넌트는 Barrel 하위에 있는 Barrel에 적용되어 있다. 부모 오브젝트는 빈 오브젝트이다.

하위의 Mesh Renderer를 연결할 변수를 선언하고 연결후 사용할 수도 있지만 BarrelCtrl스크립트에서 동적으로연결해 보자. 추가된 코드는 다음과 같다. 

public Texture[] textures;  //무작위로 적용할 텍스쳐배열

private new MeshRenderer renderer;  //하위 MeshRenderer를 저장할 변수, new를 사용하는데 Component.renderer로 정의된 멤버 변수로서 new키워드를 사용해야 한다는데 잘 이해가 안 간다.

renderer= GetComponentInChildren<MeshRenderer>(); //자식컴포넌트 추출

int idx = Random.Range(0, textures.Length);//난수발생
renderer.material.mainTexture= textures[idx]; //텍스처지정

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

public class BarrelCtrl : MonoBehaviour {

    public GameObject expEffect;  //폭발효과를 연결할 참조
    public Texture[] textures;  //무작위로 적용할 텍스쳐배열
    private new MeshRenderer renderer;  //하위 MeshRenderer를 저장할 변수
    private Transform tr;//컴포넌트를 저장할 변수
    private Rigidbody rb;
    private int hitCount = 0;  //총알맞은 회수를 누적시킬 변수
    void Start() {
        tr = GetComponent<Transform>();
        rb = GetComponent<Rigidbody>();

        renderer= GetComponentInChildren<MeshRenderer>(); //자식 컴포넌트 추출
        int idx = Random.Range(0, textures.Length);//난수발생
        renderer.material.mainTexture= textures[idx]; //텍스처지정
    }

    private void OnCollisionEnter(Collision col) {
        if (col.collider.CompareTag("BULLET")) {
            if (++hitCount == 3) {
                ExpBarrel();
            }
        }
    }
    void ExpBarrel() {  //드럼통을 폭발시킨다
        // 파티클효과을 참조
        GameObject exp = Instantiate(expEffect, tr.position, Quaternion.identity);
        Destroy(exp, 5.0f);  //3초후 파티클 효과 제거
        rb.mass = 1.0f; //mass를 20에서 1로 가볍게 한다.
        rb.AddForce(Vector3.up * 1500.0f); //위쪽으로 날라가게 힘을 준다
        Destroy(gameObject, 3.0f); //3초후 드럼통 제거
    }
}

Barrel Prefab 원본에 Models/Barrel폴도안의 Barrel_D, Barrel_D1, Barrel_D2를 끌어다 Barrel Ctrl Script의 Textures에 끌어다 놓는다.

 

실행해보면 3가지 텍스쳐가 적용되어 있는걸 볼수 있다.

폭발력 적용하기 - AddExplosionForce

드럼통이 폭발할때 주변의 드럼통한테만 폭발력이 전달되어 데미지를 주는 로직을 구현한다. 

폭발시 주변의 물체가 어던것인지 알아야 한다. 드럼통의 Collider를 통해 충돌된 물체를 알수 있지만 충돌시 주변의 물체를 추출하는 방식으로 구현해보자 도한 드럼통만 데미지를 적용한다.

스크립트를 변경하기전 Layer를 추가한다. 유니티는 게임객체를 각각의 레이어에 배치해 로직을 필요한 레어어에만 적용할 수 있다.

다음과 같이 BARREL 레이어를 추가하고 적용시킨다. 이름은 TAG와 마찬가지로 대문자로 사용한다.

폭발하는 드럼통 주위를 살필때는 Physics.OverlapsSphere함수를 사용한다. 이 함수는 검사 반경와 특정 레이어를 지정할 수 있다.

BarrelCtrl 스크립트를 다음과 같이 변경한다. 코드를 살펴보면

public float radius = 10.0f;  // 검사할 주변 반경을 설정하였고

아래 두줄을 주석처리하고

 //rb.mass = 1.0f; //mass를 20에서 1로 가볍게 한다.
 //rb.AddForce(Vector3.up * 1500.0f); //위쪽으로 날라가게 힘을 준다
IndirectDamage(tr.position);  //파괴력을 전달하는 코드를 추가했다.

IndirectDamage()는 폭발위치를 전달하면 다음과 같이 처리한다.

 Collider[] cols = Physics.OverlapSphere(pos, radius, 1 << 3);  //주변객체를 검색해서 리턴
        foreach (var col in cols) {  //차례대로 반복
            rb = col.GetComponent<Rigidbody>();
            rb.mass = 1.0f;  //무게를 가볍게하고
            rb.constraints = RigidbodyConstraints.None;  // 움직임과 회전의 제한을 푼다
            rb.AddExplosionForce(1500.0f, pos, radius, 1200.0f);  //반경안에서 폭발력을 생성
        }

BarrelCtrl.cs의 전체소스

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

public class BarrelCtrl : MonoBehaviour {

    public GameObject expEffect;  //폭발효과를 연결할 참조
    public Texture[] textures;  //무작위로 적용할 텍스쳐배열
    public float radius = 10.0f;   // 검사할 주변 반경을 설정하였고
    private new MeshRenderer renderer;  //하위 MeshRenderer를 저장할 변수
    private Transform tr;//컴포넌트를 저장할 변수
    private Rigidbody rb;
    private int hitCount = 0;  //총알맞은 회수를 누적시킬 변수
    void Start() {
        tr = GetComponent<Transform>();
        rb = GetComponent<Rigidbody>();

        renderer= GetComponentInChildren<MeshRenderer>(); //자식컴포넌트 추출
        int idx = Random.Range(0, textures.Length);//난수발생
        renderer.material.mainTexture= textures[idx]; //텍스처지정
    }

    private void OnCollisionEnter(Collision col) {
        if (col.collider.CompareTag("BULLET")) {
            if (++hitCount == 3) {
                ExpBarrel();
            }
        }
    }
    void ExpBarrel() {  //드럼통을 폭발시킨다
        // 파티클효과을 참조
        GameObject exp = Instantiate(expEffect, tr.position, Quaternion.identity);
        Destroy(exp, 5.0f);  //3초후 파티클 효과 제거
        //rb.mass = 1.0f; //mass를 20에서 1로 가볍게 한다.
        //rb.AddForce(Vector3.up * 1500.0f); //위쪽으로 날라가게 힘을 준다
        IndirectDamage(tr.position);  //파괴력을 전달
        Destroy(gameObject, 3.0f); //3초후 드럼통 제거
    }
    void IndirectDamage(Vector3 pos) {
        // 주변의 드럼통 추출
        Collider[] cols = Physics.OverlapSphere(pos, radius, 1 << 3);  //주변객체를 검색해서 리턴
        foreach (var col in cols) {  //차례대로 반복
            rb = col.GetComponent<Rigidbody>();
            rb.mass = 1.0f;  //무게를 가볍게하고
            rb.constraints = RigidbodyConstraints.None;  // 움직임과 회전의 제한을 푼다
            rb.AddExplosionForce(1500.0f, pos, radius, 1200.0f);  //반경안에서 폭발력을 생성
        }
    }
}

 OverlapSphereNonAlloc

Physics.OverlapSphere함수는 실행시 Sphere범위에 검출될 개수가 명확지 않을때만 사용해야한다. 동적으로 메모리를 만들기 때문에 메모리 Garbage가 발생하기 때문이다. 따라서 Sphere범위에 검출될 개수가 명확할 때는 Garbage가 발생하지 않는 Physics.OverlapSphereNoneAlloc함수를 사용하기를 권장한다. 이 함수는 결괏값을 저장할 정적 배열을 미리 선언하여 실행정에 배열의 크기를 변경할 수 없다. 우리는 배럴을 20개 배치했기 때문에 최대 검출치가 20개 이므로 이 함수를 사용할수 있는 것이다.

IndirectDamage()함수를 다음과 같이 변경하자

    Collider[] cols = new Collider[10];
    void IndirectDamage(Vector3 pos) {
        // 주변의 드럼통 추출
        //Collider[] cols = Physics.OverlapSphere(pos, radius, 1 << 3);  //주변객체를 검색해서 리턴
        Physics.OverlapSphereNonAlloc(pos, radius, cols, 1 << 3);
        foreach (var col in cols) {  //차례대로 반복
            rb = col.GetComponent<Rigidbody>();
            rb.mass = 1.0f;  //무게를 가볍게하고
            rb.constraints = RigidbodyConstraints.None;  // 움직임과 회전의 제한을 푼다
            rb.AddExplosionForce(1500.0f, pos, radius, 1200.0f);  //반경안에서 폭발력을 생성
        }
    }

 

 

+ Recent posts