본문 바로가기

게임 제작 기록/Insomniac

[Unity] '맵 메이킹 게임' 4 - 타일에 블럭 배치

지난 시간에 아이템 선택까지 했으니, 이제 배치를 구현해보자.


간단하게 취소부터

이전에 블럭을 잡는 것은 구현했지만, 잡은 블럭을 다시 돌려놓는 취소는 개발하지 않았다. 그러니 우클릭하면 취소하는 것부터 구현해보자.

 

선택 취소에 영향받는 오브젝트는 무엇이 있을까?

  1. 마우스에 붙어 있는 선택 블럭
  2. 블럭이 들어 있던 인벤토리

또 이는 선택 시 인벤토리에서 즉시 차감되느냐, 배치 시 차감되느냐에 따라 갈리는데, 나는 후자로 개발할 것이다.

 

그럼 내가 해야할 일은

  1. 우클릭 시 취소 함수 실행
  2. 선택 블럭 - 자기 자신의 내용물을 비우기
  3. 인벤토리 - 아무 일도 일어나지 않음.

선택 블럭만 영향을 받으니, 이 안에 우클릭 감지와 함수를 만들어보자. 함수는 이전에 ClearSelector를 SelectBlock과 함께 만들어두었으니, 이걸 그대로 쓰면 되겠다.

 

    private void Update()
    {
        if (currentBlock != null)
        {
            FollowCursor();

            if (Input.GetMouseButtonUp(1))
            {
                ClearSelector();
            }
        }
    }

 

간단히 Update에서 구현, 선택된 게 없다면 취소할 필요 없으니 저 안에 넣었다.

 

 

굿


타일 생성 - 배치

이제 타일을 만들고 블럭을 배치해보자.

 

선택된 블럭의 정보는 BlockSelector가 갖고 있으니, 이걸 타일에 전달해주는 식으로 진행하면 될 것 같다.

 

이번 동작에 영향을 받는 오브젝트는

  1. BlockSelector -> 선택 해제, 본인의 정보 전달
  2. 타일 -> 자신의 상태 변경, 프리팹 생성

이렇게 두 가지다.

 

처음엔 마우스 감지를 타일에서 하려 했다. 타일은 여러 개인 반면 BlockSelector는 유일 객체이므로, BlockSelector를 싱글톤으로 돌리면 각 타일에서 접근하기도 편할 테니.

 

IPointerClickHandler 인터페이스의 OnPointerClick을 통해 감지하려 했으나, 이는 UI 요소에만 적용된다는 단점이 있었다. 내가 만든 타일은 일반 오브젝트니, 이 방법을 쓸 수 없었다.

 

그래서 레이캐스트를 이용해 충돌을 감지해야 했는데, 타일들이 각각 좌클릭 시 Ray를 쏘는 건 낭비가 심해보였다. 그래서 BlockSelector가 Ray를 쏘고 감지하는 걸로 다시 변경했다.

 

BlockSelector의 타일 찾기

먼저 Raycast를 쏘고, 타일을 가져와보자.

 

public Tile GetClickedTile()
{
    Vector2 pos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
    RaycastHit2D hit = Physics2D.Raycast(pos, Vector2.zero);

    if (hit.collider == null)
    {
        return null;
    }

    return hit.collider.GetComponent<Tile>();
}

 

위 함수가 호출되면, 마우스 위치에 Ray를 쏘고 hit의 Tile을 반환한다.

 

GetComponent<T>() 함수는 컴포넌트를 찾을 수 없으면 null을 반환해주기에, null인지 검사하는 부분은 생략해도 된다. 나는 타일이 아닌 곳을 누를 때마다 NullReferenceException 에러를 띄우는 게 거슬려서 넣어줬다.

 

hit.collider 말고 hit.transform으로도 받을 수 있는데, 큰 차이를 못 느껴서 익숙한 collider로 했다.

 

Tile 변경하기

이제 본격적으로 선택한 블럭을 Tile에 만들 차례다.

 

이때 변하는 것은 Tile이므로, 방금 찾아낸 Tile의 어떤 함수를 실행시키는 쪽으로 구현해보겠다.

 

private void Update()
{
	...
    
    if (Input.GetMouseButtonUp(0))
    {
        Tile tile = GetClickedTile();

        if (tile != null)
        {
            // tile.ChangeTile(currentBlock);
            ClearSelector();
            // 인벤토리에서 감소
        }
    }
}

 

Update에 마우스 클릭 시 가져오는 걸로 구현, 위와 같이 3가지 함수를 실행한다.

아직 ChangeTile을 만들진 않았지만, BlockCreator가 구현되어 있으니 어떻게 구현할지 생각을 해봐야 한다.

 

일반적으로 Instantiate 함수는 비용이 크다고 알려져 있다. 만약 매 번 타일을 고르고, 지울 때마다 Instantiate와 Destroy가 실행된다면, 이는 최적화에 큰 악영향을 끼칠 것이다.

