タイルマップと併用可能なプロシージャル生成パターン(前編)

プロシージャル生成は、ゲームに多様性を与えるために多くのクリエイターによって使用されています。代表的な例としては Minecraft や、より最近のものでは Enter the Gungeon や Descenders などのゲームが挙げられます。本記事では、Unity 2017.2 で 2D 機能のひとつとして公開されたタイルマップ、および RuleTile と併用できる、いくつかのアルゴリズムについてご説明します。
プロシージャル生成でマップを作成すると、同じマップが二度出ないようにできます。様々な入力(時間、プレイヤーの現在のレベルなど)を使用して、ゲームのビルド後であってもコンテンツを動的に変更することができます。

これらのアルゴリズムのどれかを使用してマップを生成する場合は、新しいデータをすべて含む整数の配列を 1 つ受け取ることになります。この受け取ったデータを、引き続き変更したり、タイルマップにレンダーしたりすることができます。
この先は、下記の事柄を念頭に置かれた上でお読みください。
タイルとそれ以外の要素の識別はバイナリを使用して行われます。1 が ON、0 が OFF となります。
すべてのマップは整数の 2 次元配列内に保存されます。この配列は(レンダー時以外は)各関数の最後でユーザーに戻されます。
配列を扱う関数である GetUpperBound() を使用して各マップの高さと幅を取得することで、各関数内に含まれる変数の数を減らし、コードをよりクリーンにします。
私はよく Mathf.FloorToInt() を使用します。なぜなら、タイルマップの座標系が左下から始まっており、Mathf.FloorToInt() を使用することで数字を四捨五入して整数にすることができるからです。
本記事内に掲載しているコードはすべて C# で記述されています。
GenerateArray は、設定されたサイズの整数の配列を新しく 1 つ作成します。またその配列が full であるか empty(1 と 0)であるかも示すことができます。コードは以下のようになります。
public static int[,] GenerateArray(int width, int height, bool empty)
{
int[,] map = new int[width, height];
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
if (empty)
{
map[x, y] = 0;
}
else
{
map[x, y] = 1;
}
}
}
return map;
}
この関数は、マップをタイルマップにレンダーするために使用されます。マップの幅と高さの分だけ周回して繰り返しチェックし、チェックしている位置で配列が 1 を持っている場合にのみタイルを配置します。
public static void RenderMap(int[,] map, Tilemap tilemap, TileBase tile)
{
//マップをクリアする(重複しないようにする)
tilemap.ClearAllTiles();
//マップの幅の分、周回する
for (int x = 0; x < map.GetUpperBound(0) ; x++)
{
//マップの高さの分、周回する
for (int y = 0; y < map.GetUpperBound(1); y++)
{
// 1 = タイルあり、0 = タイルなし
if (map[x, y] == 1)
{
tilemap.SetTile(new Vector3Int(x, y, 0), tile);
}
}
}
}
この関数は、再レンダリングするのではなく、マップを更新するためだけに使用されます。この方法ならすべてのタイルとそのタイルデータを再描画しなくて済むため、使用するリソースの量を抑えることができます。
public static void UpdateMap(int[,] map, Tilemap tilemap) //マップとタイルマップを取得し、null タイルを必要箇所に設定する
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
//再レンダリングではなく、マップの更新のみを行う
//これは、それぞれのタイル(および衝突データ)を再描画するのに比べて
//タイルを null に更新するほうが使用リソースが少なくて済むためです。
if (map[x, y] == 0)
{
tilemap.SetTile(new Vector3Int(x, y, 0), null);
}
}
}
}
パーリンノイズは様々な形で使用できます。そのひとつは、マップのトップレイヤーを作成するための使用です。これは単純に、現在の x 軸上の位置とシードを使用して新しいポイントを取得することで行われます。
この生成手法は、パーリンノイズのステージ生成内への実装を最も単純な形で行うものです。パーリンノイズ用の Unity 関数が利用可能なので複雑なプログラミングは不要です。また、関数 Mathf.FloorToInt() を使用することで、必ずタイルマップに適した整数を得られます。
public static int[,] PerlinNoise(int[,] map, float seed)
{
int newPoint;
//パーリンノイズのポイントの位置を下げるために使用される
float reduction = 0.5f;
//パーリンノイズを生成する
for (int x = 0; x < map.GetUpperBound(0); x++)
{
newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, seed) - reduction) * map.GetUpperBound(1));
//高さの半分の位置付近からノイズが始まるようにする
newPoint += (map.GetUpperBound(1) / 2);
for (int y = newPoint; y >= 0; y--)
{
map[x, y] = 1;
}
}
return map;
}
タイルマップ上にレンダーされると以下のようになります。

