본문 바로가기
개발/Unity 내일배움캠프 TIL

Unity 3D 타일맵 자동 생성

by 석시 2023. 9. 26.



그리드에 다가 타일 브러쉬로 타일을 칠하는 것 까진 알겠는데, 스크립트로 동적 생성은 어떻게 할까 궁금했었다.

다음 내용은 그리드가 있을 때 그리드 상에 오브젝트들을 어떻게 배치할 지, 더 나아가 맵을 랜덤으로 생성하고 배치하는 법에 대해 다룬다.


프로젝트 준비

애셋은 다음 링크의 애셋을 사용하였다.

Platformer Kit · Kenney
Download this package (110 assets) for free, CC0 licensed!
https://www.kenney.nl/assets/platformer-kit
Hexagon Kit · Kenney
Download this package (60 assets) for free, CC0 licensed!
https://www.kenney.nl/assets/hexagon-kit

또한 Tilemap을 위한 패키지를 설치해야 한다.

다음 링크를 참고하여 2D Tilemap Editor를 설치하자.

Unity 3D 타일맵 만들기
패키지 설치타일맵 오브젝트 생성타일맵 브러쉬를 GameObject로 바꾸기참고) 브러쉬 프리셋 저장하기 이번엔 3D로 타일맵을 만드는 법에 대해 다뤄보겠다. 보통 타일맵의 경우 2D 게임에서 많이 활용하지만 3D 게임의 경우도 활용할 수 있다. 3D에서 타일맵으로 레벨을 구성하면 마치 마인크래프트 같은 환경의 맵이 완성되는 것이다. 패키지 설치 먼저 3D 프로젝트를 생성하고 2D Tilemap Editor를 깔아주자. [2D Tilemap Editor 설치] 상단 탭의 Window → Package Manager → 상단의 Packages: In Project를 Unity Registry로 변경 → 2D Tilemap Editor를 찾아서 설치 2D Tilemap이 맞는지 재차 물어볼 수 있겠다.진짜 맞..
https://seoksii.tistory.com/51#4d7f0ecf-461f-4ef4-9380-b2038ef3c291


그리드 상에 배치하기

남들이 좀 더 안하는 내용으로 하기 위해, 나는 육각형 그리드에 오브젝트를 배치하겠다.

Hierarchy 우클릭 → 2D Object → Tilemap → Hexagonal - Pointed-Top을 선택하여 육각형 그리드 생성

제일 먼저 해야할 것은, 오브젝트의 Pivot 설정이다.

모든 오브젝트들의 Pivot을 균일하게 맞춰줘야 한다.

다음과 같이 Pivot의 위치가 바닥의 중앙에 있는지 확인하자.

Instantiate를 할 때 이 Pivot을 기준점으로 오브젝트가 생성된다.

TileMapGenerator라는 이름의 스크립트를 생성하여 다음과 같이 작성해주자.

sing UnityEngine;

public class TileMapGenerator : MonoBehaviour
{
    [SerializeField] Grid grid;
    
    [SerializeField] GameObject[] tilePrefabs;
    
    void Start()
    {
        for (int x = 0; x < 32; x++)
        {
            Vector3 pos = grid.CellToWorld(Vector3Int.right * x);
            GameObject newObject = Instantiate(tilePrefabs[0], pos, Quaternion.identity, gameObject.transform);
        }
    }
}

다음과 같이 구성하고 생성을 원하는 오브젝트를 넣은 뒤 실행해보자.

Grid에 딱 맞게 생성이 된다.


랜덤한 맵 데이터 생성하기

이제 랜덤하게 맵이 생성되게 하고 싶으면, 맵에 대한 데이터를 담은 2차원 배열을 생성해서 그 2차원 배열대로 Grid에 오브젝트를 생성해주기만 하면 되는 것이다.

일단 무작위 랜덤으로 맵을 생성해보자.

다음과 같이 코드를 작성했다.

TilemapGenerator.cs
using System;
using UnityEngine;

public class TileMapGenerator : MonoBehaviour
{
    [SerializeField] Grid grid;
    
    [SerializeField] Blocks[] tilePrefabs;

    public string seed;
    public bool useRandomSeed;
    
    [SerializeField] private Vector2Int size;
    [SerializeField, Range(0, 100)]
    private int normalBlockPercent;
    [SerializeField, Range(0, 100)]
    private int normalBiomePercent;
    
