[Unity] Space-Shooter (4)

suhan0304·2024년 6월 19일

유니티 - SpaceShooter

목록 보기
4/6
post-thumbnail

Updates

Monster Die 로직

Main Scene

Play UI


Points

layer 분할

물리 엔진의 불필요한 부하는 특정 Collider 사이에 충돌이 감지되지 않게 설정 함으로써 줄일 수 있다. 따라서 다른 layer를 설정해서 부하를 줄일 수 있다.

Layer Collision Matrix 설정은 대표적인 예로 아군과 적군이 있을때 아군이 발사한 총알의 충돌은 적군에게만 발생하게 하는 로직에 응용하여 물리 엔진의 부하도 줄이고 로직도 간결하게 구현할 수 있다.

본 구조 최적화

본 구조의 각 관절은 모두 각각의 게임 오브젝트 + Transform 컴포넌트를 가지고 있다. 많은 Transform 컴포넌트의 이동 및 회전 처리는 런타임 시 내부적으로 다양한 연산 처리를 하므로 최적화가 필요.

모델의 Optimize Game Objects를 한 다음 실제로 사용되는 양손 관절인 L_wrist, R_wrist만 노출되고 나머지 관절은 보이지 않도록 하자.

하이러키 뷰에서도 몬스터 오브젝트가 굉장히 간단해 졌다.

SendMessage

monster.SendMessage("OnPlayerDie", SendMessageOptions.DontRequireReceiver);

SendMessage 함수는 첫 번재 인자로 전달한 함수명과 동일한 함수가 해당 게임 오브젝트의 스크립트에 있으면 실행하라는 뜻이다.

DontRequireReceiver : 호출한 함수가 없더라도 함수가 없다는 메시지를 반환받지 않겠다는 옵션

Delegate

위처럼 SendMessage 방식의 경우, 적 캐릭타가 아주 많은 상황이면 순차적인 호출 방법이 그리 효율적인 방법은 아니다.
순차적인 호출방식을 이벤트 구동 방식으로 바꿔보자. 이벤트란 특정한 조건을 만족하면 자동으로 알려주는 메시지를 의미하고. 이벤트가 발생하면 시스템에서 자동으로 이를 통보하고, 이벤트에 연결된 모든 캐릭터에서 정해진 동작을 수행하는 방식이다. 이러한 이벤트 구동은 for, foreach 구문을 사용해 순차적으로 호출하는 방식보다 메모리 사용 측면이나 구동 속도 측면에서 효율적이다.

델리게이트는 함수(메서드)를 참조하는 변수이다. 즉, 함수를 저장할 수 있는 일종의 변수, C++의 함수 포인터와 같은 의미이다. 델리게이트를 먼저 선언한 후에 사용가능하다.

PlayerCtrl.cs

public delegate void PlayerDieHandler();
public static event PlayerDieHandler OnPlayerDie;

void PlayerDie() {
    Debug.Log("Player Die !");

    /*
    GameObject[] monsters = GameObject.FindGameObjectsWithTag("MONSTER");

    foreach (GameObject monster in monsters) {
        monster.SendMessage("OnPlayerDie", SendMessageOptions.DontRequireReceiver);
    }
    */

    OnPlayerDie();
}

먼저 델리게이트를 이용해 이벤트 함수의 원형을 선언해야 한다. 이벤트는 언제 호출될지 모르기 때문에 정적 변수 (static) 키워드를 사용하여 선언해준다.

그런 다음 PlayerDie 함수에서 OnPlayerDie 이벤트를 발생시킨다. 이제 MonsterCtrl에서 이 이벤트에 반응할 함수를 연결해주면 된다.

MonsterCtrl.cs

void OnEnable() {
    PlayerCtrl.OnPlayerDie += this.OnPlayerDie;
}
void OnDisable() {
    PlayerCtrl.OnPlayerDie -= this.OnPlayerDie;
}

위와 같은 방식으로 이벤트의 연결과 해지를 처리해준다.

OnEnable()OnDisable() 스크립트가 활성화되거나 비활성화될 때 수행되는 함수로서 이벤트의 연결과 해지는 반드시 이 함수에서 처리해야 한다.

이렇게 처리해주면 이전 방식과 차이 없이, 이벤트 구동 방식으로 전환했다.

Animation Speed

Speed란 float형 파라미터를 만들어서 인스페겉의 파라미터를 체크 후에 Multiplier 매개변수로 넣어줄 수 있다.

