独習 Unity アプリ開発

独習でスマフォ向けアプリ開発を勉強中

Unity で テトリス風ゲームを作ってみる(実装編)#8

 Unity Version 2022.3.4

 

前回の続き


前回はテトリミノの高速落下とゲーム、テトリミノのステートについて説明した。今回は、スコアとレベルについて説明する。

 

ライン削除イベントとレベルアップイベントのコールバック


スコアアップ、レベルアップの仕組みは、前回の"状態とイベント処理"と同じようにゲーム作成においてよく使われる仕組みである、"インターフェース(イベントリスナー)とコールバック"の仕組みを使って実現する。

      • ゲーム中の処理の流れ

スコアをカウントするタイミングは、ライン削除が発生したタイミングである。ライン削除の処理はField クラスで行っている。また、ライン削除自体はテトリミノが着地したタイミングで実行される。テトリミノが着地したかどうかは、Tetrimino クラスが判定している。

レベルアップは、スコアが特定の値に達したタイミングで発生する。レベルが上がると落下スピードが上がる。落下スピードを制御しているのは、Tetrimino クラスである。

      • クラス間の依存関係

これらの処理を実現するには、何かの方法で相手クラスにイベントの発生を通知する仕組みが必要となる。Field クラス、LevelScore クラス、Tetrimino クラス間で直接イベントのやり取り(各クラスの関数の呼び出し)を実装すれば簡単に実現できるが、そうしてしまうと本来無関係なクラス間に必要のない依存関係が生じることになる。

例えば、Field クラスがライン削除のタイミングで、LevelScore クラスのスコアアップ関数を呼び出すには、Field クラスがLevelScore クラスのインスタンスを直接所有し、LevelScore クラスのスコアアップ関数を直接呼び出す必要ある。

また、LevelScore クラスがレベルアップのタイミングで、テトリミノの落下速度を上げる関数を呼び出すには、LevelScore クラスがTetrimino クラスのインスタンスを直接所有し、Tetrimino クラスの速度向上関数を呼び出す必要がある。

このように、他クラスのインスタンを直接所有すること、インスタンスを通した関数の呼び出すことは、そのクラスとの依存関係を強くすることを意味する。もちろん全く依存関係のないクラスのみでゲームを実装することはできないが(やるとすれば1 つのクラスで全部実装するとか)、依存関係は必要最低限にしなければならない。

無意味な依存関係が強くなればなるほど、デバッグや仕様変更時などメンテナンス性、拡張性の低下に直結する。依存関係を持つ関数や変数の扱いを変更しようとしたときに、どこのクラスがどういう意図でそれを使っているのかをすべて確認し、さらにそれを崩さないように整合性を取りながら修正をしなくてはならないというのは、かなりやっかいなことになる。

今回のような小さな規模であれば、依存関係が強くてもメンテナンスし続けることはできるが、何かしら新しいゲーム要素を追加していこうとすると規模は徐々に肥大化し、メンテナンスしきれなくなることは自明である。

 

      • オブザーバーによる依存関係の整理

そこで今回の実装の方針としては、デザインパターンのオブザーバーパターンを使うことでクラスの依存関係を一方向に制限し、GameControl クラスが各クラス間のイベントを仲介して制御するような流れを作り、不要なクラス間の依存関係を作らないように整理する(下図)。このようにすれば、例えばField クラスがスコアアップという本来知らなくてよいはずの仕事を知らないままに分離することができる。

もっと複雑な仕様でたくさんのクラスがあり、イベントの種類も多い場合は、コールバックではなくイベントメッセージの仕組みを使った方がよいが、今回は純粋なInterface  によるコールバックで実装する。

 

イベントリスナーとコールバック

 

Tetrimino クラスは、テトリミノが着地した(ブロック化した)イベントを受信するためのILockedEventListener インターフェースを提供する。

Field クラスがILockedEventListener インターフェースを実装しTetrimino クラス初期化(Init)時に、自分自身のインスタンスをILockedEventListenerのインスタンスとして渡しリスナー登録を行う(_lockedEventListenerに保持される)。

