クォータービューゲームのつくりかたPt.1「基礎解説」

こんにちは
この記事を読んでいただきありがとうございます。
昨日は自己紹介モドキみたいな記事を投稿したけど、早速技術的な記事を書いていきたいと思います。
需要はあるか分からないけど、将来的に自分がコードの内容を忘れた時の記録でもあります。

あとこの記事は中級者向けであり全くの初心者向ではありませんので、ある程度プログラミングの知識がある人向けです。

制作環境について

まず制作環境について少し紹介したいと思います。
私はUnityとMonoGame両方でVSCodeを使っています。
あとObsidianっていうローカル保存式のメモソフトも使っています。
アイディアなんかを書く感じですね。
そしてTodoはディスコードに書いてます。見返しやすくするために。
この記事ではゲームエンジンは不問です。

注意

この記事ではどの環境でも分かりやすく心がけて書くつもりです。
とはいえUnityはTilemapというマップが簡単に作れる機能があったりするのに、MonoGameにはないですよね。
そういう場合は「Unityではこの機能があり、作成する必要はありません」のような注意書きをしてMonoGameベースで解説していきたいと思います。

Unityでも既存の機能を全て無視して一から作ることも可能だけど…それじゃUnity使う意味が…ってなりますからね。

ちなみに今回の記事は、Unityには必要ない内容となってしまいました。
この記事で書く内容は全部作成しなくてもUnityが補ってくれます。

クォータービューに必要な要素

クォータービューのゲームを作る上で必要な要素とはなんでしょう?

  • タイルの画像

  • タイルを均等に並べる処理

  • プレイヤーや敵キャラ、NPC

  • マウス座標をタイル座標に変換したりする機能

  • マウスでクリックした位置にキャラクターを移動させる

  • マウスでクリックした位置にブロックを置く(壊す)

  • 光るブロックを置いた時のライティング処理

  • キャラクター移動用の経路探索

これくらいかな…?
ゲームとしてはまだ必要な要素もありますよね。インベントリだったりUI、アイテムのデータとか。そういうのはまた今度。
とりあえず上記のシステムがあればクォータービューゲームと言えるんではないでしょうか。
恐らく全て一回の記事で書ききれないので、数回に分けようかと思います。

クラスについての基本情報

まず「タイルを均等に並べる処理」に必要なクラス構成が

  • Main

  • Scene

  • TileMap

  • Tile

の4つです。
メインクラスは初期化や他のクラスのアップデートを行う(Updateメソッドを呼び出す)役割を持っています。

Unityではこのシステムは必要なく、TilemapのIsometricZ as Yを使えば簡単に描画できます。
使い方や詳細は調べれば沢山出てきます。

Sceneクラスのvirtualメソッド

SceneクラスにはvirtualUpdate, FixedUpdate, Drawの3つのメソッドがあります。

	public virtual void Update()
	{
		tileMap.Update();
	}

	public virtual void FixedUpdate(float updateTime)
	{

	}

	public virtual void Draw()
	{
		Main.SpriteBatch.Begin();
		tileMap.Draw();
		Main.SpriteBatch.End();
	}

・Updateメソッドはその名の通りゲームを起動している間Drawメソッドの      前に呼び出され続けます。
ただしUpdateメソッドはゲームのFPSが下がると呼び出される頻度下が   り、逆にFPSが上がるととんでもない頻度で呼び出されます。
このメソッドにキャラ移動処理を書いたら大変なことになりますね…
そこでFixedUpdateメソッドの出番です。
このメソッドはFPSに関係なく呼び出される頻度が一定です。
現段階では1秒に60回呼び出されます。
・Drawメソッドの中には描画系の処理を書きます

MainクラスのScene管理

MainクラスにはScenes(List<Scene>)とCurrentScene(Scene)の2つのスタティックなフィールドが存在します。

	public static Scene CurrentScene { get; set; }
	public static List<Scene> Scenes { get; set; }

Scenesには現在保持しているシーンが入っていて、その中のシーンをCurrentSceneに代入することでシーンが切り替わります。
(ScenesはDictionary<string, Scene>でもいいかも)

MonoGameで言えば、Gameクラスを継承するMainクラスで以下のように、CurrentSceneのUpdate, FixedUpdate, Drawを呼び出すことで現在のシーンのみ処理(描画)されるということが可能です。

	private float timer = 0;
	
	protected override void Update(GameTime gameTime)
	{
		// TODO: Add your update logic here
		timer += (float)gameTime.ElapsedGameTime.TotalSeconds;
		float updateTime = 1f / 60;

		//1秒間に60回までしか呼び出さない処理
		while (timer >= updateTime)
		{
			CurrentScene.FixedUpdate(updateTime); //現在のシーンのFixedUpdateメソッド呼び出し
			timer -= updateTime;
		}
		CurrentScene.Update(); //現在のシーンのUpdateメソッド呼び出し

		base.Update(gameTime);
	}

	protected override void Draw(GameTime gameTime)
	{
		GraphicsDevice.Clear(Color.CornflowerBlue);

		// TODO: Add your drawing code here
		CurrentScene.Draw(); //現在のシーンのDrawメソッド呼び出し

		base.Draw(gameTime);
	}

