타일맵과 함께 사용할 프로시저 패턴, 2부


이 블로그 게시물은 파트 1과 동일한 규칙을 준수합니다:
- 타일인지 아닌지를 구분하는 방법은 바이너리를 사용하는 것입니다. 1은 켜짐, 0은 꺼짐입니다.
- 모든 맵을 2D 정수 배열에 저장하고, 각 함수가 끝날 때(렌더링할 때 제외) 사용자에게 다시 반환합니다.
- 배열 함수 GetUpperBound() 를 사용하여 맵의 높이와 너비를 가져옵니다. 즉, 각 함수에 들어가는 변수가 줄어들어 코드를 더 깔끔하게 작성할 수 있습니다.
- 타일맵 좌표계가 왼쪽 하단에서 시작하고 Mathf. FloorToInt()를 사용하면 숫자를 정수로 반올림할 수 있기 때문에 저는 Mathf.FloorToInt()를 자주 사용합니다.
- 이 블로그 게시물에 제공된 모든 코드는 C#으로 작성되었습니다.
이전 블로그 게시물에서 펄린 노이즈를 사용하여 최상위 레이어를 만드는 몇 가지 방법을 살펴보았습니다. 다행히도 펄린 노이즈를 사용하여 동굴을 만들 수도 있습니다. 이를 위해 현재 위치의 매개변수에 수정자를 곱한 새로운 펄린 노이즈 값을 가져옵니다. 수정자는 0에서 1 사이의 값입니다. 수정자 값이 클수록 펄린 생성은 더 지저분해집니다. 그런 다음 이 값을 0 또는 1의 정수로 반올림하여 맵 배열에 저장합니다. 구현을 살펴보세요:
알 수 없는 블록 유형 "codeBlock"인 경우 `serializers.types` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.
시드 대신 수정자를 사용하는 이유는 0에서 0.5 사이의 숫자를 값에 곱할 때 펄린 생성 결과가 더 좋아 보이기 때문입니다. 값이 낮을수록 결과가 더 흐릿해집니다. 몇 가지 결과를 살펴보세요. 이 GIF는 수정자 값 0.01로 시작하여 0.25까지 증분하여 작동합니다.

이 GIF를 보면 펄린 세대가 실제로 틱할 때마다 패턴을 확대하고 있음을 알 수 있습니다.
이전 블로그 게시물에서 동전 던지기를 사용하여 플랫폼의 상승 또는 하락 여부를 결정할 수 있다는 것을 살펴보았습니다. 이 글에서는 동일한 아이디어를 사용하되 왼쪽과 오른쪽에 대한 두 가지 옵션을 추가합니다. 이러한 랜덤 워크 알고리즘의 변형을 통해 동굴을 만들 수 있습니다. 임의의 방향을 가져와서 위치를 이동하고 타일을 제거합니다. 파괴해야 하는 필요한 바닥에 도달할 때까지 이 프로세스를 계속합니다. 현재는 위, 아래, 왼쪽, 오른쪽의 4가지 방향만 사용하고 있습니다.
알 수 없는 블록 유형 "codeBlock"인 경우 `serializers.types` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.
함수의 시작은 다음과 같습니다:
시작 위치 찾기
제거해야 하는 바닥 타일 수 계산하기
시작 위치에서 타일 제거하기
층 수에 하나 추가
다음으로 동안 루프로 넘어갑니다. 이렇게 하면 동굴이 만들어집니다:
알 수 없는 블록 유형 "codeBlock"인 경우 `serializers.types` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.
우선 난수를 사용하여 어느 방향으로 이동해야 할지 결정합니다. 다음으로 스위치 케이스 문으로 새 방향을 확인합니다. 이 문에서는 해당 위치가 벽인지 확인합니다. 그렇지 않은 경우 배열에서 타일 조각을 제거합니다. 필요한 한도 금액에 도달할 때까지 이 작업을 계속합니다. 최종 결과는 아래와 같습니다:

