본문 바로가기

게임 제작 기록/Insomniac

[Unity] '맵 메이킹 게임' 2 - 캐릭터 이동과 오브젝트 생성, 모드 전환, UI

처음부터 개발 과정을 공유했으면 더 좋았겠지만, 아쉽게도 어느정도 개발이 진행된 후 포스팅을 올려야겠단 생각이 들었다.

 

그래서 이번엔 현재 개발된 부분을 간략하게 정리해보려 한다.

 

먼저, 지금까지의 플레이 영상은 이런 느낌이다.

 

 

노란 건 스프링, 파란 건 뒤돌기, 빨간 건 적 오브젝트. 적에게 닿거나 낙사하면 초기 위치로 돌아온다.


캐릭터 이동

가장 먼저 구현한 건 자동으로 움직이는 캐릭터다. 아무 생각없이 이름을 Player로 지었는데, 딱히 틀린 말은 아니었기에 앞으로 플레이어라 지칭한다.

 

void Update()
{
    if(isPlaying == false) {
        return;
    }

    if (canMove)
    {
        MoveForward();
    }
}

 

게임은 크게 플레이 모드와 배치 모드로 나뉜다. 만약 플레이 모드가 아니라면, 플레이어는 움직여선 안 된다.

 

플레이 모드지만 움직일 수 없는 상황이라면 움직이지 않는다. 두 가지 조건이 모두 충족됐을 때만 움직인다.

 

canMove가 false인 경우는 앞이 벽으로 막힌 경우다.

 

 

이번 게임은 뒤를 돌아보는 벽과, 그렇지 않은 벽을 구분해놨기에, 위처럼 구현되었다.

단순히 Collider를 사용해 벽을 막으면 드릴처럼 떨리는 현상도 발생하고, 막힌 걸 감지했을 때 뒤도는 기능도 구현하기 위해 레이캐스트로 충돌 감지를 했다.

 

bool IsBlocked()
{
    Debug.DrawRay(transform.position + Vector3.up * 0.5f, moveDirection, Color.red);

    RaycastHit2D hit = Physics2D.Raycast(transform.position + Vector3.up * 0.5f, moveDirection, 1, LayerMask.GetMask("Ground"));

    return (hit.collider != null);
}

 

비슷한 방법으로 여러 충돌 감지 함수를 만들었으며, canMove는 아래 식에 따라 계산된다.

 

canMove = ((IsGrounded() && IsBlocked()) == false);


IsGrounded 함수는 같은 방식으로 땅을 밟고 있는지 체크한다.

땅을 밟고 있으며, 앞이 막혔다면 canMove = false

앞이 막혀 있으나 공중에 떠있다면,

땅을 밟고 있으나 앞이 막히지 않았다면,

공중에 떠있으며 앞이 막히지 않았다면 canMove = true를 반환한다.

 

== false를 통해 값을 반전시켜 넣었는데, 이는 함수명때문이다.

충돌 감지를 했으니 반환은 땅을 밟았는가?와 앞이 막혔는가?를 내놓기 때문.

 

void MoveForward()
{
    transform.position += speed * Time.deltaTime * moveDirection;
}
    
void FlipPlayer()
{
    // 오브젝트 회전
    int flip = (transform.rotation.y == 0) ? 180 : 0;
    transform.localEulerAngles = new Vector3(0, flip, 0);

    // 진행 방향 전환
    moveDirection *= -1;
}

 

이 두 함수가 이동을 실행한다. MoveForward는 플레이어가 앞으로 움직이는 함수, FlipPlayer는 필요할 때 플레이어를 뒤돌게 만드는 함수다.


오브젝트 구성

현재는 벽(인 동시에 땅), 터닝 포인트, 스프링, 적으로 4가지 오브젝트를 만들었다.

처음엔 각 오브젝트에 스크립트를 붙였으나, 이동이 캐릭터에서만 실행된다는 점 때문에 Layer만 다르게 설정되어 있다.

 

