💣

【初心者向け】サークルで学んだことを生かしたマインスイーパーの作り方説明.ts

2023/09/20に公開

react を用いてマインスイーパーを作る

本記事について

前回の記事の引用です

僕は東洋大学の公認サークルである INIAD.ts に所属しており、現時点でオセロやマインスイーパーを制作し、オセロのオンライン対戦の開発に取り組んできました
その中で Typescript での開発方法やコードの書き方、教え方などの技術を向上させてきました
それらの成果の確認と記録のため

前回の記事を読んだうえでこの記事も読んでくださった方、ありがとうございます
今回僕の記事を初めて読む方、ぜひ前回の記事も読んでいただけるととてもうれしいです
また、いいねや 𝕏(旧 twitter)での拡散やコメントをしていただけると励みになります
また、本サークルに興味を持っていただけましたら、下記のリンクからメンバーの活動等も見ることができますので確認していただけると幸いです

https://twitter.com/i/lists/1658799468161146880

マインスイーパーを作成するための準備

node.js のインストール

$ PS C:\Users\ユーザー名> node -v
v18.16.0
$ PS C:\Users\ユーザー名> npm -v
9.5.1

このような表示になっていれば大丈夫です
なっていない場合は前回の記事やその他の記事を参考にしてください

フレームワークをクローンする

今回も前回同様、フレームワークにサークルで用いた next-ts-starter を使用します

https://GitHub.com/solufa/next-ts-starter

にアクセスし、code と書いてある緑の部分をクリックしてください

URL をコピーします
コマンドプロンプトを開き以下のコマンドを入力してください

$ git clone コピーしたurl minesweeper
$ cd minesweeper
$ code .

a
vscode が開いたと思いますので、vscode のターミナル上で以下のコマンドを実行してください
また、推奨された拡張機能は入れておいてください

$ npm i
$ npm run dev

自動的にフォルダが追加され、ターミナル上に様々なログが流れます
その中に localhost://3000 という部分があるので ctrl + クリックします
おそらく規定のブラウザが立ち上がり、welcome to next.jsと表示されるはずです表示されたら成功です
img

GitHub のリポジトリを作成

前回の記事からの引用です

自身の GitHub にログインします
repositorynewと進みます
リポジトリ名を入力し、既定の設定で作成します
リポジトリの URL をコピーします
リポジトリとローカル環境にあるフォルダを紐付けます
以下のように vscode のターミナルに入力してください

$ git remote set-url origin コピーしたURL
$ git init
$ git add .
$ git commit -m 'firstcommit'
$ git push -u origin main(もしくはmaster)
  • 以下ややコマンドラインの表示が異なりますがこれまでがあっているならば問題ないので適宜読み替えてください

img

img
自身のGitHubactions→ 黄色いマークが回っている → 緑になったら成功です

マインスイーパーで遊ぶ

https://minesweeper.online/ja/

今回はこのデザインをベースに開発します

マインスイーパーを作る

注意

今回はだいぶ駆け足になっていますが、前回のオセロを自力で作りきったひとなら完成させられると思います
順次解説は増やしていくので、一回あきらめてしまっても、定期的に挑戦していただけると幸いです(𝕏(旧 twitter)にて発信します)
今回の例で、下記のサイトで紹介されている配列メソッドを多用しています(僕自身完璧に使いこなしているわけではないので、より良い実装がありましたら教えていただきたいです)
https://typescriptbook.jp/reference/values-types-variables/array/array-operations

見た目を作る

