인터랙티브 맵 구현기

2023-12-10에 씀

메트로배니아는 게임 장르 중 하나로, 연결되어 있는 거대한 맵을 탐험하면서 새로운 능력을 얻어 나가며 스토리를 진행하는 방식으로 진행된다. 대표적인 게임으로는 데드 셀, 할로우 나이트, 블라스퍼머스 등이 있다.

수퍼 메트로이드라는 게임의 맵. 연결돼있는 거대한 맵을 탐험하며 숨겨진 아이템과 능력을 해금해나가는 장르이다. 출처

메트로배니아류 게임은 맵에 숨겨진 요소가 많은데, 초반 지역에 숨겨진 요소를 후반부에야 얻을 수 있는 경우가 있다. 그래서 유저들 사이에 숨겨진 요소를 표시해 놓은 지도가 공유되기도 하고, 인터랙티브 맵을 만들기도 한다. 인터랙티브 맵이 있으면 이미 얻은 요소를 표시할 수 있어서 편하다.

최근에 메트로배니아 게임 중 하나인 Blasphemouse(블라스퍼머스)의 2편이 출시됐는데, 게임을 하다 보니 숨겨진 요소를 찾아다니기 위한 지도가 너무 필요해서 직접 만들게 됐다. 만드는 과정에서 특히 할로우 나이트 인터랙티브 맵에서 아이디어를 많이 얻었다. 🙏

지도를 만들기 위해 현재 이런 기능을 개발했다.

지도 그리기

먼저 화면에 지도를 그려야 한다. 이번에 그릴 지도는 그렇게 넓진 않지만, 이 지도 파일을 일정한 크기의 타일로 나눠서 그려줄 것이다.

타일로 나누는 이유는 커다란 지도 이미지를 한 번에 로드해 오는 것보다 작게 나눠진 이미지를 여러번 다운로드하는 것이 빠르기 때문이다. 그리고 화면에 보이는 타일만 DOM 트리에 포함시킬 수 있기 때문에 DOM 업데이트에 영향을 받는 노드의 수도 줄일 수 있다.

현재 화면에 보이는 영역에 대한 타일만 렌더링하기

지도의 좌표와 화면의 좌표를 알면 어느 위치의 타일을 그려야 할 지 계산할 수 있다.

타일 하나의 사이즈를 TILE_SIZE 라고 할 때,

1const startPointX = Math.floor((sx - mx) / TILE_SIZE);
2const startPointY = Math.floor((sy - my) / TILE_SIZE);
3const lastPointX = Math.ceil((sx - mx + screenX) / TILE_SIZE) + 1;
4const lastPointY = Math.ceil((sy - my + screenY) / TILE_SIZE) + 1;

이렇게 찾을 수 있다. 이에 해당되는 타일만 렌더링되도록 하면 된다.

카메라 이동

screenLeftTop만큼 카메라를 이동했다는 것은, screenLeftTop만큼 지도를 움직였다는 것으로도 해석 가능하다.

DOM 구조를 이렇게 잡아서 카메라와 지도를 따로 관리하게 했다.

1 layerContainer: transform(${-sx}px, ${-sy}px)
2 mapContainer: transform(${mx}px, ${my}px)

map의 기준점과 screen의 기준점은 따로 고려하는 게 좋다. 마커 등 지도 위에 오버레이되는 여러 요소가 있는데, 이런 요소의 위치를 좀 더 편하게 계산할 수 있도록 지도의 기준점만 고려할 수 있는 환경을 만들어 주기 위해서다.

줌 인, 줌 아웃

줌을 구현하기 위해 scale 속성을 사용하진 않았다. 대신, 지도를 확대할수록 더 큰 축척의 지도를 사용하게 했다. 축척이 커지면 지도의 크기 자체도 커진다. 지도를 타일로 잘라서 제공하고 있기 때문에 아무리 큰 지도를 사용하더라도 적은 부담으로 빠르게 보여줄 수 있다.

