JavaScript を使ってマインスイーパーゲームを作成する

JavaScriptBeginner
オンラインで実践に進む

はじめに

皆さんは以前、クラシックなマインスイーパーゲームをプレイしたことがあると思います。ルールは簡単ですが、とても中毒性があります。皆さんは自分で開発してみたことがありますか?今日は、ウェブベースのマインスイーパーを作成しましょう。まずは、インターフェイスのスクリーンショットを見てみましょう。

👀 プレビュー

Minesweeper game interface preview

🎯 タスク

このプロジェクトでは、以下のことを学びます。

  • マインスイーパーゲームのゲームアルゴリズムをどのように設計するか
  • プロジェクトのファイル構造をどのように作成するか
  • HTML と CSS を使ってページレイアウトをどのように実装するか
  • JavaScript を使ってグリッドをどのように描画するか
  • セルにクリックイベントを追加してゲームプレイを処理する方法
  • ゲームの開始や終了などのゲーム制御機能をどのように実装するか

🏆 成果

このプロジェクトを完了すると、以下のことができるようになります。

  • ゲームアルゴリズムを設計して実装する
  • ウェブプロジェクトのファイル構造を作成する
  • HTML と CSS を使ってページレイアウトを作成する
  • JavaScript を使ってグリッドを描画し、イベントを処理する
  • ゲーム制御機能を実装する

開発準備

開発を始める前に、まずゲームアルゴリズムを設計しましょう。

マインスイーパーゲームのルールは簡単です。

  • ゲーム盤にはいくつかの正方形があり、各正方形には数字(空白は数字が 0 を示す)または爆弾が含まれています。正方形内の数字は、周囲の正方形内の爆弾の数を表します。プレイヤーのタスクは、できるだけ少ない時間で数字の正方形を見つけることです。
  • 端の正方形を除いて、各正方形には 8 つの隣接する正方形があります。上、下、左、右、および 4 つの斜めの正方形です。したがって、数字の範囲は 0 から 8 までです。

ですから、私たちのアルゴリズムは次のとおりです。

  • ユーザーが選択した難易度(初心者、中級、上級の 3 つのレベルがあり、レベルが上がるにつれて爆弾と正方形が増えます)に基づいて、一定数の爆弾をランダムに生成し、正方形にランダムに配置します。そして、正方形を横断して、各正方形内の数字を計算し、正方形にマークします。プレイヤーが正方形を左クリックすると、正方形の内容が表示されます(正方形に爆弾が含まれている場合は、チャレンジに失敗し、ゲームが終了します)。プレイヤーが正方形を右クリックすると、正方形が爆弾としてマークされます。すべての爆弾が正しくマークされ、すべての爆弾でない正方形が開かれたときにのみ、チャレンジが成功し、ゲームが終了します。

便利なヒント:正方形内の数字の範囲が 0 から 8 までであるため、爆弾がある正方形内の数字を 9 としてマークすることで計算を容易にすることができます。

✨ 解答を確認して練習

プロジェクトのファイル構造

まず、~/project パスの下に以下のファイル構造を作成する必要があります。

~/project
 |__ index.html
 |__ index.css
 |__ index.js
 |__ jms.js
 |__ mine.png
 |__ flag.png

プロジェクトディレクトリ内の mine.pngflag.png は、それぞれ地雷と旗の画像です。

✨ 解答を確認して練習

ページレイアウト

まず、ゲーム情報を表示するパネルが必要です。これには、残りの地雷の数、経過時間、難易度などが含まれます。正方形の数は固定されていないため、今は正方形を描画せず、代わりに JS コードで描画します。

index.html ファイルを作成し、以下のコードを追加します。

