랜덤 맵 시스템 개발
플레이어의 이동과 기초적인 카메라 시점 조정까지 완료했으면, 플레이어가 다른 맵으로 이동했을 때 어떤 식으로 카메라가 동작하는지 확인하고, 카메라의 움직임을 의도대로 구현할 차례다.
하지만 그 전에, 아이작, 로그라이크 장르의 뼈대가 되는 랜덤 맵 생성을 구현하는게 우선이었다.
(그래서 사전에 필요한 지식을 습득하고자 자료구조 공부를 3~4달에 걸쳐 해왔고, 지금도 계속 진행중이다)
랜덤 맵 생성 방식은 여러가지가 있을텐데, 그 중 하나를 구현하고자 자료구조 개념을 공부했다.
https://www.youtube.com/watch?v=qAf9axsyijY&list=PLBIb_auVtBwA-qr2-WnWX0LjZXkqKu5Aj
여러 관련 자료도 찾아보고 유니티 공식문서도 찾아보던 중에 꽤 유용한 영상을 하나 발견했다.
요약하자면 다음과 같다.
1. 맵 구성에 관계없이 시작하는 방(Start), 탈출하는 방(Exit)이 있어야한다.
2. 시작방과 탈출방 사이 명확한 경로(Route)가 있어야한다.
: 플레이어가 막히지 않고 목적지까지 이동할 수 있어야한다.
3. 던전은 닫혀있는 폐공간이어야 한다.
https://www.youtube.com/watch?v=1-HIA6-LBJc
추가로, Binding of Issac의 핵심 개발자이신 Florian Himsl 개발자께서 아이작의 Room Generation에 대해 설명하신 영상도 있어서 참고할 수 있었다.
1. 먼저 하나의 방으로 시작하고, 각 방에는 서로 인접한 여러 방이 있으며 이는 무작위이다.
2. 첫번째 방으로 시작하고 가능한 모든 문(상/하/좌/우)에 대해 무작위 검사를 하고 거기에 방을 배치한다.
-> 이것이 원하는 수의 방이 될때까지 이것을 반복한다.
3. 랜덤으로 배치된 빈 방이 있고, 일부에 특별한 속성이 있다고 결정한다.
4-1. 그리고 이웃이 하나 뿐인 방을 END ROOMS라고 한다. 이 END ROOMS 중 어떤 중요한 것이 있는지 선택한다.
Ex) 상점, 아이템, 보스방(시작점에서 가장 멀리 떨어진 방 중 결정)
*여기서 비밀 방은 고려하지 않는다.
4-2. 비밀방은 랜덤 맵 배치 이후에 배치되며, 폭탄 등을 활용해 키를 사용하지 않고 아이템룸으로 갈수도 있다.
처음부터 빈 방들의 지도를 만들고, 방을 채우는 방식으로 무식한? 방법으로 하게된다면, 메모리 소모도 많이하고, 불필요한 맵 리소스로 로딩시간을 늘리는 원인이 될 것이다.
5. 기존 방 목록을 여러 개 만들어두고, 방의 문에 따라 선택하여 시작하는 쪽에 문이 있는 방을 배치한다.
6-1. 방 목록에는 Easy / Medium / Hard로 각 40개 정도씩 총 120개의 방이 있는데, 스테이지마다 Easy + Medium 또는 Medium + Had의 방 목록으로 구성되는 방식으로 구성될 수 있다.
6-2. 각 방의 난이도 카운터를 만들고, 전체 난이도를 계산하도록 만들수도 있다. 추가로 적이 얼마나 있는지도 확인할 수 있다.
Up, Down, Left, Right 또는 North, West, South, East 또는 Top, Bottom, Left, Right 등 여러가지로 표현할 수 있겠지만, 위와같이 동서남북 표기를 기본으로 하였다. (플레이어 조작 표기 시 상하좌우 사용)
각 문에는 그 문에 연결된 다른 방을 스폰시키는 SpawnPoint가 있어야한다. 그리고 그 SpawnPoint는 Rigidbody2D, Box Collider2D 컴포넌트 등 적절한 설정이 필요하다. + 스크립트 포함
프리펩으로 생성해주고, 시작방을 제외한 나머지 오브젝트를 Hierarchy에서 삭제해준다.
using UnityEngine;
public class RoomSpawner : MonoBehaviour
{
public int openingDirection;//Key of OpeningRoom(생성지점에 대한 키)
//1 -> need top door
//2 -> need bottom door
//3 -> need left door
//4 -> need right door
private RoomTemplates roomTemplates;
private int rand;
public bool spawned = false;
void Start()
{
roomTemplates = GameObject.FindGameObjectWithTag("Rooms").GetComponent<RoomTemplates>();
Invoke("Spawn", 0.1f);
}
private void Spawn()
{
if(spawned == false)
{
if (openingDirection == 1) //아래쪽 문이 있는 방 생성
{
rand = Random.Range(0, roomTemplates.bottomRooms.Length);
Instantiate(roomTemplates.bottomRooms[rand], transform.position, roomTemplates.bottomRooms[rand].transform.rotation); //회전을 원하지 않으면 Quaternion.identity
}
else if (openingDirection == 2) //위쪽 문이 있는 방 생성
{
rand = Random.Range(0, roomTemplates.topRooms.Length);
Instantiate(roomTemplates.topRooms[rand], transform.position, roomTemplates.topRooms[rand].transform.rotation); //회전을 원하지 않으면 Quaternion.identity
}
else if (openingDirection == 3) //오른쪽 문이 있는 방 생성
{
rand = Random.Range(0, roomTemplates.bottomRooms.Length);
Instantiate(roomTemplates.rightRooms[rand], transform.position, roomTemplates.rightRooms[rand].transform.rotation); //회전을 원하지 않으면 Quaternion.identity
}
else if (openingDirection == 4) //왼쪽 문이 있는 방 생성
{
rand = Random.Range(0, roomTemplates.leftRooms.Length);
Instantiate(roomTemplates.leftRooms[rand], transform.position, roomTemplates.leftRooms[rand].transform.rotation); //회전을 원하지 않으면 Quaternion.identity
}
spawned = true;
}
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("SpawnPoint") && collision.GetComponent<RoomSpawner>().spawned == true)
{
Destroy(gameObject);
}
}
}
.... (후략)
위 영상의 내용을 따라갔지만, 아무래도 시스템의 원리를 정확히 파악을 못하고 영상에만 의존하게되는 문제가 있었다. 문제가 발생해도 이해가 부족한 상황에서 어떤 원인으로 발생한 것인지도 모르고, 결국 내가 의도했던 랜덤 맵과 그 기능에 대한 누락되거나 아쉬운 부분들이 구현이 부족하게 되어서 위 영상에서 소개된 알고리즘은 참고만 하고 직접 구현하는 쪽으로 방향을 잡았다.
우선 랜덤맵을 구현하기 위해 위 3가지 조건은 지켜져야한다. 그러나, 위 영상에서 소개된 알고리즘은 몇 가지 문제가 있었는데,
1. 각 오브젝트(방)이 갖는 맵 생성 포인트(Respawn Point)에서 그 다음 맵이 생성되기 때문에, 해당 맵의 좌표정보나 생성 순서에 대한 정보를 관리할 수 없는 문제가 발생한다.
: 아무래도 이런 방식이라면 이전 맵 정보를 통해 다음 맵에 접근해야할것이다.
그래서 위 영상 따라하기는 그만하고, 공식문서를 뒤져가며 개발하기 시작했다.
그런데 '방을 생성하는' 테스트 중에 프리펩이 무한으로 생성되는 똑같은 문제가 발생했는데, 결국 프리펩을 생성하는 코드인 RoomSpawner 스크립트를 프리펩 역시 갖고있어서 코드 상으로 딱 한번만 생성하게끔 작성했더라도 자기 자신을 계속 한번 씩 소환하게 되는 것이 원인이었다. 마치 재귀호출과 같은 문제이므로 종료조건만 올바르게 설정해주면 해결될 것이다.
추가로, 맵 중앙에 생성되는 방 오브젝트와 달리 SpawnPoint 오브젝트는 처음에 직접 입력하여 지정해준 Transform 좌표에 생성되는 문제도 있었는데, 이 문제에 대해서는 다른 글에서 다뤄보았다.
Grid Cell Coordinate(격자 셀 좌표) <->World Coordinate(월드 좌표) 변환
처음에는 플레이어가 들어가 방이 활성화될 때 방의 랜덤 구조가 순차적으로 결정된다고 생각했는데, 처음부터 맵의 전체적인 랜덤 방 구조가 결정된다.