소소한 나의 하루들

2d 종스크롤 슈팅(1) - 플레이어 이동 구현하기 본문

개발/유니티

2d 종스크롤 슈팅(1) - 플레이어 이동 구현하기

소소한 나의 하루 2024. 2. 12. 11:23

출처: https://youtube.com/playlist?list=PLO-mt5Iu5TeYI4dbYwWP8JqZMC9iuUIW2&feature=shared

 

📚 유니티 기초 강좌

유니티 게임 개발을 배우고 싶은 분들을 위한 기초 강좌

www.youtube.com


#1. 준비하기

우선 빈 2d 프로젝트를 준비하고, 2d 프로젝트는 (별도의 에셋을 import하여 사용하는 것이 아니라면) sprite를 담을 폴더를 만드는게 첫번째로 할 일이다. 

https://assetstore.unity.com/packages/2d/characters/vertical-2d-shooting-assets-pack-188719

 

Vertical 2D Shooting Assets Pack | 2D 캐릭터 | Unity Asset Store

Elevate your workflow with the Vertical 2D Shooting Assets Pack asset from Goldmetal. Find this & more 캐릭터 on the Unity Asset Store.

assetstore.unity.com

아틀라스에 포함되어있는 각 sprite의 크기는 24px(24*24)이다. 따라서 Pixels Per Units은 24로 입력한다. 도트 그래픽은 Filter Mode를 Point (no filter)로 설정하고, 압축모드(Compression)은 None으로 해준다. 여러 sprite가 모인 이미지라면, 아틀라스로 만들어주기 위해 Sprite Mode를 Multiple로 설정한다. [단일 sprite라면 Single]

Sprite Mode를 Multiple로 설정하고나서, 이미지를 잘라주기 위해 Sprite Editor를 클릭하여 창을 열어준다.

[주어진 에셋의 비행기 아틀라스의 윗줄은 가만히 / 두번째 줄은 오른쪽으로 이동 / 세번째 줄은 왼쪽으로 이동하는 모션 이미지]

주어진 이미지 크기에 맞게 x 24 y 24로 하고, Padding(여백)은 꼭 x 1 y 1씩 준다. 여백을 안주게되면, sprite가 밀리게 된다.
*설정이 끝나고나면 반드시 Apply를 클릭해주고, 상단 RGB 아이콘을 클릭해서 잘 설정되었는지 확인해준다.

 

에디터 Scene에 나타나는 카메라 아이콘은 Game창의 Gizmo를 통해 크기를 조절할 수 있다.

Scene에 조작하게될 플레이어의 비행기 아이콘을 갖다 놓고 Player 스크립트를 생성하여 Player 오브젝트에 드래그 적용시켜준다.

그리고 이제 스크립트를 통해 조작을 해야하므로 Scripts 폴더를 생성해서, 여기서 관리해준다.

*에셋 관리는 폴더를 통해 관리해주는 것이 좋다.


#2. 플레이어 이동

GetAxisRaw()로 키 입력을 통해 -1, 0, 1의 값이 받아질 수 있도록 float형 변수 h, v를 생성하고, 플레이어의 현재위치를 가져오기위한 변수 curPos와 키 조작으로 변하게될 다음 위치를 가져오는 변수 nextPos를 생성한다.

public class Player : MonoBehaviour
{
    public float speed;

    void Update()
    {
        float h = Input.GetAxisRaw("Horizontal");
        float v = Input.GetAxisRaw("Vertical");
        Vector3 curPos = transform.position;
        Vector3 nextPos = new Vector3(h, v, 0) * speed * Time.deltaTime;

        transform.position = curPos + nextPos;
    }
}


*이번에는 물리적인 이동(rigidbody)이 아니라, 절대적(?)인 이동(transform)이다. 무조건 transform 이동에는 Time.DeltaTime을 꼭 사용해야한다. 참고

https://sensol2.tistory.com/10

 

[유니티 궁금증] transform.position과 rigidbody.position 의 차이

