【やってみる】次は何をやってみるか。


バナンザをやるのですぐには取り掛からない。

プロシージャルなダンジョン生成をやってみようかとも思ったんだけど、僕はプロシージャルなダンジョンがあまり好きではないから麻雀以上に気が乗らない。

参考資料: 「自動生成の迷路はつまらん」って話。

そこで、モノポリー的な、いたスト的な、カルドセプト的な、あの、プレイヤー同士が地雷を置き合うタイプのボードゲームを作ってみようかと思ってる。

というわけで、僕が有する3Dモデリングの技術を最大限に駆使して、モダンなタイルセットを作成しておいた。

アイソメトリックな2Dタイルを自前で用意するのはしんどすぎるから、3Dの箱を斜め上からカメラで見下ろすことにする。

しかし純粋なアイソメトリックビューには、WASD操作と上下左右のマッピングが直感的でなくなるという問題がある。たとえば、右を押して進む方向が右上なのか右下なのか分からなくなる。その混乱を避けるにはカメラの回転を抑える必要がある。

カメラの向きがこれぐらいならば上下左右で迷わなくなる。


ちょっと進めた。

{ 01, 20, 20, 20, 20, 40 },
{ 50, 00, 00, 00, 00, 40 },
{ 50, 00, 00, 00, 00, 40 },
{ 50, 00, 00, 00, 00, 40 },
{ 50, 30, 30, 30, 30, 02 }

↑こういうデータを読んで↓こういう画面を表示するクラスを作った。

この段階で面白くなるわけがないんだけれども、それにしたって面白くなさそう。

これにバックワードの禁止とサイコロを付ければ一人用のすごろくにはなるのだけれども、一人用のすごろくですってよ? 身震いするくらい面白くなさそう。


ステージの定義で手を抜きすぎて、このままではタイルごとに地価や通行料を変えるのもままならない。後から仕様を足すと地獄を見るだろうから、最初から真面目にやることにした。……というわけで、定義ファイルの新仕様とパーサを作った。前の配列よりもずっと見やすいと思う。

#Level

S00,R00,R00,R00,R00,G00
Y00,---,---,---,---,G00
Y00,---,---,---,---,G00
Y00,---,---,---,---,G00
Y00,---,---,---,---,G00
B00,B00,B00,B00,B00,S01

#General

name=Test
tileset=StandardTiles

#Identification

[S00]
prefab=Castle
start=true
goal=true

[S01]
prefab=CheckPoint
checkpoint=true

[A00]
prefab=Blank
basements=100,150,250,400,600
tolls=100,150,250,400,600

[R00]
prefab=Red
basements=100,150,250,400,600
tolls=100,150,250,400,600

[B00]
prefab=Blue
basements=100,150,250,400,600
tolls=100,150,250,400,600

[G00]
prefab=Green
basements=100,150,250,400,600
tolls=100,150,250,400,600

[Y00]
prefab=Yellow
basements=100,150,250,400,600
tolls=100,150,250,400,600

ブロック名にIdentificationを使っているのは、コボラーだった頃の名残である。

見苦しい。タイルの情報を表示するまでは容易いが、見やすく表示するとなるとしんどい。どうしてもセンスを問われてしまうが、いかんせん僕にはそれがない。あとなんかカメラが斜めなの邪魔だな。なぜ数日前の僕はアイソメトリックに拘っていたのか。

案の定、まっすぐ見る方が分かりやすかったね。当初タイルの属性はUIで表示するつもりでいたのだけれども、座標変換が上手くいかなかったのでやめた。カメラとオブジェクトの深度の差やら、カメラの中心点とオブジェクトの距離やら、被写界深度やら、アスペクト比やらを複合的にこねくりまわして、それっぽい数字を出すことはできても、微妙にズレが生じてしまう。そのズレがどうにも許せなかったから、開き直ってタイルそのものに数字を書いてしまうことにした。……というのが、この画像。


見た目的にはカメラがプレイヤーを追従するようになっただけに見えるんだけど、中身はだいぶ変わってる。

ゲームらしい挙動を組み込めるようにメインループを実装したのさ!

メインループと言われてもゲーム作りと縁のない人にはピンとこないかも知れない。逆に、ゲーム作りの経験がある人であれば、おそらく誰もが知っていると思う。というわけで、ゲーム作りと縁のない人のために一度ぐらいはしっかり説明しようと思う。