<!-- index.html -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Minesweeper with JavaScript</title>
    <link
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
      rel="stylesheet"
    />
    <link rel="stylesheet" type="text/css" href="index.css" />
  </head>
  <body class="bg-gray-100">
    <div
      id="JMS_main"
      class="container mx-auto p-4 bg-white shadow-lg rounded-lg"
    >
      <table id="landmine" class="mx-auto"></table>
      <div id="operation" class="text-center">
        <div class="text-lg text-red-600">
          残りの地雷の数:
          <span class="font-semibold" id="landMineCount">0</span>
        </div>
        <div class="text-lg text-orange-500">
          経過時間:<span class="font-semibold" id="costTime">0</span> s
        </div>
        <fieldset class="my-4">
          <legend class="text-lg font-semibold">難易度選択:</legend>
          <input
            type="radio"
            name="level"
            id="llevel"
            checked="checked"
            value="10"
            class="mr-2"
          />
          <label for="llevel">初心者 (10*10) </label><br />
          <input
            type="radio"
            name="level"
            id="mlevel"
            value="15"
            class="mr-2"
          />
          <label for="mlevel">中級者 (15*15) </label><br />
          <input
            type="radio"
            name="level"
            id="hlevel"
            value="20"
            class="mr-2"
          />
          <label for="hlevel">上級者 (20*20) </label><br />
        </fieldset>
        <button
          id="begin"
          class="px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg hover:bg-blue-700"
        >
          ゲームを開始する</button
        ><br />
        <div class="text-left mt-4">
          <div class="font-semibold">ヒント:</div>
          <ul class="list-disc ml-6">
            <li>
              1. "ゲームを開始する"
              をクリックすると、ゲームタイマーが始まります。
            </li>
            <li>
              2. ゲーム中に "ゲームを開始する"
              をクリックすると、新しいゲームが始まります。
            </li>
          </ul>
        </div>
      </div>
    </div>
    <script src="jms.js"></script>
    <script src="index.js"></script>
  </body>
</html>
✨ 解答を確認して練習

ページレイアウトのスタイル

次に、パネル上のゲーム情報の位置を調整し、いくつかのスタイルを追加する必要があります。index.css に以下のコードを追加して保存します。

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

.container {
  max-width: 600px;
}
table {
  background: #ccc;
  margin: 0 auto;
}
td {
  border: 2px outset #eee;
  font-size: 20px;
  width: 32px;
  height: 32px;
  text-align: center;
  cursor: pointer;
}
button {
  padding: 2px 10px;
  margin: 5px;
  font-size: 20px;
  cursor: pointer;
}

.clicked-cell {
  background-color: #a0aec0;
  color: #fff;
  font-weight: bold;
}

.landMine {
  background-image: url(mine.png);
  background-position: center;
  background-repeat: no-repeat;
}

.flag {
  background-image: url(flag.png);
  background-position: center;
  background-repeat: no-repeat;
}

このステップを完了したら、ブラウザで index.html を開きます。

Minesweeper game interface
Minesweeper game interface
✨ 解答を確認して練習

描画グリッド

前の手順を完了した後、グリッドを描画する必要があります。コードを明確にするために、ゲームの実装部分と呼び出し部分を分離します。ゲームの実装部分は jms.js に配置され、index.html と同じディレクトリにあり、ゲームの呼び出し部分は index.js に配置され、同じディレクトリにあります。

グリッドを描画するには、いくつかのパラメータを渡す必要があります。たとえば、グリッドが配置されるテーブルの id と、セルの数(行と列の数で表されます)です。また、他のゲーム関連のデータを初期化する必要があります。

jms.js の部分

jms.js に以下のコードを追加して保存します。