    public int[,] MapHeights;
    public int[,] MapBiomes;
    
    void Start()
    {
        GenerateMap();
        DrawMapTiles();
    }

    void GenerateMap()
    {
        MapHeights = new int[size.x, size.y];
        MapBiomes = new int[size.x, size.y];
        RandomFillMap();
    }

    void RandomFillMap()
    {
        if (useRandomSeed)
            seed = DateTime.Now.ToBinary().ToString();

        System.Random psuedoRandom = new System.Random(seed.GetHashCode());

        for (int x = 0; x < size.x; x++)
            for (int y = 0; y < size.y; y++)
            {
                if (x == 0 || x == size.x - 1 || y == 0 || y == size.y - 1)
                    MapHeights[x, y] = 1;
                else MapHeights[x, y] = psuedoRandom.Next(0, 100) < normalBlockPercent ? 0 : 1;
                MapBiomes[x, y] = psuedoRandom.Next(0, 100) < normalBiomePercent ? 0 : 1;
            }
    }

    void DrawMapTiles()
    {
        for (int x = 0; x < size.x; x++)
            for (int y = 0; y < size.y; y++)
            {
                Vector3 pos = grid.CellToWorld(new Vector3Int(x, y, 0));
                Instantiate(tilePrefabs[MapBiomes[x, y]][MapHeights[x, y]], pos ,Quaternion.identity, gameObject.transform);
            }
                
    }
}

[Serializable]
public class Blocks
{
    public GameObject normalBlock;
    public GameObject highBlock;

    public GameObject this[int i]
    {
        get { return (i == 0) ? normalBlock : highBlock; }
    }

생성해보면 다음과 같이 지형이 생성되는 걸 볼 수 있는데, 이런 것은 너무 지저분해서 맵으로 쓰기가 힘들다….

우리는 어느정도는 깔끔하고, 블럭들이 어느정도 군집을 이루는 맵을 생성하고 싶다.


절차적 맵 생성 (Procedural Map Generation)

여러 방법이 있지만, 모두 절차적 맵 생성이라는 하나의 카테고리로 묶인다.

따라서 검색해볼 때는 절차적 맵 생성 또는 Procedural Map Generation으로 찾아보자.

대표적으로 다음 방법들이 있다.

  • 펄린 노이즈
  • 셀룰러 오토마타
  • BSP 알고리즘

이번에는 셀룰러 오토마타를 써보고자 한다.

관련해서 참고하기 아주 좋은 영상이 있었기 때문이다.

[Unity] Procedural Cave Generation (E01. Cellular Automata)
Learn how to create procedurally generated caverns/dungeons for your games using cellular automata and marching squares. Source code: https://github.com/SebLague/Procedural-Cave-Generation Follow me on twitter @SebastianLague Support my videos on Patreon: http://bit.ly/sebPatreon
https://youtu.be/v7yyZZjF1z4?si=xmHhb1WFbmy-3o3u

다음과 같은 코드들을 추가해줬다.

먼저 셀룰러 오토마타를 기반으로 한 Smooth 함수이다.

void SmoothMap(int[,] map)
{
    int[,] newMap = new int[size.x, size.y];
    
    for (int x = 0; x < size.x; x++)
        for (int y = 0; y < size.y; y++)
        {
            int neighbourDifferentTiles = GetSurroundingTiles(map, x, y);

            if (neighbourDifferentTiles > 4)
                map[x, y] = 1;
            else if (neighbourDifferentTiles < 4)
                map[x, y] = 0;
        }
}

근처 타일 중 1인 타일의 개수를 세서 비율이 반반이면 현상유지, 나머지는 더 큰 쪽의 타일을 따라가는 것이다.

int GetSurroundingTiles(int[,] map, int gridX, int gridY)
{
    int wallCount = 0;
    for (int neighbourX = gridX - 1; neighbourX <= gridX + 1; neighbourX++)
        for (int neighbourY = gridY - 1; neighbourY <= gridY + 1; neighbourY++)
            if (neighbourX >= 0 && neighbourX < size.x && neighbourY >= 0 && neighbourY < size.y)
            {
                if (neighbourX != gridX || neighbourY != gridY)
                    wallCount += map[neighbourX, neighbourY];
            }
            else wallCount++;

    return wallCount;
}

근처 타일을 체크하는 건.. 그냥 열심히 돌렸다.

[SerializeField] private int smoothingFactor;

void GenerateMap()
{
    MapHeights = new int[size.x, size.y];
    MapBiomes = new int[size.x, size.y];
    RandomFillMap();

    for (int i = 0; i < smoothingFactor; i++)
    {
        SmoothMap(MapHeights);
        SmoothMap(MapBiomes);
    }
}

그 뒤 이제 남은건 맵을 생성할 때 스무딩을 열심히 해주는 것이다.

여러번 할 수록 더욱더 군집을 형성하게 되는데, 다른 예제들도 그렇고 한 5번정도 하니깐 딱 적절한 모양인듯 하다.

나머지는 직접 수치를 조정해가면서 확인해보자.

다음은 전체 코드이다.

TileMapGenerator.cs
using System;
using UnityEngine;

public class TileMapGenerator : MonoBehaviour
{
    [SerializeField] Grid grid;
    
