[Unity] Space-Shooter (3)

suhan0304·2024년 6월 18일

유니티 - SpaceShooter

목록 보기
3/6
post-thumbnail

Updates

Monster Update

ㅇㅈ

  • Navigation AI를 이용한 플레이어 추적
  • State에 따른 Animation 구현
  • 총알 피격 시 혈흔 이펙트 추가

Points

Enemy AI

NPC의 인공지능을 구현하는 방식 중 하나는 유한 상태 머신(FSM)을 이용하는 것이다. 실제 지금 개발에도 FSM을 사용할 것인데 FSM의 단점은 상태가 많아질 수록 상태와 상태 간의 연결(Transition)이 복잡해지고 코드의 확장과 유지보수가 어렵다는 것이다. 이러한 점을 개선하기 위해 계층적 유한 상태 머신행동 트리 방식도 개발에 많이 적용한다. (나중에 한번 다뤄보도록 하자.)

유니티는 메카님(Mecanim)을 사용해 애니메이션을 제어해준다. 리타게팅 기능을 제공해 인체형(Humanoid) 모델의 필수 본(Bone) 구조와 일치하면 다른 모델의 애니메이션이나 캡처 에니메이션을 바로 적용할 수 있다.

메카님 에니메이션

메카님 애니메이션을 사용하려면 애니메이션 타입을 설정해주어야 한다.

Humanoid로 아바타 타입 설정 후 Configure 버튼을 누르면 아래 그림처럼 Avatar 에셋을 볼 수 있다.

인스펙터 뷰에 표시된 Avatar에서 모델의 여러 관절이 어떻게 설정되어 있는지 볼 수 있다.

리타게팅
메카님의 장점 중 하나는 리타게팅으로 다른 애니메이션 동작을 가져와 재사용한다는 점이다. (http://www.mixamo.com) 회원가입만 하면 다양한 무료 애니메이션 사용이 가능하다.

3D 모델을 직접 이 사이트에 업로드한 후 미리보기를 사용할 수도 있다.

잘만 사용하면 몬스터가 춤을 추게 할 수 있다.

Animator Contoller

Controller를 이용해서 FSA를 유사하게 구현할 수 있다.

내비게이션

길 찾기 알고리즘은 여러 방식이 있지만 A * Pathfinding 알고리즘이 제일 많이 쓰인다. 유니티 자체적으로 내비게이션 기능을 제공하기 이전에 많이 사용하던 알고리즘 중 하나이다.

유니티에서 제공하는 내비게이션의 구현 방식은 스테이지를 구성하고 있는 3D 메시를 분석해 내비메시 데이터를 미리 생성하는 방식이다. 추적할 수 있는 영역(Walkable Area)과 장애물로 판단해 지나갈 수 없는 영역(Non Walkable Area)의 데이터를 메시로 미리 만드는 것이다. 또한 높은 곳에서 낮은 곳으로 연결하는 오프 메시 링크(Off Mesh Link) 기능을 이용해 뛰어내리는 동작을 연출할 수도 있다.

유니티 에디터 모드에서 미리 빌드(베이크라고 함)해서 내비메시를 생성한 다음 런타임에서 그 정보에 따라 최단 거리를 계산해 추적 또는 이동할 수 있게한다.

AI Navigation

아래 순서대로 따라한다.

Floor를 선택한 후에 Navigation Static을 체크해주면 된다.

Barrel도 동일하게 진행하되 Navigation Area를 Not Walkable로 설정한다.

이렇게 Navigation Area 설정이 다 끝났으면 Bake 해주면 된다.

NavMeshAgent 컴포넌트는 내비메시 데이터를 기반으로 목적지까지 최단 거리를 계산해 이동하는 역할을 하며, 장애물과 다른 NPC 간의 충돌을 회피하는 기능도 제공한다. 또한 NavMeshAgent 컴포넌트는 실시간으로 최단 거리를 계산할 때 A * PathFinding 알고리즘을 사용한다.

NavMeshAgent 컴포넌트는 자주 사용하는 컴포넌트로 속성의 의미를 정확히 이해하는 것이 좋다.

Monster Settings

이제 Monster를 설정해보자. Stopping Distance가 0이면 목표물과 겹쳐버리기 때문에 2로 설정해준다.

이제 플레이어의 태그를 "PLAYER"로 설정해준 후에 추적 로직을 추가해준다.

완성된 애니메이터 컨트롤러는 아래와 같고 코드는 아래 Scripts와 같이 작성했다. 코드에서 중요한 포인트는 Animator의 파라미터를 문자열 접근이 아닌 해시값을 미리 추출해서 접근한다는 점이다. 꼭 기억해두자.

Animator.StringToHash : 애니메이터 컨트롤에 정의한 파라미터는 모두 해시 테이블(Hash Table)로 관리하기 때문에 문자열보다 파라미터의 해시값을 미리 추출해 인자로 전달하는 방식이 속도 면에서 더 바람직하다.

Resources.Load<T> : Resources 폴더에 있는 특정 에셋을 바로 로드하는 코드, 에셋의 타입에 따라 서브 폴더로 분류한다면 로드할 에셋명에 폴더까지 지정해야한다.

공격 시스템 구현

플레이어를 공격에 성공했는지 판단을 위해 양손에 Sphere Collider와 Rigidbody 컴포넌트를 추가해주었다.

Collider에는 Is Trigger를 Rigidbody에는 Is Kinematic을 설정해주는 것을 잊지말자

  • Is Trigger : 물리적인 간섭을 없앰
  • Is Kinematic : 물리 시뮬레이션 연사을 하지 않음

플레이어도 충돌 이벤트 감지를 해야하기 대문에 Collider, Rigidbody를 추가해준다. 공격할 때 충돌이 잘 되는지 먼저 확인해준 다음에 진행해주면 좋다.


Scipts

MonsterCtrl.cs

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

public class MonsterCtrl : MonoBehaviour
{
    public enum State {
        IDLE,
        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;
    private Animator anim;

    private readonly int hashTrace = Animator.StringToHash("IsTrace");
    private 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>("BloodSprayEffect");

        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
    }

    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);

            float distance = Vector3.Distance(playerTr.position, monsterTr.position);

            if (distance <= attackDist) {
                state = State.ATTACK;
            }
            else if (distance <= traceDist) {
                state = State.TRACE;
            }
            else {
                state = State.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);
        }
    }

    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);
        Destroy(blood, 1.0f);
    }

    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);
        }
    }
}

PlayerCtrl.cs

void OnTriggerEnter(Collider coll) {
    if (currHP >= 0.0f && coll.CompareTag("PUNCH")) {
        currHP -= 10.0f;
        Debug.Log($"Plater hp = {currHP/initHP}");

        if (currHP < 0.0f) {
            PlayerDie();
        }
    }
}

void PlayerDie() {
    Debug.Log("Player Die !");
}
profile
Be Honest, Be Harder, Be Stronger

0개의 댓글