テトリミノが着地したタイミングは、Tetrimino クラスがLock 状態で経過時間が_lockTime 以上になった時である。これはTickEventInLockState のステート関数内で判定が行われ、 リスナーインスタンス_lockedEventListener のOnLocked 関数を呼び出してLock イベントを通知する。これによりTetrimino クラスはField クラスを直接は知らないし所有もしていないが、ILockedEventListener インターフェースを通して着地イベントをField クラスに通知することができる。

Tetrimino.cs の抜粋(イベントリスナーとコールバック)

  1.   public interface ILockedEventListner {
  2.     void OnLocked ();
  3.   }
  4.  
  5.   private ILockedEventListner _lockedEventListner;
  6.   
  7.   public void Init (Type type, ILockedEventListner lockedEventListner) {
  8.     this.Init(type);
  9.     _lockedEventListner = lockedEventListner;
  10.   }
  11.  
  12.   private void TickEventInLockState () {
  13.     if (_totalDeltaTime > _lockTime) {
  14.       _lockedEventListner?.OnLocked();
  15.       _state = State.Idle;
  16.       _totalDeltaTime = 0f;
  17.     }
  18.   }

 

Field クラスは、ILockedEventListener.OnLocked 通知で着地イベントを受け取ると、ライン削除判定を行い(CheckAndDeleteLines)、削除されたライン数(0 だった場合も含め)をIFieldEventListener インターフェースのライン削除イベント通知OnDeletedLine を呼び出す。GameControl クラスがIFieldEventLister インターフェースを実装しており、この通知を受け取ることになる。

Field.cs の抜粋(イベントリスナーとコールバック)

  1.    public interface IFieldEventListener {
  2.     void OnDeletedLine (int lines);
  3.   }
  4.  
  5.   private IFieldEventListener _fieldEventListener;
  6.  
  7.   public void OnLocked() {
  8.     var lines = CheckAndDeleteLines();
  9.     AddBlocks();
  10.     _fieldEventListener?.OnDeletedLine(lines);
  11.   }

 

GameControl クラスは、IFieldEventListener.OnDeletedLine 通知でライン削除イベントを受け取ると、LevelScore クラスのスコアアップ関数(LevelScore.AddScore)を呼び出して、スコアアップを通知する。その下にある_readyTetrimino 変数は、次のテトリミノを生成することが可能になったことを表す変数である。ライン削除が終わったことイコール、次のテトリミノを作成するタイミングになったという解釈になるため、このタイミングで設定している(本来関係ないイベントなので分離すべきではあるが)。

GameControl.cs の抜粋(イベントリスナーとコールバック)

  1.   // Listener Interface Implementation
  2.   public void OnDeletedLine(int lines) {
  3.     if (lines > 0) {
  4.       _levelScore.AddScore(lines);
  5.     }
  6.     _readyTetrimino = true;
  7.   }

 

LevelScore クラスは、AddScore 関数にて削除したライン数を通知されると、ライン数からスコア値へ換算を行い、スコア変数(_score)に加算する。スコア変数が更新した場合は、レベルアップが発生する可能性があるため、UpdateLevel 関数にてそのチェックと更新を行っている。

スコアUI は、Text コンポーネントによって実現されているため、そのtext 変数にスコア値を文字列で設定すれば、UI 上の表示も勝手に更新される。

LevelScore.cs の抜粋

  1.    public void AddScore (int deletedLineCount) {
  2.     _deletedLineCount += deletedLineCount;
  3.     _score += SCORE_RATE[deletedLineCount - 1];
  4.     if (_score > MAX_SCORE) {
  5.       _score = MAX_SCORE;
  6.     }
  7.     UpdateScoreText();
  8.     UpdateLevel();
  9.   }
  10.  
  11.   private void UpdateScoreText () {
  12.     _scoreText.text = $"{_score:D4}";
  13.   }
  14.  
  15.   private void UpdateLevelText () {
  16.     _levelText.text = $"{_level:D2}";
  17.   }

 

レベルアップから落下速度を更新する流れは、ILevelScoreEventListener.OnLevelUp イベントにて行われるが、やるないようは上記と同様なので説明は割愛する(後ほどすべてのコードを添付するのでそこで確認できるようにする)。

 

次の記事


from20150817.hatenablog.com

 

 

 


目次に戻る