타일형 게임을 구현할 때 유니티에서 제공하는 타일맵을 사용하면 팔레트를 이용해 쉽게 타일을 배치 할 수 있다는 장점이 있다. 단순히 타일을 배치하는 것만으로 충분할 수도 있지만, 구현하려는 게임에 따라 각 타일에 데이터를 저장 해야 할 때가 있다. 어떻게 해야할까? 먼저 타일맵부터 설명하고 그 이후 구현 방법을 서술하겠다.
타일맵에서 특정 좌표의 타일 얻기(또는 셀 좌표 얻기)
타일맵은 그리드 형태로 되어있으며 각 타일(셀)의 좌표는 다음과 같이 얻을 수 있다.
Tilemap tilemap; // 타일맵 (GetComponent로 얻거나 인스펙터에서 할당해줬다고 가정)
Vector3 localPos; // 로컬 포지션
Vector3 worldPos; // 월드 포지션
Vector3Int worldToCellPos = tilemap.WorldToCell(worldPos);
Vector3Int localToCellPos = tilemap.LocalToCell(localPos);
LocalToCell
은 로컬 포지션으로 셀 포지션을 얻고, WorldToCell
은 월드 포지션으로 셀 포지션을 얻는 함수이다. 다른 포지션들과 셀 포지션의 차이는 뭘까? (월드 포지션과 로컬 포지션의 차이는 이 글에서 다루지 않겠다.)
예를 들어 한 셀의 크기가 2 x 2이고 월드 좌표에서 원점이 (0, 0)이라고 해보자.
왼쪽 셀은 월드 좌표가 (0, 0)이고 셀 좌표도 (0, 0)이지만, 셀 크기가 2이므로 다음 셀의 월드 좌표는(2, 0)이 된다. 하지만 셀 좌표로 보면 1개의 셀 건너에 있으므로 셀 좌표는 (1,0)이 된다. 셀 좌표는 2D 배열의 인덱스처럼 생각하면 된다. 이를 이해하면 위의 코드에서 왜 셀 좌표가 Vector3Int인지도 이해가 될 것이다. 셀은 무조건 1개, 2개처럼 정수개가 있을 뿐 0.2개, 0.3개와 같이 소수개는 있을 수가 없기 때문이다.
이를 통해 우리는 월드 좌표나 로컬 좌표가 있을 때, 타일맵에서 그 좌표에 해당하는 셀 좌표를 얻을 수 있다.
그리고 더 나아가 셀 좌표를 이용해 타일이 있는지 없는지 판단하거나 타일을 얻을 수 있다.
관련 함수는 GetTile(Vector3Int position)
또는 HasTile(Vector3Int position)
이다. 이 함수들은 해당 좌표에 있는 타일 클래스인 TileBase
를 반환한다. 그렇다면 TileBase
클래스는 뭘까?
TileBase 클래스
TileBase
클래스는 ScriptableObject
를 상속받은 클래스로, 렌더링에 필요한 데이터인 TileData
구조체나 애니메이팅하는데 필요한 데이터인 TileAnimationData
구조체를 갖고 있다.
그렇다면 TileBase
클래스를 상속받은 새로운 타일 클래스를 만들어서, 변수를 추가해 여기에 저장하면 되는 것 아닐까?
// TileBase를 상속받은 새로운 타일 클래스
// 여기에 정보를 저장하면 되지 않을까?...
public class CustomTile : TileBase
{
public bool objectOnTile; // 오브젝트가 타일 위에 있는가?에 대한 정보
public int objectCount; // 타일 위에 있는 오브젝트 개수에 대한 정보
public int tileType; // 현재 타일의 타입에 대한 정보
// 예를 들면 0은 흙, 1은 물 등...
}
아쉽게도, 위의 두 정보는 안되고, 아래 타일 타입에 대한 정보만 저장해 활용할 수 있다. 차이가 뭘까?
앞서 말했듯 TileBase
클래스는 ScriptableObject
를 상속받는다. ScriptableObject
는 참조 형식으로 데이터를 읽고 쓴다. 예를 들어 동일한 ScriptableObject
데이터를 갖고 있는 인스턴스 A와 B가 있다고 하면, A의 데이터와 B의 데이터가 따로 인스턴스화되어 있는 것이 아니라 같은 데이터를 참조한다. 즉 데이터를 바꿨을 때 A와 B 모두에게 적용된다.
따라서 위 코드의 오브젝트 관련 멤버 변수처럼 동일한 타일이여도 각자 다른 정보를 가져야 할 필요가 있다면 TileBase
를 상속받는 방법으로는 불가능하다. 왜냐면 한 타일의 정보를 바꾸면 동일한 클래스인 타일의 정보가 모두 바뀌기 때문이다. 반면 타일 유형 변수인 tileType
처럼 같은 타일들이 같은 데이터를 갖고 있을때는 활용할 수 있다.
그렇다면 우리는 타일마다 데이터를 인스턴스화해서 관리해야 한다.
구현
저장하려는 데이터가 한 두개라면 기본적으로 제공하는 자료형만으로도 충분하다.
// 예시
// 타일의 정보를 관리하는 클래스
public class TileManager : MonoBehaviour
{
public Dictionary<Vector3Int, bool> objectOnTile; // 오브젝트가 타일 위에 있는가?에 대한 정보
public Dictionary<Vector3Int, int> objectCount;// 타일 위에 있는 오브젝트 개수에 대한 정보
public Tilemap tilemap; // 타일맵 컴포넌트
private void Start()
{
// 타일맵 내의 셀 좌표들에 대해서 타일이 있다면 딕셔너리에 초기 정보를 추가한다.
foreach(Vector3Int pos in tilemap.cellBounds.allPositionsWithin)
{
// 해당 좌표에 타일이 없으면 넘어간다.
if(!tilemap.HasTile(pos)) continue;
// 해당 좌표의 타일을 얻는다.
var tile = tilemap.GetTile<TileBase>(pos);
// 정보 초기화
objectOnTile.Add(pos, false);
objectCount.Add(pos, 0);
}
}
}
반면 저장하려는 데이터가 많으면 차라리 구조체나 클래스를 만드는 게 편할 것이다.
// 예시
// 새롭게 추가된 데이터 클래스
public class CustomData
{
bool objectOnTile;
int objectCount;
// ...
}
// 타일의 정보를 관리하는 클래스
public class TileManager : MonoBehaviour
{
public Dictionary<Vector3Int, CustomData> dataOnTiles; 타일의 데이터
public Tilemap tilemap; // 타일맵 컴포넌트
private void Start()
{
// 타일맵 내의 셀 좌표들에 대해서 타일이 있다면 딕셔너리에 초기 정보를 추가한다.
foreach(Vector3Int pos in tilemap.cellBounds.allPositionsWithin)
{
// 해당 좌표에 타일이 없으면 넘어간다.
if(!tilemap.HasTile(pos)) continue;
// 해당 좌표의 타일을 얻는다.
var tile = tilemap.GetTile<TileBase>(pos);
// 정보 초기화
dataOnTiles[pos] = new CustomData();
}
}
}
딕셔너리는 해시 충돌이 발생하지 않는 한 삽입, 삭제, 검색이 상수 시간인 O(1)에 가능하다. 이렇게 하면 셀 좌표 cellPos
가 있을 때 dataOnTiles[cellPos]
와 같이 접근이 가능하다. 또한 위와 같이 초기화 시 모든 타일의 정보값을 딕셔너리에 추가해놓으면, 나중에 특정 좌표로 타일 정보값을 구할 때 KeyNotFoundException
이 발생할 일이 없다(편하다)는 장점이 있다. 하지만 메모리가 낭비되는 단점이 있는데, 이를 방지하기 위해 데이터를 저장하려고 할 때에만 딕셔너리에 값을 추가하는 방식도 있겠지만 보통의 경우는 타일맵의 크기가 그리 크지 않아 문제가 없을 것이다.
배열로도 셀 좌표의 x, y값을 토대로 타일 데이터들의 컨테이너를 만들 수 있지만 셀 좌표가 음수일 때 이것을 그대로 인덱스로 쓰면 안되므로 처리를 해줘야 한다. 배열로 만들면 데이터가 연속되어 있기 때문에 캐시 친화적이라는 장점이 있다. 하지만 단점은 메모리이다. 딕셔너리와 달리 저장하려는 데이터가 없을 때에도 항상 배열은 초기값을 들고 있어야 하기 때문이다.
딕셔너리와 배열의 차이는 메모리 오버헤드의 차이다. 배열은 정보값의 변경이 없어도 모든 타일에 대해 초기값을 가지고 있어야 하지만, 딕셔너리는 데이터의 변경이 필요할 때만 딕셔너리에 추가하는 방식으로 사용하면 메모리가 절약된다.예를 들어 타일 10,000,000개 중 한 타일에 불을 질렀다는 의미에서 1을 저장한다고 가정해보자. 배열은 9,999,999개의 타일에 0을 저장하고 1개의 타일에 1을 저장해야 한다. 반면 딕셔너리는 해당 타일의 좌표키의 값에 1을 저장하면 끝이다.
하지만 이것도 데이터가 필요한 타일의 개수에 따라 다를 것이다. 예를 들어 타일 10,000,000개 중 80%의 타일에 불을 질렀다는 의미로 1을 저장한다면? 딕셔너리는 해시 충돌때문에 오버헤드가 발생할 수도 있고... 그럴 것이다.
장단점만 알아놓고 상황에 따라 잘 판단해서 사용하자.
번외
Vector3
를 딕셔너리의 키값으로 사용하면 어떨까?
최근 GMTK 게임잼에서 타일맵을 사용하면서 Vector3
를 키값으로 사용했는데, 문제가 있었다. 플레이어의 좌표가 (0, 0, 0)일 때 transform.position
으로 딕셔너리의 키를 검색하면 어쩔때는 잘되고 어쩔때는 KeyNotFoundException
이 발생했다. 아무래도 플레이어 좌표는 계속해서 연산이 되기 때문에 내 추측으로는 소수점 정밀도 관련해서 오류가 발생하는 것 같았다.
게임 잼 중이라 시간도 별로 없었고 오히려 리팩토링 과정 중에 더 헷갈릴 것 같아서, 차라리
transform.position
으로 셀 좌표를 얻고 다시 셀 좌표로 월드 좌표를 얻은 다음, 이것으로 딕셔너리의 키를 검색하는 방식을 사용했다. (트랜스폼 좌표 -> 셀 좌표 -> 월드 좌표) 그랬더니 정말 다행히도 고쳐져서 예외가 발생하지 않았다.
이를 토대로 봤을 때, Vector3
도 키값으로 사용이 가능하긴 하지만 버그가 발생할 수 있으니 Vector3Int
를 사용하는게 안전해보인다.