[Unity2D] #3 - 절차적 맵 생성-3 | AutoTiling

qweasfjbv·2024년 3월 8일

Unity2D

목록 보기
3/15
post-thumbnail

절차적 맵 생성을 통해서 던전 모양을 만들었습니다.
하지만 이 검정색 네모 덩어리를 바로 게임에 쓸 수는 없기 때문에 무료 에셋으로 AutoTiling을 해주겠습니다.

사용할 에셋은 이 에셋입니다.
사람과 몬스터가 싸우고있는게 귀여워보여서 골랐습니다.

에셋 링크 : https://assetstore.unity.com/packages/2d/environments/minifantasy-dungeon-206693

개요

AutoTiling을 하기 위해서 바로 떠오르는 방법은 다음과 같습니다.

  1. map을 normalize
  2. map[i, j]==floor 라면, 바닥 타일을 깔아줍니다.
  3. map[i, j]!=floor 라면, 주변 8개 타일을 확인하고 어울리는 벽 타일을 깔아줍니다.

여기서, 두 가지 걸리는 점이 있습니다.

  1. 위/아래 벽 타일이 두 칸으로 이루어져 있습니다.
  2. 주변 8개 타일을 확인하고 어울리는 벽 타일을 찾을 때, 경우의 수가 2^8개 입니다.
    단순한 switch, case로는 고려해야할 경우의 수가 너무 많습니다.

문제 해결

1. 벽 타일

벽이 두 칸짜리 벽한 칸짜리 벽이 따로 있는 것을 확인할 수 있습니다. 하지만 맵을 랜덤으로 생성했기 때문에, 두 칸짜리 벽이 들어가지 못하는 한 칸짜리 틈새가 생길 수도 있습니다.
그래서, 저는 바닥과 벽의 타일맵을 따로 두고 위의 초록색 부분처럼 바닥과 벽이 겹치도록 하겠습니다.

자세히는 바닥, 벽, 절벽, 그림자의 Tilemap을 따로 두고 Layer를 절벽<바닥<물체<그림자<벽 순서로 하면 자연스러울 것 같습니다.

2. 비트마스킹

주변 8개의 타일을 256개의 케이스에 대해서 전부 확인하는 것은 정말 어렵습니다.
하지만 비트마스킹을 사용하면 개발 시간, 실행 시간, 메모리 전부 줄일 수 있습니다.
혹시 비트마스킹이나 비트 연산이 헷갈리신다면 이 링크를 참고해주세요.
자세한 내용은 아래에서 다시 다루겠습니다.

구현

1. 2차원 배열 정규화

생성한 사각형의 개수를 N이라고 할 때, map에는 -1부터 N-1, 그리고 hallwayId가 할당되어 있습니다.
복도를 생성할 때에는 각 방의 인덱스가 필요했지만, 이제 정규화를 해서 case를 줄여야 합니다.

public class Define
{
    public enum GridType { 
        None = 0,
        MainRoom,
        HallWay
    }
}
    private void MapArrNormalization()
    {
        for (int i = 0; i < map.GetLength(0); i++)
        {
            for (int j = 0; j < map.GetLength(1); j++)
            {
                if (map[i, j] < 0 || (map[i, j] != hallwayId && !rooms[map[i, j]].activeSelf)) map[i, j] = (int)Define.GridType.None;
                else if (map[i, j] == hallwayId) map[i, j] = (int)Define.GridType.HallWay;
                else map[i, j] = (int)Define.GridType.MainRoom;
            }
        }
    }

0, 1, 2 보단 enum 선언을 통해서 가독성을 높여줍니다.

2. AutoTiling

    private void AutoTiling()
    {
        for (int i = map.GetLength(0)-1; i>=0; i--)
        {
            for (int j = map.GetLength(1)-1; j >= 0 ; j--)
            {
                if (map[i, j] == (int)Define.GridType.None)
                {
                    PlaceTile(j, i, 2);
                }
                else
                {
                    PlaceTile(j, i, 1);
                }
            }

        }
    }

여기까진 쉽습니다. 단순히 반복문을 돌면서 해당 칸을 확인하고 floor인지 wall인지만 구분해줍니다.
여기서 i와 j를 거꾸로 세는 이유는 밑의 코드에서 확인하실 수 있습니다.
같은 팔레트에서 해당 칸보다 위를 그리는 경우가 있기 때문에, 원근감을 주기 위해서 거꾸로 세야합니다.

3. 타일 배치