この関数を平滑化したものを使用することもできます。間隔を設定してパーリンノイズの高さを記録し、複数のポイント間で平滑化します。間隔内に収まる整数のリストが必要になるので、若干高度な関数となります。
public static int[,] PerlinNoiseSmooth(int[,] map, float seed, int interval)
{
//ノイズを平滑化して整数の配列内に保存する
if (interval > 1)
{
int newPoint, points;
//パーリンノイズのポイントの位置を下げるために使用される
float reduction = 0.5f;
//平滑化のプロセスで使用される
Vector2Int currentPos, lastPos;
//平滑化の際に対応するポイント(x のリストと y のリストが 1 つずつ)
List<int> noiseX = new List<int>();
List<int> noiseY = new List<int>();
//ノイズを生成する
for (int x = 0; x < map.GetUpperBound(0); x += interval)
{
newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, (seed * reduction))) * map.GetUpperBound(1));
noiseY.Add(newPoint);
noiseX.Add(x);
}
points = noiseY.Count;
この関数の冒頭部でまず間隔が 1 より大きいかどうか確認します。1 より大きい場合はノイズを生成し、これを間隔を設定して行うことで平滑化されたノイズを生成します。後続部は、実際に各ポイントの平滑化処理を行います。
// 1 で開始するので既に直前の位置があることになる
for (int i = 1; i < points; i++)
{
//現在の位置を取得する
currentPos = new Vector2Int(noiseX[i], noiseY[i]);
//直前の位置も取得する
lastPos = new Vector2Int(noiseX[i - 1], noiseY[i - 1]);
// 2 つの間の差異を特定する
Vector2 diff = currentPos - lastPos;
//高さ変更の値を設定する
float heightChange = diff.y / interval;
//現在の高さを特定する
float currHeight = lastPos.y;
//最後の x から現在の x までの処理を行う
for (int x = lastPos.x; x < currentPos.x; x++)
{
for (int y = Mathf.FloorToInt(currHeight); y > 0; y--)
{
map[x, y] = 1;
}
currHeight += heightChange;
}
}
}
平滑化は以下の手順で行われます。
現在の位置と最後の位置を取得する。
2 つの位置の間の差異を取得する(主に必要な情報は y 軸上の差異)。
次に、該当箇所をどの程度変更すべきか特定します。これは y の差異を変数 interval で割って求められます。
この時点で位置の設定が開始できます。ゼロまで処理を行い続けます。
y 軸で 0 に到達すると、高さの変更値を現在の高さに加え、次の x 軸の位置についてプロセスを繰り返します。
直前の位置と現在の位置の間の各位置の処理が完了したら、次のポイントに移ります。
間隔が 1 以下の場合は、上記のひとつ前の関数を使用して処理を行います。
else
{
//デフォルトでは通常のパーリンノイズ生成が使用される
map = PerlinNoise(map, seed);
}
return map;
レンダーされると以下のように表示されます。

