JavaScriptでマインスイーパをつくる

## 動機
暇なときになんとなくマインスイーパを作ってみたら楽しかったので、記録として。
（ゲーム終了判定などは割愛してます。。）

## マインスイーパのルールと処理
遊び方はここに分かりやすく載っています。
http://dotpico.com/mine/ja/rule.php

### 左クリックの処理
-  クリックすると、そのセルを開くことができる
  - そのセルに爆弾が入っていたら、その時点でゲームオーバー
  - そのセルに爆弾が入っていなければ、周囲に存在する爆弾の数が表示される
  - そのセルに爆弾が入っておらず、周囲に1つも爆弾が存在しないとき、周囲のセルが自動的に開かれる

### 右クリックの処理
- 1回右クリックすると、そのセルに「フラグ」をつけることができる
- 2回右クリックすると、そのセルに「？」をつけることができる
- 3回右クリックすると、元に戻る

### ダブルクリックの処理
- 開いたセルに書いてある数字と、その周囲の「フラグ」の数が一致しているとき、そのセルをダブルクリックすると、「周囲の開かれていないセル」を一度に開くことができる

## 実装プログラム
```html:index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <style>
        td {
            padding: 0px;
            width: 40px;
            height: 40px !important;
            text-align: center;
            border: 1px solid rgb(60, 60, 60);
        }

        /**　未開封のクラス　*/
        .closed {
            background-color: lightgray;
            color: black;
            cursor: pointer;

        }

        /**　開封して爆弾だったときのクラス　*/
        .bombed {
            background: red;
            color: white;
            cursor: auto;
        }

        /**　開封して爆弾ではなかったときのクラス　*/
        .opened {
            background: white;
            color: black;
            cursor: auto;
        }
    </style>
</head>

<body>
    <div id="msg"></div>
    <table>
        <tbody id="target">
            <!-- 中身はJavaScriptでつくる -->
        </tbody>
    </table>

    <script src="main.js" type="text/javascript"></script>
</body>

</html>
```