지금은 편한 테스트를 위해 높이를 제각각으로 뒀지만, 나중엔 모두 1x1 블럭으로 바꿀 것이다.

 

그냥 벽이다. IsBlocked 함수와 IsGrounded 함수에서 체크되며, 앞을 가로막거나 땅이 되어준다.

 

터닝 포인트

이 오브젝트를 만나면 플레이어는 뒤로 돈다. 방향을 바꿔줄 수 있는 오브젝트.

 

bool IsMetTurningPoint()
{
    RaycastHit2D hit = Physics2D.Raycast(transform.position, moveDirection, 1, LayerMask.GetMask("TurningPoint"));

    return (hit.collider != null);
}

 

역시나 레이캐스트로 구현했으며, IsBlocked 함수와 구조가 같다. 레이어만 다를 뿐.

 

이 함수가 True를 리턴하면, FlipPlayer 함수를 실행시킨다.

 

스프링

public void Jump()
{
    rigid.velocity = Vector2.zero;
    rigid.AddForce(jumpPower * Vector2.up);
}
    
...

bool IsSteppingSpring()
{
    Debug.DrawRay(transform.position + Vector3.up * 0.5f, Vector2.down * 0.5f, Color.red);

    RaycastHit2D hit = Physics2D.Raycast(transform.position + Vector3.up * 0.5f, Vector2.down, 0.5f, LayerMask.GetMask("Spring"));

    return (hit.collider != null);
}

 

스프링은 위를 밟았을 때만 점프를 실행시킨다. 초기엔 OnCollisionEnter2D로 충돌 감지를 했지만, 벽면에 닿을 시 무한 점프가 되는 현상 때문에 레이캐스트로 바꿔주었다.

 

IsGrounded 실행 시 같이 체크하면 안 되나?라는 생각이 들었다. 실제로 불가능하진 않다. 하지만 그 경우 함수의 역할이 모호해진다. 내가 밟은 땅이 무엇인지를 반환해야 하기에, 반환 값도 bool을 쓸 수 없다.

 

그리고, 그렇게 무거운 게임도 아니기에 레이캐스트를 4개나 써서 문제가 발생한다면 그때 고치기로 하고, 지금은 구조화를 위해 위처럼 분리시켜 놨다.

 

닿으면 배치 모드로 돌아가는 오브젝트. 게임 오버 시킨다고 보면 된다.

 

다른 오브젝트들과 달리 적 오브젝트는 OnCollisionEnter2D에서 처리한다. 어딜 닿아도 오버시키기 때문.

 

private void OnCollisionEnter2D(Collision2D collision)
{
    if (collision.gameObject.CompareTag("Enemy"))
    {
        GameManager.Instance.ReplaceStage();
    }
}

 

레이캐스트와 달리 태그를 사용했다. 큰 이유는 없고, 내가 콜라이더를 사용한 충돌 처리엔 태그를 쓰는 게 익숙하기 때문.

 

Enemy에 닿으면 GameManager의 ReplaceStage 함수를 실행시킨다.

GameManager는 싱글톤 패턴을 적용해, 어디서나 쉽게 호출할 수 있다.

 

public UnityEvent onReplaceStage;

...

public void ReplaceStage()
{
    onReplaceStage.Invoke();
}

 

이는 유니티 이벤트로 구현되었다. 그래서 GameManager가 각 오브젝트를 알 필요가 없고, 각 이벤트에 맞는 오브젝트들이 함수를 등록해두면 실행만 시켜준다.

 

일반적인 전지적 작가 시점의 GameManager가 아닌, 게임 모드를 관리해주는 친구라고 보면 더 정확하겠다. (아직은.)


모드 구현

게임은 크게 플레이 모드와 배치 모드로 나뉘며, 이를 위해 GameManager엔 3개의 이벤트를 만들었다.

 

