Appearance
第08回 GameManagerとゲーム状態
3 / 5 GameManagerを実装する
このステップの目的
イベントを集計し、遷移条件に従ってゲームクリアとゲームオーバーを決定します。
GameManagerを作成する
Assets/Scripts/GameManager.csを作成し、フィールドとプロパティを定義します。
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
審判としてのGameManagerの独立性を保つため、多くのプロパティがprivate setで外部からの書き換えを禁止しています。オブジェクトの内部動作を可能な限り隠蔽するのが現代的なプログラミングの基本です。便利だからといってpublicを基本にしてしまうと、オブジェクトが意図しないタイミングでの書き換えを許すことになり、追跡困難なバグの原因になります。プロパティはまずprivate setで定義し、必要に応じて公開用のメソッドを追加する方針を徹底しましょう。
イベント購読を有効期間とそろえる
GameManagerが有効な間だけ、Enemyの撃破通知とPlayerの死亡通知を受け取るように、OnEnable()とOnDisable()で購読と解除を行います。
GameManager.cscs
private void OnEnable()
{
// 自作のEnemyクラスが共通で発行する撃破通知を購読する
Enemy.Defeated += OnEnemyDefeated;
playerDamageReceiver.Died += OnPlayerDied;
}
private void OnDisable()
{
// GameManagerが無効になるとき、同じ通知の購読を解除する
Enemy.Defeated -= OnEnemyDefeated;
playerDamageReceiver.Died -= OnPlayerDied;
}+=は購読の開始、-=は購読の解除を意味します。GameManagerが無効な間も通知を受けたり、Sceneを読み直すたび同じ処理が追加されたりしないよう、OnEnable()とOnDisable()で購読と解除を対にすることを徹底しましょう。
ReadyからPlayingへの遷移
cs
private void Start()
{
ChangeState(GameState.Playing);
DefeatCountChanged?.Invoke(DefeatCount, clearDefeatCount);
}ReadyからPlayingへ遷移し、撃破数の初期値を通知します。撃破数の初期通知は、次回UIを追加したときに0 / 目標数を表示するためのものです。
遷移をChangeStateへ集約する
cs
private void ChangeState(GameState nextState)
{
if (State == nextState)
return;
if (!CanTransition(State, nextState))
{
Debug.LogWarning(
$"許可されていない状態遷移です: {State} -> {nextState}");
return;
}
State = nextState;
StateChanged?.Invoke(State);
}ステートの変更はenumのStateを直接変更するのではなく、専用の遷移関数を通して行います。状態を変更する入口を1か所にまとめることで、重複遷移の防止、遷移条件の検査、通知を一括して行えるようになります。
TIP
今回は簡略化していますが、ステートマシン自体をクラスとして独立させればStateを直接変更できないようにして処理を集約できます。状態の変更や遷移条件の検査はステートマシンクラスに任せるのが一般的です。どのように設計実装するかを考えてみましょう。
cs
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()では、撃破数を更新してゲームクリア条件を判定します。
cs
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()が呼ばれ、ゲームオーバーへ遷移します。
cs
private void OnPlayerDied()
{
Finish(GameState.GameOver);
}GameManagerはPlayerのHPを毎フレーム確認せず、死亡した瞬間の通知だけを受け取ります。Enemyの撃破とPlayerの死亡は、それぞれ独立したイベントとしてゲームの終了条件へ接続されます。
cs
private void Finish(GameState result)
{
if (State != GameState.Playing)
return;
playerController.SetControlEnabled(false);
enemySpawner.StopGameplay();
ChangeState(result);
}クリアとゲームオーバーに共通する処理をFinish()へまとめます。入口でもPlayingを確認するため、終了処理は一度しか実行されません。
同じフレームにEnemy撃破とPlayer死亡が発生した場合も、先にFinish()へ届いた結果でPlayingから終了状態へ遷移します。後から届いた通知はState != GameState.Playingにより無視されるため、先に決まった結果が上書きされません。
終了の際はPlayerには入力停止を、EnemySpawnerには新規生成と既存Enemyの行動停止を依頼します。GameManagerは各コンポーネントの内部処理を直接操作せず、公開された停止用メソッドを呼び出します。
[Finish()の実装の正解は]
Finish()の中ではplayerControllerやenemySpawnerの停止用メソッドを呼びだしています。停止対象が明確で理解しやすい一方、GameManagerが具体的なコンポーネントを知る構造になっています。停止対象が増えた場合、GameManagerのフィールドと処理も変更する必要があります。
大きなゲームでは、GameManagerが停止対象を知らずに済むよう、イベントで通知する設計も考えられます。ゲームの規模や開発チームの方針に応じて正解は変わります。様々な設計を検討し、どのような構造が適しているかを考えてみましょう。
チェックリスト
終了処理が一度だけ実行され、先に決まった結果が後から上書きされない。