3 / 5 GameManagerを実装する
イベントを集計し、遷移条件に従ってゲームクリアとゲームオーバーを決定します。
このページの終了処理では、次のステップで作る停止用メソッドを使います。GameManagerを編集したら、続けて停止処理を実装してください。コンパイルと動作の確認は、ステップ4の末尾で行います。
GameManagerを作成する
Assets/Scripts/GameManager.csを作成し、フィールドとプロパティを定義します。
[SerializeField] private DamageReceiver playerDamageReceiver;
[SerializeField] private PlayerController playerController;
[SerializeField] private EnemySpawner enemySpawner;
[SerializeField] private int clearDefeatCount = 10;
public event Action<GameState> StateChanged;
public event Action<int, int> DefeatCountChanged;
public GameState State { get; private set; } = GameState.Ready;
public int DefeatCount { get; private set; }
public int ClearDefeatCount => clearDefeatCount;TIP
StateとDefeatCountにはprivate setを付けています。他のクラスから値を書き換えられないようにし、GameManagerで状態と撃破数を管理するためです。
内部処理をできるだけ外部から操作できない設計にするのが、現代的なプログラミングの基本です。便利さを理由に書き換えをpublicで許すと、意図しないタイミングで値が変わり、原因を追いにくいバグにつながります。プロパティはまずprivate setで定義し、必要な操作だけを公開用のメソッドにしてください。
イベント購読を有効期間とそろえる
GameManagerは、有効な間だけEnemyの撃破通知とPlayerの死亡通知を受け取ります。そのために、OnEnable()で購読し、OnDisable()で解除します。
private void OnEnable()
{
// 自作のEnemyクラスが共通で発行する撃破通知を購読する
Enemy.Defeated += OnEnemyDefeated;
playerDamageReceiver.Died += OnPlayerDied;
}
private void OnDisable()
{
// GameManagerが無効になるとき、同じ通知の購読を解除する
Enemy.Defeated -= OnEnemyDefeated;
playerDamageReceiver.Died -= OnPlayerDied;
}+=は購読の開始、-=は購読の解除です。OnEnable()で登録した処理は、必ずOnDisable()で解除してください。無効になったGameManagerが通知を受けたり、Sceneを読み直すたびに同じ処理が重複して登録されたりするのを防ぎます。
ReadyからPlayingへの遷移
private void Start()
{
ChangeState(GameState.Playing);
DefeatCountChanged?.Invoke(DefeatCount, clearDefeatCount);
}Start()では、ReadyからPlayingへ切り替えて撃破数の初期値を通知します。次回UIを追加したとき、この通知で0 / 目標数を表示します。
遷移をChangeStateへ集約する
private void ChangeState(GameState nextState)
{
if (State == nextState)
return;
if (!CanTransition(State, nextState))
{
Debug.LogWarning(
$"許可されていない状態遷移です: {State} -> {nextState}");
return;
}
State = nextState;
StateChanged?.Invoke(State);
}状態を変えるときは、Stateに直接代入せず、ChangeState()を呼び出します。同じ状態への重複した切り替えを防ぎ、遷移条件の判定と通知を一つのメソッドにまとめるためです。
TIP
今回は簡単な実装ですが、ステートマシンを別のクラスにする設計もあります。Stateを直接変更できないようにし、状態の変更と遷移条件の判定をそのクラスにまとめられます。これらの処理をステートマシンのクラスに任せるのが一般的です。どのように設計して実装するか考えてください。
private static bool CanTransition(
GameState currentState,
GameState nextState)
{
// 遷移表にある組み合わせだけtrueを返す
return (currentState, nextState) switch
{
(GameState.Ready, GameState.Playing) => true,
(GameState.Playing, GameState.GameClear) => true,
(GameState.Playing, GameState.GameOver) => true,
_ => false
};
}switch式では、現在の状態と次の状態の組み合わせを調べます。遷移表にある組み合わせならtrueを返します。どのパターンにも一致しなければ、_の分岐でfalseを返し、遷移を拒否します。
ゲームを終了する
今回は敵を一定数撃破したらゲームクリア、プレイヤーが死亡したらゲームオーバーとします。
Enemyが倒されたときの通知を受け取るOnEnemyDefeated()では、撃破数を更新してゲームクリア条件を判定します。
private void OnEnemyDefeated(Enemy enemy)
{
if (State != GameState.Playing)
return;
DefeatCount++;
DefeatCountChanged?.Invoke(DefeatCount, clearDefeatCount);
if (DefeatCount >= clearDefeatCount)
Finish(GameState.GameClear);
}集計はGameState.Playingのときだけ行い、撃破数の更新後に通知します。
Playerが死亡したときはDamageReceiver.DiedからOnPlayerDied()が呼ばれ、ゲームオーバーへ遷移します。
private void OnPlayerDied()
{
Finish(GameState.GameOver);
}GameManagerは、PlayerのHPを毎フレーム調べる必要はありません。死亡した瞬間の通知だけを受け取ります。Enemyの撃破とPlayerの死亡は、それぞれ別のイベントから終了条件を判定します。
private void Finish(GameState result)
{
if (State != GameState.Playing)
return;
playerController.SetControlEnabled(false);
enemySpawner.StopGameplay();
ChangeState(result);
}ゲームクリアとゲームオーバーに共通する処理は、Finish()にまとめます。最初にPlayingかを調べるので、終了処理は一度しか実行しません。
同じフレームでEnemyの撃破とPlayerの死亡が起きても、先にFinish()へ渡された結果を採用します。そこで終了状態へ切り替わるため、後から呼ばれたFinish()ではState != GameState.Playingが成立します。すぐに処理を終えるので、先に決まった結果は上書きしません。
GameManagerは、終了時にPlayerControllerの停止用メソッドを呼び出します。止めるのはPlayerの入力です。EnemySpawnerの停止用メソッドでは、新規生成と既存Enemyの行動を止めます。各コンポーネントの内部を直接操作せず、公開メソッドを使います。
Finish()の設計を考える
Finish()では、playerControllerとenemySpawnerの停止用メソッドを呼び出しています。停止する対象が明確で、処理を理解しやすい設計です。ただし、GameManagerから具体的なコンポーネントを参照するため、停止対象を増やすとフィールドと処理の変更が必要になります。
大きなゲームでは、終了をイベントで通知する設計も考えられます。その場合は、GameManagerで停止対象を参照する必要がありません。適した設計は、ゲームの規模や開発チームの方針によって変わります。様々な設計を比べ、どの構造が適しているか考えてください。
- 終了処理が一度だけ実行され、先に決まった結果が後から上書きされない。