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

ETHAN BRUINS / UNITY TECHNOLOGIES Contributor
Jun 7, 2018|14 분
타일맵과 함께 사용할 프로시저 패턴, 2부
이 웹페이지는 이해를 돕기 위해 기계 번역으로 제공됩니다. 기계 번역으로 제공되는 콘텐츠에 대한 정확도나 신뢰도는 보장되지 않습니다. 번역된 콘텐츠의 정확도에 관해 의문이 있는 경우 웹페이지의 공식 영어 원문을 참고해 주시기 바랍니다.

1부에서는 펄린 노이즈 및 랜덤 워크와 같은 다양한 방법을 사용하여 절차적으로 최상위 레이어를 만드는 몇 가지 방법을 살펴봤습니다. 이 글에서는 절차적 생성으로 동굴을 만드는 몇 가지 방법을 살펴보면서 가능한 변형에 대한 아이디어를 얻을 수 있을 것입니다.

이 블로그 게시물에서 설명하는 모든 내용은 이 프로젝트 내에서 사용할 수 있습니다. 에셋을 자유롭게 다운로드하여 절차적 알고리즘을 사용해 보세요.

이미지
이미지

이 블로그 게시물은 파트 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` 프롭에서 해당 블록에 대한 직렬화기를 지정하세요.

난수를 생성하여 러프니스 값과 비교하여 확인하고, 이 값보다 크면 경로의 너비를 변경할 수 있습니다. 또한 너비를 너무 작게 만들고 있지는 않은지 확인합니다. 다음 코드를 통해 지도를 따라 이동하면서 터널을 만들고 있습니다. 각 단계마다 다음을 수행합니다:

커브 값과 비교할 새로운 난수를 생성합니다. 이전 확인과 마찬가지로 이 값보다 크면 경로의 중심점을 변경할 수 있습니다. 또한 지도의 가장자리에서 벗어나지 않는지 확인합니다.

마지막으로 생성한 새 섹션을 터널링합니다.

이 구현의 최종 결과는 다음과 같습니다:

image
image
셀룰러 오토마타

셀룰러 오토마타는 셀의 이웃을 사용하여 현재 셀이 켜져 있는지(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;
}
}
}
}
//수정된 지도 반환
지도를 반환합니다;
}

최종 결과는 아래에서 볼 수 있듯이 무어 이웃보다 훨씬 더 뭉툭해 보입니다:

image
image

다시 말하지만, 무어 이웃과 마찬가지로 생성 위에 추가 스크립트를 실행하여 맵의 영역 간에 더 나은 연결을 제공할 수 있습니다.

맺는말

프로젝트에서 어떤 형태로든 절차적 생성을 사용하는 데 영감을 얻으셨기를 바랍니다. 아직 프로젝트를 다운로드하지 않았다면 여기에서 다운로드할 수 있습니다. 프로시저럴 생성 맵에 대해 자세히 알아보려면 프로시저럴 생성 위키 또는 로그베이신닷컴에서 훌륭한 리소스를 확인하세요.

절차적 생성을 사용하여 멋진 것을 만들었으면 트위터에 메시지를 남기거나 아래에 댓글을 남겨 주세요!

유나이트 베를린의 2D 프로시저럴 생성

더 자세히 알아보고 라이브 데모를 체험하고 싶으신가요? 또한 6월 20일에 열리는 유나이트 베를린의 엑스포홀 미니 극장에서 타일맵과 함께 사용할 수 있는 프로시저럴 패턴에 대해 발표할 예정입니다. 강연이 끝난 후 직접 만나서 이야기를 나누고 싶으시다면 제가 근처에 있을게요!