Unity 2D Roguelike 公式チュートリアル Board Manager編を使った学習(4/14)
本稿、または本シリーズは私自身が理解できるように、Unity初心者の方が読んでも理解できるような説明を心がけています。とはいえ、あくまでも個人の記録なので、ノークレームでお願いします。ご理解の上、読んで頂きますようお願いします。
学習のコツ: 公式チュートリアルの動画を観れば大体は手順は分かります。慣れている人は本稿をすっ飛ばしても良いでしょう。ただし、本稿では公式チュートリアルではカバーしていない「なぜ、そうするのか」という意図も解説していたりします。初学者の方の参考になれば幸いです。
Unity 2D Roguelike 公式チュートリアルを学習シリーズ PART4(全14回) Board Manager編
本稿ではUnity公式チュートリアル 2D Roguelikeの第4回を日本語で学ぶ回になっています。
プレイするたびに毎回変化するマップを実装するスクリプト
今回はゴリっとスクリプトを書いていきます。しかも、ランダム要素のあるマップを自動生成して、プレイするたびにその構造が変わるというものです。ただし、チュートリアルなのでUnity公式は可能な限り最小構成にしているものと思われます。このチュートリアルを理解すれば、おそらく多くのゲームに応用可能な知識が入手できるのではないでしょうか。
※筆者もひよっこなので、理解に乏しい箇所がありますが暖かい目で見守ってください
まずはAssetsフォルダの配下にScriptsフォルダを作成してください
Scriptsフォルダの中に「BoardManager」と「GameManager」というスクリプトを作ってください
スクリプトの作り方はScriptsフォルダ内で右クリック⇨Create→C# Scriptで作成できます
Tips: スクリプトを書き始める前に説明しておくと、BoardManagerは前回までに作ったタイル(床やアイテム)などの配置を自動で行なってくれるスクリプトです。また、GameManagerはゲーム全体の管理をしてくれるスクリプトです。このように役割を分けるのは、一つのスクリプトに全ての役割を任せるのは危険だからです。コードの視認性やメンテナンス性が下がるだけでなく、効率的な開発の妨げになるため役割ごとにスクリプトを分けます。もちろん以後のチュートリアルでも、いくつかのスクリプトが登場します。
続きを見ていきましょう。
ヒエラルキー上に空のGameObjectを作り、名前をGameManagerとしてください(以後、本稿ではこれをGameManagerオブジェクトと呼びます。GameManagerスクリプトとの違いを明確にするため)。
GameManagerオブジェクトのコンポーネントに先ほど作った2つのスクリプトを追加してください
BoardManagerを作る
さて、ここからは公式チュートリアル動画を観ながら手を進めてみましょう。そして、本稿では可能な限り全ての変数や関数、クラスの解説を行います。そのため、本稿は分からなくなった時や、もっと詳しく知りたい時に補助的に使えるものを目指します(コード書きながら、同時に動画を観て、さらに本稿を読むのは辛い作業になるはずなので、ハマったら読む!くらいの感じで)。
BoardManagerはプレイヤーがプレイするたびに、現在のレベル(ステージ)に基づいて、マップをランダムに生成して並べる機能を持ちます。
□ using ….って何してるの?
using System;
using Random = UnityEngine.Random;
最初にUsing Systemというものを追加します。これはSerializable属性(attribute)を使うために追加します。簡単に述べると、用意したクラス内の変数やリストの中身をエディタ側で簡単に変更できるようにするものです。毎回毎回調整するたびに、スクリプトを変更するのは面倒なのでこういった便利機能を使います。
そして「using Random = Unity.Engine.Random」も追加します。これはUnityエンジンを利用して、ランダムな数値を求めることができます。
こうした「using 〜」はnamespaceというものを指定しています。それはいろいろな機能が入ったフォルダのようなものです。そこにアクセスすることで機能を提供してもらうのです。
□ Countクラスについて
[Serializable]
public class Count
{
public int minimum;
public int maximum;
public Count(int min, int max)
{
minimum = min;
maximum = max;
}
}
[Serializable]属性をつけたパブリッククラス、CountをStart関数の上に作ります。
このCount関数はマップ上に破壊可能な壁(Wall)やアイテム(SodaやFood)などが、どのくらいの数登場するのか、ということを決めてくれるクラスです。最後に作る関数SetupSceneと合わせて理解すると良いでしょう。
□ 変数columnsとrowsについて
public int columns = 8;
public int rows = 8;
変数columnsとrowsはマップの縦と横のタイルの数です。縦横キャラクター8体分の広さを持っているということですね。そして、その周りに前回用意した、外壁が囲うことになります。
□ public Count wallCountについて
public Count wallCount = new Count(5, 9);
これはCountクラスを使って、一つのマップに登場する破壊可能な壁(Wall)の最小値と最大値を出そうとしているのです。実際にマップを生成する際に最小と最大の間でランダムの値を決めて、その数の分だけWallも生成します。その下準備ですね。
□ public Count foodCountについて
public Count foodCount = new Count(1, 5);
こちらも考え方はwallCountと全く同じです。
□ public GameObject exitとは
こちらは前回までに作った、Exit Prefabを収納するための変数です。exitは一つのマップに一個だけしか生成されないので最小値と最大値を決める必要がありません。そのため、Count関数を使っていません。
□ public GameObject[] floorTiles
public GameObject[] floorTiles;
こちらは前回作った床(Floor)のタイル画像を収納するための変数です。しかし、単なる変数ではなく、配列になっています。Exitはマップに一つだけでしたが、Floorは一つのマップにたくさん出てきます。
□ wallTiles,foodTiles他
public GameObject[] wallTiles;
public GameObject[] foodTiles;
public GameObject[] enemyTiles;
public GameObject[] outerWallTiles;
こちらもfloorTilesと同様に生成する際に複数のタイル画像がそれぞれの配列の中に入ります。
Tips: この「生成される際に配列に入る」というのはこの後、シーンを再生した際、ヒエラルキーのboardHolder内にたくさんのタイルが入っている様子を見ると感覚が掴めるでしょう。
boardHolderについて
実際にマップを生成した際にヒエラルキーにはたくさんのタイルも同時に生成されます。boardHolderはこれらを収納するオブジェクトとして使われます。
List gridPositionsについて
private List<Vector3> gridPositions = new List<Vector3>();
gridPositionsはListと呼ばれる同じデータ型の値を取り扱うことのできるものでできています。重要なのはこのデータ型です。
gridPositionsはVector3というデータ型の値を取り扱うことができます。それは位置情報に関するデータです。つまり、gridPositionsの役割はゲーム中の盤面全ての位置情報を記憶し、その位置にオブジェクトが生成されたかどうかを記録することです。盤面(Board)というのはタイルが生成可能な範囲全てを表しています。そこにFloorやSodaやFoodが生成されれば、その位置を記録してくれます。
これが必要な理由は意図しないオブジェクトが重複して生成されるのを防ぐためです。
Tips: この後にRandomPosition()という関数を作る際に登場します。要するに壁やアイテムを生成するときにランダムな場所を決めたいのです。そのためには事前にそれらを置ける場所を全て覚えておきたいので、オブジェクト設置可能な場所をマルっとgridPositionsに格納しています。
void InitialiseList関数について
このInitialiseListは先ほど作ったgridPositionsを初期化するための関数です。なぜ、初期化する必要があるのでしょうか。それは、次の階に進んだ時などに毎回gridPositionsが呼ばれるためです。新しい階へ進んだのに前の階の情報が残ったままだと、永久に変わり映えのないマップが続いちゃいますよね。なので、初期化する必要があります。
そのためにClear()を使っています。このように消去と追加が容易にできるのがListの便利なところです。
void InitialiseList関数内のfor文について
void InitialiseList()
{
gridPositions.Clear();
for (int x = 1; x < columns - 1; x++)
{
for (int y = 1; y < rows - 1; y++)
{
gridPositions.Add(new Vector3(x, y, 0f));
}
}
}
こちらのfor文については、公式チュートリアルの動画の画像が非常に分かりやすかったため引用します。下の画像をご覧ください。
なぜ2回for文を回しているのでしょうか。その理由は完全に通行不可能なマップを生成することを回避するためです。プレイヤーが立っている地点のXが0、Yが0です。そのため、for文のXとYの初期値は両方とも1である必要がありますね。
//この部分
for (int x = 1; x < columns - 1; x++)
//この部分
for (int y = 1; y < rows - 1; y++)
仮にここのXとYの初期値を0にしてしまった場合、プレイヤーがいきなり壁にめり込んだ状態でスタートすることもありますし、Exitが壁にめり込むこともあるでしょう。そうなると困りますよね。なので、6*6のスペースにアイテムや壁を生成するようにします。
そして、gridPositionsにAddしていますね。これも考えてみましょう。
gridPositions.Add(new Vector3(x, y, 0f));
上にも述べたようにgridPositionsはVector3型のデータを格納できます。そして、Listは削除と追加が非常に簡単にできるのでしたね。ここではAddでVector3型のデータをgridPositionsに追加しています。そして、ただ格納しているのではなく、for文を回しているのでxとyが回り続ける限り、範囲内の情報を全て格納していきます。
private void BoardSetup()
公式チュートリアルの動画ではprivate(アクセス修飾子)はつけていませんが筆者はつけています。そもそもアクセス修飾子をつけない場合、デフォルトはprivateになります。勝手にあちこちのスクリプトで呼び出し可能になると管理が大変ですからね。
privateと明確にしておいた方が視認性が上がるという理由で私は書いていますし、おそらく近年の教材ではほとんどの場合privateも記述しているように思えます。
さて、BoardSetup()について見てみましょう。
private void BoardSetup()
{
}
このprivate関数はOuterWallとFloorを敷き詰めるための関数です。ここからマップを生成するためにオブジェクトに値を渡したりします。
BoardSetup()の内容を見てみる
private void BoardSetup()
{
boardHolder = new GameObject("Board").transform;
}
こちらのコードは先ほど作ったboardHolderというTransform型の変数に「新たに作ったBoardという名前のGameObjectのTransformコンポーネントを格納している」ということになります。
最初に作ったboardHolder変数は言わば単なる箱です。中身は空っぽですね。ここで実際にBoardというGameObjectを作り出して、そのTransformコンポーネント(位置、回転、スケール)を格納しているのです。
次に先ほどのInitialseListで作ったようなfor文を書いていきます。少し値は変わりますが考え方は全く同じです。
private void BoardSetup()
{
boardHolder = new GameObject("Board").transform;
for (int x = -1; x < columns + 1; x++)
{
for (int y = -1; y < rows + 1; y++)
{
}
}
}
このfor文は何を意味するのでしょうか。こちらも公式チュートリアルの画像が分かりやすいので引用します。
このBoardSetupはOuterWallとFloorを敷き詰めるためにあるのでしたね。この時点では簡単に述べると、この範囲全部に床か外壁を置きたい!ということです。そのためにひとまず、全ての範囲を対象にしているという感じです。
ではそのforの中にどんなものを書くのでしょうか。
private void BoardSetup()
{
boardHolder = new GameObject("Board").transform;
for (int x = -1; x < columns + 1; x++)
{
for (int y = -1; y < rows + 1; y++)
{
GameObject toInstantiate = floorTiles[Random.Range(0, floorTiles.Length)];
if (x == -1 || x == columns || y == -1 || y == rows)
{
toInstantiate = outerWallTiles[Random.Range(0, outerWallTiles.Length)];
}
GameObject instance = Instantiate(toInstantiate, new Vector3(x, y, 0f), Quaternion.identity) as GameObject;
instance.transform.SetParent(boardHolder);
}
}
}
for文の中にGameObject型のメンバ変数 toInstantiateがありますね。そして、それにfloorタイル配列内に入っている全てのタイルの中からランダムで選ばれたものを代入しています。
そして次のif文がミソです。if文の条件当てはまった場合、先ほどのtoInstatiateにOuterWallのタイルを入れ直します。
if文の条件の意味するところは「もし、OuterWallを生成する場所に該当するなら」というものです。
以下に、この流れを可能な限り詳細に説明します。
GameObject変数toInstatiateにfloorTiles配列に入っているタイルの中からランダムで選ばれたものが入ります
if文の条件は「もし、Xが-1、あるいはXがcolumnsと同じ値(8)、あるいはyが-1、あるいはyがrowsと同じ値(8)であれば」という意味です。この条件に一つでも当てはまれば、括弧内の処理が行われます。上の画像の通り、「xかyがOuterWallを生成する場所であれば」ということですね
if文の条件に当てはまれば、outerWallTilesの中に入っているタイルからランダムで一つ選ばれたものが、変数toInstantiateに入ります
以下のコードはFloorあるいはOuterWallのPrerfabを「Instantiate」で生成しつつ、その時に設定したVector3情報をGameObject型の変数instanceに持たせています。その後、instanceはSetParentでboardHolderを親オブジェクトにしています。
GameObject instance = Instantiate(toInstantiate, new Vector3(x, y, 0f), Quaternion.identity) as GameObject;
instance.transform.SetParent(boardHolder);
private Vector3 RandomPosition
この関数はマップ上にアイテムや壁を配置するために必要なものです。内容は後述。
RandomPositionの内容について
RandomPositionは「0からgridPositionの要素数の間」でランダムな数値を返すための関数です。以下のコードから考えてみましょう。
private Vector3 RandomPosition()
{
int randomIndex = Random.Range(0, gridPositions.Count);
}
int型の変数randomIndexはランダムな数値を入れるための箱です。ランダムな数の最小値は0、最大値はListであるgridPositionの要素数の数を示す「gridPosition.Count」ですね。
Tips: RandomPosition()ではアイテムや壁などが設置できる場所の中から、実際にどこに設置するかをランダムで決めています。最後のSetupScene関数内で呼ばれるので、合わせて確認すると理解がしやすいでしょう。
次のコードもまとめて説明します。
private Vector3 RandomPosition()
{
int randomIndex = Random.Range(0, gridPositions.Count);
Vector3 randomPosition = gridPositions[randomIndex];
gridPositions.RemoveAt(randomIndex);
return randomPosition;
}
Vector3型のメンバ変数 randomPositionを宣言
gridPositionというのは全ての盤面の位置情報を記憶させているものでしたね
gridPositions[randomIndex]は「gridPositionsのrandomIndex番目の要素」という意味を持ちます
つまり、randomPositionという変数にはgridPisition[randomIndex]の要素のVector3情報を持たせています
gridPositions.RemoveAt[randomIndex]では、先ほどListに登録した[randomIndex]を消去しています。その理由は2つのオブジェクトを同じ場所に生成しないためです。
そして、最後にVector3型の変数randomPositionをreturnしています
LayOutObjectAtRandomについて
private void LayoutObjectAtRandom(GameObject[] tileArray, int minimum, int maximum)
{
int objectCount = Random.Range(minimum, maximum + 1);
for (int i = 0; i < objectCount; i++)
{
Vector3 randomPosition = RandomPosition();
GameObject tileChoice = tileArray[Random.Range(0, tileArray.Length)];
Instantiate(tileChoice, randomPosition, Quaternion.identity);
}
}
この関数は最後に作るSetupSceneと見比べながら理解すると良いでしょう。多くの引数がありますが、それらは冒頭に作った変数や配列を受け取るためにあります。
引数のGameObject型の配列 tileArrayはwallTilesやfoodTilesなどを受け取ります
引数のint minimumやmaxmumはSetup Sceneで呼ばれる時に、冒頭で作った「public Count wallCount = new Count(5, 9)」、「public Count foodCount = new Count(1, 5)」を受け取ります
int objectCount はWallやFoodの最小値と最大値+1の間のランダムな値が入ります。最大値に1を足している理由は次にfor文を回すためです
Vector3型のメンバ変数randomPositionには、先ほど作ったRandomPosition()関数を呼び、ランダムな位置情報が入ります
tileChoiceはいくつか種類のあるWallの画像の中からランダムで選ばれたものを入れています
そしてInstatiateでPrefabが作られます。tileChoice(どの画像のタイルか)、randomPosition(どの位置に)で設定されているわけですね。Quaternionというのは回転の情報を表しますが、2Dゲームには必要のないものなので、identity(元々の回転具合でー)という風にしています。
SetupSceneについて
この関数では本稿で作ってきた関数をまとめて呼び出しています。
public void SetupScene(int level)
{
BoardSetup();
InitialiseList();
LayoutObjectAtRandom(wallTiles, wallCount.minimum, wallCount.maximum);
LayoutObjectAtRandom(foodTiles, foodCount.minimum, foodCount.maximum);
int enemyCount = (int)Mathf.Log(level, 2f);
LayoutObjectAtRandom(enemyTiles, enemyCount, enemyCount);
Instantiate(exit, new Vector3(columns - 1, rows - 1, 0f), Quaternion.identity);
}
BoardSetup()を呼び出します
InititaliseList()を呼び出します
LayoutObjectAtRandomを呼び出します。括弧にはwallTilesなどの値を渡しています。これは順番に、作りたいタイル、作りたいタイルの最小値、作りたいタイルの最大値を入れています
int enemyCountはレベル(階層)に応じた数の敵が出るようにしています
最後にexitを生成しています
まとめ
ここまで、BoardManagerの全ての工程の解説をしてきました。BoardManager内の変数や関数について一通りの情報を揃えましたが、気がつけば一万字を超える膨大な情報になってしまいました。それでも基本的なところなどは情報を削ってお届けしました。次回はもっと解説を削減していきたいところです。
また、私自身も学習中の身ですので、誤りがあるかもしれません。そのため、冒頭にも述べたように公式チュートリアルの動画を見ながら手を進めて本稿は補助的に使用するようにしましょう。
誤記についてこちらが気がついた際には適宜修正いたします。
この記事が気に入ったらサポートをしてみませんか?