[Unity] Custom TileMap 구현하기

kkado·2024년 9월 27일
0

Unity

목록 보기
1/2
post-thumbnail

<심시티>, <림월드> 와 같이 인게임에서 직접 유저가 지형 지물을 건설해 나가는 게임, 또는 아예 사용자로 하여금 플레이어블 맵을 만들 수 있는 에디터 기능을 지원하는 <슈퍼 마리오 메이커> 등의 게임과 같은 에디터 기능을 직접 만들어보고자 한다. 이것을 이른바 '커스텀 타일맵' 이라고 하겠다.

참고한 영상 : https://youtube.com/playlist?list=PLJBcv4t1EiSz-wA35-dWpcI98pNiyK6an&si=BNd6yHzfSgvwIjan

Building Object Base

먼저, 유저가 직접 배치할 수 있는 오브젝트들을 BuildingObject 라고 하자.
지나갈 수 없는 벽, 또는 바닥 무늬, 배치할 수 있는 가구 및 식물 등은 모두 빌딩 오브젝트에 해당할 것이며, 우선 BuildingObjectBase 클래스는 이와 같은 다양한 종류의 오브젝트들이 공통적으로 가지고 있는 속성, 즉 '타일맵에 배치될 수 있는' 기능이 구현된다.

public enum Category
{
    Wall,
    Floor
}

[CreateAssetMenu (fileName = "Buildable", menuName = "BuildingObjects/Create Buildable")]
public class BuildingObjectBase : ScriptableObject
{
    [SerializeField] private Category category;
    [SerializeField] private TileBase tileBase;
    
    // Public Getter
    public Category Category => category;
    public TileBase TileBase => tileBase;
}

카테고리와 타일을 필드로 갖는 BuildingObjectBase 클래스 생성

Building Button Handler

에디터 화면에서 배치할 타일을 선택할 수 있는 영역을 만든다.
대충 아래 사진과 같은 계층 구조를 가지도록 한다.

각각의 Item은 Button 컴포넌트를 부착하여 클릭할 수 있도록 하고, 또한 BuildingButtonHandler 스크립트를 생성, 부착하여 정보를 전달할 수 있도록 한다.

뒤에 나오겠지만 모든 과정은 BuildingCreator 클래스에 의해 이루어지며 BuildingCreator에는 selectedObject, 그리고 SelectObject setter가 존재한다.

public class BuildingButtonHandler : MonoBehaviour
{
    [SerializeField] private BuildingObjectBase item;
    private Button _button;

    private BuildingCreator buildingCreator;

    private void Awake()
    {
        _button = GetComponent<Button>();
        _button.onClick.AddListener(ButtonClicked);
        buildingCreator = BuildingCreator.GetInstance();
    }

    private void ButtonClicked()
    {
        buildingCreator.SelectObject(item);
    }
}

싱글톤 객체를 GetInstance() 를 통해 가져와서, 버튼에 바인딩된 OnClicked 함수에서 SelectObject를 호출

Building Creator

유저가 어떤 블록을 클릭하여, 화면의 어떤 곳을 클릭했는지 등등, 일련의 과정들을 모두 관리하는 Manager 클래스이다.
이 클래스는 싱글톤 패턴을 사용한다.

public abstract class Singleton<T> : MonoBehaviour where T : Component {
    private static T instance;

    protected static bool DontDestroy = true;

    private static bool m_applicationIsQuitting = false;

    public static T GetInstance () {
        if (m_applicationIsQuitting) { return null; }

        if (instance == null) {
            instance = FindObjectOfType<T> ();
            if (instance == null) {
                GameObject obj = new GameObject ();
                obj.name = typeof (T).Name;
                instance = obj.AddComponent<T> ();
            }
        }
        return instance;
    }

    /* IMPORTANT!!! To use Awake in a derived class you need to do it this way
     * protected override void Awake()
     * {
     *     base.Awake();
     *     //Your code goes here
     * }
     * */

    protected virtual void Awake () {
        if (instance == null) {
            instance = this as T;
            if (DontDestroy) DontDestroyOnLoad (gameObject);
        } else if (instance != this as T) {
            Destroy (gameObject);
        } else if (DontDestroy) { DontDestroyOnLoad (gameObject); }
    }

    private void OnApplicationQuit () {
        m_applicationIsQuitting = true;
    }
}
public class BuildingCreator : Singleton<BuildingCreator>

이 에디터는 두 가지의 타일맵으로 구성할 예정이다.