vscode でothello/src/pages/index.page.tsxを開きます
6 行目から 54 行目は今回も使わないので消します
img
img
vscode の右画面に./index.module.css を開きます
img
このようにすると作業の効率が上がります(個人差があります)

  1. CSS スプライトを理解する
    マインスイーパーには爆弾が周囲に何個あるかを数字で表示していますね
    それぞれの数字に対して画像を割り当ててもよいのですが、CSS スプライトを用いると以下のようにメリットがあります

    CSS スプライトは、サイトの読み込みを速くする目的で使われている CSS の技法です。 具体的には、「サイト内の複数の画像をなるべく 1 枚の画像にまとめ、CSS で表示範囲を指定することによって表示させる」ことで画像の読み込みを減らし、サイトの読み込みが速くなるという仕組みです。
    [1]

    この技法を用いて数字を表示していきます
    まずはこの画像を名前をつけて保存してください

    img
    保存したこの画像をマインスイーパーのプロジェクトディレクトリ上にコピーして保存します
    保存先とするフォルダを作成するため、以下のコマンドをターミナルに打ち込んでください

     $ mkdir assets
     $ mkdir assets/images
    

    エクスプローラーを開き先ほど保存した画像をドラッグ&ドロップでコピーします
    img

    このようになっていることを確認してください
    img

    以下のようにファイルを書き換えてください

    index.tsx
    import styles from "./index.module.css";
    
    const Home = () => {
      return (
        <div className={styles.container}>
          <div className={styles.number} />
        </div>
      );
    };
    
    export default Home;
    
    index.module.css
    .container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    min-height: 100vh;
    padding: 0 0.5rem;
    }
    
    .number {
    width: 30px;
    height: 30px;
    background: url('../../assets/images/icons.png') no-repeat;
    }
    

    画面の中央のに1が表示されたはずです

  2. CSS スプライトを tsx ファイルでうまく利用する
    今は1が表示されていますが、ほかの数字も表示させる必要があります
    先ほど、はみ出ている部分は表示されていないと言いましたが、表示されていないだけで存在はしています
    どうにかして表示領域を23などの右側の数字上にずらせばいいですね

    ブラウザの開発者ツールを開きます
    数字の要素を選択して CSS の属性を追加します

    img
    img

    マウスホイールを動かして値を変えると表示される領域が変化したことがわかると思います
    これを用いて表示する数字を変化させます

    index.tsx
    const Home = () => {
      const number = 2; //任意の0~13の数字
      return (
        <div className={styles.container}>
          <div
            className={styles.number}
            style={{ backgroundPositionX: 30 - 30 * number }}
          />
        </div>
      );
    };
    
    export default Home;
    
  3. マインスイーパーの石が敷き詰められた部分を作る
    css ファイルの他のクラスや、tsx ファイルの container を参考に空気を読んでクラス board を作ってください
    ボードのサイズは基本を 30 * 9 で 270px とします

  4. セルごとの要素を作る
    マインスイーパーの最初の状態であるクラス stone を作成します
    立体感を出すために CSS の属性に

     {
      boarder: outset 5px;
    }
    

    を用いるといい感じになります
    また、数字のセルは白い区切り線が入っているので追加します

    index.module.css
    
     board {
      display: flex;
      flex: row;
      flex-wrap: wrap;
      width: 288px;
      height: 288px;
      background-color: #aaa;
     }
    
     number {
      width: 32px;
      height: 32px;
      background: url('../../assets/images/icons.png') no-repeat;
      border: solid 1px #fff;
     }
    
     stone {
      width: 32px;
      height: 32px;
      background-color: #ccc;
      border: outset 5px #ddd;
     }
    
    

    数字に枠をつけると中の画像がずれるのでサイズを変更しました

  5. セルを増やす
    セルを敷き詰めるロジックについては前回で詳しく解説したので今回は深く解説しません
    今回ポイントとなるのは、石の時と数字の時で異なるクラスをあてる必要があることです
    見た目と対応させる 2 次元配列は

    const board = [...Array(9)].map((_, y) =>
      [...Array(9)].map((_, x) => ((y + x + 1) % 13) - 1)
    );
    

    を用いるとよいです

    index.tsx
    
     import styles from './index.module.css';
    
     const Home = () => {
       const board = [...Array(9)].map((_, y) => [...Array(9)].map((_, x) => ((y + x + 1) %  13) - 1));
       return (
         <div className={styles.container}>
           <div className={styles.board}>
             {board.map((row, y) =>
               row.map((number, x) => (
                 <div
                   className={number === -1 ? styles.stone : styles.number}
                   style={{ backgroundPositionX: 30 - 30 * number }}
                   key={`${y}-${x}`}
                 />
               ))
             )}
           </div>
         </div>
       );
     };
    
     export default Home;
    
    
    