public UnityEvent onInitializeStage;
public UnityEvent onReplaceStage;
public UnityEvent onPlayStage;

 

가장 먼저, 스테이지 자체를 초기화하는 onInitializeStage,

플레이 중 배치 모드로 돌아가는 onReplaceStage,

플레이 모드에 진입하는 onPlayStage.

 

UIManager나 Player 등이 여기에 알맞은 함수를 등록시켜 사용한다.

 

간단히 예를 들어보면, Player는 아래와 같이 이벤트를 할당한다.

 

private void Awake()
{
    startPosition = transform.position;

    AssignObjects();
    AddEvents();
}

void AddEvents()
{
    GameManager.Instance.onInitializeStage.AddListener(InitializePlayer);

    GameManager.Instance.onReplaceStage.AddListener(MoveToStartPosition);

    GameManager.Instance.onPlayStage.AddListener(PlayStage);
}

 

이처럼 Awake에서 이벤트를 등록하며, 각 이벤트에 알맞은 함수를 등록한다.

유니티 이벤트를 활용하면 느슨한 연결로도 여러 오브젝트의 함수를 실행시킬 수 있어, 자주 사용한다.

 

만약 GameManager가 이벤트를 쓰지 않는다면

  1. Player 변수 생성, 씬 위의 Player 할당, GetComponent<Player>, 해당 스크립트의 함수 실행
  2. UIManager 변수 생성, 이하 동일
  3. ...
  4. 관련된 모든 오브젝트를 GameManager가 찾고 일일이 실행

이렇게 복잡하게 해야 되며, GameManager는 모든 오브젝트의 스크립트를 알고 있어야 한다.

 

하지만 유니티 이벤트를, 싱글톤 패턴과 함께 사용하면

  1. 이벤트에 등록할 오브젝트들이 GameManager에 접근, 자신의 함수 등록
    1. 싱글톤 패턴을 사용했기에(static 오브젝트기에) 검색이나 GetComponent가 필요없다.
  2. GameManager는 이벤트만 Invoke해 실행.

이토록 단순해진다.


UI 구현

UI는 크게 2가지 파트로 나뉜다.

 

재생 및 재배치 버튼

 

지금은 여기에 테스트를 위한 점프 버튼까지 있지만, 실제 게임엔 위 두 가지만 적용할 것이다.

 

단순하게 UGUI의 Button을 사용했으며, GameManager의 onPlayStage, onReplaceStage 두 이벤트를 실행하는 함수를 할당했다.

 

초기엔 상단해 배치해뒀으나, 이후 오브젝트 인벤토리가 생성되며 아래로 내려왔다.

 

왜 아래로 내려왔는가?

이 게임의 씬을 시뮬레이션 해보자. 주로 사용되는 부분이 어디일까?

 

중앙~상단 부분보단, 중앙~하단 부분이 좀 더 많이 쓰일 것이다. 

 

베타뉴스, https://m.betanews.net/article/1310060, Skul: The Hero Slayer의 한 장면

 

이는 내 게임이 일반적인 플랫포머의 구성을 띄기 때문이다. 플레이어가 주로 활동하는 부분은 상단보단 하단이다.

 

이에 따라 UI의 크기가 큰 인벤토리를 위로 올렸고, 자연스레 재생과 배치 버튼은 아래로 내려왔다.

 

 

Horizontal Layout Group을 적용해뒀기에, 버튼이 늘어나거나 줄어들어도 매 번 위치를 옮길 필요가 없다. 자주 쓰는 컴포넌트 중 하나.


오브젝트 인벤토리

이 부분은 이름을 오래 고민했는데, 아이템이 들어 있다는 점에서 인벤토리로 명명했다.

 

 

크게 두 가지 파트로 나눌 수 있는데, 실제로 오브젝트들이 들어 있는 파트와 열고 닫는 버튼이다.

 

여닫이 버튼

 

