Post

[Jem Clash] 개발일지 #10 - 맵 생성 구현 및 로직 추가

[Jem Clash] 개발일지 #10 - 맵 생성 구현 및 로직 추가

맵 생성 방식 기획

이제 게임 씬 개발은 잠시 멈추고 플레이어가 맵을 선택하는 씬을 개발하려 합니다

우선 맵이 어떻게 생겼는지, 생성은 어떻게 할지부터 정해야겠죠?

이 게임은 로그라이크 게임으로 설계했고, 때문에 생성되는 맵도 매번 형태가 달라야 합니다

로그라이크 게임의 가장 큰 특징 중 하나인 랜덤성을 적용하는 거죠

완전히 새로운 시스템을 설계하기엔 아직 제 역량도 부족하고, 시간을 많이 잡아먹을 것 같아서 유명한 예시를 참고하기로 했습니다

덱 빌딩 로그라이크 장르의 대가라고 할 수 있는 ‘슬레이 더 스파이어’라는 게임에서는 플레이어가 이동할 수 있는 경로가 매번 다르게 생성됩니다

Image

이런 식으로 플레이어는 미리 지도를 확인해서 자신이 이동할 길을 선택하고, 분기점에서 적과 전투할지 아니면 아이템을 구매할지 선택을 내리게 되죠

이와 매우 유사한 시스템을 ‘인스크립션’, ‘컬트 오브 더 램’에서도 확인할 수 있습니다

Image

Image

이렇듯 맵을 랜덤으로 생성하는 것은 절차적 생성을 통해 이루어집니다

그럼 절차적 생성을 이용해 맵을 한번 만들어봅시다

알고리즘은 해당 글을 참고하여 만들었어요

참고 링크

맵 생성 알고리즘

먼저 코드를 담을 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();

맵의 수치를 관리하는 변수들과 생성된 경로를 담아줄 리스트, 생성된 방을 담아줄 리스트를 생성해 줍니다

다음으로 맵 생성 과정을 간략하게 설명해 볼게요

  1. InitializeGrid : 맵 데이터를 생성할 그리드를 초기화합니다
  2. GeneratePaths : 규칙에 따라 방을 선택하고 경로를 이어줍니다
  3. RemoveUnconnectedRooms : 경로와 이어지지 않는 방은 제거합니다
  4. AssignRoomTypes : 규칙에 따라 방마다 타입 유형을 할당합니다
  5. 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

그다음으로 방과 경로를 규칙에 따라 생성해 주는 코드를 작성합니다

생성 규칙은 다음과 같습니다

  1. 첫 번째 층에서 2개의 서로 다른 방을 선택하여 최소 2개의 시작 지점이 존재하도록 한다
  2. 이후 startRoomCount개의 방을 더 선택해 첫 번째 층의 시작 지점으로 정한다 (중복 가능)
  3. for 문을 돌며 다음 층의 방 중 y 좌표 차이가 1 이하인 방들을 가져와 랜덤으로 하나를 선택해 경로를 생성한다
  4. 이 과정을 마지막 층까지 반복한다
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));
}

이후 게임 오브젝트에 스크립트를 추가하고 확인해 보면,

Image

이런 식으로 잘 작동하는 것을 확인할 수 있습니다 🔥

참고로 위 사진은 startRoomCount = 0으로 해놓은 상태라 경로가 2개만 생성되었습니다

경로 생성 조건 추가

startRoomCount를 올려서 맵 생성을 테스트해 보면 의도하지 않은 현상이 발생하는 것을 확인할 수 있습니다

Image

바로 이런 식으로 경로가 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;
}

경로 두 개가 교차하는지 확인하기 위해서 경로의 방향벡터가 평행한지 먼저 확인해 줍니다

둘이 평행하면 교차하지 않겠죠?

Image

만약 det = 0이면 교차하지 않는다는 의미이므로 false를 리턴해줍니다


다음으로 평행하지 않은 경우 두 경로의 교점이 x1과 x2 사이에 있는지 확인해 줄 거예요

Image

선분의 매개변수식을 작성해 주고 교점을 찾기 위해 연립방정식으로 나타낸 다음, 크래머 공식을 사용해 행렬식을 계산합니다

0 < t < 1이고 0 < u < 1이면 교점이 범위 안에 있다는 뜻으로 경로가 교차한다는 의미에요

이 과정을 코드로 나타내주면 LinesIntersect 메서드가 완성됩니다 🫠


한번 테스트해 보겠습니다

Image

여러 번 돌려본 결과 교차하는 경로 없이 잘 생성되네요 😎

후기

구현하는 데 많은 어려움이 있던 파트였습니다

처음 해보는 내용이라 최대한 이해해 보면서 구현하려고 하다 보니 시간이 오래 걸렸지만 그래도 많이 배운 것 같네요

여기에 새로운 규칙들도 적용해야 하는데, 다행히 그 파트는 조금 더 쉬울 것 같습니다

선형대수와 알고리즘 공부를 더 해두는 게 미래를 위해 좋을 것 같네요 ㅎ…

다음 포스팅에서 뵙겠습니다 👋

This post is licensed under CC BY 4.0 by the author.