
절차적 맵 생성을 통해서 던전 모양을 만들었습니다.
하지만 이 검정색 네모 덩어리를 바로 게임에 쓸 수는 없기 때문에 무료 에셋으로 AutoTiling을 해주겠습니다.
사용할 에셋은 이 에셋입니다.
사람과 몬스터가 싸우고있는게 귀여워보여서 골랐습니다.

에셋 링크 : https://assetstore.unity.com/packages/2d/environments/minifantasy-dungeon-206693
AutoTiling을 하기 위해서 바로 떠오르는 방법은 다음과 같습니다.
여기서, 두 가지 걸리는 점이 있습니다.

벽이 두 칸짜리 벽과 한 칸짜리 벽이 따로 있는 것을 확인할 수 있습니다. 하지만 맵을 랜덤으로 생성했기 때문에, 두 칸짜리 벽이 들어가지 못하는 한 칸짜리 틈새가 생길 수도 있습니다.
그래서, 저는 바닥과 벽의 타일맵을 따로 두고 위의 초록색 부분처럼 바닥과 벽이 겹치도록 하겠습니다.
자세히는 바닥, 벽, 절벽, 그림자의 Tilemap을 따로 두고 Layer를 절벽<바닥<물체<그림자<벽 순서로 하면 자연스러울 것 같습니다.
주변 8개의 타일을 256개의 케이스에 대해서 전부 확인하는 것은 정말 어렵습니다.
하지만 비트마스킹을 사용하면 개발 시간, 실행 시간, 메모리 전부 줄일 수 있습니다.
혹시 비트마스킹이나 비트 연산이 헷갈리신다면 이 링크를 참고해주세요.
자세한 내용은 아래에서 다시 다루겠습니다.
생성한 사각형의 개수를 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 선언을 통해서 가독성을 높여줍니다.
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를 거꾸로 세는 이유는 밑의 코드에서 확인하실 수 있습니다.
같은 팔레트에서 해당 칸보다 위를 그리는 경우가 있기 때문에, 원근감을 주기 위해서 거꾸로 세야합니다.
일단, 함수를 보기 전에, 변수들부터 보겠습니다.
[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) ...) 부분을 보시면 타일링을 위에서 아래로 하는 이유를 알 수 있습니다.
DetermineWallTile(); 함수에서 비트마스킹을 어떻게 사용하는지 보겠습니다.

위 그림은 TopMask와 TopMatch의 비트마스킹 예시입니다.
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들은 위의 상수값들로 처리하지 못한 부분들을 예외로 처리한 부분입니다.
( 추후에 더 늘어날 수 있습니다. )
마지막으로 그림자를 추가하겠습니다.
절벽은 위에서 처리하고 그림자를 나중에 따로 처리하는 이유는 그림자가 벽보다 layer가 높아서 수정된 부분에 따라 다시 조정해줘야하기 때문입니다.
어떤 그림자를 어떤 상황에 써야하는지부터 확인해보겠습니다.

이 세 규칙을 코드로 구현해보겠습니다.
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