기준점 잡기

자연스럽게 확대/축소를 구현하기 위해선 화면의 위치를 적절히 조정해줘야 한다.

mapLeftTop이 0,0에 그대로 위치하는 상태에서 지도를 확대했을 때의 스크린 위치를 나타낸 것이다. 확대되기 전의 스크린에서 확인할 수 있는 영역은 확대된 후 분홍색으로 색칠된 사각형 영역이 된다. 원래 분홍색 점 위치에 있던 오브젝트는 파란색 점 위치에 그려진다.

지도가 확대된 후에도 스크린 위치가 동일하다면 위 그림처럼 이전에 보고 있던 것과 다른 부분을 보게 된다. 이런 부자연스러움을 해결하기 위해서는 특정 포인트를 기준으로 확대/축소가 일어나도록 카메라 위치를 조정해줘야 한다.

축소 이전의 분홍색 점을 기준점으로 잡았을 때, 확대 이후의 지도에서 분홍색 점이 위치하게 될 곳에 스크린을 맞춰 주면 위의 그림과 같게 된다. 이렇게 하면 원래 보고 있던 화면을 좀 더 확대해서 볼 수 있게 된다.

기준 포인트는 스크린의 정가운데가 될 수도 있고, 마우스 휠 업/다운으로 줌이 일어나는 경우에는 마우스 포인터의 위치가 될 수 있다.

이제 기준점을 맞춰주기 위한 계산을 해야 한다. 스크린을 기준점 위치로 옮겨줄 수도 있고, 새 지도를 기준점에 맞춰서 그릴 수도 있다.

두 방법 중에서, 스크린은 가만히 있고 mapLeftTop을 이동시키는 방법을 사용했다.

1const newMapLeftTopX = mx - cx * (n - 1);
2const newMapLeftTopY = my - cy * (n - 1);

그러면 이렇게 자연스러운 확대/축소를 구현할 수 있다

마커 그리기

마커의 기본 위치는 지도의 배율이 1일 때, mapLeftTop으로부터 떨어진 거리로 계산했다. 그러면 mapLeftTop이 정해져 있을 때, 마커의 기본적인 위치는 { x: mx + px * scale, y: my + py * scale } 이 된다.

결론

짧은 구글링으로는 지도를 구현하는 방법에 대한 AtoZ가 나오진 않아서, 예전에 주워 들은 지도 렌더링 지식 약간과 다른 서비스를 뜯어 보면서 추측한 내용으로 구현해 봤다. 화면 이동과 확대, 축소를 통해 좌표가 계속 바뀌다 보니 좌표를 관리하는 게 가장 어려웠던 것 같다. 좌표 계산이 복잡하다 보니, 지도와 화면, 마커의 좌표를 최대한 분리해서 관리하는 게 중요한 것 같다.

아직 핀치 줌과 같은 모바일 지원은 부족한 상태다. 터치 이벤트에서 화면 이동과 줌 이벤트를 함께 지원하려니 좀 어려운 것 같다..; 아직 추가해 보고 싶은 기능이 많은데, 게임 엔딩 보기 전에 완성하고 싶다...

프론트엔드 프레임워크로 Qwik을 사용하다가, 컴포넌트나 이벤트 핸들러를 사용할 때마다 보일러 플레이트가 큰 것 같아서 Solid로 갈아탔다. 아직까지는 React에 비해 만족스러운 것 같다. 시그널이 확실히 리액트 상태에 비해 편리한 것 같고, 다음 사이드 프로젝트에서도 Solid를 사용해 볼 것 같다. 더 자세한 비교는 다른 글에서 해보려고 한다.

프로필 사진

조예진

이전 포스트
SSR 서버 만들기 (3) - Linaria로 CSS-in-JS 적용하기
다음 포스트
Storybook Interaction Test를 활용한 바텀시트 시각적 테스트