(function () {
  // マインスイーパーオブジェクトを初期化し、データを初期化する
  var JMS = function (
    id,
    rowCount,
    colCount,
    minLandMineCount,
    maxLandMineCount
  ) {
    if (!(this instanceof JMS))
      return new JMS(
        id,
        rowCount,
        colCount,
        minLandMineCount,
        maxLandMineCount
      );
    this.doc = document;
    this.table = this.doc.getElementById(id); // グリッドを描画するテーブル
    this.cells = this.table.getElementsByTagName("td"); // セル
    this.rowCount = rowCount || 10; // グリッドの行数
    this.colCount = colCount || 10; // グリッドの列数
    this.landMineCount = 0; // 地雷の数
    this.markLandMineCount = 0; // マークされた地雷の数
    this.minLandMineCount = minLandMineCount || 10; // 最小地雷数
    this.maxLandMineCount = maxLandMineCount || 20; // 最大地雷数
    this.arrs = []; // セルに対応する配列
    this.beginTime = null; // ゲームの開始時間
    this.endTime = null; // ゲームの終了時間
    this.currentSetpCount = 0; // 実行されたステップ数
    this.endCallBack = null; // ゲーム終了時のコールバック関数
    this.landMineCallBack = null; // 地雷がマークされたときの残りの地雷数を更新するコールバック関数
    this.doc.oncontextmenu = function () {
      // 右クリックメニューを無効にする
      return false;
    };
    this.drawMap();
  };

  // JMS のプロトタイプにセルを作成する
  JMS.prototype = {
    // グリッドを描画する
    drawMap: function () {
      var tds = [];
      // ブラウザ互換性のため
      if (
        window.ActiveXObject &&
        parseInt(navigator.userAgent.match(/msie ([\d.]+)/i)[1]) < 8
      ) {
        // 新しい CSS スタイルファイルを作成する
        var css = "#JMS_main table td{background-color:#888;}",
          // ヘッドタグを取得する
          head = this.doc.getElementsByTagName("head")[0],
          // スタイルタグを作成する
          style = this.doc.createElement("style");
        style.type = "text/css";
        if (style.styleSheet) {
          // CSS スタイルをスタイルタグに割り当てる
          style.styleSheet.cssText = css;
        } else {
          // スタイルタグ内にノードを作成する
          style.appendChild(this.doc.createTextNode(css));
        }
        // スタイルタグをヘッドタグの子タグとして追加する
        head.appendChild(style);
      }
      // ループしてテーブルを作成する
      for (var i = 0; i < this.rowCount; i++) {
        tds.push("<tr>");
        for (var j = 0; j < this.colCount; j++) {
          tds.push("<td id='m_" + i + "_" + j + "'></td>");
        }
        tds.push("</tr>");
      }
      this.setTableInnerHTML(this.table, tds.join(""));
    },
    // テーブルに HTML を追加する
    setTableInnerHTML: function (table, html) {
      if (navigator && navigator.userAgent.match(/msie/i)) {
        // テーブルの所有者ドキュメント内に div を作成する
        var temp = table.ownerDocument.createElement("div");
        // テーブルの tbody の内容を作成する
        temp.innerHTML = "<table><tbody>" + html + "</tbody></table>";
        if (table.tBodies.length == 0) {
          var tbody = document.createElement("tbody");
          table.appendChild(tbody);
        }
        table.replaceChild(temp.firstChild.firstChild, table.tBodies[0]);
      } else {
        table.innerHTML = html;
      }
    }
  };

  window.JMS = JMS;
})();

上記のコードには、IE ブラウザとの互換性を持たせるためのコードが含まれていますが、無視して構いません。

index.js の部分

index.js の呼び出しコードでは、難易度選択ボタンのイベントをバインドし、その後、上で定義した JMS を呼び出してグリッドの描画を開始する必要があります。

index.js に以下のコードを追加して保存します。

var jms = null,
  timeHandle = null;
window.onload = function () {
  var radios = document.getElementsByName("level");
  for (var i = 0, j = radios.length; i < j; i++) {
    radios[i].onclick = function () {
      if (jms != null)
        if (jms.landMineCount > 0)
          if (!confirm("Are you sure you want to end the current game?"))
            return false;
      var value = this.value;
      init(value, value, (value * value) / 5 - value, (value * value) / 5);
      document.getElementById("JMS_main").style.width =
        value * 40 + 180 + 60 + "px";
    };
  }
  init(10, 10);
};

function init(rowCount, colCount, minLandMineCount, maxLandMineCount) {
  var doc = document,
    landMineCountElement = doc.getElementById("landMineCount"),
    timeShow = doc.getElementById("costTime"),
    beginButton = doc.getElementById("begin");
  if (jms != null) {
    clearInterval(timeHandle);
    timeShow.innerHTML = 0;
    landMineCountElement.innerHTML = 0;
  }
  jms = JMS("landmine", rowCount, colCount, minLandMineCount, maxLandMineCount);
}

その後、ブラウザで index.html を開き、グリッドが表示されるはずです。結果は以下の通りです。

Minesweeper game grid display

右側の難易度選択をクリックして、セルの数が変化するのを確認してください。

✨ 解答を確認して練習

ゲーム初期化

さて、ゲームの初期化を始めましょう。これは主に 3 つのステップで構成されます。

  1. すべてのセル(コード内では配列で表されます)を 0 に初期化します。
  2. ランダムな数の地雷を生成し、配列内にランダムに配置し、配列要素の値を 9 に設定します。
  3. 他のセルの数字を計算し、配列に値を格納します。

jms.jsJMS.prototype 内に以下のコードを追加します。