anim.SetFloat(hashSpeed, Random.Range(0.8f, 1.2f));
anim.SetTrigger(hashPlayerDie);

이런 식으로 애니메이션 속도 조절이 개별적으로 가능하다.

UI

유니티에서는 크게 3가지로 UI를 제공한다

  • IMGUI : 코드를 이용해 UI를 표시하는 방법으로 개발 과정에서 간단한 테스트용으로 사용한다. IMGUI는 OnGUI 함수에서 코드를 구현한다.

  • UI Toolkit : UI Toolkit은 UXML, USS와 같은 UI Asset 파일로 UI 스타일을 정의하고 디자인한다. UXML 기반의 UI 디자인은 위치, 크기, 정렬 등의 속성을 수치로 관리하기 때문에 정확한 디자인을 하기에 매우 유리한 방식이다.

UI Toolkit은 비교적 최신 UI 기술이기 때문에 관련 강의 영상이나 자료가 최신게 비교적으로 많다.

  • Unity UI : 게임 오브젝트 기반의 UI로서 모든 UI 구성요소를 게임 오브젝트로 관리한다. 기존의 개발 과정과 동일한 게임 오브젝트, 컴포넌트 형태로 구현하기 때문에 편리하게 사용할 수 있다.

Canvas

캔버스를 하나 만들면 EventSystem도 같이 생기는 EventSystem은 EventSystem, Standalone Input Module 컴포넌트를 포함하고 있다. EventSystem 컴포넌트가 없다면 UI 항목이 클릭 또는 터치와 같은 다양한 입력 이벤트에 반응하지 않는다.

Render Mode

  • Screen Space - Overlay : 화면의 해상도에 맞춰 자동으로 스케일이 조절
  • Screen Space - Camera : "Screen Space - Overlay" 옵션과 동일하지만 UI 항목을 렌더링하는 별도의 카메라를 설정할 수 있다. Render Mode 속성을 이 옵션으로 변경하면 Render Camera 속성이 노출된다. 또한 UI Camera의 Projection 속성을 Perspective로 설정하고 UI 항목의 Transform Position의 Y축을 회전시키면 원근감을 표현할 수 있다.

    UI 항목을 렌더링하기 위해 추가한 카메라는 기존의 Main Camera와 충돌이 없도록 반드시 Clear Flag, Culling Mask, Depth 속성을 적절히 설정해야 한다.

  • World Space : 다른 게임 오브젝트에 직접 UI 항목을 추가한다. 대표적으로 HUD를 구현할 때와 VR, AR 콘텐츠 속의 UI를 구현할 때 사용한다. World Space로 설정하면 더 이상 Rect Transform의 영향을 받지 않으며, 해당 게임 오브젝트 위치에 영향을 받는다. World Space 옵션은 Canvas의 물리적인 위치를 이동시킬 수 있다.

Image

UI Z-Order
UI는 하이러키 뷰의 순서로 Z-Order를 결정한다. 위에서 아래로 갈수록 Z-Order가 높게 설정되는 방식이다.

Button

Navigation : 버튼이 여러 개 있을 경우 키보드로 포커스를 어떻게 이동시킬 것인가에 대한 속성

EventSystem의 FirstSelected에 Button을 연결해줘서 처음 포커스를 어느 버튼에 두고 시작할 것인지를 설정할 수 있다.

On Click

  • 버튼 이벤트에 연결할 함수는 반드시 public 접근자
  • 함수에 전달할 인자가 있을 경우 인스펙터 뷰에 입력 항목이 생성
  • 인자로 사용할 수 있는 데이터 타입
    - int
    - float
    - bool
    - string
    - Object (Transform, MeshRender 등 다양한 컴포넌트도 전달 가능)

버튼 이벤트 처리

유니티 에디터에서 Button 별로 함수를 연결하는 방식은 UI가 복잡할 경우 매우 번잡스럽고 반복적인 작업으로 개발 능률이 떨어진다. 또한 런타임에 생성되는 버튼에는 이벤트를 연결할 방법이 없다.

따라서 UnityEvent 또는 델리게이트를 사용한다.

UnityEvent는 유니티 에디터에서 편하게 사용하기 위해 미리 정의된 델리게이트일 뿐이다.

using UnityEngine.Events;
using UnityEngine.UI; // UnityEvent 관련 API를 사용하기 위한 네임스페이스


public Button startButton;
public Button optionButton;
public Button shopButton;