```js:main.js
'use strict';

//===================================
// マインスイーパ用のセルクラス
// セルはこのクラスのインスタンスとする
//===================================
class MSCell extends HTMLTableCellElement {

    //-----------------------------------
    // コンストラクタ
    //-----------------------------------
    constructor() {
        super();

        // イベント登録
        this.addEventListener('click', this.clickFunc);
        this.addEventListener('contextmenu', this.clickRightFunc);
        this.addEventListener('dblclick', this.clickDblFunc);
    }

    //-----------------------------------
    // マインスイーパ初期設定
    // x座業、y座標、爆弾かどうかをパラメータにとる
    //-----------------------------------
    init(x, y, bombFlg) {

        // 開封フラグ（未開封のときfalse/開封済みのときtrue）
        this.openedFlg = false;
        // x座標
        this.x = x;
        // y座標
        this.y = y;
        // 爆弾フラグ（爆弾のときtrue/爆弾でなければfalse）
        this.bombFlg = bombFlg;
        // 見た目のクラス
        this.classList.add('closed')

    }

    //-----------------------------------
    // 周辺セルを設定する
    // 周辺セルと、周辺セルの合計爆弾数を設定する
    //-----------------------------------
    setArounds(arounds) {
        // 周辺セル
        this.arounds = arounds;
        // 周辺セルの爆弾数
        this.aroundBombCount = this.arounds.filter(around => around.bombFlg).length;
    }

    //-----------------------------------
    // そのセルの中身を表示する
    //-----------------------------------
    show() {
        if (this.bombFlg) {
            // 爆弾のときは「爆」
            this.textContent = '爆';

            // 見た目の変更
            this.classList.remove('closed');
            this.classList.add('bombed');
        } else {
            // 爆弾ではないとき
            if (this.aroundBombCount > 0) {
                // 周辺の爆弾数が1個以上のときは数を表示
                this.textContent = this.aroundBombCount;
            }

            // 見た目の変更
            this.classList.remove('closed');
            this.classList.add('opened');
        }
    }

    //-----------------------------------
    // セルを左クリックしたときの関数
    //-----------------------------------
    clickFunc() {

        if (this.openedFlg) {
            // 開封済みのときは何もしない
            return;
        }

        if (this.textContent === '旗' || this.textContent === '？') {
            // 「旗」や「？」がついてるときも何もしない
            return;
        }

        // 開封済みにする
        this.openedFlg = true;

        // このセルを開く
        this.show();

        if (this.bombFlg) {
            // このセルが爆弾のときはゲームオーバーなので全セルを開く
            msCells.forEach(button => button.show());
        } else {
            // このセルが爆弾でないとき
            if (this.aroundBombCount === 0) {
                // 周囲に爆弾が無いときは周囲のセルを全部開く
                this.arounds.forEach(around => around.clickFunc());
            }
        }
    }

    //-----------------------------------
    // セルを右クリックしたときの関数
    //-----------------------------------
    clickRightFunc(e) {

        // 右クリックメニュー禁止
        e.preventDefault();

        if (this.openedFlg) {
            // 既に開かれている場合は何もしない
            return;
        }

        if (this.textContent === '') {
            // 旗を表示
            this.textContent = '旗';
        } else if (this.textContent === '旗') {
            // ？を表示
            this.textContent = '？';
        } else if (this.textContent === '？') {
            // 元に戻す
            this.textContent = '';
        }

    }

    //-----------------------------------
    // セルをダブルクリックしたときの関数
    //-----------------------------------
    clickDblFunc() {
        if (!this.openedFlg) {
            // 既に開かれている場合は何もしない
            return;
        }

        // 周囲の旗の数を取得
        let flgCount = this.arounds.filter(around => around.textContent === '旗').length;

        // 周囲の旗の数と、クリックしたセルに表示されている爆弾数が一致していれば
        // 周囲のセルをすべて開く
        if (this.aroundBombCount === flgCount) {
            this.arounds.forEach(around => around.clickFunc());
        }
    }

}

//===================================
// カスタム要素の定義
//===================================
customElements.define('ms-td', MSCell, { extends: 'td' });

//===================================
// 全セルを格納しておく変数
//===================================
let msCells = [];

//===================================
// ゲーム初期化用関数
//===================================
let initGame = function (xSize, ySize) {

    // ボタン配置
    for (let y = 0; y < ySize; y++) {
        let tr = document.createElement('tr');
        for (let x = 0; x < xSize; x++) {
            // セルを作る
            let msCell = document.createElement('td', { is: 'ms-td' });
            // セルの初期化
            msCell.init(x, y, Math.random() * 100 < 10);
            // セルをtrにいれておく
            tr.appendChild(msCell);
            // msCellsにも入れておく
            msCells.push(msCell);
        }
        document.getElementById('target').appendChild(tr);
    }

    // aroundsの設定
    msCells.forEach(msCell => {

        // 周囲8マスを取得
        let arounds = msCells.filter(otherCell => {

            if (msCell === otherCell) {
                return false;
            }

            let xArea = [msCell.x - 1, msCell.x, msCell.x + 1];
            let yArea = [msCell.y - 1, msCell.y, msCell.y + 1];

            if (xArea.indexOf(otherCell.x) >= 0) {
                if (yArea.indexOf(otherCell.y) >= 0) {
                    return true;
                }
            }
            return false;
        });

        // 周囲8マスをaroundsとして設定
        msCell.setArounds(arounds);
    });

}

//===================================
// ゲーム初期化
//===================================
initGame(15, 15);
```

## 実装のポイント
### セルの作り方
JavaScriptのcreateElementでtableタグの中身をつくっていますが、
その中のtdタグは、独自に定義したカスタム要素「MsCell」です。（Msはマインスイーパの略）
MsCellはHTMLTableCellElementを継承しています。

そのため、見た目は普通のtdタグと同じですが、
マインスイーパに必要なopenedFlgなどのプロパティと、clickFuncなどの関数を持っています。
### セルに対する動作
ポイントは二つです。

>　開いたセルに爆弾が入っていなければ、そのセルの周囲に存在する爆弾の数が表示される

つまり、セルはその周囲のセルの中身を知っている必要があります。
そこで、セルにaroundsというプロパティを持たせ、周囲の8セルを参照させておくことにしました。

>　開いたセルに爆弾が入っておらず、そのセルの周囲に1つも爆弾が存在しないとき、周囲のセルが自動的に開かれる

クリックされたセルの周囲の爆弾数で0であれば、そのセルのaroundsもすべて再帰的にクリックしていきます。
また、再帰処理を抜けるための条件として、openedFlg（未開封：false／開封済：true）を持たせています。

## 参考文献
[Custom Elements v1で独自のHTML要素を定義する](https://sbfl.net/blog/2016/09/01/custom-elements-v1/)