게임 엔진/Unity

Unity 7일차 - 화살표 피하기 게임-2(충돌 판정, 오브젝트 복사, 감독 스크립트)

FakeZero 2021. 8. 1. 22:21

  저번에 만들던거에 이어서 다시 게임제작을 이어가 봅시다.


7. 충돌 판정 만들기

  게임에서 충돌이라고 하는 것은 게임의 이벤트의 방아쇠(트리거)를 만드는 매우매우 중요한 과정입니다. 따라서 어디에 닿았을때 충돌했다고 인식하게 설정하는 것이 매우 중요합니다. 이것은 게임마다 상황에 맞추어 설정해야합니다.

 

  대전 격투 게임을 예로 들어봅시다. 충돌 판정을 너무 유하게 만들었다면 공격이 제대로 맞기도 전에 충돌판정이 들어가서 데미지가 들어가게 될 것입니다. 대전격투 게임에서 이것은 너무 불공평하죠. 그렇다고 해서 너무 타이트하게 만든다면 캐릭터마다 충돌판정이 너무 달라서 한 캐릭터가 유리해지는 수가 있습니다. 예를 들어 몸집이 큰 캐릭터는 충돌 판정이 너무 커서 공격을 맞기가 너무 쉽겠죠. 그에 반해 슬림한 캐릭터는 공격을 맞기가 힘들어 질 껍니다. 물론 이것도 게임의 장치중 하나로 쓸 수 있지만 이것이 너무 편향되어버리면 게임의 밸런스에 문제가 생길껍니다. 그래서 충돌 판정을 적절히 자연스럽다고 생각되는 선에서 균형을 맞추는 것이 정말정말 중요합니다.

 

  오늘 쓸 것은 간단한 충돌 판정입니다. 너무 빡빡하게 할 필요는 없으니 간단하게 만드는 겁니다.

 

  충돌 판정을 만드는 데에는 여러 방법이 있습니다. 오브젝트를 정확하게 막처럼 감싸서 판정을 만드는 방법이 있고 직사각형, 원 등의 간단한 도형을 통해 판정을 만드는 방법이 있습니다. 이는 3D게임도 마찬가지입니다. 오늘 사용할 충돌 판정을 만드는 방법은 간단한 도형을 통해 판정을 만드는 방법입니다. 그중에서도 원을 사용하도록하겠습니다.

 

  충돌 판정을 만드는데에는 알고리즘이 필요합니다. 어떤 때에 충돌이 되었다고 판단할 판단의 기준이 필요하기 때문이죠. 바로 이 알고리즘을 만드는 과정에서 수학이 필요하게 됩니다. 오늘은 유명하고 중고등학생이라면 대부분 알고있을 피타고라스의 정리를 이용할 것이며 그 중에서도 좌표평면에서 점과 점 사이의 거리를 구하는 방법을 이용할 겁니다.

  중고등학교 수학에서 흔히 쓰는 점과 점 사이의 거리를 구하는 방법입니다. 우리는 원과 원이 만나는 상황을 가정해야하므로 나올 수 있는 상황은 총 세가지가 있습니다.

  1. 원끼리 만나지 않았을 때

  2. 원끼리 접하게 만났을 때(=한 점에서 만났을 때)

  3. 원끼리 서로 다른 두 점에서 만났을 때

  오늘 게임에서는 이 세가지 상황 중 3번 경우에만 충돌 판정이라고 알고리즘을 만들 것입니다. 방식은 간단합니다.

  이렇게 해서 두 개의 원의 중심 사이의 거리가 두 원의 반지름 길이를 더한 길이보다 짧으면 이것은 곧 두 원이 3번 경우에 해당한다는 것을 의미합니다.

  (다들 알고있겠지만 부등호가 왼쪽으로 벌려지게되면(>) 서로 만나지 않았다는 것을, 부등호 대신 등호(=)가 들어가면 접한다는 것을 의미합니다.)

 

  이제 알고리즘의 설계가 끝났습니다. 이제 직접 스크립트를 조정하도록 합시다. ArrowController 스크립트를 열어서 다음과 같이 수정합니다.

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

public class ArrowController : MonoBehaviour
{
    GameObject player;

    void Start()
    {
        this.player = GameObject.Find("player");
    }