또한 대각선 방향도 포함하는 이 기능의 사용자 지정 버전도 만들었습니다. 이 함수의 코드는 다소 길기 때문에 보고 싶으시면 이 블로그 글의 시작 부분에 있는 프로젝트 링크를 확인하시기 바랍니다.
방향성 터널은 맵의 한쪽 끝에서 시작하여 반대쪽 끝으로 이어지는 터널입니다. 터널의 곡선과 거칠기를 함수에 입력하면 터널의 곡선과 거칠기를 제어할 수 있습니다. 또한 터널 부분의 최소 및 최대 길이를 결정할 수도 있습니다. 아래에서 구현을 살펴보겠습니다:
public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curviness)
{
//이 값은 마이너스 값에서 양수 값으로 이동하며, 이 경우 너비 값이 1이면 터널의 너비는 3이 됩니다.
int tunnelWidth = 1;
//시작 X 위치를 터널 중앙으로 설정합니다.
int x = map.GetUpperBound(0) / 2;
//시드로 무작위 설정
System.Random rand = new System.Random(Time.time.GetHashCode());
//터널의 첫 번째 부분 만들기
for (int i = -tunnelWidth; i <= tunnelWidth; i++)
{
map[x + i, 0] = 0;
}
먼저 너비 값을 설정합니다. 이 너비 값은 마이너스 값에서 플러스 값으로 바뀝니다. 이렇게 하면 원하는 실제 크기를 얻을 수 있습니다. 이 경우 1 값을 사용하고 있습니다. 이렇게 하면 -1, 0, 1 값을 사용하므로 총 너비는 3이 됩니다.
다음으로 할 일은 시작 X 위치를 설정하는 것인데, 이는 맵 너비의 중간을 가져와서 수행합니다. 이제 첫 번째 값을 설정했으니 맵의 첫 번째 부분을 터널링할 수 있습니다.

이제 나머지 지도를 만들어 보겠습니다.
알 수 없는 블록 유형 "codeBlock"인 경우 `serializers.types` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.
난수를 생성하여 러프니스 값과 비교하여 확인하고, 이 값보다 크면 경로의 너비를 변경할 수 있습니다. 또한 너비를 너무 작게 만들고 있지는 않은지 확인합니다. 다음 코드를 통해 지도를 따라 이동하면서 터널을 만들고 있습니다. 각 단계마다 다음을 수행합니다:
커브 값과 비교할 새로운 난수를 생성합니다. 이전 확인과 마찬가지로 이 값보다 크면 경로의 중심점을 변경할 수 있습니다. 또한 지도의 가장자리에서 벗어나지 않는지 확인합니다.
마지막으로 생성한 새 섹션을 터널링합니다.
이 구현의 최종 결과는 다음과 같습니다:

셀룰러 오토마타는 셀의 이웃을 사용하여 현재 셀이 켜져 있는지(1) 또는 꺼져 있는지(0)를 결정합니다. 이러한 이웃의 기본은 무작위로 생성된 셀 그리드를 사용합니다. 여기서는 C#의 Random.Next 함수를 사용하여 이 초기 그리드를 생성하겠습니다.
셀룰러 오토마타에는 몇 가지 다른 구현이 있기 때문에 이 기본 그리드를 생성하기 위해 별도의 함수를 만들었습니다. 함수는 아래와 같습니다.
알 수 없는 블록 유형 "codeBlock"인 경우 `serializers.types` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.
이 기능에서는 그리드에 벽을 표시할지 여부를 결정할 수도 있습니다. 그 외에는 비교적 간단합니다. 채우기 비율에 대해 임의의 숫자를 확인하여 현재 셀이 켜져 있는지 또는 꺼져 있는지 확인합니다. 결과를 살펴보세요:

무어 이웃은 초기 셀룰러 오토마타 생성을 원활하게 하는 데 사용됩니다. 무어 지역은 다음과 같은 모습입니다:

이웃에 대한 규칙은 다음과 같습니다:
- 이웃이 있는지 모든 방향을 확인하세요.
- 이웃이 활성 타일인 경우 서라운드 타일에 추가합니다.
- 이웃이 활성 타일이 아닌 경우 아무것도 하지 마세요.
- 셀에 주변 타일이 4개 이상 있는 경우 해당 셀을 활성 타일로 만듭니다.
- 셀에 정확히 4개의 서라운드 타일이 있는 경우 타일을 그대로 둡니다.
- 지도의 모든 타일을 시도할 때까지 이 과정을 반복합니다.
무어 이웃을 확인하는 기능은 다음과 같습니다:
static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* 무어 이웃은 다음과 같습니다('T'는 우리 타일, 'N'은 이웃).
*
* N N N
* N T N
* N N N
*
*/
int tileCount = 0;
for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++)
{
for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++)
{
if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1))
{
//우리는 주변을 검사하는 타일을 세고 싶지 않습니다.
if(neighbourX != x || neighbourY != y)
{
tileCount += map[neighbourX, neighbourY];
}
}
}
}
타일 수를 반환합니다;
}
타일을 확인한 후에는 스무딩 기능에서 정보를 사용합니다. 초기 셀룰러 오토마타 생성과 마찬가지로 맵의 가장자리가 벽인지 여부를 설정할 수 있습니다.
public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1)))
{
//에지가 있는 경우 에지를 벽으로 설정합니다.
map[x, y] = 1;
}
//기본 무어 규칙에는 4개 이상의 이웃이 필요합니다.
else if (surroundingTiles > 4)
{
map[x, y] = 1;
}
else if (surroundingTiles < 4)
{
map[x, y] = 0;
}
}
}
}
//수정된 지도 반환
지도를 반환합니다;
}
이 함수에서 주목해야 할 점은 맵을 일정 횟수만큼 부드럽게 처리하는 for 루프가 있다는 사실입니다. 이렇게 하면 결과적으로 더 멋진 지도를 만들 수 있습니다.