機能を付ける

  1. 今回の設計思想のお話
    前回useStateについて詳しく解説しなかったような気がするので必要な知識の範囲で解説します(あくまで僕の経験則による理解です)
    react では UI が変化するごとに Home関数を呼び出していてuseStateに関しては以下のように動作します

    • Home関数が実行される → 値が返ってくる → 返ってきた値に基づいてレンダリング → useStateの値を書き換え → Home関数の呼び出し...のようなサイクル
    • useStateで管理していない定数、変数の値がレンダリングのたびに初期化される
    • レンダリング間で useState で管理されている変数の値は不変

    この挙動はなじみづらさがありますが慣れてくると以のようなメリットを享受できます(個人の感想です)

    • レンダリング間でuseStateの値が不変なので値が書き換わったかを考える必要がない
    • 必要なものをすべてuseStateの値から計算するように設計するので、関数 1 つの正しさに注力すればよくなる

    そこで、react での開発を学ぶ上で今考えるべきは計算できるかになります
    例としてオセロを挙げます

    • 石の数や置ける場所は計算で算出できます
    • 1 方、ユーザーがどこに石を置くかは計算できません

    そのため、ユーザーがどこに石を置いたかはuseStateで管理しています

    ここでマインスイーパーで計算できないものを考えます
    クリックした場所はユーザーが無作為に選ぶので算出できませんね
    ボムの配置もランダムなので算出できません
    しかし、表示用のboardはこの 2 つがあれば必ず 1 つに決まります
    ということで、今回useStateで管理するのはuserInputs:(0 | 1 | 2 | 3)[][]bombMup:(0 | 1)[][]です

  2. ゲームとしての機能を追加する 1
    今回必要な機能のうち

    • 左クリック初回に爆弾をランダムに 10 個配置

    • 左クリック毎にクリックしたセルに対応する 2 次元配列の要素を 0 なら 1 に変更

    • 右クリック毎にクリックしたセルに対応する 2 次元配列の要素を 0 → 2 → 3 → 0 と変更(旗 → はてな → 取り消し)

    を追加します
    それぞれの機能を実装したらboard.map(…boardをそれぞれの配列に書き換えて動作確認をするとよいです

    index.tsx
    import { useState } from "react";
    import styles from "./index.module.css";
    
    const Home = () => {
      const board = [...Array(9)].map((_, y) =>
        [...Array(9)].map((_, x) => ((y + x + 1) % 13) - 1)
      );
      const zeroBoard = [...Array(9)].map(() => [...Array(9)].map(() => 0));
      const [userInputs, setUserInputs] = useState(zeroBoard);
      const [bombMap, setBombMap] = useState(zeroBoard);
      const newBombMap = structuredClone(bombMap);
      const newUserInputs = structuredClone(userInputs);
    
      //初回かどうか
      const isFirst = () => !bombMap.flat().includes(1);
    
      //左クリックしたときに呼ぶ関数
      const clickL = (x: number, y: number) => {
        //最初ならボムをセット
        if (isFirst()) {
          //ボムを配置する関数を定義
          const setUpBombMap = () => {
            //ユーザーがクリックした場所にあらかじめボムを置くことで
            //ほかの場所に10個配置されるようにする
            newBombMap[y][x] = 1;
    
            //存在するボムの数が10+ユーザーの場所の1個まで繰り返す
            while (
              newBombMap.flat().filter((cell) => cell === 1).length <
              10 + 1
            ) {
              const nx = Math.floor(Math.random() * 9);
              const ny = Math.floor(Math.random() * 9);
              newBombMap[ny][nx] = 1;
            }
            //ユーザーがクリックした場所のボムをなくす
            newBombMap[y][x] = 0;
          };
          //関数を呼び出し
          setUpBombMap();
          //書き換えたbombMapをセット
          setBombMap(newBombMap);
        }
        const userInput = userInputs[y][x];
    
        //その場所のuserInputsが0なら1にしてセット
        if (userInput === 0) {
          newUserInputs[y][x] = 1;
          setUserInputs(newUserInputs);
        }
      };
    
      //右クリックしたときに呼ぶ関数
      const clickR = (x: number, y: number) => {
        //デフォルトの右クリックのメニューが出ないようにする
        document.getElementsByTagName("html")[0].oncontextmenu = () => false;
    
        const userInput = userInputs[y][x];
    
        //その場所をすでに左クリックしていたら以降のことを行わない
        if (userInput === 1) return;
    
        //0,2,3を0,1,2,にして2,3,4にして2,3,0にする
        const newUserInput = (Math.max(0, userInput - 1) + 2) % 4;
        newUserInputs[y][x] = newUserInput;
        //書き換えたuserInputsをセット
        setUserInputs(newUserInputs);
      };
    
      /* 略 */
    };
    
    export default Home;
    
  3. ゲームとしての機能をつける 2
    今回必要な機能のうち

    • すべてのセルを確認してクリックされた履歴があったら周囲 8 セルのボムの開かれた数をboardに代入
    • 周囲 8 セルのボムの数が 0 だったなら周囲 8 セルに対して上記の動作を行う

    を追加します
    まずboardを以下のように書き換えてください

    const board = [...Array(9)].map(() => [...Array(9)].map(() => -1));
    

    また、returnの直前に

    index.tsx
    userInputs.forEach((row, j) =>
      row.forEach((userInput, i) => {
        if (userInput === 1) {
          //checkAround8(i, j);
        }
      })
    );
    

    を追加し、

    index.tsx
     <div className={styles.container}>
       <div className={styles.board}>
         {board.map((row, y) =>
    

    となっていることを確認してください

    次に周囲 8 セルのボムの数を、確認したセルの位置のboardのセルに代入して、表示するものを作る関数checkAround8を定義してください

    index.tsx
    const checkAround8 = (x: number, y: number) => {
      board[y][x] = [-1, 0, 1]
        .map((dx) =>
          [-1, 0, 1].map(
            (dy) =>
              bombMap[y + dy] !== undefined && bombMap[y + dy][x + dx] === 1
          )
        )
        .flat()
        .filter(Boolean).length;
    };
    

    定義出来たらコメントアウトを外して動作確認をしましょう
    最後にcheckAround8の中でcheckAround8を呼び出します
    このようにすることで周囲 8 セルに対して行う動作を無限に繰り返して、表示するboardを作ることができます

    index.tsx
    const checkAround8 = (x: number, y: number) => {
      board[y][x] = [-1, 0, 1]
        .map((dx) =>
          [-1, 0, 1].map(
            (dy) =>
              bombMap[y + dy] !== undefined && bombMap[y + dy][x + dx] === 1
          )
        )
        .flat()
        .filter(Boolean).length;
    
      if (board[y][x] === 0) {
        [-1, 0, 1].forEach((dx) =>
          [-1, 0, 1].forEach((dy) => {
            if (board[y + dy]?.[x + dx] === -1) {
              checkAround8(x + dx, y + dy);
            }
          })
        );
      }
    };
    
  4. ゲームとしての機能をつける 3
    今回必要な機能のうち

    • 爆弾をクリックした履歴があったら(負けていたら)爆弾を表示する
    • 爆弾をクリックした履歴があったら(負けていたら)クリックできない

    を追加します
    負けたかはbombMapuserInputsを 1 セルごとに比較して、bombMapが 1 かつuserInputsが 1 の場所を探せばよいです

    index.tsx
    const isFailed = () =>
     bombMap.flat().filter((bomb, index) => bomb === 1 && userInputs.flat()[index] === 1).length > 0;
    
    

    この関数がfalseの時にはクリックしたときに何もせずにreturnすればよいです

    index.tsx
    const clickL = (x: number, y: number) => {
     if (isFailed()) return;
     /* 略 */
    };
    

    そして、boardを作成するコードの直後に、board を上書きしてbombを表示すればよいです

    index.tsx
    if (isFailed()) {
     bombMap.forEach((row, j) =>
       row.forEach((userInput, i) => {
         if (userInput === 1) {
           board[j][i] = 11;
         }
       })
     );
    }
    

    これでひと先ず遊べる程度の機能ができたかと思います

さらに機能をつける

  • 旗/はてなを表示する
  • 数字をクリックしたときに周囲の旗の数と数字が同じなら空いていないセルをクリックしたことにする
  • 盤面の周りに縁をつけ、顔文字でプレイ状況を表示する
  • タイマー/残り爆弾数を表示する
  • 中級や上級のサイズを作る
  • 画面の大きさを変えるとそれにあった大きさに自動的に変わる
脚注
  1. https://uxmilk.jp/61374#:~:text=CSSスプライトは、サイトの,速くなるという仕組みです。から引用 ↩︎

Discussion