はじめに
こちらは前回の記事
の続きになります。
上記は基本的な使用方法になりますので、ある程度タイルマップの理解があれば今回の記事を読み進めることは可能だと思います。
また、こちらの記事を投稿する上で下記のゲームを作成しております。
制作したゲームの地面部分を生成する上で使用した手法ですので、一度プレイをしていただくと、概要がわかると思います。
AstroRunというゲームを制作しました
UnityRoomさんにアップロードしているため、画像クリックで直接ページに飛んでゲームをプレイすることができます。
本記事にて記述している内容
- タイルマップをスクリプトから自動生成する方法
- 乱数を用いたプロシージャルなマップの生成
今回は、ルールタイルを用いてより自然な2Dランゲームのマップを自動生成するような仕組みを作りましたので、備忘録として残しておきます。
参考にした記事
パーリンノイズでマイクラみたいなマップの自動生成【Unity】【パーリンノイズ】
スクリプトからタイルマップを設置する
まずはじめに、今回使用するスクリプトが下記になります。
使用したスクリプト
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
public class MapGenerator : MonoBehaviour
{
/// <summary>
/// プレイヤーゲームオブジェクト
/// </summary>
[SerializeField]
public GameObject Player;
[Header("タイルマップ生成関連")]
/// <summary>
/// 描画するタイルマップ
/// </summary>
[SerializeField]
public Tilemap GroundTileMap;
/// <summary>
/// 地面として設置するタイルマップテクスチャー
/// </summary>
[SerializeField]
public TileBase GroundTileBase;
/// <summary>
/// ノイズの処理間隔
/// </summary>
[SerializeField]
public int interval = 10;
/// <summary>
/// 変化量調整
/// </summary>
[SerializeField]
public float noiseScale = 0.5f;
/// <summary>
/// パーリンノイズを使用するかどうか
/// </summary>
[SerializeField]
public bool isPerlineNoise = true;
/// <summary>
/// 一度に生成するタイル数
/// </summary>
[SerializeField]
public int generateTilemapNum = 100;
/// <summary>
/// 生成間隔
/// </summary>
[SerializeField]
public float generateMapDistance = 50;
/// <summary>
/// 生成するタイルマップの高度限界
/// </summary>
[SerializeField]
public int generateTileMap_UpperHeight = 5;
// ---------------メンバー変数----------------------------------------
/// <summary>
/// 配置するタイルマップ配列
/// </summary>
private int[,] map;
/// <summary>
/// シード値
/// </summary>
private float seed;
/// <summary>
/// プレイヤーの走破した距離
/// </summary>
private float runThroughDistance;
// ------------------------------------------------------
// Start is called before the first frame update
void Start()
{
// 初期位置
runThroughDistance = Player.transform.position.x;
// マップ生成
map = GenerateArray(generateTilemapNum, generateTileMap_UpperHeight, runThroughDistance, false);
// 同じマップにならないようにシード作成
seed = Random.value;
Debug.Log("Seed:" + seed);
// ノイズ作成
var perlinMap = GeneratePerlinNoise(map, seed, interval, noiseScale);
// マップ描画
if (!!isPerlineNoise) {
RenderMap(perlinMap, GroundTileMap, GroundTileBase);
}
else {
RenderMap(map, GroundTileMap, GroundTileBase);
}
}
// Update is called once per frame
void Update()
{
// 現在のプレイヤーのX座標の取得
var currentPlayerPositionX = Player.transform.position.x;
// 前回の生成タイミングから走破した距離
var distance = currentPlayerPositionX - runThroughDistance;
// 生成タイミング
// 一度に生成するタイルマップ数から走破した距離の差が一定値になった時
if (generateMapDistance > generateTilemapNum - distance) {
// タイル生成
// 生成した続きにマップ作成
var generateTileXNow = map.GetUpperBound(0);
var width = generateTileXNow + generateTilemapNum;
// 配列の生成
map = GenerateArray(width, generateTileMap_UpperHeight, currentPlayerPositionX, true);
// 平滑化した二次元配列の生成
var perlinMap = GeneratePerlinNoise(map, seed, interval, noiseScale);
// タイルマップの描画
RenderMap(perlinMap, GroundTileMap, GroundTileBase);
// 走破距離の追加
runThroughDistance += generateTilemapNum;
}
}
/// <summary>
/// 配列の生成
/// </summary>
/// <param name="wideth">高さ</param>
/// <param name="height">横幅</param>
/// <param name="currentPlayerPosition">現在のプレイヤー位置</param>
/// <param name="eraseTileMap">タイルマップを途中から設置するかどうか</param>
/// <returns>0か1の配列</returns>
private int[,] GenerateArray(int wideth, int height, float currentPlayerPosition, bool eraseTileMap)
{
int[,] map = new int[wideth, height];
if (!!eraseTileMap) {
// 既に走破した部分のタイルを生成しないようにする
for (int x = (int)currentPlayerPosition; x <= map.GetUpperBound(0); x++) {
for (int y = 0; y <= map.GetUpperBound(1); y++) {
map[x, y] = 1;
}
}
}
else {
for (int x = 0; x <= map.GetUpperBound(0); x++) {
for (int y = 0; y <= map.GetUpperBound(1); y++) {
map[x, y] = 1;
}
}
}
Debug.Log("マップ生成実行");
return map;
}
/// <summary>
/// 平滑化した2次元配列の生成
/// </summary>
/// <param name="map">タイルマップ配列</param>
/// <param name="seed">シード値</param>
/// <param name="interval">生成処理間隔</param>
/// <param name="noiseScale">変化量調整</param>
/// <returns>平滑化したタイルマップ配列</returns>
private int[,] GeneratePerlinNoise(int[,] map, float seed, int interval, float noiseScale)
{
var resultArray = new int[map.GetUpperBound(0) + 1, map.GetUpperBound(1) + 1];
//平滑化の際に対応する(x,y)のリスト
List<int> noiseX = new List<int>();
List<int> noiseY = new List<int>();
//ノイズを生成
for (int x = 0; x < map.GetUpperBound(0); x += interval) {
// パーリンノイズでノイズを作成
var perlinNoise = Mathf.PerlinNoise(x, seed * noiseScale);
var ground = perlinNoise * map.GetUpperBound(1);
// ノイズを加えた後の値
var newPoint = Mathf.FloorToInt(ground);
noiseY.Add(newPoint);
noiseX.Add(x);
}
var points = noiseY.Count;
// ノイズ処理を行った回数分、平滑化処理を実行
for (int i = 1; i < points; i++) {
//現在の位置を取得
var currentPos = new Vector2Int(noiseX[i], noiseY[i]);
//直前のノイズ処理した位置を取得
var lastPos = new Vector2Int(noiseX[i - 1], noiseY[i - 1]);
// 2つの間の差異を特定
var diff = currentPos - lastPos;
//高さ変更の値を設定
float heightChange = diff.y / interval;
//現在の高さを特定
float currHeight = lastPos.y;
//直前に処理をしたX値から現在のX値までの処理を実行
for (int x = lastPos.x; x < currentPos.x; x++) {
// 高さ分配列に「1」を設定する
for (int y = Mathf.RoundToInt(currHeight); y > 0; y--) {
resultArray[x, y] = 1;
}
currHeight += heightChange;
}
}
return resultArray;
}
/// <summary>
/// タイルマップの描画処理
/// </summary>
/// <param name="map">描画する配列</param>
/// <param name="tilemap">描画するタイルマップ</param>
/// <param name="tilechip">タイルチップ</param>
private void RenderMap(int[,] map, Tilemap tilemap, TileBase tilechip)
{
//タイルマップのクリア
tilemap.ClearAllTiles();
//マップ幅(X軸)
for (int x = 0; x < map.GetUpperBound(0); x++) {
////マップの高さ(Y軸)
for (int y = 0; y <= map.GetUpperBound(1); y++) {
// 配列の値が「1」であれば描画
if (map[x, y] == 1) {
tilemap.SetTile(new Vector3Int(x, y, 0), tilechip);
}
}
}
}
}
スクリプトの説明
今回のスクリプトで行っている内容に関しては
- 0,1からなる配列の作成:
GenarateArray()
関数 - 作成した配列にノイズ処理を加える:
GeneratePerlinNoise()
関数 - 生成した配列を元にタイルマップの描画
となります。
色々ごちゃごちゃ書いていますが、重要なポイントである2番目を説明します。
ノイズの作成について
ノイズ処理はGeneratePerlinNoise()
関数にて作成しております。
単純にノイズを作るのであればRandom関数を使用すればいいのですが、
それでは急にそりたつ壁()やアビスの大穴()ができてしまい、今回のランゲームのマップを作成する上で攻略不可能なマップを作成してしまうおそれがあります。
そこで、今回はパーリンノイズを使用しています。
関数名にある通り、ここでのノイズ作成にはMath.PerlinNoise()
関数を使用しています。
Wiki:パーリンノイズ
「パーリンノイズ」を一言でまとめると、
テクスチャーの作成をする際に使用する技法の1つであり、滑らかな値のノイズを作成することができます。
//ノイズを生成
for (int x = 0; x < map.GetUpperBound(0); x += interval) {
// パーリンノイズでノイズを作成
var perlinNoise = Mathf.PerlinNoise(x, seed * noiseScale);
var ground = perlinNoise * map.GetUpperBound(1);
// ノイズを加えた後の値
var newPoint = Mathf.FloorToInt(ground);
noiseY.Add(newPoint);
noiseX.Add(x);
}
上記の部分でパーリンノイズを作成しています。
Mathf.PerlinNoiseは入力に応じて0~1を返すので、それに最大の高さをかけた値を新しいY座標として設定しています。
ただし、Mathf.PerlinNoiseは与えた値が同じ場合、返す値も同じになるので、事前にRandom関数でseed値を取得することで、毎回違うノイズを取得することが可能
になっています。
// ノイズ処理を行った回数分、平滑化処理を実行
for (int i = 1; i < points; i++) {
//現在の位置を取得
var currentPos = new Vector2Int(noiseX[i], noiseY[i]);
//直前のノイズ処理した位置を取得
var lastPos = new Vector2Int(noiseX[i - 1], noiseY[i - 1]);
// 2つの間の差異を特定
var diff = currentPos - lastPos;
//高さ変更の値を設定
float heightChange = diff.y / interval;
//現在の高さを特定
float currHeight = lastPos.y;
//直前に処理をしたX値から現在のX値までの処理を実行
for (int x = lastPos.x; x < currentPos.x; x++) {
// 高さ分配列に「1」を設定する
for (int y = Mathf.RoundToInt(currHeight); y > 0; y--) {
resultArray[x, y] = 1;
}
currHeight += heightChange;
}
}
ここの部分では、ノイズ処理をした各々の区間を平滑化処理しています。
ノイズ処理を行う部分は数か所(interval変数で調整可能)なので、補間をする形です。
処理内容は、ノイズ処理をしたY座標について現在と直前の差を取得し、その差をinterval変数で割った値分だけ下げて補間する。
というものです。ここの処理については、単純すぎる気がするので改良の余地はあるかなと思います。
実際の使用方法
スクリプトはプレイヤーオブジェクトさえあれば、そのまま使用できる形になっています。
ですので、下記の手順にて使用することが可能です。
- ヒエラルキーにてプレイヤーオブジェクトを作成
- ヒエラルキーにて空オブジェクトを作り、そこに上記のスクリプトをコンポーネントとして追加
- プレイヤー、タイルマップ、ルールタイルをインスペクターにドラッグ&ドロップで登録して実行!
実際にスクリプトを実行してみると、下記の感じのようなマップが生成されます。
今回の記事のまとめ
今回は「パーリンノイズ」を用いて自然なマップ生成を心掛けました。
似たようなものが多くなるのも、パーリンノイズの特徴かなと思います。
マップとして自然を残しつつ、毎回違うマップを生成してゲームをプレイするユーザーに毎回新しさを認知してもらう。
非常に難しいことだなぁと思いつつ、既存のゲームには感心するあまりです。
最後に
今回で「AstroRun」編は終了となります。
タイルマップ機能があれば、簡単に2Dゲームのマップを作成することができるので本当に優れた機能だと思いました。
何よりプログラマーだけでなくアーティスト系、プランナー系の人でも使用できるのが何よりの強みですね。
Unityはプログラミングを使用しなくても、できることが多いのでどんどん触っていきたいものです。