일단, 함수를 보기 전에, 변수들부터 보겠습니다.

    [Header("Tilemaps")]
    [SerializeField] private Tilemap floorTilemap;
    [SerializeField] private Tilemap wallTilemap;
    [SerializeField] private Tilemap cliffTilemap;


    [Header("Tiles")]
    [SerializeField] private Tile wall_Top_Left;
    [SerializeField] private Tile wall_Top_Right;
    [SerializeField] private Tile wall_Top_Center;
    [SerializeField] private Tile wall_Bottom_Left;
    [SerializeField] private Tile wall_Bottom_Right;
    [SerializeField] private Tile wall_Bottom;
    [SerializeField] private Tile wall_Top;
    [SerializeField] private Tile wall_Right;
    [SerializeField] private Tile wall_Left;
    [SerializeField] private Tile wall_Center;
    [SerializeField] private Tile wall_Center_Center;
    [SerializeField] private Tile wall_Center_Right;
    [SerializeField] private Tile wall_Center_Left;
    [SerializeField] private Tile floor;
    [SerializeField] private Tile cliff_0;
    [SerializeField] private Tile cliff_1;

    [Header("Random Tiles")]
    [SerializeField] private Tile wall_Top_Random;
    [SerializeField] private Tile floor_Random_0;
    [SerializeField] private Tile floor_Random_1;
    [SerializeField] private Tile floor_Random_2;
    [SerializeField] private Tile floor_Random_3;

Tilemaps, Tiles, RandomTiles로 구분했습니다.
RandomTiles는 같은 바닥/ 벽만 쓰면 지루할 것 같아서 슬라임이 묻은 벽 + 금 간 바닥 등을 추가했습니다.
밑의 코드는 [x, y]에 타일을 배치하는 함수입니다.

    private void PlaceTile(int x, int y, int tileType)
    {
        Tile tile = null;
        Vector3Int tilePos = new Vector3Int(x, y, 0);

        switch (tileType)
        {
            case 1: // floor
                tile = floor;
                floorTilemap.SetTile(tilePos, GetRandomFloorTile());
                break;
            case 2: // wall
                tile = DetermineWallTile(x, y);
                if (tile == null) break;

                if (tile == wall_Left || tile == wall_Right)
                {
                    wallTilemap.SetTile(tilePos, tile);
                }
                else if(tile == wall_Top_Left || tile == wall_Top_Right)
                {
                    wallTilemap.SetTile(tilePos + new Vector3Int(0, 1, 0), tile);
                    if (tile == wall_Top_Left) wallTilemap.SetTile(tilePos, wall_Left);
                    else wallTilemap.SetTile(tilePos, wall_Right);
                }
                else if(tile == wall_Top_Center)
                {
                    wallTilemap.SetTile(tilePos + new Vector3Int(0, 1, 0), tile);
                    wallTilemap.SetTile(tilePos, wall_Center_Center);
                }
                else
                {
                    wallTilemap.SetTile(tilePos + new Vector3Int(0, 1, 0), tile);

                    if (tile == wall_Bottom_Left || tile == wall_Bottom_Right || tile == wall_Bottom)
                    {
                        cliffTilemap.SetTile(tilePos - new Vector3Int(0, 1, 0), cliff_0);
                        cliffTilemap.SetTile(tilePos - new Vector3Int(0, 2, 0), cliff_1);
                        if (tile == wall_Bottom_Left)
                        {
                            wallTilemap.SetTile(tilePos, wall_Center_Left);
                        }
                        else if (tile == wall_Bottom_Right)
                        {
                            wallTilemap.SetTile(tilePos, wall_Center_Right);
                        }
                        else
                        {
                            wallTilemap.SetTile(tilePos, wall_Center);
                        }
                    }
                    else
                    {
                        wallTilemap.SetTile(tilePos, wall_Center);
                    }
                }
                break;
            default:
                break;
        }
    }

DetermineWallTile(); 함수에서 어떤 타일을 리턴하는지에 따라 절벽이나 2층 벽들을 만들어줍니다.
특히 2층 벽을 만들 때, SetTile(tilePos + new Vector3Int(0, 1, 0) ...) 부분을 보시면 타일링을 위에서 아래로 하는 이유를 알 수 있습니다.

4. 비트마스킹

DetermineWallTile(); 함수에서 비트마스킹을 어떻게 사용하는지 보겠습니다.


위 그림은 TopMaskTopMatch의 비트마스킹 예시입니다.
3x3 배열에서 pattern을 뽑고 mask와 비트연산을 한 후에 Match와 비교합니다.
D, F가 0이고 H가 1이면 나머지 블록을 정의하지 않아도 전부 wall_Top tile을 칠할 수 있습니다.
Exception을 포함한 나머지 변수들도 똑같이 정의합니다.