僕のメインループはリレー競争の仕組みで動いてる。今、動いているプログラムが、次に動くプログラムにバトンを渡すような形で、数珠つなぎにプログラムが動いていく。たとえば現在「タイトル画面の管理を担当するプログラム」が動いていたとする。このプログラムの役割は、プレイヤーの入力に合わせて次にバトンを渡す相手を決定することだ。「ニューゲーム担当」や「ロード画面担当」や「ギャラリーモード担当」や「設定画面担当」や「ゲーム終了担当」のうちの誰かにバトンを渡すことになる。そして、バトンを渡された側も同じように、状況に合わせた判断をして、次の担当者を選びバトンを渡す。実に単純な仕組みでしょ? このように、状況に応じて次の担当者を選びながら処理を進めていく仕組みのことを、プラグラミングの世界では「有限状態マシン」または「FSM」と呼ぶのですよ。

実際にはどうやるのかって? こうだよ!

// コメントがやたらめったら多いのは、ブログ感覚で書き足してるからだよ。
// 普段はコメント書かないよ。

using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;

/*
    状態そのものを表すデリゲートだよ。
    遷移を関数で表現する上で最も重要な定義がこれだね。
    これまでの僕は、
    public delegate StateDelegate StateDelegate();
    としていたんだけど、LLMにアドバイスを求めたら「非同期にしてみなよ」と言われたから、そうしたよ。
    非同期処理はかっこいいけれど、想定外の挙動(コンフリクトとか)がありそうでなんだか嫌な予感がするよ。
*/
public delegate UniTask<StateDelegate> StateDelegate();

// これが有限状態マシンにおける「状態」ってやつの実装だよ。
// 最初に呼ばれるStart関数の戻り値が、次に呼ばれる関数になるよ。
public partial class FsmStates
{
	// Start関数はInuを返しているから、次はInuが呼ばれるよ。
	public async UniTask<StateDelegate> Start()
	{
		Debug.Log("[FsmStates] 開始");
		await UniTask.Yield(); // フレーム待機
		return Inu;
	}

	// Inu関数はNekoを返しているから、次はNekoが呼ばれるよ。
	public async UniTask<StateDelegate> Inu()
	{
		Debug.Log("[FsmStates] いぬ");
		await UniTask.Yield();
		return Neko;
	}

	// Neko関数はInuを返しているから、次はまたInuが呼ばれるよ。
	// 以後、Inu→Neko→Inu→Neko...と永遠に繰り返すよ。
	public async UniTask<StateDelegate> Neko()
	{
		Debug.Log("[FsmStates] ねこ");
		await UniTask.Yield();
		return Inu;
	}
	/*
		【追記】
		FsmStatesをpartialにしている理由は、この実装を実際に使う状況では状態ごとにファイルを分けるからだよ。
		全ての状態を同じクラスに入れている理由は、メンバ変数を事実上のグローバル変数として扱うためだよ。
		このクラスの変数をシリアライズすることによりマシンの状態を保存する(セーブ機能を作る)という考え方だよ。
		ただし、グローバル変数に依存する実装は汚物のような仕上がりになりがちだから気をつけなきゃね。
	*/
}

// 何の変哲もないUnityのMonoBehaviourだよ。
// ここにFSMを使ったメインループを仕込むよ。
public class GameMain : MonoBehaviour
{
	private FsmStates fsmStates = new FsmStates();
	
	// 現在の状態(currentState)にFSMの開始状態(上の方に書いたStart関数)を設定するよ。
	private StateDelegate currentState = fsmStates.Start;

	// 非同期処理を安全に終わらせるためのトークンだよ。
	private CancellationTokenSource cts = new CancellationTokenSource();

	// 一時停止用のクラスだよ。実装はこのソースの下の方に書いてあるよ。
	private AsyncManualResetEvent pauseEvent = new AsyncManualResetEvent(true);

	// 一時停止用の関数だよ。
	public void Pause() => pauseEvent.Reset();
	public void Resume() => pauseEvent.Set();

	// FSMをスタートさせるよ。
	// AwakeでFSMを開始しているからStartは書かないよ。
	void Awake()
	{
		_ = StartAsync(cts.Token);
	}

	// こいつがメインループだよ。超短いね。
	async UniTask StartAsync(CancellationToken token)
	{
		// 状態(関数)がnullを返すか停止を命令されるまで永遠に働けって書いてるよ。
		while (currentState != null && !token.IsCancellationRequested)
		{
			await pauseEvent.WaitAsync(); // 一時停止の時はここで止まるよ。
			currentState = await currentState(); // 状態を実行して遷移を書き換えてるよ。
		}
	}

