[Jem Clash] 개발일지 #10 - 맵 생성 구현 및 로직 추가
맵 생성 방식 기획
이제 게임 씬 개발은 잠시 멈추고 플레이어가 맵을 선택하는 씬을 개발하려 합니다
우선 맵이 어떻게 생겼는지, 생성은 어떻게 할지부터 정해야겠죠?
이 게임은 로그라이크 게임으로 설계했고, 때문에 생성되는 맵도 매번 형태가 달라야 합니다
로그라이크 게임의 가장 큰 특징 중 하나인 랜덤성을 적용하는 거죠
완전히 새로운 시스템을 설계하기엔 아직 제 역량도 부족하고, 시간을 많이 잡아먹을 것 같아서 유명한 예시를 참고하기로 했습니다
덱 빌딩 로그라이크 장르의 대가라고 할 수 있는 ‘슬레이 더 스파이어’라는 게임에서는 플레이어가 이동할 수 있는 경로가 매번 다르게 생성됩니다
이런 식으로 플레이어는 미리 지도를 확인해서 자신이 이동할 길을 선택하고, 분기점에서 적과 전투할지 아니면 아이템을 구매할지 선택을 내리게 되죠
이와 매우 유사한 시스템을 ‘인스크립션’, ‘컬트 오브 더 램’에서도 확인할 수 있습니다
이렇듯 맵을 랜덤으로 생성하는 것은 절차적 생성을 통해 이루어집니다
그럼 절차적 생성을 이용해 맵을 한번 만들어봅시다
알고리즘은 해당 글을 참고하여 만들었어요
맵 생성 알고리즘
먼저 코드를 담을 MapGenerator 스크립트를 생성하고, 기본적인 변수들을 만들어줍니다
1
2
3
4
5
6
public int height;
public int width;
public int startRoomCount;
private readonly List<Path> _paths = new();
private readonly List<RoomNode> _rooms = new();
맵의 수치를 관리하는 변수들과 생성된 경로를 담아줄 리스트, 생성된 방을 담아줄 리스트를 생성해 줍니다
다음으로 맵 생성 과정을 간략하게 설명해 볼게요
- InitializeGrid : 맵 데이터를 생성할 그리드를 초기화합니다
- GeneratePaths : 규칙에 따라 방을 선택하고 경로를 이어줍니다
- RemoveUnconnectedRooms : 경로와 이어지지 않는 방은 제거합니다
- AssignRoomTypes : 규칙에 따라 방마다 타입 유형을 할당합니다
- AddBossRoom : 마지막으로 보스 방을 추가합니다
이렇게 5단계를 거치며 규칙에 따라 맵을 생성합니다
방을 나타내는 RoomNode, 경로를 나타내는 Path 클래스를 생성해 줍니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class RoomNode
{
public enum RoomType
{
Shop,
Treasure,
Enemy,
Rest
}
private RoomType _roomType;
public int x, y;
public RoomNode(int x, int y)
{
this.x = x;
this.y = y;
}
public void AssignRoomType()
{
Array values = Enum.GetValues(typeof(RoomType));
_roomType = (RoomType)values.GetValue(Random.Range(0, values.Length));
}
}
public class Path
{
public RoomNode room1, room2;
public Path(RoomNode r1, RoomNode r2)
{
room1 = r1;
room2 = r2;
}
}
이번 포스팅에선 타입 유형을 정해주는 규칙 및 적용하는 코드를 작성하지 않을 예정이니 참고해 주세요
다음 포스팅에서 소개하도록 하겠습니다 👍
InitializeGrid
먼저 간단하게 그리드를 생성해 줍니다
1
2
3
4
5
6
private void InitializeGrid()
{
for (int x = 0; x < width; x++)
for (int y = 0; y < height; y++)
_rooms.Add(new RoomNode(x, y));
}
생성한 모든 방의 정보를 _rooms 리스트에 담아주는 것으로 InitializeGrid 함수의 역할이 끝납니다
GeneratePaths
그다음으로 방과 경로를 규칙에 따라 생성해 주는 코드를 작성합니다
생성 규칙은 다음과 같습니다
- 첫 번째 층에서 2개의 서로 다른 방을 선택하여 최소 2개의 시작 지점이 존재하도록 한다
- 이후 startRoomCount개의 방을 더 선택해 첫 번째 층의 시작 지점으로 정한다 (중복 가능)
- for 문을 돌며 다음 층의 방 중 y 좌표 차이가 1 이하인 방들을 가져와 랜덤으로 하나를 선택해 경로를 생성한다
- 이 과정을 마지막 층까지 반복한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void GeneratePaths()
{
List<RoomNode> firstFloorRooms = new();
foreach (RoomNode room in _rooms)
if (room.x == 0)
firstFloorRooms.Add(room);
RoomNode start1 = firstFloorRooms[Random.Range(0, firstFloorRooms.Count)];
RoomNode start2;
do
{
start2 = firstFloorRooms[Random.Range(0, firstFloorRooms.Count)];
} while (start1 == start2);
var currentFloorRooms = new List<RoomNode> { start1, start2 };
for (int i = 0; i < startRoomCount; i++)
currentFloorRooms.Add(firstFloorRooms[Random.Range(0, currentFloorRooms.Count)]);
for (int x = 1; x < width; x++)
{
List<RoomNode> nextFloorRooms = new();
foreach (RoomNode room in currentFloorRooms)
{
var possibleRooms = GetNextFloorRooms(room);
if (possibleRooms.Count > 0)
{
RoomNode nextRoom = possibleRooms[Random.Range(0, possibleRooms.Count)];
_paths.Add(new Path(room, nextRoom));
nextFloorRooms.Add(nextRoom);
}
}
currentFloorRooms = nextFloorRooms;
}
}
private List<RoomNode> GetNextFloorRooms(RoomNode room)
{
List<RoomNode> result = new();
foreach (RoomNode r in _rooms)
if (r.x == room.x + 1 && Mathf.Abs(r.y - room.y) <= 1)
result.Add(r);
return result;
}
슬레이 더 스파이어의 맵도 이런 규칙을 토대로 경로가 생성된다고 하네요
RemoveUnconnectedRooms
이제 생성된 경로와 무관한 방들은 삭제해 줍니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void RemoveUnconnectedRooms()
{
var roomsToRemove = new List<RoomNode>();
foreach (RoomNode r in _rooms)
{
bool isInPaths = false;
foreach (Path p in _paths)
if (p.Contains(r))
{
isInPaths = true;
break;
}
if (!isInPaths) roomsToRemove.Add(r);
}
foreach (RoomNode r in roomsToRemove) _rooms.Remove(r);
}
간단하게 foreach 문을 돌면서 삭제하도록 구현했어요
AssignRoomTypes
그 다음 규칙에 따라 방마다 타입 유형을 할당해 줍니다
아직 상세한 규칙을 기획하진 않아서 이 파트는 다음 포스팅에 이어서 작업하겠습니다
1
2
3
4
5
private void AssignRoomTypes()
{
foreach (RoomNode room in _rooms)
room.AssignRoomType();
}
AddBossRoom
마지막으로 보스 방을 추가해 줍니다
1
2
3
4
5
6
7
8
9
10
private void AddBossRoom()
{
RoomNode bossRoom = new(width, height / 2);
foreach (RoomNode room in _rooms)
if (room.x == width - 1)
_paths.Add(new Path(room, bossRoom));
_rooms.Add(bossRoom);
}
마지막 층에 있는 모든 방과 보스 방을 연결해 주면 기본적인 맵 생성 코드가 마무리됩니다
유니티의 Scene 화면에 방과 경로들이 표시되도록 OnDrawGizmos 메서드에 코드를 추가해 줄게요
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void OnDrawGizmos()
{
for (int i = 0; i < height; i++)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(new Vector3(0, i, 0), 0.2f);
Gizmos.color = Color.red;
Gizmos.DrawSphere(new Vector3(width - 1, i, 0), 0.2f);
}
if (_rooms == null || _paths == null) return;
foreach (RoomNode room in _rooms)
{
Gizmos.color = Color.white;
Gizmos.DrawSphere(new Vector3(room.x, room.y, 0), 0.2f);
}
Gizmos.color = Color.yellow;
foreach (Path path in _paths)
Gizmos.DrawLine(new Vector3(path.room1.x, path.room1.y, 0),
new Vector3(path.room2.x, path.room2.y, 0));
}
이후 게임 오브젝트에 스크립트를 추가하고 확인해 보면,
이런 식으로 잘 작동하는 것을 확인할 수 있습니다 🔥
참고로 위 사진은 startRoomCount = 0으로 해놓은 상태라 경로가 2개만 생성되었습니다
경로 생성 조건 추가
startRoomCount를 올려서 맵 생성을 테스트해 보면 의도하지 않은 현상이 발생하는 것을 확인할 수 있습니다
바로 이런 식으로 경로가 X자 모양으로 교차하는 경우가 생기는 건데, 이런 식으로 생성되면 맵이 복잡해지고 알아보기 어려워질 것이기에 이를 방지하기 위한 코드를 추가해 줄 거예요
위에서 만든 GeneratePaths 메서드에서 작업해 봅시다
1
2
3
4
5
6
7
var possibleRooms = GetNextFloorRooms(room);
if (possibleRooms.Count > 0)
{
RoomNode nextRoom = possibleRooms[Random.Range(0, possibleRooms.Count)];
_paths.Add(new Path(room, nextRoom));
nextFloorRooms.Add(nextRoom);
}
기존에 이런 식으로 다음 층에서 조건에 맞는 방 중 하나를 랜덤으로 선택해 경로를 설정했는데, 여기에 코드를 추가해 줍니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var possibleRooms = GetNextFloorRooms(room);
if (possibleRooms.Count > 0)
{
// 겹치지 않는 경로로 랜덤 생성
RoomNode nextRoom = null;
do
{
nextRoom = possibleRooms[Random.Range(0, possibleRooms.Count)];
possibleRooms.Remove(nextRoom);
} while (PathIntersects(room, nextRoom));
if (nextRoom != null)
{
_paths.Add(new Path(room, nextRoom));
nextFloorRooms.Add(nextRoom);
}
}
PathIntersects 메서드는 현재 방(room)과 선택한 다음 방(nextRoom)을 연결하는 경로가 기존 경로와 겹치는지 확인하는 함수입니다
foreach 문을 사용하면 리스트 요소를 순서대로 가져오기 때문에 맵이 편향되게 생성될 것 같아 do while 문을 대신 써줬어요
만약 PathIntersects가 false를 리턴하면 nextFloorRooms 리스트에 nextRoom을 추가합니다
1
2
3
4
5
6
7
8
9
10
11
private bool PathIntersects(RoomNode a, RoomNode b)
{
foreach (Path path in _paths)
if (path.room1.x == a.x)
if (LinesIntersect(a.x, a.y, b.x, b.y,
path.room1.x, path.room1.y,
path.room2.x, path.room2.y))
return true;
return false;
}
PathIntersects 안에서 foreach 문을 돌며 기존 경로 중 room a와 같은 층에 있는 방에서 시작하는 경로와 교차하는지 비교합니다
LinesIntersect 메서드에 방 4개의 좌표를 넣어주어 계산해 주면 끝이에요
경로 교차 여부 계산
여기서부터 수학이 조금 들어갑니다
1
2
3
4
5
6
7
8
9
10
11
private bool LinesIntersect(float x1, float y1, float x2, float y2,
float x3, float y3, float x4, float y4)
{
float det = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3);
if (det == 0) return false; // 평행
float t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / det;
float u = ((x3 - x1) * (y2 - y1) - (y3 - y1) * (x2 - x1)) / det;
return t is > 0 and < 1 && u is > 0 and < 1;
}
경로 두 개가 교차하는지 확인하기 위해서 경로의 방향벡터가 평행한지 먼저 확인해 줍니다
둘이 평행하면 교차하지 않겠죠?
만약 det = 0이면 교차하지 않는다는 의미이므로 false를 리턴해줍니다
다음으로 평행하지 않은 경우 두 경로의 교점이 x1과 x2 사이에 있는지 확인해 줄 거예요
선분의 매개변수식을 작성해 주고 교점을 찾기 위해 연립방정식으로 나타낸 다음, 크래머 공식을 사용해 행렬식을 계산합니다
0 < t < 1이고 0 < u < 1이면 교점이 범위 안에 있다는 뜻으로 경로가 교차한다는 의미에요
이 과정을 코드로 나타내주면 LinesIntersect 메서드가 완성됩니다 🫠
한번 테스트해 보겠습니다
여러 번 돌려본 결과 교차하는 경로 없이 잘 생성되네요 😎
후기
구현하는 데 많은 어려움이 있던 파트였습니다
처음 해보는 내용이라 최대한 이해해 보면서 구현하려고 하다 보니 시간이 오래 걸렸지만 그래도 많이 배운 것 같네요
여기에 새로운 규칙들도 적용해야 하는데, 다행히 그 파트는 조금 더 쉬울 것 같습니다
선형대수와 알고리즘 공부를 더 해두는 게 미래를 위해 좋을 것 같네요 ㅎ…
다음 포스팅에서 뵙겠습니다 👋