유저가 실제로 타일을 배치할 수 있는 기본 타일맵이 있고, 유저가 타일을 배치하기 전, 특정 타일을 선택하고 마우스를 이동할 시 마우스 커서를 따라 타일이 따라오도록 해서 일종의 미리보기 형식을 지원할 계획인데, 이 미리보기 타일들이 배치될 타일맵이 있다.

이 미리보기 타일들은 현재 마우스 커서의 위치에 생성될 것이며, 마우스 커서가 이동하면 직전에 배치되었던 위치의 타일은 삭제돼야 한다.

private Vector3Int _currentGridPosition; // 현재 가리키고 있는 그리드의 idx
private Vector3Int _lastGridPosition; // 직전 idx (마우스가 이동함에 따라 미리보기 타일을 삭제하기 위함)

현재 마우스의 위치와 그에 대응하는 그리드 인덱스는 다음과 같이 구할 수 있다.

Vector3 pos = _camera.ScreenToWorldPoint(_mousePos);
Vector3Int gridPos = previewTileMap.WorldToCell(pos); // 현재 마우스 위치의 WorldPosition -> GridIndex

ScreenToWorldPoint는 2D 스크린 posiiton (마우스 커서의 위치)를 3D world position으로 변환하고,
WorldToCell은 이 3D world position에 대응하는 타일맵의 인덱스로 변환한다.

    // 타일을 배치하기 전, 타일을 선택한 채로 마우스를 이동하면 마우스 포인터를 따라 미리보기 타일을 생성하는 기능
    private void UpdatePreview()
    {
        // 이전 위치의 타일을 삭제
        previewTileMap.SetTile(_lastGridPosition, null);
        // 현재 위치에 tileBase를 set
        previewTileMap.SetTile(_currentGridPosition, _selectedObj ? _selectedObj.TileBase : null);
    }

_currentGridPositionSetTile 할 때 삼항 연산자를 쓴 이유는, 우클릭을 통해 그리기를 취소하면 _selectedObjnull이 되는데 이 때도 UpdatePreview() 함수를 호출하기 위함이다.

여기까지 진행하면 단순 클릭을 통해서 타일을 배치할 수 있게 된다.

Line & Rectangle

<림월드> 에서 구역을 설정하거나 벽을 건설하는 행동은 약간 다르게 동작한다.
구역 설정은 '영역' 을 만듦을 가정하며 직사각형 형태의 구역을 드래그하여 지정할 수 있다.
벽 건설은 말 그대로 '벽' 이기 때문에 일직선 형태로 건설하는 것이 옳으므로 드래그해서 지정하는 것은 유사하나 x, y축 중 하나의 축으로만 늘어난다는 특징이 있다.

에디터에서도 특정 타일이 이른바 '벽' 처럼 배치될지, '구역' 처럼 배치될지를 설정할 수 있도록 하고, 그에 따라 서로 다른 구현 방식을 만들어야 한다.

public enum PlaceType
{
    Single,
    Line,
    Rectangle
}

[CreateAssetMenu (fileName = "Buildable", menuName = "BuildingObjects/Create Buildable")]
public class BuildingObjectBase : ScriptableObject
{
    [SerializeField] private Category category;
    [SerializeField] private TileBase tileBase;
    [SerializeField] private PlaceType placeType;
    
    // Public Getter
    public Category Category => category;
    public TileBase TileBase => tileBase;
    public PlaceType PlaceType => placeType;
}

BuildingObjectBasePlaceType enum을 추가하고, single, line, rectangle을 만든다. single은 가구 등과 같이 동시에 여러 개를 주르르 설치할 필요가 없는 구조물이 해당된다.

드래그 Input

LeftClick 이벤트를 핸들링하는 함수 하나에서 모든 작업을 처리할 것이며, InputAction.CallbackContext를 통해 조건을 분기해 나갈 수 있다.

먼저 짧게 누르는 클릭의 경우 cxt.interactionTapInteraction이고, cxt.phasestarted에서 performed로 변경된다.
그러나 길게 누르고 있는 클릭의 경우 (드래그 포함) phaseperformed 대신 canceled 상태로 전환되며, 곧이어 바로 cxt.interactionSlowTapInteraction으로 전환된다.

로그를 찍어보면 짧게 누르는 클릭의 경우 다음과 같이 기록된다.

길게 누르는 클릭의 경우에는, 먼저 TapInteraction으로 전환되었다가, 일정 시간이 지나면 SlowTapInteraction으로 넘어간다.