오브젝트의 위치를 변경하고 싶을때 position 값을 바꾸게 된다. 그런데 rigidbody가 적용된 오브젝트를 움직일 때 궁금증이 생겼다. transform.position과 rigidbody.position를 각각 수정해보았더니 두 값 모

sensol2.tistory.com

transform.position
오브젝트의 위치가 순간이동한다. : 퍼포먼스 떨어짐 → 따라서 Time.DeltaTime을 사용해야한다.

순간이동하는 순간 연동된 collider가 위치를 다시 계산한다.

 

rigidbody.postion

다음 물리 시뮬레이션 단계 이후 오브젝트 위치가 순간이동한다.

transform에 비해 더 나은 속도와 퍼포먼스를 보여준다.


#3. 해상도 조절

지금 개발하고있는 종스크롤 슈팅게임은 세로형 게임이니까 Game창의 해상도를 Free Aspect로 설정하거나 (Free Aspect : 자유 비율 (카메라 Size = Game뷰 크기)) 직접 추가해서 Aspect Ratio Type으로 화면 해상도 비율을 만들수도 있다. 

스마트폰 기준으로 9:16, 9:19 비율로 해상도를 설정해서 새로 만든다.


#4. 경계 설정

실행 후, 조작해보면 비행기가 화면 밖을 나가게 된다. 따라서 화면 밖으로 못나가도록 해본다. (이전에 했던대로 롤타일을 활용하여 경계구역을 설정하는 방법도 있다. 참고)

우선 플레이어에게 Collider 컴포넌트를 추가한다.

*나중에는 이 Collider 히트박스를 피격범위로도 사용한다.

그리고 경계구역을 만들기 위해 빈 오브젝트를 생성한다. 그 안에 또 빈 오브젝트를 4개 더 생성한다. 4방향 각각 콜라이더를 추가해서 경계를 만든다. Box Collider 컴포넌트의 Size를 설정해서 경계구역을 설정한다. 화면 영역의 경계에 맞닿게 해도 좋고 살짝 걸치도록 해도 좋다.

*컴포넌트의 상단 설정 메뉴로 Copy Component하고, 같은 속성 컴포넌트에 Paste Component해서 쉽게 똑같은 컴포넌트 설정을 다른 컴포넌트에 복사할 수 있다. (직접 크기 조정하고, 드래그해서 위치 조정해주는 것보다 상단 부모 오브젝트를 기준으로 값을 설정하고, 컴포넌트 복사 붙여넣기로 위치/크기를 설정해주는 것이 정확하다.)

 

이렇게 맵의 경계를 Box Collider로 막아줬으면, 물리연산을 위해 플레이어 오브젝트에 Rigidbody 2D 컴포넌트를 추가해준다. 이때 플레이어 Rigidbody 2D Body Type은 Kinematic로 설정해준다.

(Dynamic끼리 부딪히면 서로 상호작용해서 서로 밀리고, 옆으로 회전한다. 그런데 Kinematic으로 설정하면 이러한 물리연산을 받지 않겠다. 스크립트에서 설정한 움직임으로만 움직이겠다는 것이다.)

경계구역 오브젝트들에도 Rigidbody 2D 컴포넌트를 추가하고, 타입은 Static으로 설정한다.

 

*지금까지 했던 방법은 알아서 물리연산이 플레이어 이동을 막도록 하는 것이었다. 이렇게 하려면 플레이어의 Rigidbody 2D type이 Dynamic이어야하고, 중력을 받지 않아야하니까 Gravity Scale을 0으로 설정했다.

실행해보면, 플레이어의 이동이 Collider에 의해 막힌다. 그런데 떨리는 문제가 있다.

transform 이동으로는 나아가려고 하는데, 물리연산이 막으려고 하기 때문에 transform 이동 + 물리연산 충돌은 떨림 현상을 발생시킨다.

스크립트를 별도로 입력하지 않아도 Rigidbody 2D의 물리연산이 알아서 플레이어의 이동을 막지만, 떨림 현상이 발생하는 문제가 생긴다.

 

