Class GameDev* SheepAdult

[Unity2D] 내 마음대로 만들어 보는 아이작식 랜덤 맵 생성 (1) - 맵 생성 본문

Unity

[Unity2D] 내 마음대로 만들어 보는 아이작식 랜덤 맵 생성 (1) - 맵 생성

SheepAdult 2024. 3. 12. 03:32

깃허브 : https://github.com/devminjae97/GP1/tree/GP1-2_MapGeneratorTest

개요

 맵 자동 생성을 구현해 보고 싶다는 생각이 들어 장르를 고민하던 중 로그라이크 게임이 눈에 들어왔고, 해당 장르는 유니티 엔진이 언리얼 엔진보다 더 어울릴 것 같아 유니티를 공부하기로 했다. 

랜덤 맵 생성 방법에는 여러가지가 있는데, 그중 유명한 건 BSP알고리즘을 활용한 맵 생성이다. 방이 트리 형태로 저장되고 리프 노드가 각 방이 되며, 중간중간 통로를 놓게 되면 완성된다. 하지만 아이작처럼 통로가 아닌 문을 달아 다음 방으로 넘어가게 하고 싶었고, 모든 방이 붙어 있는 형태로 만들고 싶어 다른 방식으로 구현했다. 또한, 각 방은 1 ~ 4개의 상자 모양으로 이루어진 방으로만 사용하고 싶어 랜덤 모양으로 방을 만들어 붙여가는 방식으로 맵을 만들었다.

 

방생성

방을 먼저 생성해보자. 각 방들은 셀들로 이루어져 있다고 치자. 셀 1~4개가 모여서 하나의 방을 만드는 것이다. 먼저, 얼마나 방을 만들지(방의 깊이라고 해야 할까?) 설정하고, 깊이만큼 재귀적으로 방을 생성해 나갈 것이다. 방은 랜덤으로 1~4개의 셀로 이루어지며, 모양은 아래와 같다.

회전된 버전도 사용할 것이다.

그리고 셀은 아래의 클래스로 추상화된다.

public class Cell
{
    private bool isChecked;
    private int id;
    private Vector2Int pos;
    private Vector2 posWorld;
    private GameObject[,] tiles;

    public Cell(Vector2Int pos, Vector2 posWorld)
    {
        this.pos = pos;
        this.posWorld = posWorld;
    }
    
      // getter setter
    .
    .
 	.
}

각 방을 생성할때마다 매번 dfs를 사용하여 셀을 설치하는 것보다는 미리 기준 좌표를 기준으로 배열을 만들어 놓는 것이 훨씬 경제적이라고 판단하여 배열을 만들었다. 아마 방의 종류가 많았다면 당연히 dfs 등 다른 방법으로 생성했겠지만 이번 구현에서는 방의 종류가 많지 않으니 이렇게 했다.

...
// 이런식으로 1, 2, 3, 4 크기에 해당하는 방의 배열을 만들었다.
public Vector2Int[][] size3 = new Vector2Int[][]
{
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 0, 1 ), new Vector2Int(-1, 0) },              // 오른쪽 위
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 0, 1 ), new Vector2Int(1, 0) },              // 오른쪽 아래
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 1, 0 ), new Vector2Int(0, 1) },              // 아래 오른쪽
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 1, 0 ), new Vector2Int(0, -1) },              // 아래 왼쪽
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 0, -1 ), new Vector2Int(-1, 0) },              // 왼쪽 위
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( 0, -1 ), new Vector2Int(1, 0) },              // 왼쪽 아래
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( -1, 0 ), new Vector2Int(0, 1) },              // 위 오른쪽
    new Vector2Int[] { new Vector2Int( 0, 0 ), new Vector2Int( -1, 0 ), new Vector2Int(0, -1) },              // 위 왼쪽          
};         
...

그리고 방 생성은 아무래도 랜덤이 되어야 하므로 모든 것을 랜덤으로 정해주기로 했다. 현재 만들 방 사이즈는 몇 개짜리 인지, 회전하여 나올 수 있는 모양 중 몇 번째 타입인지를 모두 랜덤으로 정해 주었다. 하지만 만약 랜덤으로 정했다가 그 자리에 방을 놓을 수 없다면 계속해서 놓을 수 있을 때까지 랜덤을 돌린다면 성능상 좋지 못하므로, 나올 것으로 기대하는 값들을 셔플한 배열을 순회하며 현재 값이 불가능하다면 다음 값을 넣는 방식으로 구현했다.

// 먼저 몇 개의 셀로 이루어질지 랜덤으로 정하고
for (int i = 0; i < roomTypeNum; i++)
    currentCellSizeArray[i] = i;

// 셔플하여 랜덤을 순차적으로 진행할 수 있게 한다
currentCellSizeArray = ShuffleArray<int>( currentCellSizeArray );