(일반적으로 슈팅 게임에서, Bullet을 이렇게 만들지 않는 이치다.)

 

그렇다면 오브젝트 풀링을 써야 한다. 다행히 여유롭게 생성하는 일반적인 풀링과 달리, 우린 만들어야 하는 블럭의 갯수가 정해져 있으니, 미리 만들기 더 편하다.

 

그럼 블럭은 어디에 담아둬야 할까? 인벤토리 UI에 담아두자니, 여긴 RectTransform을 쓰는 만큼 좋은 장소가 아니다. 그러니 BlockCreator 아래에 저장해두고 비활성화, 배치되는 순간 이동 및 활성화를 하는 방식이 효율적일 것이다.

 

우선 생성부터 해보자.

 

BlockCreator 아래, 블럭 미리 생성

블럭의 종류와 개수는 StageStarter이 갖고 있다.

 

public void StartStage()
{
    Inventory.Instance.SetAllItemToSlot(blockDict);

    BlockCreator.Instance.CreateBlocks(blockDict);

    GameManager.Instance.InitializeStage();
}

 

그러니 스테이지를 시작할 때, BlockCreator에게 미리 만들라고 명령을 보낸다.

 

하지만 BlockCreator에는 이미 CreateObjects 함수가 있었다. 이 함수는 모든 종류의 블럭을, Resources 폴더에서 하나씩 로드하는 역할이므로, LoadPrefebs가 더 어울릴 것 같다. 이름을 변경해주자.

 

이제 CreateBlocks 함수를 구현해보자.

 

public void CreateBlocks(Dictionary<BlockCode, int> blockDict)
{
    foreach(var data in blockDict)
    {
        for(int i = 0; i < data.Value; ++i)
        {
            Instantiate(blocks[data.Key], transform);
        }
    }
}

 

모든 Dictionary를 순회하며, 개수만큼 Instantiate. 이 행위는 매 스테이지가 시작할 때마다 일어난다.

스테이지 재시작 시가 좀 걸리긴 하는데, 이건 나중에 다시 살펴보자.

 

 

저장한 대로 생성된 것을 볼 수 있다.

 

이들을 Tile에 제때 전달하기 위해선, BlockCreator가 각 Clone의 주소를 미리 알아둬야 빠르다. 즉, 생성과 동시에 해당 오브젝트를 어떤 자료구조에 담아둬야 한다.

 

Tile에서 오는 요청값은 BlockCode일 것이고, 우린 이걸 보고 빠르게 건네줘야 한다. 그럼 역시 Dictionary가 좋을 것 같다.

 

그럼 Key는 BlockCode고, Value는 뭐로 하지? 큐, 스택, 배열(리스트)가 우선적으로 떠오르는데, 사실 뭘 쓰든 상관없다. 담아만 두면 되니까.

 

그래도 이왕이면 큐를 써보겠다. (딱히 이유는 없다.)

 

private Dictionary<BlockCode, Queue<GameObject>> blockPool = new Dictionary<BlockCode, Queue<GameObject>>();

public void CreateBlocks(Dictionary<BlockCode, int> blockDict)
{
    foreach(var data in blockDict)
    {
        blockPool[data.Key] = new Queue<GameObject>();

        for (int i = 0; i < data.Value; ++i)
        {
            GameObject obj = Instantiate(blocks[data.Key], transform);
            obj.SetActive(false);
            blockPool[data.Key].Enqueue(obj);
        }
    }
}

 

(new Dictionary와 new Queue를 안 해줘서 에러가 많이 떴었다.)

위와 같은 방법으로 블럭들을 미리 생성하고, 각 주소를 Dictionary<code, Queue<GameObject>>에 담는 부분이 완성되었다.

 

이제 블럭이 배치되면 Dequeue, 삭제되면 Enqueue하며 조절할 수 있게 되었다.

 

생성된 블럭 가져오기

꽤 많은 게 변했다. 이쯤에서 구조를 정리하며 생각해보자.

 

 

BlockCreator와 Tile은 BlockCode와 GameObject가 둘 다 필요하다. Tile의 경우, 변경한 뒤 오브젝트를 BlockCreator에 넣고, Slot을 변경해야하니 더 그렇다.

 

그에 반해 BlockSelector와 Slot은 BlockCode만 필요하고, 오브젝트는 꼭 필요하진 않다.

 

이제 아래 3개의 기능을 구현해야 하는데

  1. Slot을 클릭해 BlockSelector 등록 -> BlockSelector를 이용해 Tile에 오브젝트 이동
  2. Tile의 블럭이 삭제되거나 변경될 때, BlockCreator에 오브젝트 이동 -> Slot의 개수 변화

BlockCode -> GameObject는 쉽게 변환할 수 있다. 반대로 GameObject -> BlockCode도 변환할 수 있다면 일이 쉬워질 듯하다.

 

public BlockCode ObjectToCode(GameObject block)
{
    string layerName = LayerMask.LayerToName(block.layer);
    BlockCode code = (BlockCode)Enum.Parse(typeof(BlockCode), layerName);

    return code;
}

 