    [SerializeField] Blocks[] tilePrefabs;

    public string seed;
    public bool useRandomSeed;
    
    [SerializeField] private Vector2Int size;
    [SerializeField, Range(0, 100)]
    private int normalBlockPercent;
    [SerializeField, Range(0, 100)]
    private int normalBiomePercent;

    [SerializeField] private int smoothingFactor;
    
    public int[,] MapHeights;
    public int[,] MapBiomes;
    
    void Start()
    {
        GenerateMap();
        DrawMapTiles();
    }

    void GenerateMap()
    {
        MapHeights = new int[size.x, size.y];
        MapBiomes = new int[size.x, size.y];
        RandomFillMap();

        for (int i = 0; i < smoothingFactor; i++)
        {
            SmoothMap(MapHeights);
            SmoothMap(MapBiomes);
        }
    }

    void RandomFillMap()
    {
        if (useRandomSeed)
            seed = DateTime.Now.ToBinary().ToString();

        System.Random psuedoRandom = new System.Random(seed.GetHashCode());

        for (int x = 0; x < size.x; x++)
            for (int y = 0; y < size.y; y++)
            {
                if (x == 0 || x == size.x - 1 || y == 0 || y == size.y - 1)
                    MapHeights[x, y] = 1;
                else MapHeights[x, y] = psuedoRandom.Next(0, 100) < normalBlockPercent ? 0 : 1;
                MapBiomes[x, y] = psuedoRandom.Next(0, 100) < normalBiomePercent ? 0 : 1;
            }
    }

    void SmoothMap(int[,] map)
    {
        int[,] newMap = new int[size.x, size.y];
        
        for (int x = 0; x < size.x; x++)
            for (int y = 0; y < size.y; y++)
            {
                int neighbourDifferentTiles = GetSurroundingTiles(map, x, y);

                if (neighbourDifferentTiles > 4)
                    map[x, y] = 1;
                else if (neighbourDifferentTiles < 4)
                    map[x, y] = 0;
            }
    }

    int GetSurroundingTiles(int[,] map, int gridX, int gridY)
    {
        int wallCount = 0;
        for (int neighbourX = gridX - 1; neighbourX <= gridX + 1; neighbourX++)
            for (int neighbourY = gridY - 1; neighbourY <= gridY + 1; neighbourY++)
                if (neighbourX >= 0 && neighbourX < size.x && neighbourY >= 0 && neighbourY < size.y)
                {
                    if (neighbourX != gridX || neighbourY != gridY)
                        wallCount += map[neighbourX, neighbourY];
                }
                else wallCount++;

        return wallCount;
    }

    void DrawMapTiles()
    {
        for (int x = 0; x < size.x; x++)
            for (int y = 0; y < size.y; y++)
            {
                Vector3 pos = grid.CellToWorld(new Vector3Int(x, y, 0));
                Instantiate(tilePrefabs[MapBiomes[x, y]][MapHeights[x, y]], pos ,Quaternion.identity, gameObject.transform);
            }
                
    }
}

[Serializable]
public class Blocks
{
    public GameObject normalBlock;
    public GameObject highBlock;

    public GameObject this[int i]
    {
        get { return (i == 0) ? normalBlock : highBlock; }
    }
}


Uploaded by N2T