// 初期化:配列の初期値を 0 に設定し、地雷の数を決定する
init: function () {
    for (var i = 0; i < this.rowCount; i++) {
        this.arrs[i] = [];
        for (var j = 0; j < this.colCount; j++) {
            this.arrs[i][j] = 0;
        }
    }
    this.landMineCount = this.selectFrom(this.minLandMineCount, this.maxLandMineCount);
    this.markLandMineCount = 0;
    this.beginTime = null;
    this.endTime = null;
    this.currentSetpCount = 0;
},
// 地雷が含まれる配列要素の値を9に設定する
landMine: function () {
    var allCount = this.rowCount * this.colCount - 1,
        tempArr = {};
    for (var i = 0; i < this.landMineCount; i++) {
        var randomNum = this.selectFrom(0, allCount),
            rowCol = this.getRowCol(randomNum);
        if (randomNum in tempArr) {
            i--;
            continue;
        }
        this.arrs[rowCol.row][rowCol.col] = 9;
        tempArr[randomNum] = randomNum;
    }
},
// 他のセルの数字を計算する
calculateNoLandMineCount: function () {
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            if (this.arrs[i][j] == 9)
                continue;
            if (i > 0 && j > 0) {
                if (this.arrs[i - 1][j - 1] == 9)
                    this.arrs[i][j]++;
            }
            if (i > 0) {
                if (this.arrs[i - 1][j] == 9)
                    this.arrs[i][j]++;
            }
            if (i > 0 && j < this.colCount - 1) {
                if (this.arrs[i - 1][j + 1] == 9)
                    this.arrs[i][j]++;
            }
            if (j > 0) {
                if (this.arrs[i][j - 1] == 9)
                    this.arrs[i][j]++;
            }
            if (j < this.colCount - 1) {
                if (this.arrs[i][j + 1] == 9)
                    this.arrs[i][j]++;
            }
            if (i < this.rowCount - 1 && j > 0) {
                if (this.arrs[i + 1][j - 1] == 9)
                    this.arrs[i][j]++;
            }
            if (i < this.rowCount - 1) {
                if (this.arrs[i + 1][j] == 9)
                    this.arrs[i][j]++;
            }
            if (i < this.rowCount - 1 && j < this.colCount - 1) {
                if (this.arrs[i + 1][j + 1] == 9)
                    this.arrs[i][j]++;
            }
        }
    }
},
// ランダムな数を取得する
selectFrom: function (iFirstValue, iLastValue) {
    var iChoices = iLastValue - iFirstValue + 1;
    return Math.floor(Math.random() * iChoices + iFirstValue);
},
// 値に基づいて行と列の番号を見つける
getRowCol: function (val) {
    return {
        row: parseInt(val / this.colCount),
        col: val % this.colCount
    };
},
✨ 解答を確認して練習

セルにクリックイベントを追加する

今、セルにクリックイベントを追加する必要があります。左クリックすると、セル内の数字を表示します(地雷の場合はゲームが終了します)。右クリックすると、それを地雷としてマークします。

また、セルが初めてクリックされたとき(通常は運が関係します)、その周囲に空白領域がある場合、その領域を直接展開する必要があります。

jms.jsJMS.prototype に以下のコードを追加します。

// 要素を取得する
$: function (id) {
    return this.doc.getElementById(id);
},
// 各セルにクリックイベント(左と右)をバインドする
bindCells: function () {
    var self = this;
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            (function (row, col) {
                self.$("m_" + i + "_" + j).onmousedown = function (e) {
                    e = e || window.event;
                    var mouseNum = e.button;
                    var className = this.className;
                    if (mouseNum == 2) {
                        if (className == "flag") {
                            this.className = "";
                            self.markLandMineCount--;
                        } else {
                            this.className = "flag";
                            self.markLandMineCount++;
                        }
                        if (self.landMineCallBack) {
                            self.landMineCallBack(self.landMineCount - self.markLandMineCount);
                        }
                    } else if (className!= "flag") {
                        self.openBlock.call(self, this, row, col);
                    }
                };
            })(i,j);
        }
    }
},
// 地雷のない領域を展開する
showNoLandMine: function (x, y) {
    for (var i = x - 1; i < x + 2; i++)
        for (var j = y - 1; j < y + 2; j++) {
            if (!(i == x && j == y)) {
                var ele = this.$("m_" + i + "_" + j);
                if (ele && ele.className == "") {
                    this.openBlock.call(this, ele, i, j);
                }
            }
        }
},
// 表示する
openBlock: function (obj, x, y) {
    if (this.arrs[x][y]!= 9) {
        this.currentSetpCount++;
        if (this.arrs[x][y]!= 0) {
            obj.innerHTML = this.arrs[x][y];
        }
        obj.className = "clicked-cell";
        if (this.currentSetpCount + this.landMineCount == this.rowCount * this.colCount) {
            this.success();
        }
        obj.onmousedown = null;
        if (this.arrs[x][y] == 0) {
            this.showNoLandMine.call(this, x, y);
        }
    } else {
        this.failed();
    }
},
// 地雷を表示する
showLandMine: function () {
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            if (this.arrs[i][j] == 9) {
                this.$("m_" + i + "_" + j).className = "landMine";
            }
        }
    }
},
// すべてのセルの情報を表示する
showAll: function () {
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            if (this.arrs[i][j] == 9) {
                this.$("m_" + i + "_" + j).className = "landMine";
            } else {
                var ele=this.$("m_" + i + "_" + j);
                if (this.arrs[i][j]!= 0)
                    ele.innerHTML = this.arrs[i][j];
                ele.className = "normal";
            }
        }
    }
},
// 表示されているセルの情報をクリアする
hideAll: function () {
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            var tdCell = this.$("m_" + i + "_" + j);
            tdCell.className = "";
            tdCell.innerHTML = "";
        }
    }
},
// セルにバインドされているイベントを削除する
disableAll: function () {
    for (var i = 0; i < this.rowCount; i++) {
        for (var j = 0; j < this.colCount; j++) {
            var tdCell = this.$("m_" + i + "_" + j);
            tdCell.onmousedown = null;
        }
    }
},
✨ 解答を確認して練習