このアルゴリズムは、ちょうどコインを投げて裏か表かでランダムに決定するのと同様の仕組みです。2 つのうちのどちらかの結果が出ます。結果が表であれば 1 ブロック上に進み、裏であれば 1 ブロック下に進みます。常に上または下に移動することで、ステージに高さが加わります。このアルゴリズムの唯一のデメリットは、見た目が非常にギザギザになることです。この仕組みを具体的に見てみましょう。
public static int[,] RandomWalkTop(int[,] map, float seed)
{
//乱数のシード値を与える
System.Random rand = new System.Random(seed.GetHashCode());
//高さの開始値を設定する
int lastHeight = Random.Range(0, map.GetUpperBound(1));
//幅の繰り返し処理
for (int x = 0; x < map.GetUpperBound(0); x++)
{
//コインを投げる
int nextMove = rand.Next(2);
//表で、最下部付近でない場合は、高さを減少する
if (nextMove == 0 && lastHeight > 2)
{
lastHeight--;
}
//裏で、最上部付近でない場合は、高さを増加する
else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) - 2)
{
lastHeight++;
}
//直前の高さから最下部まで繰り返し処理する
for (int y = lastHeight; y >= 0; y--)
{
map[x, y] = 1;
}
}
//マップを戻す
return map;
}
この生成手法だと、パーリンノイズ生成の場合よりも高さが平滑になります。
この生成手法はパーリンノイズ生成の場合よりも高さが平滑になります。
ランダムウォークのこのバリエーションは、上述のバージョンと比較して、結果が大幅に平滑になります。これは、関数に新しい変数を 2 つ加えることで行えます。
- 最初の変数は、現在の高さが維持されていた長さを格納するために使用されます。これは整数で、高さを変更するとリセットされます。
- 2 つ目の変数はこの関数の入力値で、同じ高さのセクション幅の最小値として使用されます。これは実際に関数を見て頂くと分かりやすいと思います。
これで、追加が必要な要素が把握できました。それでは関数を確認してみましょう。
public static int[,] RandomWalkTopSmoothed(int[,] map, float seed, int minSectionWidth)
{
//乱数のシード値を与える
System.Random rand = new System.Random(seed.GetHashCode());
//開始位置を特定する
int lastHeight = Random.Range(0, map.GetUpperBound(1));
//どの方向に進むかの特定に使用される
int nextMove = 0;
//現在のセクション幅の把握に使用される
int sectionWidth = 0;
//配列の幅において処理を行う
for (int x = 0; x <= map.GetUpperBound(0); x++)
{
//次の動きを特定する
nextMove = rand.Next(2);
//セクション幅の最小限の値より大きい現在の高さを使用した場合にのみ、高さを変更する
if (nextMove == 0 && lastHeight > 0 && sectionWidth > minSectionWidth)
{
lastHeight--;
sectionWidth = 0;
}
else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) && sectionWidth > minSectionWidth)
{
lastHeight++;
sectionWidth = 0;
}
//セクション幅をインクリメントする
sectionWidth++;
//高さから 0 まで処理を繰り返す
for (int y = lastHeight; y >= 0; y--)
{
map[x, y] = 1;
}
}
//修正されたマップを戻す
return map;
}
以下の GIF 画像からお分かりの通り、ランダムウォークアルゴリズムの平滑化処理では、ステージ内に広い平らな部分を作り出すことができます。

本記事が、皆様のプロジェクトに何らかのプロシージャル生成を使用するきっかけとなれば嬉しく思います。プロシージャル生成マップについてより詳しく学びたい場合は、Procedural Generation Wiki(英語)または Roguebasin.com(英語)が非常に役立つ資料となっています。
本シリーズの次の記事では、プロシージャル生成を使った洞窟システムの作成方法をご紹介します。
プロシージャル生成を使用して面白いものが作成できたら、ぜひ Twitter または下のコメント欄で私に教えてください!
より深く掘り下げて学べるライブデモ・セッションに参加したいですか? 6 月 20 日、Unite Berlin 会場のホールのミニシアターで、タイルマップと併用可能なプロシージャル生成パターンについてお話します。また講演後も会場におりますので、直接お話しましょう!(※訳注:Unite Berlin は 6 月 21 日、盛況のうちに閉幕しました)