    void Update()
    {
        // 프레임마다 등속으로 낙하시킨다.
        transform.Translate(0, -0.1f, 0);

        //화면 밖으로 나오면 오브젝트를 소멸시킨다.
        if(transform.position.y < -5.0f)
        {
            Destroy(gameObject);
        }

        //충돌 판정 (추가)
        Vector2 p1 = transform.position;    //화살 오브젝트의 중심 좌표
        Vector2 p2 = this.player.transform.position;    //플레이어 오브젝트의 중심 좌표
        Vector2 dir = p1 - p2;
        float d = dir.magnitude;
        float r1 = 0.5f;    //화살 오브젝트 중심으로부터의 반경(원의 반지름)
        float r2 = 1.0f;    //플레이어 오브젝트 중심으로부터의 반경

        if(d < r1 + r2)
        {
            // 충돌한 경우는 화살을 지운다.
            Destroy(gameObject);
        }
    }
}

아마 모르는 부분은 한 군데 밖에 없을테니 그것만 설명하겠습니다.

float d = dir.magnitude;

  이 코드는 dir 변수를 통해 구한 좌표와 좌표 사이의 거리를 벡터값이 아닌 스칼라값, 즉, 절대값으로 만드는 과정입니다. dir은 p1에서 p2를 뺀 값이기 때문에 -값이 생기게 됩니다. 만약 벡터값을 절대값으로 바꾸지 않으면 거리를 비교하는 과정에서 오류가 생길 것입니다.

float r1 = 0.5f;    //화살 오브젝트 중심으로부터의 반경(원의 반지름)
float r2 = 1.0f;    //플레이어 오브젝트 중심으로부터의 반경

  이 과정에서도 의문을 가질 것입니다. 게임엔진에선 아직 원을 만들지 않았는데 스크립트에 갑자기 추가되었으니 그럴 수도 있습니다.

  사실 스크립트에서 판정이라는 것은 가상의 장치로 볼 수 도 있습니다. 무슨 말이냐 하면, r1은 화살의 중심으로부터 반지름이 0.5인 가상의 원을, r2는 플레이어 오브젝트의 중심으로부터 반지름이 1인 가상의 원이 있다고 가정한 후에 스크립트를 쓰는 것이라는 겁니다.

  실제로 원이 있는지 없는지는 중요하지 않습니다. 결과적으로 스크립트가 어떻게 받아들이냐가 중요한 것이죠.

 

  이제 Unity에서 실행해보고 잘 되는지 확인해봅시다.

 


8. 프리팹과 오브젝트 복사

  이제 게임 완성까지 2가지 스탭이 남았습니다.

  이번에 할 것은 화살표를 복사하는 일입니다. 화살표가 한 개만 떨어져서야 게임이 안돼죠. 여기저기서 여러가지가 떨어져야합니다.

 

  유니티에서 오브젝트를 복사하는 방법은 마치 공장과 같습니다. 설계도를 준비하고, 양산 기계에 설계도를 보내서 제품을 만듭니다. 여기서 유니티에선 설계도 역할을 프리팹이라 부르고 양산 기계를 제네레이터 스크립트라고 부릅니다. 제품은 인스턴스구요. 지금부터 하는 공정은 게임에 같은 오브젝트를 많이 만들고 싶을 때 주로 사용되게 됩니다.

  물론 오브젝트를 복사하고 싶으면 일일이 오브젝트를 추가하고 스크립트를 적용하는 방법도 있습니다. 그러나 이렇게 하면 수정사항이 있을 때 일일이 모든 오브젝트를 수정해야하죠. 그러나 프리팹을 사용하면 설계도가 되는 오브젝트 하나만 수정하면 나머지도 한번에 수정이 가능합니다.

 

자, 이제 직접 프리팹을 만들어봅시다.

  Assets 창의 오브젝트를 Hierarchy 창에 끌어다 놓으면 해당 오브젝트의 프리팹이 만들어집니다. 이 프리팹의 이름은 arrowPrefab이라고 짓도록 하겠습니다.

  프리팹을 생성했다면 더 이상 Scene창에 배치한 화살표 오브젝트는 필요가 없습니다. 설계도를 만들었으니 샘플은 더이상 필요하지 않다는 느낌입니다. Hierarchy창에서 arrow 오브젝트를 지워주도록 합시다.

  이렇게 만들어낸 프리팹에는 단순히 오브젝트의 형상 뿐 아니라 기능, 즉 스크립트까지 설계되어 있습니다. 즉 프리팹을 통해 오브젝트가 만들어지면 떨어져서 사라지는 기능까지 자동으로 추가되어진다는 뜻입니다.

 

  이제 제네레이터 스크립트를 작성합시다. 새로운 C# 스크립트를 만들고 ArrowGenerator이라 이름붙입시다. 코드는 다음과 같습니다.

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

