공부
랜덤 맵 생성] 1. 횡스크롤 수평 랜덤맵 생성하기
때려쳐아니때려치지마
2025. 2. 9. 18:38
구현 목표 리스트
- 1920 * 1080을 기준으로 16 : 9 비율의 화면에서 수평적인 랜덤 맵을 생성한다.
- 수평칸 내에서 생성되는 방은 (맵 내의 분리된 개별 공간을 ‘방’이라고 명명) 서로 떨어져 있다.
- 수직적으로 연결되어있는 방은 이동할 수 있는 최소 사이즈 면적이 붙어있다
최종 룩
구현 과정
- Random Tree를 생성한다
- 생성된 트리를 방으로 시각화한다
1. 맵 slice 및 grid의 최소 사이즈 설정
먼저 화면을 n:m 으로 slice하여 grid를 생성한 후 최소 칸 사이즈를 설정한다.
좌측 하단을 (0, 0)으로 x의 최대는 n, y의 최대는 m으로 화면을 나타낼 수 있다.
2. Random Tree 생성
이런식으로 방이 서로 이어지는 최대 이진 트리를 랜덤하게 생성한다.
부모 노드의 자식 노드는 각각 생성률이 70%의 확률로 0~2개중 랜덤하게 생성된다.
[Serializable]
public class Node
{
public int x; // X 좌표 (가로 위치)
public int y; // Y 좌표 (세로 위치)
public int length; // 길이
public Node left;
public Node right;
public Node(int x, int y)
{
this.x = x;
this.y = y;
this.length = 0; // 초기값 (나중에 설정)
}
}
각 노드는 아래를 변수로 갖는다.
- x : 시작 x 좌표
- y : 시작 y 좌표
- length : 방의 가로 길이
- left : 왼쪽 자식 노드
- right : 오른쪽 자식 노드
private const int GRID_WIDTH = 16;
private const int GRID_HEIGHT = 9;
public SerializedDictionary<int, List<Node>> levels = new SerializedDictionary<int, List<Node>>(); // int : 층(y), List<Node> : 노드
public SerializedDictionary<int, int> floorWidthPair = new SerializedDictionary<int, int>();
/// <summary>
/// 랜덤 이진 트리 생성
/// </summary>
/// <param name="x">길 블록 Grid의 x 좌표</param>
/// <param name="y">길 블록 Grid의 y 좌표</param>
/// <param name="depth">트리의 크기를 결정하는 변수</param>
/// <param name="parent">부모 노드</param>
/// <returns></returns>
private Node GenerateRandomBinaryTree(int x, int y, int depth, Node parent)
{
if (depth <= 0 || x >= GRID_WIDTH || (floorWidthPair.TryGetValue(y, out int value) && value < ROAD_SIZE_MIN)) return null;
Node node = new Node(x, y);
// 랜덤 길이 할당
AssignLengthsPerLevel(node);
// 랜덤 x 위치 할당 (이전 노드의 길이를 고려해서 x start 위치의 랜덤 배치)
if (x != 0) // 최초 노드 생성시는 x를 0으로 유지
{
if(levels.TryGetValue(y, out List<Node> nodes))
{
Node latestNode = nodes[nodes.Count - 1];
// 같은층의 이전 노드 끝점의 한칸뒤와 부모 노드의 끝점 한칸(이동공간) 뒤의 영역에서 랜덤 생성
x = UnityEngine.Random.Range(latestNode.x + latestNode.length + 1, x - 1);
}
else
{
// 부모의 시작점 + 1 - length ~ x-1 사이에서의 랜덤값
x = UnityEngine.Random.Range(Mathf.Max(0, parent.x + 1 - node.length), x - 1); // x가 가설과 맞는지 확인필요
}
node.x = x;
}
// 층 리스트에 추가
if (!levels.ContainsKey(y))
levels[y] = new List<Node>();
levels[y].Add(node);
// 해당 층의 수용량 갱신
CheckCapacity(node);
Debug.Log($"[X:{node.x}, Y:{node.y}, Length:{node.length}] === capacity:{floorWidthPair[node.y]}");
// 자식 생성률
bool hasLeft = UnityEngine.Random.value > 0.3f; // 70% 확률로 왼쪽 자식 생성
bool hasRight = UnityEngine.Random.value > 0.3f; // 70% 확률로 오른쪽 자식 생성
// 자식 노드 생성 (위/아래 이동), x : 끝점 전달
if (hasLeft)
node.left = GenerateRandomBinaryTree(x + node.length, Mathf.Clamp(y - 1, 0, GRID_HEIGHT - 1), depth - 1, node);
if (hasRight)
node.right = GenerateRandomBinaryTree(x + node.length, Mathf.Clamp(y + 1, 0, GRID_HEIGHT - 1), depth - 1, node);
return node;
}
랜덤 Binary Tree 생성
/// <summary>
/// 노드의 층 기준으로 가로 길이를 제한하여 할당
/// </summary>
/// <param name="node"></param>
private void AssignLengthsPerLevel(Node node)
{
// 공간 수용량
int capacityWidth = GRID_REMAIN_MAX;
if (floorWidthPair.ContainsKey(node.y))
{
// 층 별로 남은 공간 사용량
capacityWidth = floorWidthPair[node.y];
}
// 수용량과 랜덤값 사이에서 min으로
int allocatedLength = UnityEngine.Random.Range(ROAD_SIZE_MIN, Mathf.Min(capacityWidth, ROAD_SIZE_MAX)); // 두칸 ~ 가로의 절반 사이에서 랜덤 길이
node.length = allocatedLength;
}
가로 길이 할당
private void CheckCapacity(Node node)
{
// 현재노드 끝점과 층의 끝 사이의 공간 확인
int curCapacity = GRID_WIDTH - (node.x + node.length + 1);
if (floorWidthPair.ContainsKey(node.y))
{
floorWidthPair[node.y] = curCapacity;
}
else
{
floorWidthPair.Add(node.y, curCapacity);
}
}
가로 층의 수용량 확인 및 갱신
3. Tree를 Debug Rect로 시각화 하여 테스트하기
// 생성된 tree 목록, capacity는 방 생성후 각 층의 수용 가능한 남은 공간을 표기
[X:0, Y:4, Length:7] === capacity:8
[X:1, Y:3, Length:5] === capacity:9
[X:8, Y:4, Length:7] === capacity:0
[X:9, Y:3, Length:4] === capacity:2
[X:9, Y:2, Length:3] === capacity:3
[X:14, Y:3, Length:2] === capacity:-1
[X:3, Y:5, Length:6] === capacity:6
[X:3, Y:6, Length:7] === capacity:5
[X:10, Y:5, Length:5] === capacity:0
[X:13, Y:6, Length:4] === capacity:-2
16 : 9로 grid가 그려진 화면에 위와같은 tree가 각 좌표에 그려진다
좌표 별 방을 대입하면 이런 느낌
현재는 디버그 화면으로 캔버스 좌표계에서 좌측하단 min (0, 0) ~ 우측상단 (16, 9) 그리드로 표현중이다.
실제 월드 좌표계에서 구현하려면 해당 값을 게임의 맵 사이즈에 맞게 Remap 해줘야 한다.
4. 월드 좌표에서 Tree를 map으로 생성하기
_bottomLeft = _cam.ViewportToWorldPoint(new Vector3(0, 0, _cam.nearClipPlane)); // 좌측하단
_topRight = _cam.ViewportToWorldPoint(new Vector3(1, 1, _cam.nearClipPlane)); // 우측상단
월드내에 카메라 영역의 좌측하단, 우측상단을 구하여 맵 사이즈 만큼의 가상의 영역을 설정한다
_worldPerGridSizeX = Mathf.Abs(_topRight.x - _bottomLeft.x) / _gridColums;
_worldPerGridSizeY = Mathf.Abs(_topRight.y - _bottomLeft.y) / _gridLows;
설정된 공간의 최소 한칸을 구한다
private void TilingMapSprite()
{
foreach (var level in levels)
{
foreach (var node in level.Value)
{
GameObject mapInstanced = Instantiate(_groundOBJ.gameObject);
SimpleGround ground = mapInstanced.GetComponent<SimpleGround>();
if (ground == null)
{
return;
}
// 월드 좌표계에 맞게 위치를 재설정
float x = _bottomLeft.x + (node.x * _worldPerGridSizeX);
float y = _bottomLeft.y + (node.y * _worldPerGridSizeY);
ground.SetGround(new Vector3(x, y, _bottomLeft.z), new Vector3(_worldPerGridSizeX/3, _worldPerGridSizeY/3, 1f), node.length * 3);
}
}
}
월드 좌표계로 데이터를 변경한 후 2d sprite 오브젝트를 생성하여 위치 및 사이즈를 할당한다.
2d sprite 오브젝트를 월드 맵 (1920 : 1080) 사이즈위에 인스턴스 한 모습이다.
개발 후 수정 방향성
수정사항
- 땅이 아닌 사면이 막혀있는 방 형태로 만들기
- 상하로 이동할 수 있는 수단 만들기 (포탈?)
- 이동 포인트 생성하기
개발 방향성
- 구현하고 보니까 완전 랜덤보다는 방 구성에 대한 템플릿이 존재하고 해당 템플릿을 사용한 배치 랜덤으로 개발해야 난이도 조절 및 맵 구성에 대해 제어가 가능할 것 같다.
- 로그라이크 게임의 특성상 맵의 구성요소가 곧 난이도 조절이 되는만큼 Runtime중에 생성되는 방식 보다는 맵을 구성할때 개발자가 사용하는 랜덤 생성기로 편집 가능하게 만드는게 더욱 좋아보인다.