	// アプリケーション終了時に勝手に呼ばれるすごい関数で停止用のトークンを発動させるよ。
	void OnApplicationQuit()
	{
		cts?.Cancel();
	}
}

// UniTaskには一時停止を実装するのに丁度よいクラスがなかったため、
// Microsoft.VisualStudio.ThreadingのAsyncManualResetEventの代替品をLLMに作ってもらったのがこれだよ。
public class AsyncManualResetEvent
{
	private volatile UniTaskCompletionSource tcs = new UniTaskCompletionSource();
	private volatile bool signaled;
	public AsyncManualResetEvent(bool initialState = false)
	{
		signaled = initialState;
		if (signaled) tcs.TrySetResult();
	}
	public void Set()
	{
		if (!signaled)
		{
			signaled = true;
			tcs.TrySetResult();
		}
	}
	public void Reset()
	{
		if (signaled)
		{
			signaled = false;
			tcs = new UniTaskCompletionSource();
		}
	}
	public UniTask WaitAsync()
	{
		if (signaled) return UniTask.CompletedTask;
		return tcs.Task;
	}
}

実際には(遷移をまたいでの並列処理やらモーダルダイアログやら割り込みといった)例外が山ほどあるからFSMだけで全てを処理できるわけではないのだけれども、メインループの基本形というのは誰が作ってもこんな感じになると思うよ。


それっぽい機能を、それっぽく作った。

この動画のフレームレートでは分からないと思うけど、このダイスはギリギリで目押しができる。

あれ? なんだかこの動画のダイスは偏りが酷いな。RandomNumberGeneratorから出しているから酷いことにはならないと思ってたのに。


確認のためサイコロを約2000回振ってみた結果。

1: 414回
2: 424回
3: 417回
4: 419回
5: 403回
6: 399回

全ての目の数が平均の±5%のレンジに収まっているから偏りはないとみなして良いと思う。周期性のチェックもした方が良いのだろうけど、乱数生成に使っているRandomNumberGeneratorは暗号論的疑似乱数生成器だから実用上問題になるような周期性は無いと思って良いだろう(願望)。


【余談】線形合同法についての難しくない話

かつてCやC++といった言語の標準ライブラリではランダムな数字を作るのに線形合同法というアルゴリズムが使われていた(今も使われているかも知れない)。線形合同法とは、「前回計算したランダムな数字」に、適当な数を掛けて、さらに適当な数を足した数字を、適当な数で割った余りの値をもとに、次のランダムな数字を決めるというアルゴリズムで、とてつもなく簡単にランダムっぽい数字を作り出せることを特徴としている(Xₙ₊₁ = (a × Xₙ + c) mod m)。なお、ここで「適当な数」と書いているものについては、本当に適当な数字だと思ってくれていい。この「適当な数字」をどうするかによって乱数の品質は大きく変わる。そのため、これまでに多くの賢人たちが「最も適切な適当な数字」を研究してきたのだけれども、そもそも線形合同法自体が「予測が可能かつ周期性を有する」という欠陥を抱えているものだから、適当な数字をより良くしたとて、無くなることのない欠陥をごまかして見せるのが精一杯だった。線形合同法の扱いを誤ると「偶数または奇数しか出ない上に周期性のあるサイコロ」といった、トンでもない代物が生みだされてしまう。カルドセプト・サーガというゲームでは、まさにその現象が問題となった。そもそもの話をすると、「計算的にランダムな数字を生成する」という発想自体が暗号論的な脆弱性を孕んでいる。計算できるということは即ち、再現可能性があるということだからね。そうしたジレンマから逃れるために、今日まで様々な手法が編み出されてきた。「コンピューターの中に生じるノイズを集めて乱数を作る装置」だとかね(この機能は一部ゲーム機にも搭載されてるよ)。そして今ではコンピューターの性能の向上も相まって、線形的な生成アルゴリズムよりもずっとまともな暗号論的擬似乱数列生成器(CSPRNG)が使えるようになったわけだよ。ありがたいことだね。


プレイヤーもFSMで制御することにした。半分書き直すぐらいの大工事である。メインループをstaticな実装にしたのに対し、プレイヤーのFSMは動的に生成する。プレイヤーの状態はプレイヤー毎に抱えるべきだし、プレイヤーの数だけFSMをインスタンス化して持つのが筋だろうから。


これまでに作った部品たちをゲームの構造に当て込んでいく作業をしている。新しいものを足していないのでお見せできるものはなし。最初はちょろいと思ってたんだけど、気がついたらいっぱいコードを書いてた。


僕が短時間で作れるゲームなんてタイピングゲームくらいだよ。

Closed