예를 들어 방과 방 사이에 블록이 2개만 있는 경우 방을 서로 연결하여 이 알고리즘을 수정할 수 있습니다.
폰 노이만 이웃은 셀룰러 오토마타의 또 다른 인기 구현 방법입니다. 이번 세대에서는 무어 세대에 사용했던 것보다 더 단순한 이웃을 사용합니다. 동네는 이렇게 생겼습니다:

이 이웃에 대한 규칙은 다음과 같습니다:
- 대각선을 제외한 타일 주위를 직접 이웃하는 타일로 확인합니다.
- 셀이 활성화되어 있으면 셀 수에 하나를 더합니다.
- 셀이 비활성 상태이면 아무것도 하지 않습니다.
- 이웃이 2개 이상이면 현재 셀을 활성화합니다.
- 이웃이 2개 미만인 경우 현재 셀을 비활성화합니다.
- 정확히 2개의 이웃이 있는 경우 현재 셀을 수정하지 마세요.
두 번째 결과는 첫 번째 결과와 동일한 원칙을 사용하지만 이웃 영역을 확장합니다.
다음 기능을 사용하여 이웃을 확인합니다:
static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* 폰 노이만 이웃은 다음과 같습니다('T'는 타일, 'N'은 이웃).
*
* N
* N T N
* N
*
*/
int tileCount = 0;
// 가장자리를 벽으로 유지
if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1)))
{
tileCount++;
}
//지도의 왼쪽을 건드리지 않도록 합니다.
if(x - 1 > 0)
{
타일 수 += map[x - 1, y];
}
//지도 하단을 건드리지 않아야 합니다.
if(y - 1 > 0)
{
타일 수 += map[x, y - 1];
}
//지도의 오른쪽을 건드리지 않았는지 확인합니다.
if(x + 1 < map.GetUpperBound(0))
{
타일 수 += map[x + 1, y];
}
//지도 상단을 건드리지 않아야 합니다.
if(y + 1 < map.GetUpperBound(1))
{
tileCount += map[x, y + 1];
}
타일 수를 반환합니다;
}
얼마나 많은 이웃이 있는지 결과를 얻은 후에는 배열을 평활화하는 단계로 넘어갈 수 있습니다. 이전과 마찬가지로 필요한 입력량에 대해 스무딩을 반복하는 for 루프가 있습니다.
public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
//주변 타일 가져오기
int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1)))
{
// 가장자리를 벽으로 유지
map[x, y] = 1;
}
//폰 노이만 이웃은 주변 타일을 3개 이상만 타일로 변경해야 합니다.
else if (surroundingTiles > 2)
{
map[x, y] = 1;
}
else if (surroundingTiles < 2)
{
map[x, y] = 0;
}
}
}
}
//수정된 지도 반환
지도를 반환합니다;
}
최종 결과는 아래에서 볼 수 있듯이 무어 이웃보다 훨씬 더 뭉툭해 보입니다:

다시 말하지만, 무어 이웃과 마찬가지로 생성 위에 추가 스크립트를 실행하여 맵의 영역 간에 더 나은 연결을 제공할 수 있습니다.
프로젝트에서 어떤 형태로든 절차적 생성을 사용하는 데 영감을 얻으셨기를 바랍니다. 아직 프로젝트를 다운로드하지 않았다면 여기에서 다운로드할 수 있습니다. 프로시저럴 생성 맵에 대해 자세히 알아보려면 프로시저럴 생성 위키 또는 로그베이신닷컴에서 훌륭한 리소스를 확인하세요.
절차적 생성을 사용하여 멋진 것을 만들었으면 트위터에 메시지를 남기거나 아래에 댓글을 남겨 주세요!
더 자세히 알아보고 라이브 데모를 체험하고 싶으신가요? 또한 6월 20일에 열리는 유나이트 베를린의 엑스포홀 미니 극장에서 타일맵과 함께 사용할 수 있는 프로시저럴 패턴에 대해 발표할 예정입니다. 강연이 끝난 후 직접 만나서 이야기를 나누고 싶으시다면 제가 근처에 있을게요!