따라서 우리가 직접 스크립트로 물리현상을 제어해야한다. (충돌박스에 진입을 했을 때 그 방향으로의 값을 0으로 만들어버린다) 플레이어의 Rigidbody 2D 타입을 다시 Kinematic로 설정한다. 

public class Player : MonoBehaviour
{
    public float speed;
    public bool isTouchTop;
    public bool isTouchBottom;
    public bool isTouchLeft;
    public bool isTouchRight;

    void Update()
    {
        float h = Input.GetAxisRaw("Horizontal");
        if ((isTouchRight && h == 1) || (isTouchLeft && h == -1))
            h = 0;

        float v = Input.GetAxisRaw("Vertical");
        if ((isTouchTop && v == 1) || (isTouchBottom && v == -1))
            v = 0;

        Vector3 curPos = transform.position;
        Vector3 nextPos = new Vector3(h, v, 0) * speed * Time.deltaTime;

        transform.position = curPos + nextPos;
    }

    public void OnTriggerEnter2D(Collider collision)
    {
        if(collision.gameObject.tag == "Border")
        {
            switch(collision.gameObject.name)
            {
                case "Top":
                    isTouchTop = true;
                    break;
                case "Bottom":
                    isTouchBottom = true;
                    break;
                case "Left":
                    isTouchLeft = true;
                    break;
                case "Right":
                    isTouchRight = true;
                    break;
            }
        }

    }
}

플레이어가 4방향 경계에 닿았다는 것을 인지해야한다. : 4방향 경계에 닿았다는 플래그 변수 추가

물리적인 충돌이 아니니까 OnCollisionEnter2D()가 아니라, OnTriggerEnter2D()를 사용해야한다. 플래그 변수를 사용하여 경계 이상을 넘지 못하도록 값을 제한한다.

플레이어가 위쪽 경계지역에 닿아서 isTouchTop이 true가 됐다. 다시 경계지역에서 아래쪽으로 이동해서 떨어지면 isTouchTop을 false로 만들어줘야한다.

public void OnTriggerEnter2D(Collider collision)
{
    if(collision.gameObject.tag == "Border")
    {
        switch(collision.gameObject.name)
        {
            case "Top":
                isTouchTop = true;
                break;
            case "Bottom":
                isTouchBottom = true;
                break;
            case "Left":
                isTouchLeft = true;
                break;
            case "Right":
                isTouchRight = true;
                break;
        }
    }

}

public void OnTriggerExit2D(Collider collision)
{
    if (collision.gameObject.tag == "Border")
    {
        switch (collision.gameObject.name)
        {
            case "Top":
                isTouchTop = false;
                break;
            case "Bottom":
                isTouchBottom = false;
                break;
            case "Left":
                isTouchLeft = false;
                break;
            case "Right":
                isTouchRight = false;
                break;
        }
    }

}

: OnTriggerExit2D()로 플래그 변수를 false로 만들어줘야한다.

 

이제 에디터에서 4방향 경계 모두 Tag는 "Border"로 해주고 Box Collider 2D 컴포넌트의 IsTrigger를 체크해준다. Is Trigger를 체크하면 물리적으로 막는 것이 아니라, Trigger를 통해서 막겠다는 의미가 된다.


충돌 관련 이벤트 제어

1) 플레이어 vs  콜라이더 컴포넌트의 Is Trigger를 체크한 '특정 태그'의 오브젝트 간 충돌 : 물리충돌x [ex) 아이템 획득 등]

OnTriggerEnter(Collider collision) / OnTrigger(Collider collision) / OnTriggerExit(Collider collision)

collision.gameObject.tag == "태그명" 으로 조건 판단

*2d는 뒤에 붙여서 써줘야함 : OnTriggerEnter2D(Collider2D collision)

 

2) 플레이어 vs 콜라이더 컴포넌트를 가진 '특정 태그'의 오브젝트 간 충돌 : 물리충돌 [ex) NPC 대화, 몬스터 피격 등]