public class ArrowGenerator : MonoBehaviour
{
    public GameObject arrowPrefab;
    float span = 1.0f;
    float delta = 0;

    void Update()
    {
        this.delta += Time.deltaTime;
        if(this.delta > this.span)
        {
            this.delta = 0;
            GameObject go = Instantiate(arrowPrefab) as GameObject;
            int px = Random.Range(-6, 7);
            go.transform.position = new Vector3(px, 7, 0);
        }
    }
}

  처음보는 코드들이 꽤 나온 것 같습니다. 하나하나 보도록 하죠

this.delta += Time.deltaTime;

  이 코드는 시간의 흐름과 관련된 코드입니다. 하지만 이 코드를 분석하기 위해선 코드 전반에 대한 이해가 필요합니다.

먼저 Time.deltaTime은 앞 프레임과 현재 프레임 사이의 시간 차이값을 저장하는 장소 입니다. 우리는 그 값을 꺼내다가 위에서 선언한 delta라는 변수에 저장하는 것이구요.

 

Update 메서드는 매 프레임마다 반복되어 실행된다고 알려드린 적 있습니다. 따라서 delta변수에는 계속해서 프레임당 시간값이 추가되게 됩니다. 저희는 이 시간을 이용해서 화살표를 1초에 한 번씩 복사할 것입니다. 이 1초를 비교하는데 사용하는 것이 위에서 delta와 같이 선언한 span 변수입니다. delta에 시간값을 저장한 뒤에 그 뒤에 if문을 통해 span값과 비교해서 delta에 저장된 값이 1(초)를 넘어가게 되면 delta값을 0으로 초기화 시키는 것입니다. 이것이 반복되어 1초마다 if문 안에 있는 실행문이 실행되는 것입니다.

GameObject go = Instantiate(arrowPrefab) as GameObject;
int px = Random.Range(-6, 7);
go.transform.position = new Vector3(px, 7, 0);

  그리고 그 실행문이 이 세 코드가 되겠습니다.

  go라는 변수 안에는 Instantiate라는 키워드가 들어가 있습니다. 이 키워드는 인자에 프리팹을 적용하면 프리팹 인스턴스를 반환하는 키워드입니다. 즉 복사죠. 인스턴스에 대해서는 Unity 3일차 강좌에서 설명한 적이 있습니다. 복습해보면 인스턴스란 다른 클래스의 실체를 만들어 주는 것이라고 했었죠. Instantiate 키워드의 경우 변수에 'Object형' 인스턴스를 반환하게 됩니다.

  하지만 저희가 go라는 변수에 집어넣을 자료형은 'GameObject형'입니다. 따라서 'as'라는 키워드를 사용해 'Object'형 변수를 'GameObject형'으로 형변환을 시키는 것입니다. 이와 같이 한 자료형을 원하는 형태의 다른 형태의 자료형으로 변환 시키는 것을 C#에서는 캐스트라고 부릅니다. 캐스트는 형변환이 가능한 자료형은 형변환이 잘 되지만 그렇지 못할 경우엔 null값을 반환하게 됩니다. 이번 경우엔 형변환이 가능한 경우이니 잘 변환되었을 것입니다. 이를 통해 게임에 오브젝트를 복사할 준비가 되었습니다.

 

  px변수는 난수를 생성합니다. 인자를 두 개 사용합니다. Random.Range가 바로 난수를 생성하는 키워드입니다. '첫번째 인자 이상, 두번째 인자' 미만의 범위 안에서 난수를 생성하게 됩니다. 즉, 이 코드에서는 -6이상 7미만의 자연수 정수 난수를 생성합니다.

 

  난수를 만드는 이유는 바로 오브젝트가 생성될 x값을 지정하기 위함입니다. 마지막 줄의 코드가 바로 복제된 오브젝트를 생성하는 코드인 것입니다.

 

  참고로 본 코드에서 생성한 arrowPrefab 변수는 아직 빈 변수입니다. 즉 이곳에 원하는 프리팹을 넣어줘야 합니다. 이 작업은 Unity에서 하게됩니다.

 

  이제 스크립트의 작성이 끝났으니 오브젝트를 만드는 공장을 만들어봅시다.

  공장 역할은 빈 오브젝트가 맡게 될 것입니다.

  그런데 Inspector창에 적용된 스크립트 부분에 평소엔 보지 못했던 부분이 보입니다. Arrow Prefab이라고 되어 있는 부분입니다. 아까 위 코드에서 arrowPrefab이라는 변수를 만들때 앞에 public 접근 수식자를 붙였던 것을 기억할 것입니다. 자료형은 GameObject였구요. 거기다 아무런 오브젝트도 넣지 않아서 아직 빈 변수였다는 것을 기억하실 것입니다. public 접근 수식이 붙은 변수는 이렇게 Unity에서 그 값을 조정할 수 있습니다. 스크립트에서 arrowPrefab 변수는 자료형이 GameObject 였기에 오브젝트를 할당하도록 되어 있습니다. 만약 정수형이었다면 숫자를 할당하도록 나오겠죠.

  아무튼 이 arrowPrefab에는 우리가 이전에 만들었던 프리팹을 넣어주어야 합니다. 방법은 간단합니다. 에셋창에 있는 프리팹을 드래그해서 None이라고 써있는 부분에 놓으면 됩니다.

 

  이제 게임을 한 번 실행해봅시다. 플레이어에게 닿거나 땅 밑으로 떨어지면 사라지는 화살표 들이 1초 간격으로 나타나는 것을 알 수 있습니다.

 