private Tile DetermineWallTile(int x, int y)
    {
        // 패턴 계산
        int pattern = CalculatePattern(x, y);

        // 패턴에 따른 타일 결정

        if (Matches(pattern, ExceptionMask_0, ExceptionMatch_0)) return wall_Top_Center;
        if (Matches(pattern, ExceptionMask_1, ExceptionMatch_1)) return wall_Top_Center;
        if (Matches(pattern, ExceptionMask_2, ExceptionMatch_2)) return wall_Top_Center;
        if (Matches(pattern, ExceptionMask_3, ExceptionMatch_3)) return wall_Top_Center;


        if (Matches(pattern, TopMask, TopMatch)) return GetRandomTopTile();
        if (Matches(pattern, BottomMask, BottomMatch)) return wall_Bottom;
        if (Matches(pattern, LeftMask, LeftMatch)) return wall_Left;
        if (Matches(pattern, RightMask, RightMatch)) return wall_Right;
        if (Matches(pattern, TopLeftMask_0, TopLeftMatch_0)) return wall_Top_Left;
        if (Matches(pattern, TopRightMask_0, TopRightMatch_0)) return wall_Top_Right;
        if (Matches(pattern, BottomLeftMask_0, BottomLeftMatch_0)) return wall_Bottom_Left;
        if (Matches(pattern, BottomRightMask_0, BottomRightMatch_0)) return wall_Bottom_Right;

        if (Matches(pattern, TopLeftMask_1, TopLeftMatch_1)) return wall_Top_Left;
        if (Matches(pattern, TopRightMask_1, TopRightMatch_1)) return wall_Top_Right;
        if (Matches(pattern, BottomLeftMask_1, BottomLeftMatch_1)) return wall_Bottom_Left;
        if (Matches(pattern, BottomRightMask_1, BottomRightMatch_1)) return wall_Bottom_Right;

        // 기본값
        return null;
    }

ExceptionMask와 Match들은 위의 상수값들로 처리하지 못한 부분들을 예외로 처리한 부분입니다.
( 추후에 더 늘어날 수 있습니다. )

5. 그림자 추가

마지막으로 그림자를 추가하겠습니다.
절벽은 위에서 처리하고 그림자를 나중에 따로 처리하는 이유는 그림자가 벽보다 layer가 높아서 수정된 부분에 따라 다시 조정해줘야하기 때문입니다.

어떤 그림자를 어떤 상황에 써야하는지부터 확인해보겠습니다.

  1. 오른쪽 벽에 평평한 그림자
  2. 기둥이나 코너같이 끝나는부분엔 왼쪽위로 찌르는 그림자
  3. 기둥 시작하는 부분에는 왼쪽 위로 올라가는 그림자

이 세 규칙을 코드로 구현해보겠습니다.

    private void PlaceShadowTile(int x, int y)
    {
        Vector3Int tilePos = new Vector3Int(x, y, 0);

        Tile wallTile = wallTilemap.GetTile<Tile>(tilePos);

        if (wallTile == null) return;
        if (wallTilemap.GetTile<Tile>(tilePos - new Vector3Int(1, 0)) != null) return;

        if (wallTile == wall_Right || wallTile == wall_Left)
            shadowTilemap.SetTile(tilePos - new Vector3Int(1, 0), shadow_Right);
        else if (wallTile == wall_Top_Left || wallTile == wall_Top_Center)
            shadowTilemap.SetTile(tilePos - new Vector3Int(1, 0), shadow_Right_Top);
        else if (wallTile == wall_Bottom_Left)
            shadowTilemap.SetTile(tilePos - new Vector3Int(1, 0), shadow_Right);
        else if (wallTile == wall_Center_Center || wallTile == wall_Center_Left)
            shadowTilemap.SetTile(tilePos - new Vector3Int(1, 0), shadow_Right_Bottom);

    }

GetTile로 해당 칸에 어떤 타일이 있는지를 확인할 수 있어서 어렵지 않습니다.

아래는 실행 결과입니다.


마무리

그림자, 랜덤 타일, 비트마스킹 전부 어색하지 않게 잘 됐습니다.
절벽이 아래로 갈수록 어두워져서 검은색 배경이 어울리네요.
맵은 이제 여기까지 만들고 다음에는 Enemy AI를 구현해보겠습니다.

참고자료

https://assetstore.unity.com/packages/2d/environments/minifantasy-dungeon-206693
https://www.tcpschool.com/c/c_operator_bitwise

0개의 댓글