Sceneクラスの継承

Sceneクラスは継承してオリジナルの処理を持つシーンを作成できます。
その場合は

	public override void Update()
	{
		base.Update();

		//ここに独自の処理
	}

こんな感じでオーバーライドして独自の処理を追加できます。
Sceneクラスを継承していればSceneの型にも代入できるので、
MainクラスのScenesやCurrentSceneにインスタンスを代入してそのまま処理してもらうことも可能です。

Sceneクラス

Sceneクラスには2つのフィールドがあります。

	public TileMap tileMap;
	public Vector2 screenPosition;
	public readonly Vector2 gridSize;

TileMapはその名の通りシーン内のタイルを管理するクラスです。
ScreenPositionはカメラの位置…といいますか、このVector2に値を足せばその分タイルマップやキャラクターが左に動く…
つまりカメラ自体を動かしているんではなく、今描画されているキャラクターやUI、タイルマップをScreenPositionの分だけ動かしているだけです。
貴方が動いてるのではなく、貴方が動こうとすると世界が動いているのです。
カメラを動かしたいときは、このScreenPositionの値をいじります。
GridSizeはタイルの横と縦のサイズ。
(注意:GridSizeはグリッドのサイズなのでタイルの画像サイズではない、タイルを敷き詰めた際にできる各タイルのグリッドの大きさ)

TileMapクラス

TileMapクラスはVector3(タイルの座標)とTileをDictionaryで保持しています。

	protected Dictionary<Vector3, Tile> _tiles { get; private set; }

SceneクラスはTileMapを持っていてDrawメソッド内でTileMapのDrawメソッドを呼び出しています。
つまりTileMapクラスのDrawメソッドに_tilesを使用して描画処理を書けばタイルが表示されます。

その他にも以下のように汎用的なメソッドが用意されています。

	//描画順にソートされた全てのタイルを返す
	public List<KeyValuePair<Vector3, Tile>> GetAllTiles()
	{
		return sortTiles.ToList();
	}

	//タイルを取得する
	public Tile GetTile(Vector3 position)
	{
		return _tiles[position];
	}

	//タイルを消す
	public void RemoveTile(Vector3 position)
	{
		_tiles.Remove(position);
	}

	//タイルの色を変更する
	public void SetColor(Vector3 position, Color color)
	{
		_tiles[position].color = color;
	}

	//タイルを追加する
	public void SetTile(Tile tile)
	{
		var hasTile = _tiles.ContainsKey(tile.position);
		if (hasTile)
			_tiles[tile.position] = tile;//タイルが存在しない場合は追加
		else
			_tiles.Add(tile.position, tile); //タイルが存在する場合は上書き

		bool isModifiedMaxZ = false;

		//追加するタイルのZが一番上ならtileMapMaxZを更新する
		if (tile.position.Z > tileMapMaxZ)
		{
			tileMapMaxZ = (int)tile.position.Z;
			isModifiedMaxZ = true;
		}

		//tileMapMaxZ(タイルマップの一番高いZ座標)が変更されたら引数でtrueを渡す
		TileModified(isModifiedMaxZ); //タイルが追加されたら呼び出される処理
	}

	//タイルが存在するか判定
	public bool HasTile(Vector3 position)
	{
		return _tiles.ContainsKey(position);
	}

TileModifiedメソッドは_tilesをListに変換し描画順にソートする処理です。
タイルが変更されたら呼び出されます。

描画順にソートする必要がある理由なんですが、クォータービューでは2Dで疑似3Dを行うのでタイルが重なったりします。
MonoGameは最後に描画したものが上に表示されるので、追加順で描画するとタイルの前後関係がおかしくなります。
クォータービューにおいて「タイルを画面の左上から右下へ、尚且つ高さが低いところから高いところへ」の順番で描画するとうまく表示されます。
その順番にソートする処理が以下です。

	private void TileModified(bool isModifiedMaxZ)
	{
		var maxPosition = _tiles.Max((_tile) => 
		{
			return _tile.Value.position.X + _tile.Value.position.Y;
		});
		sortTiles = _tiles.ToList();
		sortTiles.Sort((_tileA, _tileB) => 
		{
			return (int)(_tileB.Value.position.X - _tileB.Value.position.Y - _tileB.Value.position.Z * maxPosition)
			- (int)(_tileA.Value.position.X - _tileA.Value.position.Y - _tileA.Value.position.Z * maxPosition);
		});
		//引数がtrueだった場合はvibisibleMaxZを更新する
		//visibleMaxZは描画するタイルの最大Z座標
		//visibleMaxZ以上のZ座標のタイルは描画されない
		if (isModifiedMaxZ)
			visibleMaxZ = tileMapMaxZ;
	}