9. UI 만들기(감독 스크립트)

  이제 이번 게임 만들기도 막바지에 다다랐습니다. UI와 감독 스크립트만 만들면 게임 제작이 끝납니다.

 

  이번에 UI에는 HP를 표시합니다. HP는 Hit Point의 약자로 우리나라에선 흔히 한글로 체력이라고 표기하기도 합니다. 저번에 만든 UI는 텍스트 였지만 이번에 이미지를 이용합니다.

위 방법으로 UI를 생성하고 이름을 hpGauge로 바꾸어주었습니다.

  이제 hpGauge 오브젝트를 클릭하면 Inspector창의 Image항목에 Source Image 항목이 있을 것입니다. 여기에 에셋창의 hp_Gauge 파일을 그래그 앤 드롭 해주어서 UI의 이미지를 정해주도록 합시다.

 

  이번엔 UI에 앵커 포인트를 설정해봅시다. 앵커 포인트란 게임의 화면 크기가 바뀔 때 어디를 원점으로 해서 UI 좌표를 계산할 것인가.에서 원점에 해당하는 부분입니다.

  예를 들어봅시다. 원래 플랫폼은 모바일이었는데 갑자기 플랫폼이 PC로 바뀌면 화면만 커지고 UI의 위치는 그대로 휴대폰의 UI 위치와 똑같을지도 모릅니다. 여기서 UI를 화면 크기에 따라서 맞춰줄 수 있도록 기본 좌표를 설정해 주는 것이 바로 앵커 포인트입니다, 이번 게임에서는 앵커 포인트를 게임 화면의 오른쪽 위로 잡겠습니다.

  hpGauge 오브젝트의 Inspector창에서 앵커 포인트 아이콘을 클릭하고 표시된 그림중 오른쪽 위에 고정 아이콘을 클릭합니다. 그리고 Rect Transform 항목의 Pos를 -120, -120, 0으로 Width와 Height를 각각 200으로 설정합니다.

 

  이제 HP UI를 만들어주었으니 HP가 깎이는 기능도 만들어주어야 합니다. 이번 강좌에서는 Unity에서 제공하는 Fill Amount라는 이미지의 표시 영역을 조정하는 기능을 사용하겠습니다. 이 기능에도 스크립트를 작성해야하지만 그 이전에 Unity에서 사전설정을 해야하니 그쪽을 먼저 하도록 합시다.

  hpGauge 오브젝트의 Inspector 창에서 Image (Script) 항목의 Image Type을 Filled, Fill Method를 Radial 360으로 설정합니다. 이 설정은 원형으로 이미지를 잘라낼때 원형으로 잘라내는 설정입니다. 나머지 설정에 대한 설명은 아래와 같습니다.

