2차원 지형을 표현하는 맵은 어떻게 구현할 수 있을까요?
이번 포스팅에서는 비트연산을 활용하여 2차원 Map Editor를 만드는 방법에 대해 알아보겠습니다.
https://cupnooble.itch.io/sprout-lands-asset-pack
저는 위의 무료 애셋을 사용했습니다.
참고로 예시처럼 sprite 이미지로 저장되어 있는 애셋을 사용하면 코드작성 및 리소스 관리측면에서 편리하고 네트워크 요청을 한번만 보내기 때문에 성능상 유리할 수 있습니다.
만일 타일 이미지가 따로 저장되어있는 애셋을 구했다면 Sprite generator(링크) 툴을 이용하여 만들어 사용하기를 권장합니다.
맵 애셋을 준비했으면 각 타일들의 정보를 데이터로 저장해야합니다.
저는 각 타일의 데이터를 key(비트마스크) => value(sprite상의 좌표)
형식의 Map 데이터로 저장했습니다.
각 맵의 타일모양은 위와같이 3x3 그리드 형식으로 표현될 수 있습니다.
이 타일들을 비트마스크 형식의 데이터로 치환하기위해 빈 곳을 0, 땅을 1이라고 했을 때,
가장 왼쪽위의 타일은
000
011
011
이 됩니다. 그리고 sprite 좌표상으로는 0번째 행과, 0번째 열에 있습니다.
그럼 해당 타일에 대한 정보는 000011011 => [0,0]
이 되는 것입니다.
export const GRASS_COORDINATE = new Map([
[0b000011011, [0, 0]],
[0b000111111, [0, 1]],
[0b000110110, [0, 2]],
[0b000010010, [0, 3]],
[0b000011010, [0, 4]],
[0b000111110, [0, 5]],
[0b000111011, [0, 6]],
[0b000110010, [0, 7]],
[0b000111010, [0, 8]],
[0b110111011, [0, 9]],
[0b011011011, [1, 0]],
[0b111111111, [1, 1]],
[0b110110110, [1, 2]],
[0b010010010, [1, 3]],
[0b011011010, [1, 4]],
[0b111111110, [1, 5]],
[0b111111011, [1, 6]],
[0b110110010, [1, 7]],
[0b111111010, [1, 8]],
[0b011111110, [1, 9]],
[0b011011000, [2, 0]],
[0b111111000, [2, 1]],
[0b110110000, [2, 2]],
[0b010010000, [2, 3]],
[0b010011011, [2, 4]],
...
]);
Map의 key는 이진수 리터럴로 작성합니다. (댓글로 의견주신 superlipbalm님 감사합니다)
여기서 순수 Object가 아닌 Map 타입을 쓴 이유는, Number타입을 key로 사용하기 위해서입니다.
순수 Object는 String, Symbol값만을 key로 가질 수 있습니다. ({1:'hello'}
와 같이 Object를 선언하는것이 가능하지만 이 경우도 key가 String 타입으로 묵시적 형변환 되는 것입니다.)
그런데, 각 맵타일이 3*3 그리드, 즉 9비트짜리 이진수와 대응된다는 점에서 착안하면 훨씬 많은 타일이 있어야하지 않을까요?
비어있는 맵을 제외하면 000000001 ~ 111111111 에 해당하는 타일이 모두 있어 총 2^9-1개의 타일이 있어야 하는거 아닌가?
라는 의문이 들 때쯤, 가져온 map asset을 다시 살펴봅시다.
보아하니 모든 타일들은 두가지 가정을 만족합니다.
- 중앙 비트(5번째비트)가 1이다.
- 꼭지점 비트(1,3,5,7번째 비트)가 1인 경우, 해당 비트와 인접한 두개의 비트도 모두 1이다.
이는 타일들이 밟을 수 있는 땅(중앙비트가 1)만을 표현하며, 타일이 이어질때는 항상 면끼리 맞대어서 이어진다는 것을 의미합니다.
위의 두 가정을 만족하지 못하는 타일 데이터는 asset에 존재하지 않는다. 즉, 빈 땅으로 보여지는 유효하지 않은 타일이다.
위 규칙을 잘 활용하면, 47개의 타일로도 면으로 인접했을 때 이어지는 맵 을 충분히 구현해낼 수 있습니다.
참고로 꼭짓점 비트를 기준으로 세어보면 타일의 경우의 수를 쉽게 찾아낼 수 있습니다.
그렇다면 현재 편집중인 map 데이터는 어떻게 저장하면 좋을까요?
저는 맵 데이터를 유효한 데이터로 변경하는 전처리를 위해 raw
, real
이라는 두가지 배열을 선언하여 사용했습니다.
가령, 위처럼 맵을 그리면 데이터는 다음과 같이 변합니다. (주변의 비어있는 부분은 편의상 생략했습니다.)
raw
: 땅과 주변에 이어진 땅의 유무를 모두 가지고 있는 데이터 배열
real
: raw 데이터를 유효한 타일 데이터로 변경한 데이터 배열
raw = Array.from(Array(CONSTANT_ROW), () => Array(CONSTANT_COL).fill(0))
real = Array.from(Array(CONSTANT_ROW), () => Array(CONSTANT_COL).fill(0))
원하는 맵 에디터의 행, 열만큼 0으로 채운 동일한 Array로 선언합니다.
raw 데이터의 선택셀과 주변 셀을 확인하여 유효 타일로 변환 후 real 배열에 반영
[유효타일 변환 과정]
- 중앙비트가 1이 아닌경우 -> 000000000
- 모든 꼭짓점을 확인하여 꼭짓점 비트가 1이면서 해당 꼭짓점과 인접한 비트가 1이 아닌경우 해당 꼭짓점비트 0으로 변경
위의 작업들을 처리하기위해 자바스크립트에서 지원하는 몇가지 비트 연산자들을 사용할 수 있었습니다.
n번 비트를 1로 변경
bitData |= 1 << n-1
n번 비트가 1인지 확인
bitData & (1 << n-1)
n번 비트를 0으로 변경
bitData &= ~(1 << n-1)
꼭짓점 비트가 1이면서 꼭짓점 주변 비트가 1이 아닌지 확인
(bitData & (1 << 꼭짓점인덱스)) && !(bitData & (1 << 주변1인덱스) && bitData & (1 << 주변2인덱스))
const [coordX, coordY] = GRASS_COORDINATE.get(bitData)
...
<div style={{ background: `url(grass.png) -${coordY * GRID_SIZE}px -${coordX * GRID_SIZE}px` }} />
real 데이터 배열을 2중 map을 돌려 bitData로 뽑아내고, 위에서 정의한 GRASS_COORDINATE
를 이용하여 asset상의 좌표로 변환합니다.
이 좌표와 한 타일의 가로, 세로 픽셀크기에 해당하는 GRID_SIZE
를 이용하여 해당하는 맵 이미지를 가져올 수 있습니다.
(GRID_SIZE
는 본인이 사용하는 이미지에 맞게 정의해서 사용하면 됩니다.)
https://playcode.io/1083380
위의 링크를 누르면 react로 구현한 소스코드와 실행화면을 확인해 볼 수 있습니다.
잘 읽고갑니다