Angularでブラウザ版マインスイーパを作ってみた


成果物


(このアプリケーションは非公開です。ごめんなさい)

なぜこれを作ったのか

  • Angularでゲームを作ってみたかったから
  • 比較的簡単そうだったから
  • マインスイーパが好きだから

開発環境

  • angular/cli: v10.0.8
  • rxjs: v6.5.5
  • typescript: v3.9.7
  • npm: v6.14.4
  • node: v12.17.0

アプリケーションの簡単な説明

マインスイーパとは

フィールドには正方形のボタンが縦横に並んでいます。これらのうち、いくつかには地雷(mine)が隠されており、mineがあるボタンを押してしまうとゲームオーバーです。
また、mineがない安全なボタンであり、かつ周囲にmineが隠されている場合、そのボタンを押すと数字が表示されます。この数字は周囲にあるmineの個数を表しています。
この数字の情報を基にして、フィールド上の全ての安全なボタンを押すとゲームクリアとなります。
ゲームの機能として、「ここにmineがある」と思ったボタンには(flag)を立てることができます(取り外し可能・間違っても良い)。これをメモ代わりにしてゲームを解き進めます。

下は例です。
数字のマスは既に押したボタンです。
緑色に塗られたマスは「0」のマスです。
「P」はflagを立てたマスです。(旗の形に似てるので)
「*」はmineを踏んでしまったマスです。こうなるとゲームオーバーです。

詳細: マインスイーパ - Wikipedia

実装機能

  • ボタンを押す(左クリック)
  • flagを立てる(右クリック)
  • 0のボタンを押すと周りのボタンが全て自動で押される
  • 「?」マーク機能(個人的には全く使いませんが、一応)
  • 数字とflagの数が一致している場合、周りの未確定ボタンを全て押す(「コード」と呼ぶそうです)
  • リセットボタン(ヘッダーの「R」のボタン)
  • 時間計測(クリアまたはゲームオーバーでストップ)
  • 残りのmineの個数表示(flagを立てると-1)

実装したかったけど厳しかった機能

  • HowToPlay画面
  • ゲームサイズと難易度の設定画面
  • 最初のクリックでゲームオーバーにならないようにする(0のマスにする)

これらの未実装がアプリ非公開の原因です...。

おおまかな処理の流れ

ゲームの準備

ゲームの準備に必要なコンポーネントとサービスクラスは以下の3つです。

  • ContentsComponent: ゲームが動く部分を全て包括したコンポーネント。ヘッダーも含まれる。
  • ButtonComponent: ボタンひとつひとつのコンポーネント。ContentsComponentで縦×横の数だけ生成する。
  • StandByService: ゲーム準備処理を行うためのサービス。ContentsComponentとButtonComponentと(ついでにヘッダーと)を中継する。


(ReplaySubjectについては後述)

ContentsComponent.html
<game-header></game-header>
<div class="mine-button-area">
    <div class="mine-button-row" *ngFor="let array of map; index as top">
        <div class="mine-button-col" *ngFor="let button of array; index as left">
            <mine-button [left]="left" [top]="top"></mine-button>
        </div>
    </div>
</div>

記事にする都合上、図のコンポーネント名などは変更していますが、HTMLソースを見ると、ContentsComponentの中にButtonComponentが含まれているのが分かると思います。親コンポーネントの中でマップになる2次元配列(map)を生成し、ngForを活用してボタンを複数生成しています。

次の図は、ゲーム準備処理(GameStandBy)の流れを追ったシーケンス図です。

ここで、ReplaySubjectについてですが、これはSubjectクラスの種類で、インスタンス生成からsubscribeされるまでの過去のstreamを流すというものになります。
参考:RxJS を学ぼう #5 - Subject について学ぶ / Observable × Observer

今回の流れでは、ButtonComponentでのsubscribeとゲーム準備処理開始が非同期なので、通常のObservableやSubjectでは上手くいかないかもな~ということでReplaySubjectを採用しました。

工夫した点

特に工夫したのは、周囲のmineの個数をボタン自身が判断するという点です。
これは、Angularの仕様やRxJSの恩恵を大いに活用した結果実現できた工夫です。

以下は、RxJSを使わない(親のコンポーネントが子の情報を管理する)場合のシーケンス図です。

この流れを最初に考えた時、言ってしまえば「めんどくさいなぁ」と思いました。
StandByServiceではloopとifがネストされていたり、ContentsComponentでも2重ループ(図では1重ですが2次元配列なので2重)していたりと、冗長で計算効率の悪い処理になるので、なんとか良い方法はないかと考えた末にRxJSにたどり着きました。
また、これらがリスタート機能や残りmine数表示などの実装を比較的容易にしています。

ゲームプレイ中の処理

クリック時の基本動作