private UnityAction action;

void Start() {
	// UnityAction을 사용한 이벤트 연결 방식
    action = () => OnButtonClick(startButton.name);
    startButton.onClick.AddListener(action);

	// 무명 메서드를 활용한 이벤트 연결 방식
    optionButton.onClick.AddListener(delegate {OnButtonClick(optionButton.name);});

	// 람다식을 활용한 이벤트 연결 방식
    shopButton.onClick.AddListener(() => OnButtonClick(shopButton.name));
}

무명 메서드 방식을 람다식 문법으로 사용한 경우는 단순히 무명 메서드 방식을 간략화한 방식이라고 보면 된다.

한글 글꼴

Text Mesh Pro를 임포트하고 폰트를 다운받아서 Window > TextMesh Pro > Font Asset Creator를 통해 Font Atlas 이미지를 생성해서 저장해야 한다.

한글은 11,172자 이기 때문에 Atals Resolution은 최소한 4096*4096을 선택해야한다.

하지만 저사양 모바일 기기에서 구동해야 하거나 텍스처 크기를 줄여야 할 때는 2350자를 사용한다. 한글 2350자는 KSX1001 규격으로 한국 산업 표준이다.
아래 사진과 같이 Cutom Character list에 사용할 KSX1001 글자를 입력해주고, Atlas Resolution을 2048*2048로 줄여준다. (Packing Method도 Optimum으로 설정해주면 인코딩 시간은 더 걸려도 텍스처 공간을 최적화해준다.)

글꼴을 저장한 Atals 이미지, SDF(Signed Distance Field)로 변경해주면 아래와 같이 한글이 잘 나온다.

TMP는 Text에 마크업 언어 사용이 가능하다

if문 - null체크

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

다음과 같이 문법에 ? 연산자를 써서 null 체크를 간단하게 할 수 있다. null이 아니면 뒤에 있는 구문을 실행해서 GetComponent 함수가 실행되고 null이면 null 값을 반환한다.


Scripts

추가적인 업데이트가 있는 코드만 포함되어 있습니다.

PlayerCtrl.cs

private Image hpBar;

public delegate void PlayerDieHandler();
public static event PlayerDieHandler OnPlayerDie;

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

    currHP = initHP;

    tr = GetComponent<Transform>();
    anim = GetComponent<Animation>();

    anim.Play("Idle");

    turnSpeed = 0.0f;
    yield return new WaitForSeconds(0.3f);
    turnSpeed = 80.0f;
}

void OnTriggerEnter(Collider coll) {
    if (currHP >= 0.0f && coll.CompareTag("PUNCH")) {
        currHP -= 10.0f;
        DisplayHealth();

        Debug.Log($"Plater hp = {currHP/initHP}");

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

void PlayerDie() {
    Debug.Log("Player Die !");

    /*
    GameObject[] monsters = GameObject.FindGameObjectsWithTag("MONSTER");

    foreach (GameObject monster in monsters) {
        monster.SendMessage("OnPlayerDie", SendMessageOptions.DontRequireReceiver);
    }
    */

    OnPlayerDie();
}

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

MontserCtrl.cs

void OnEnable() {
    PlayerCtrl.OnPlayerDie += this.OnPlayerDie;
}
void OnDisable() {
    PlayerCtrl.OnPlayerDie -= this.OnPlayerDie;
}

UIManager.cs

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

public class UIManager : MonoBehaviour
{
    public Button startButton;
    public Button optionButton;
    public Button shopButton;

    private UnityAction action;

    void Start() {
        action = () => OnButtonClick(startButton.name);
        startButton.onClick.AddListener(action);

        optionButton.onClick.AddListener(delegate {OnButtonClick(optionButton.name);});

        shopButton.onClick.AddListener(() => OnButtonClick(shopButton.name));
    }

    public void OnButtonClick(string msg) {
        Debug.Log($"Click Button : {msg}");
    }
}

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 :
                isDie = true;
                agent.isStopped = true;
                anim.SetTrigger(hashDie);
                GetComponent<CapsuleCollider>().enabled = false;
                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);

        hp -= 10;
        if (hp <= 0) {
            state = State.DIE;
        }
    }
}

void OnPlayerDie() {
    StopAllCoroutines();

    agent.isStopped = true;
    anim.SetFloat(hashSpeed, Random.Range(0.8f, 1.2f));
    anim.SetTrigger(hashPlayerDie);
}

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글