각 오브젝트가 레이어로 구별된다는 점에서 착안, Layer의 이름을 가져와 BlockCode로 바꾸는 함수를 만들었다. 이건 나중에 필요한 스크립트에 집어넣을 것이다.

 

그럼 다시, 배치부터 정리해보자.

 

슬롯 클릭 -> Slot의 SelectBlock 함수 실행(코드 전달) -> BlockSelector에 정보 전달(코드 전달) -> Tile 클릭 -> BlockSelector가 Tile을 감지, Tile에 정보 전달(코드 전달) -> Tile에서 BlockCreator에 접근 -> BlockCreator의 객체 반환(코드로 오브젝트를 가져옴)

 

4개의 스크립트가 상호 작용하니 복잡해보인다. 하지만 각 함수들을 따로 분리해 구현해보면 그리 어렵진 않을 것이다.

 

// Slot.cs
public void SelectBlock()
{
    BlockSelector.Instance.SelectBlock(currentBlockCode);
}

 

Slot은 BlockSelector에게 자신의 코드를 인자로 넘겨준다.

 

// BlockSelector.cs
public void SelectBlock(BlockCode code)
{
    currentBlockCode = code;

    GameObject obj = BlockCreator.Instance.GetBlockData(currentBlockCode);

    spriteRenderer.sprite = obj.GetComponent<SpriteRenderer>().sprite;
}

 

BlockSelector엔 code를 전달받아 저장, 이미지를 바꾸는 함수를 생성한다.

 

위 세 줄은 순서나 디테일이 달라질 수 있는데, 굳이 저렇게 한 이유를 꼽자면

  1. code를 바로 쓰는 대신, currentBlockCode에 저장하고 currentBlockCode를 사용함으로써 저장된 코드와 이미지가 다른 경우를 방지한다.
  2. 오브젝트는 실제 객체가 아닌 이미지 데이터만 필요한 것이니, BlockCreator의 GetBlockData 함수를 통해 정보만 가져온다.

이제 BlockSelector에도 정보가 전달되었다. 타일이 선택된 경우를 보자.

 

// BlockSelector.cs
if (Input.GetMouseButtonUp(0))
{
    Tile tile = GetClickedTile();

    if (tile != null)
    {
        tile.ChangeTile(currentBlockCode);
        ClearSelector();
        // 인벤토리에서 감소
    }
}

 

Update 문에 작성된, 클릭으로 타일을 선택한 경우다. 선택된 tile 내의 ChangeTile을 실행하고, 코드를 전달한다.

 

코드를 통해 오브젝트를 가져오는 건 타일이 할 일이니, 우선 이정도만 하고 Tile을 살펴보자.

 

// Tile.cs
public void ChangeTile(BlockCode code)
{
    if(currentBlock == null)
    {
        currentBlock = BlockCreator.Instance.GetBlockInPool(code);
        currentBlock.transform.parent = transform;
        currentBlock.transform.localPosition = Vector3.zero;

        currentBlock.SetActive(true);
    }

    if(currentBlock != null)
    {

    }
}

 

우선 타일이 비어있는 경우를 먼저 해보자.

 

currentBlock을 BlockCreator 아래 Pool에서 가져온다.

이후 부모 오브젝트를 자신으로 변경하고, 로컬 포지션을 (0, 0, 0)으로 맞춘다. 한 번 테스트해보자.

 

 

타일은 잘 적용되지만, 스프링 역할은 못 하는 걸 볼 수 있다. 우선 타일 적용에 에러가 없다는 것에 만족하고, 이제 이걸 고쳐보자.

왜 스프링이 안 될까?

스프링은 플레이어가 Raycast를 쏘고, 여기에 닿은 게 스프링일 때 점프하는 구조다.

 

먼저, 변경된 타일만 점프가 안 되는지, 아니면 기존 스프링도 안 되는지 테스트 해보자.

 

 

다행히 기존 스프링은 문제 없이 잘 작동한다. 즉, 플레이어의 코드가 고장난 건 아니다.

 

그럼 Raycast 감지가 안 되는 것 같은데, 한 번 Tile 오브젝트를 면밀히 살펴보자.

 

 

레이캐스트 디버그를 써보니 레이캐스트가 짧아 닿지 않는 것을 발견했다.

 

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

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

    if(hit.collider != null) Debug.Log(hit.collider.name);

    return (hit.collider != null);
}

 

길이를 0.6정도로 늘려준(IsGrounded 함수는 늘렸었는데) 후에 다시 실행해보니

 

 

그러니 잘 된다!


원래 더 하려고 했는데, 길이가 너무 길어져 여기서 잠깐 끊어간다.

 

다음엔 타일 배치 후 인벤토리에서 개수가 줄어드는 기능과, 개수가 0이 되면 숨겨지는 기능, 기존 타일을 다른 타일로 덮어쓰고, 사라진 타일이 다시 인벤토리로 넘어오는 기능을 구현해보겠다.