해당 인벤토리를 열고 닫는 버튼은 위처럼 구성되어 있다.

각각 InventoryPanel 스크립트의 OpenInventoryTab과 CloseInventoryTab 함수를 할당했다.

 

public void OpenInventoryTab()
{
    rect.DOAnchorPos(openPosition, moveTime);
    closeButton.SetAsLastSibling();
}

public void CloseInventoryTab()
{
    rect.DOAnchorPos(closePosition, moveTime);
    openButton.SetAsLastSibling();
}

 

DOTween을 이용해 손쉽게 애니메이션을 구현했다. 없어도 구현은 되지만 임포트하면 삶의 질이 올라가는 에셋 중 하나.

SetEase로 여러 효과를 줘봤으나, 이 부분은 효과가 없는 게 더 자연스러웠다.

 

 

기본 상태는 내려온 상태다. Middle Top을 기준으로, 피벗을 0.5, 1로 주었다.

피벗이 중앙에 있으면 해상도가 변경될 때 위치가 달라지는 불상사가 발생할 수 있다. 이에 확실하게 하기 위해, 화면의 최상단을 기준으로 아래로 높이를 주며 UI를 만들었다.

 

Close시 자신의 높이만큼 Y축 방향으로 이동시켜, 정확히 여는 버튼만 보이게 조절했다. Open은 반대.

 

기존엔 SetAsLastSibling은 버튼 이벤트에서 바로 할당했었으나, 스크립트의 규모가 커진 김에 함께 넣어버렸다.

 

자신의 컴포넌트는 여기서 바로 조작할 수 있다.

 

SetAsLastSibling은 부모 오브젝트의 마지막 자식 오브젝트로 바꿔주는 함수로, 자식 오브젝트 간 계층 관계를 변화시킨다.

 

유니티 UI는 더 아래에 있는 오브젝트가 위로 올라오며, Sorting Layer가 없다.

 

즉, 인벤토리를 열면 Close 버튼을 위로 올리고, 반대로 닫으면 Open 버튼을 위로 올리는 역할이다.

이를 통해 Open/Close 버튼이 자연스럽게 토글된다.

 

스크롤 뷰

오브젝트가 들어간, 클릭해 가져오는 부분은 스크롤 뷰로 구현했다. 오브젝트의 개수가 많아져도 사용에 문제가 없게 하기 위함이다. (그 정도로 많아질 진 모르겠지만, 확장성을 생각했다하자.)

 

기본 UGUI를 그대로 쓰며, 스크롤 바를 지우고 방향을 Vertical로 바꾼 게 전부다. 그나마 Content 오브젝트에 Horizontal Layout Group을 넣어준 게 차이라면 차이.


현재는 이정도 개발되어 있으며, 남은 작업으로는

  • StageInfo 오브젝트 생성, 각 스테이지의 정보를 담는다.
  • 오브젝트 인벤토리 업그레이드. 오브젝트를 클릭해 손에 들고, 맵에 클릭해 배치하는 기능이 필요하다.
    • 아마 타일 방식으로 작업할 것 같다.
  • 스테이지 클리어 시 다음 스테이지로 넘어가는 기능이 필요하다.
    • 모든 스테이지가 한 씬에 구현될 것 같다. 볼륨이 큰 게임도 아니고, 매 번 씬을 로드하는 게 플레이에 방해가 되기 때문.
    • 내친 김에 로비 씬도 안 만들 수 있다.
  • 제목도 아직 제대로 정하지 않았다. 엔딩에 들어갈 대사는 언제든 쓸 수 있으니 맨 마지막으로 미루기로.
  • 2D 조명을 사용하며, 색이 그라데이션으로 변하는 것도 구현해야 한다.
    • 엔딩 때 해가 뜨는 연출을 보여주기 위함이다.

이후 그림을 그리고 넣어주고, 노래도 작곡하거나 찾아서 집어넣어야 한다. 갈 길이 멀다.