또는 애매한 시간동안 눌렀다가 떼면, SlowTapInteractioncanceled로 끝나는 것을 볼 수 있다.

이 시간은 Input Manager 패키지 설정에서 설정할 수 있다.

현재 홀드를 하고 있는지를 나타내는 holdActive 불리언 값과, 홀드를 시작했을 때의 좌표를 나타내는 holdStartPosition을 만든다.

        if (_selectedObj != null && !EventSystem.current.IsPointerOverGameObject())
        {
            // Debug.Log(ctx.interaction + " / " + ctx.phase);
            
            if (ctx.phase == InputActionPhase.Started) // 누르기 시작
            {
                holdActive = true;
                // 이 조건이 없으면 SlowTapInteraction.Started에서 값이 갱신되어 마우스를 빠르게 이동시키는 경우 끊기게 된다.
                if (ctx.interaction is TapInteraction)
                {
                    holdStartPosiiton = _currentGridPosition;
                }
                HandleDrawing();
            }
            if ((ctx.interaction is TapInteraction && ctx.phase is InputActionPhase.Performed) ||
                (ctx.interaction is SlowTapInteraction && ctx.phase is InputActionPhase.Performed) ||
                (ctx.interaction is SlowTapInteraction && ctx.phase is InputActionPhase.Canceled)) // 마우스를 뗌
            {
                holdActive = false;
                HandleDrawRelease();
            }
        }

bounds 설정

드래그를 했다가 드롭했을 때, 시작점과 끝점이 이루는 직사각형의 좌표를 구해야 한다. 이 때 BoundsInt 자료형을 쓰면 간편하게 저장할 수 있다.

드래그하는 방향에 따라 끝점의 x좌표, y좌표가 시작점의 x, y좌표보다 클 수도, 작을 수도 있다. 따라서 범위를 지정할 때는 두 값의 차의 절댓값을 사용한다.

bounds.xMin = _currentGridPosition.x < holdStartPosiiton.x ? _currentGridPosition.x : holdStartPosiiton.x;
bounds.xMax = _currentGridPosition.x > holdStartPosiiton.x ? _currentGridPosition.x : holdStartPosiiton.x;
bounds.yMin = _currentGridPosition.y < holdStartPosiiton.y ? _currentGridPosition.y : holdStartPosiiton.y;
bounds.yMax = _currentGridPosition.y > holdStartPosiiton.y ? _currentGridPosition.y : holdStartPosiiton.y;

    private void DrawBounds(Tilemap map)
    {
        for (int x = bounds.xMin; x <= bounds.xMax; x++)
        {
            for (int y = bounds.yMin; y <= bounds.yMax; y++)
            {
                map.SetTile(new Vector3Int(x, y, 0), _selectedObj.TileBase);
            }
        }
    }

그 결과 맛좋은 영역 에디터가 만들어졌다.

Line 타입의 경우

라인 타입의 타일, 즉 벽을 건설함에 있어 드래그 동작을 하면 한쪽 방향으로만 확장되어야 한다. 그리고 이 방향은 두 점이 이루는 직사각형 중 더 길이가 긴 곳이다.

따라서 두 지점의 좌표 차이를 구해서 가로 세로 중 어디가 더 긴지 판단한 후에 bounds를 설정해야 한다.

    private void LineRenderer()
    {
        float diffX = Mathf.Abs(_currentGridPosition.x - holdStartPosiiton.x);
        float diffY = Mathf.Abs(_currentGridPosition.y - holdStartPosiiton.y);

        if (diffX >= diffY) // 가로
        {
            bounds.xMin = _currentGridPosition.x < holdStartPosiiton.x ? _currentGridPosition.x : holdStartPosiiton.x;
            bounds.xMax = _currentGridPosition.x > holdStartPosiiton.x ? _currentGridPosition.x : holdStartPosiiton.x;
            bounds.yMin = holdStartPosiiton.y;
            bounds.yMax = holdStartPosiiton.y;
        }
        else // 세로
        {
            bounds.xMin = holdStartPosiiton.x;
            bounds.xMax = holdStartPosiiton.x;
            bounds.yMin = _currentGridPosition.y < holdStartPosiiton.y ? _currentGridPosition.y : holdStartPosiiton.y;
            bounds.yMax = _currentGridPosition.y > holdStartPosiiton.y ? _currentGridPosition.y : holdStartPosiiton.y;
        }
        
        DrawBounds(previewTileMap);
    }

다음 글에서 심화된 기능을 만들어 보도록 하고 우선 여기까지...

profile
베이비 게임 개발자

0개의 댓글