ゲーム制御機能の追加

これまでで、ゲームの主な部分は完了しています。次のステップは、ゲームを円滑に動作させるためのゲーム制御機能を追加することです。主なステップは以下の通りです。

  1. 開始ボタンにクリックイベントリスナーを追加して、ゲームパラメータをリセットします。
  2. ゲームが開始されたときに時間計測を開始します。
  3. ゲームが終了したときに時間計測を停止し、プロンプトを表示します。

jms.js のセクション

jms.jsJMS.prototypeにゲームエントリーと開始関数を追加します。

// ゲーム開始
begin: function() {
    this.currentSetpCount = 0; // ステップ数をゼロにリセット
    this.markLandMineCount = 0;
    this.beginTime = new Date(); // ゲーム開始時間
    this.hideAll();
    this.bindCells();
},
// ゲーム終了
end: function() {
    this.endTime = new Date(); // ゲーム終了時間
    if (this.endCallBack) { // コールバック関数が存在する場合は呼び出す
        this.endCallBack();
    }
},
// ゲーム成功
success: function() {
    this.end();
    this.showAll();
    this.disableAll();
    alert("Congratulation!");
},
// ゲーム失敗
failed: function() {
    this.end();
    this.showAll();
    this.disableAll();
    alert("GAME OVER!");
},
// エントリーポイント
play: function() {
    this.init();
    this.landMine();
    this.calculateNoLandMineCount();
},

index.js のセクション

index.jsで、開始ボタンにイベントリスナーを追加してゲームを開始し、ゲーム時間と残りの地雷数を表示します(index.jsinit関数内のjms = JMS("landmine", rowCount, colCount, minLandMineCount, maxLandMineCount);の後)。

jms.endCallBack = function () {
  clearInterval(timeHandle);
};
jms.landMineCallBack = function (count) {
  landMineCountElement.innerHTML = count;
};

// "Start Game"ボタンにイベントをバインド
beginButton.onclick = function () {
  jms.play(); // ゲームを初期化

  // 地雷の数を表示
  landMineCountElement.innerHTML = jms.landMineCount;

  // ゲームを開始
  jms.begin();

  // 経過時間を更新
  timeHandle = setInterval(function () {
    timeShow.innerHTML = parseInt((new Date() - jms.beginTime) / 1000);
  }, 1000);
};
✨ 解答を確認して練習

実行とテスト

この時点で、私たちのウェブ版のマインスイーパーは完成しています。ブラウザでindex.htmlファイルを開いて遊び始めてください。

WebIDE の右下隅にあるGo Liveボタンをクリックし、Web 8080タブに切り替えます。

Minesweeper gameplay demonstration
✨ 解答を確認して練習

まとめ

この実験では主に JavaScript を使って、クラシックなゲームであるマインスイーパーのウェブ版を実装しました。この実験を通じて、ユーザーは JavaScript の理解と応用スキルを向上させることができると考えられます。また、ユーザーは JavaScript 言語を使ってゲーム内のオブジェクトを抽象化してカプセル化する方法も学ぶことができます。