こちらはコードを見てもらった方が分かりやすいと思います。
基本的には、クリックされた時に「文字を表示するかどうか」と「文字と背景の色」を変更するだけです。

ButtonComponent.html
<div class="mine-button-wrapper" name="mine-button" (click)="openDisplay()" (contextmenu)="setFlag()">
    <!-- flagValue: 右クリックでローテーションする値を表す数字(0: '', 1: 'P', 2: '?') -->
    <!-- isOpened: ボタンが左クリックされたかを表すboolean値 -->
    <div class="mine-display" *ngIf="flagValue >= 1 || isOpened">
        {{ display }}
    </div>
</div>
ButtonComponent.ts
// 左クリックされた時の処理
public openDisplay() {
    // 既にゲームオーバーの場合は何もしない
    if (this.isGameOver) {
      return;
    }
    // 左クリックのフラグをtrueにする
    this.isOpened = true;

    // mineValue: 自分のマスの数字やmineの情報を表す値
    // 自分がmineのマスの場合
    if (this.mineValue == -1) {
      this.display = ButtonDisplays[this.mineValue];
      this.element.style.backgroundColor = 'red';
    } 
    // 0のマスである(周囲のボタンが自動で開く)場合
    else if (this.mineValue == 0) {
      this.display = '';
      this.element.style.backgroundColor = '#338833';
    } 
    // その他(数字のマス)の場合
    else {
      this.display = this.mineValue + '';
      this.element.style.backgroundColor = 'black';
      this.element.style.color = 'lightgreen';
    }
}

// 右クリックされた時の処理
public setFlag() {
    if (this.isGameOver || this.isOpened) {
      return false;
    }
    // 空、P、? でローテーション
    this.flagValue = (this.flagValue + 1) % 3;
    // 表示文字と色をセット
    let flagDisplays = ['', 'P', '?'];
    this.display = flagDisplays[this.flagValue];
    let flagColors = ['black', 'red', 'cyan'];
    this.element.style.color = flagColors[flagDisplays];
    // コンテキストメニューを非表示
    return false;
}

このようなシンプルな処理ですが、デザインやスタイルを工夫することでゲームっぽく見えます。

周囲に影響するクリックの処理

  • 0のボタンを押すと周りのボタンが全て自動で押される
  • 数字とflagの数が一致している場合、周りの未確定ボタンを全て押す

上記2つの機能の実装についてです。
マスを開くか否かはボタン自身が判断するので、他の(周囲の)のボタンが押されたかどうかは個々で判断してもらう必要があります。
ここで、クリックされたボタンとそうでないボタンとの間にサービスクラス(OpenMineService)を介し、Subjectを利用して監視・通知を行います。以下は、そのコンポーネント図とシーケンス図です。シーケンス図は0のボタンを押すと周りのボタンが全て自動で押されるでの流れのみを記述しています。

先ほどのopenDisplay()に、OpenMineServiceへnextを依頼する処理を追加します。

ButtonComponent.ts.openDisplay()
// 左クリックされた時の処理
public openDisplay() {
    // 既にゲームオーバーの場合は何もしない
    if (this.isGameOver) {
      return;
    }

    // -----------------追加-----------------
    // OpenMineServiceにnextを依頼
    this.openMineService.nextOpenMine({
      left: this.left,
      top: this.top,
      value: this.mineValue,
    });
    // --------------------------------------

    // 左クリックのフラグをtrueにする
    this.isOpened = true;

    // mineValue: 自分のマスの数字やmineの情報を表す値
    // 自分がmineのマスの場合
    if (this.mineValue == -1) {
      this.display = ButtonDisplays[this.mineValue];
      this.element.style.backgroundColor = 'red';
    } 
    // 0のマスである(周囲のボタンが自動で開く)場合
    else if (this.mineValue == 0) {
      this.display = '';
      this.element.style.backgroundColor = '#338833';
    } 
    // その他(数字のマス)の場合
    else {
      this.display = this.mineValue + '';
      this.element.style.backgroundColor = 'black';
      this.element.style.color = 'lightgreen';
    }
}

これでも比較的簡素だと思います。RxJSのおかげで処理の分散に成功し、ひとつひとつのクラスが大きくならずに済みました。

苦労したこと

CSSゥ...ですかねぇ...

キレイに長方形に並べたりするのはいいですが、妙に隙間が空いたり、動的にスタイルを設定しなければならないなどで、script処理を考えるよりも大変でした。やはりCSSは闇...。

感想

以前にもゲームを作った記事を書きましたが、パズルゲームやボードゲームなどは他のジャンルに比べてラクですね。決して簡単ではないんですが、作りやすい方だと思います。Angularでアクションゲームとか作れるんですかね...。

次はチェスでも作ってみようかと思います。大変そう...特殊ルール多くて...。