maxPositionには一番X+Yが高い値が入ります。
X-Y-Z*maxPositionを_tileAと_tileBで比較し数値が高い順番でソートします。
そうすることでsortTilesに正しい描画順でタイルが入ります。

Tileクラス

Tileクラスは現時点では単純でテクスチャとポジション(タイル座標)とスクリーン座標とカラー(色)です

	public Texture2D texture;
	public Vector3 position;
	public Vector2 screenPosition;
	public Color color;

タイルを均等に並べる処理

ではタイルを均等に並べる処理をTileMapクラスのUpdateメソッドとDrawメソッドに書いていきましょう。
といってもさっきのTileModifiedメソッドで描画順にソートしているので、あとはタイル座標をスクリーン座標に変換して描画するだけです。

	//描画タイルの座標計算
	public void Update()
	{
		_drawTiles.Clear();

		foreach (var tile in sortTiles)
		{
			//表示制限値を超える高さのタイルは描画しない
			if (tile.Value.position.Z)
				continue;

			var position = tile.Value.position;

			//タイル座標からスクリーン座標を計算する
			var x = position.X * gridSize.X / 2 + position.Y * gridSize.X / 2 + offset.X;
			var y = position.Y * gridSize.Y / 2 - position.X * gridSize.Y / 2 + offset.Y;
			var z = position.Z * gridSize.Y + offset.Z;

			//スクリーンのポジションに合わせて移動させる
			x -= Main.CurrentScene.screenPosition.X;
			y -= Main.CurrentScene.screenPosition.Y;

			var screenPosition = new Vector2(x, y - z);

			//タイルのスクリーン座標をタイルクラスに代入する
			_tiles[tile.Value.position].screenPosition = screenPosition + new Vector2(gridSize.X / 2, gridSize.Y / 2);

			_drawTiles.Add(tile.Value);
		}
	}

一旦Updateメソッドでタイルのスクリーン座標を計算します。
描画するタイルを_drawTiles(List<Tile>)フィールドに追加していき、それをDrawメソッドで一気に描画します。

	public void Draw()
	{
		foreach (var tile in _drawTiles)
		{
			Main.SpriteBatch.Draw(tile.texture, tile.screenPosition, null, tile.color, 0, new Vector2(), new Vector2(1, 1), SpriteEffects.None, 0);
		}
	}

これがタイルを均等に並べる処理の全貌です。
あとはメインクラスのInitializeでタイルを設置すれば無事描画されます。

	protected override void Initialize()
	{
		//シーン生成
		var scene = new Scene();
		//タイルマップ設定
		scene.tileMap = new TileMap(new Vector2(32, 16)); //コンストラクタの引数はGridSize
		//シーン追加
		Scenes.Add(scene);
		//現在のシーンに設定
		CurrentScene = scene;

		//50x50の範囲にタイルを敷き詰める
		for (int x = 0; x < 50; x++)
		{
			for (int y = 0; y < 50; y++)
			{
				CurrentScene.tileMap.SetTile(new Tile(Registry.GetData.GetBlock("Template.Grid_Block_Black"), new Vector3(x, y, 0), Color.White));
			}
		}

		//1x10x6の範囲にタイルを敷き詰める
		for (int y = 0; y < 10; y++)
		{
			for (int z = 1; z < 6; z++)
			{
				CurrentScene.tileMap.SetTile(new Tile(Registry.GetData.GetBlock("Template.Grid_Block_Black"), new Vector3(0, y, z), Color.White));
			}
		}

		base.Initialize();
	}
結果

あと途中で使われていた

Registry.GetData.GetBlock("Template.Grid_Block_Black")

というコードですが、これはテクスチャを取得する独自の処理です。解説も省きます。
なのでここは自分でTexture2Dを渡してください。

最後に

大変長くなってしまい申し訳ありません。
更にプログラミングしてる人にしか分からない上に、随分とコアな内容になってしまいました。
ほぼ自己満足なので温かい目で見てくださいね。

というわけでここまで読んでくれてありがとうございます。
次回はキーボードやマウスの入力を受け付けたりする処理を書こうかなと思っています。(いつ投稿するかは未定)

一応github置いておきます。
(githubのコードは今回の内容から少しだけ機能が追加されています)
https://github.com/NoMoreAlone/MonoGame_Project

バイバイ~~

この記事が気に入ったらサポートをしてみませんか?