OnCollisionEnter(Collision collision) / OnCollision(Collision collision) / OnCollisionExit(Collision collision)

collision.gameObject.tag == "태그명"으로 조건 판단

*2d는 뒤에 붙여서 써줘야함 : OnCollisionEnter2D(Collision2D collision)

 

3) 플레이어 또는 움직이는 몬스터/NPC vs 콜라이더 컴포넌트를 가진 '특정 레이어'의 오브젝트를 인지하고 충돌이벤트를 설정 : 특정 레이어의 오브젝트 충돌 판단 [ex) 플레이어 점프 후 바닥 착지 인지, 이동 중 오브젝트 인지 : 몬스터 AI 설계에 활용]

Debug.DrawRay(출발지점, 벡터(방향*힘(길이)), 색상); //Scene에서 나타나는 Raycast 빔
RaycastHit2D rayHit = Physics2D RayCast(출발지점, 방향벡터, 힘(길이), LayerMask.GetMask("레이어명")); //rayHit은 빔에 맞은 오브젝트의 정보를 가짐

RaycastHit2D rayHit = Physics2D.Raycast(rigid.positoin, dirVec, 0.7f, LayerMask.GetMask(”Object”));
	if (rayHit.collider ≠ null)

DrawRay로 미리보고, RayCast를 구현하면 쉽다. 

RayCast를 구현할 때 플레이어의 'Collider(충돌박스)'는 무시해야한다. 따라서 레이어를 사용한다.

→레이어를 생성해서 '조사가능한 오브젝트'를 다른 Layer로 설정한다. (플레이어 제외 나머지) 조사하려는 오브젝트는 콜라이더 컴포넌트를 가져야한다.

rayHit.collider != null 또는 == null을 통해 조건 판단

빔이 충돌한 오브젝트(rayHit)가 null이 아니면 = 무엇인가 충돌하여 값 생성됨

RayCast된 오브젝트를 변수로 저장하여 활용한다. (변수 scanObject)

만약 충돌된 오브젝트가 없다면, null 값이다. 충돌한 오브젝트의 이름을 출력해본다.


#5. 애니메이션

이제 애니메이션을 적용시켜본다. 주어진 에셋에서 적절한 sprite(: 프레임)를 다중선택한 후, 플레이어에게 드래그한다. (1~4번째: center / 5~8번째: left / 9~12번째: right)

그리고 Animator에서 각 애니메이션 State에 대해 Transition(화살표)를 배치하고, 매개변수로 Input.GetAxisRaw()의 값인 -1, 0, 1을 그대로 사용하기 위해 Int형 매개변수를 추가한다. [center: 0 / left: -1 / right: 1)

 

보통 2d sprite 애니메이션들은 키를 눌렀을 때 즉각적으로 반응해야하므로 Has Exit Time을 체크해제한다. 그리고 Transition Duration은 0으로 설정한다.

void Update()
{
    float h = Input.GetAxisRaw("Horizontal");
    if ((isTouchRight && h == 1) || (isTouchLeft && h == -1))
        h = 0;

    float v = Input.GetAxisRaw("Vertical");
    if ((isTouchTop && v == 1) || (isTouchBottom && v == -1))
        v = 0;

    Vector3 curPos = transform.position;
    Vector3 nextPos = new Vector3(h, v, 0) * speed * Time.deltaTime;

    transform.position = curPos + nextPos;

    if (Input.GetButtonDown("Horizontal") || Input.GetButtonUp("Horizontal"))
        anime.SetInteger("Input", (int)h);
}

애니메이션이 작동되어야할 때를 생각해보면 키를 눌렀을 때, 떼었을 때가 된다.

*float형 변수 h, v를 고려해서 상황에 따라 Int값으로 강제 형 변환해주는 것이 중요하다.

 

마지막으로 에셋에 주어진 배경 sprite를 Hierarchy에 드래그하여 배경을 생성하면 된다.

Comments