foreach (int currentcellSize in currentCellSizeArray)
{
    currentRoomTypeArray = new int[RoomTypeManager.GetInstance().RoomTypes[currentcellSize].Length];
	// 어떤 방향의 방으로 정할지 랜덤으로 정한다
    for (int i = 0; i < currentRoomTypeArray.Length; i++)
        currentRoomTypeArray[i] = i;
        
    // 여기도 셔플
    currentRoomTypeArray = ShuffleArray<int>( currentRoomTypeArray );
    foreach (int currentRoomType in currentRoomTypeArray)
    {
    	...

만약 맵을 나가거나 다른 방들과 겹치는 등의 문제가 생긴다면 다음 경우로 넘어간다. 그렇지 않고 해당 방을 맵에 설치할 수 있다면 각 셀을 그릴 차례이다. 위에서 설치 가능하다면 설치할 셀들이 List에 저장되는데, 이 셀들을 순회하며 타일과 문을 그려줄 것이다. 먼저 타일부터 보자. 처음에는 각 셀에 아래와 같은 타일 하나씩만 배치했었다.

무료 에셋의 힘을 빌린 Tilemap

하지만 이런 경우 맵의 크기를 정하는 것에 있어 제약이 있었기 때문에 각 셀에 일정 개수의 타일이 들어간다면 유연하게 만들 수 있을 것 같아 셀 하나 당 타일의 수와 사이즈를 의미하는 변수를 두어 관리했다.

// 셀 하나당 타일의 크기 = 셀의 크기 / 셀 하나당 타일의 개수
tileSizePerCell = cellSize / tileNumPerCell;

 

해당 값을 통해 값 하나의 조절로 사이즈를 조절할 수 있게 했다. 이제 하나의 셀의 기준 위치를 중심으로 CellSize만큼 루프를 돌며 tilemap을 채워나갈 것이다.

void DrawTilesInCell(Cell cell)
{
    Vector3Int curPos;
    // cell의 크기 만큼 채워준다
    for (int i = 0; i < tileNumPerCell; i++)
    {
        for (int j = 0; j < tileNumPerCell; j++)
        {
            curPos = new Vector3Int( cell.tilemapLocalPos.x + i, cell.tilemapLocalPos.y + j, 0 );
            groundTilemap.SetTile( curPos, groundTile );
        }
    }
}

벽은 groundTilemap과 다른 wallTilemap에 그렸다. 상, 하, 좌, 우에 같은 id의 셀이 있는지 없는지 판단하며 그려나갔다.

또한, 동남, 동북, 서남, 서북 방향 또한 자연스러움을 위하여 신경써서 그렸다.

void DrawWall( Cell cell, EDir dir )
{
    Vector3Int pos = new Vector3Int(0, 0, 0);
    switch (dir)
    {
    	// 상, 하, 좌, 우는 한 줄을 쭉 그려야 하기 때문에 for문을 돈다
        case EDir.eDown:
            for (int i = 0; i < tileNumPerCell; i++)
            {
                pos = new Vector3Int( cell.tilemapLocalPos.x + tileNumPerCell - 1, cell.tilemapLocalPos.y + i );
                wallTilemap.SetTile( pos, wallTile );
                DungeonManager.GetInstance().AddToTilemapDic( roomCount, wallTilemap, pos );
            }
            break;
        ...
        // 나머지는 타일 하나만 그린다
        case EDir.eLeftUp:
            pos = new Vector3Int( cell.tilemapLocalPos.x, cell.tilemapLocalPos.y );
            wallTilemap.SetTile( pos, wallTile );
            DungeonManager.GetInstance().AddToTilemapDic( roomCount, wallTilemap, pos );
            break;
    }
}

 

만약 해당 방을 모두 그렸다면 다음 방을 그려야 한다. 다음 방은 방금 그린 방의 주변 셀 중 그려지지 않은 방, 즉 빈 공간을 랜덤으로 정한 후 그린다. 이 또한 셔플하여 랜덤 값을 구하며, 위의 과정을 반복하게 된다.

  // 타일을 그리는 함수
  // checkRoomResult.Item2: 방을 구성하는 셀 List
  SetTileMap(checkRoomResult.Item2);

  // 주변 사용하지 않은 셀 탐색
  HashSet<Cell> nearCells = GetNearCells( checkRoomResult.Item2 );
  foreach (Cell cell in nearCells)
  {
      GenerateRoom(cell );
      if (roomCount > roomDepth)
          return;
  }
결과

아래는 실행 결과이다. 방의 depth를 30으로 설정했으며 각 방의 타일의 개수는 5로, 1x1 크기의 방은 타일이 5x5개로 이루어져있다.

 

TODO

문은 서로 다른 방 사이의 문은 무조건 하나만 달 것이다. 또한 벽의 타일을 문으로 교체하는 방식으로 진행할 것이기 때문에 어떤 타일을 바꿀지에 대한 정책이 필요하다. 또한, 문에 들어가면 다음 방으로 들어가야 하기에 문에 다음에 스폰될 위치를 저장해 두어야 한다.

 

또한, 스프라이트는 타일맵 형식으로 바꿀 예정이다.