Fill Method 역할
Horizontal 가로 방향으로 이미지를 잘라 낸다.
Vertical 세로 방향으로 이미지를 잘라 낸다.
Radial 90 90도 선형으로 이미지를 잘라 낸다.
Radial 180 반원형으로 이미지를 잘라 낸다.
Radial 360 원형으로 이미지를 잘라 낸다.

  Fill Origin은 잘라 내기 시작하는 위치입니다. 이 게임에선 위부터 잘라내야 하므로 Fill Origin을 Top으로 설정합시다.

  Fill Origin의 밑에 있는 항목인 Fill Amount의 슬라이드를 조정하면 HP 게이지가 늘었다 줄었다 하는 것을 확인 할 수 있습니다.(오브젝트를 더블 클릭하면 해당 오브젝트가 보이도록 이동합니다.) 이 게임에서는 체력이 가득 채워진 상태에서 시작해야 하므로 1로 설정해 둡시다.

 

  이제 감독 스크립트를 만들어 UI를 제어합시다. 새로운 C#스크립트를 만들고 이름을 GameDirector로 바꿔줍시다. 코드는 아래와 같습니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI; //UI 사용 라이브러리

public class GameDirector : MonoBehaviour
{
    GameObject hpGauge;

    void Start()
    {
        this.hpGauge = GameObject.Find("hpGauge");
    }

    public void DecreaseHP()
    {
        this.hpGauge.GetComponent<Image>().fillAmount -= 0.1f;
    }
}

  이번 코드에선 별로 볼 것이 없군요. 마지막 줄만 봅시다.

this.hpGauge.GetComponent<Image>().fillAmount -= 0.1f;

  이전에 만들었던 텍스트형 UI와 방식이 비슷합니다. 컴포넌트를 불러오고 거기서 fillAmout값을 조정하는 겁니다. 하지만 이래서는 아직 메서드를 만든 것에 불과합니다. 감독 프로그램에 프로그램 되어 있는 DecreaseHP 메서드를 사용할 곳이 필요합니다. 저희는 이 메서드를 ArrowController에서 쓰도록 합시다. 충돌 판정에 화살표가 닿자마자 HP가 닳면 되는 것이니까요.

 

  하지만 그 전에 감독 스크립트가 적용될 오브젝트를 만드는 것이 필요합니다. 이전에 했던 것처럼 빈 오브젝트를 만들고 이름을 GameDirector로 바꾸고 GameDirector 스크립트를 적용하도록 합시다.

 

  이제 ArrowController 스크립트를 다음과 같이 수정하면 됩니다.

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

public class ArrowController : MonoBehaviour
{
    GameObject player;

    void Start()
    {
        this.player = GameObject.Find("player");
    }

    void Update()
    {
        // 프레임마다 등속으로 낙하시킨다.
        transform.Translate(0, -0.1f, 0);

        //화면 밖으로 나오면 오브젝트를 소멸시킨다.
        if(transform.position.y < -5.0f)
        {
            Destroy(gameObject);
        }

        //충돌 판정 (추가)
        Vector2 p1 = transform.position;    //화살 오브젝트의 중심 좌표
        Vector2 p2 = this.player.transform.position;    //플레이어 오브젝트의 중심 좌표
        Vector2 dir = p1 - p2;
        float d = dir.magnitude;
        float r1 = 0.5f;    //화살 오브젝트 중심으로부터의 반경(원의 반지름)
        float r2 = 1.0f;    //플레이어 오브젝트 중심으로부터의 반경

        if(d < r1 + r2)
        {
            //감독 스크립트의 메서드를 사용.
            GameObject director = GameObject.Find("GameDirector");
            director.GetComponent<GameDirector>().DecreaseHP();

            // 충돌한 경우는 화살을 지운다.
            Destroy(gameObject);
        }
    }
}

  if문 안에 GameDirector 스크립트에서 만들었던 DecreaseHP 메서드를 사용하는 코드가 추가되었습니다.

 

  자 이제 모든 과정이 끝났습니다. 게임을 한 번 실행보고 문제가 없다면 빌드해서 게임을 실행해보도록 합시다.

 


  자 이렇게 해서 하나의 게임이 완성되었습니다. 어떻습니까? 이번 게임은 스크립트도 여기저기 옮겨다니고 정신이 없었죠? 게임 개발이 그렇습니다. 각 오브젝트 간의 상호작용, 알고리즘의 제작, 등등 이 외에도 할 일이 산더미처럼 있습니다. 대규모의 게임일 경우 더욱 그렇죠.

 

  이전부터 강조했습니다만 게임 개발의 핵심은 역시 협동입니다. 물론 혼자서도 훌륭한 게임을 만들 수 있습니다. 그러나 여러사람이 힘을 합치면 더 엄청난 게임을 같은 기간 내에 만들어 낼 수 있습니다.

 

  지금까지 강좌 보느라 수고하셨습니다. 다음 강좌에서 뵙겠습니다.