React公式チュートリアルの次に最適!マインスイーパーをつくる【設計編】

Reactの公式チュートリアルを読み終わったけど、個別に説明されているトピックの定着に不安がある、具体的にどんな感じでHooksを使うのかしっくりこない。

そんな人に最適な実践形式のチュートリアルとして、Reactで出来るマインスイーパーのつくりかたを紹介します。

デモページ / Github

チュートリアルは、設計編(本記事)と実装編(後日投稿)に分けています。自信のある人は、設計編とGithubのコードを照らし合わせながら自力で実装できると、より良いですね。

記事の内容を理解することで、バニラReactのSPAでできることの大半を網羅できるかと思います。

・コンポーネント・stateの設計

・少し複雑なstateの管理

・イベントハンドラの扱い

・React v18以降のHooks : useReducer(), useContext(), useEffect(), useRef()

・レイアウト・スタイリング

実装編の詳細な部分は、React完全初心者には難しいかと思います。ミニゲームとはいえ、コンポーネントのコードだけで450行程度あるので、全て理解しようとすると疲れてしまうかもしれません。
その場合、適宜公式チュートリアルを見ながら進めることをお勧めします。

・Classコンポーネントを用いたレガシーなコーディング

・TypeScriptを使用した開発

・Next.jsを用いたSSR

・外部API繋ぎ込み

ゲームのコアになる部分と実装の方針を考えよう

スクラッチから作る際には、最初に主要なロジックは何であり、どのように実装するべきか方針を立てましょう。

これを明確にしておかないと、どこから手をつけていいか分からなくなってしまいます(筆者が最初そうでした)。

必須のstateとコンポーネント

まず、マインスイーパーのルールのコアな部分を確認し、実装の方針を立てられるように適宜言い換えていきましょう。

ゲームのルール

・グリッド上のマス目があり、開いたマスの周囲8マスにある爆弾の数が表示される

・爆弾マスは、初期でランダムに配置されている

・爆弾マスを選択してしまうとゲームオーバー

・爆弾マス以外の全てのマスを開けられればゲームクリア

そうすると、ゲームを作る上で必須のstateとして、

value : 各マスに割り当てられた、周囲の爆弾の数

isRevealed : マスの値(value)が見えているかどうか

の2つを設計する必要がありそうです。

ユーザーがマスを開けた時、そのマスが爆弾マスかどうか判定する必要がありますが、例えば、

・爆弾マスはvalue = -1

・それ以外のマスはvalue = 爆弾の個数(0含む)

と分けておけば、マスの種類もvalueに含む形で表せますね。

以上から、ゲームの最も重要なロジックは、

マスのクリックイベントの中に、valueによって分岐する処理を実装する。value = -1 だとゲームオーバー、それ以外ならゲームを続行する

とすれば良さそうです。

ゲーム進行のイメージ図。紫丸に到達するとクリア

また、ユーザーがマスを開ける度に、(ゲームオーバーにならない限り)ゲームのクリア判定が必要です。判定には開いたマスの個数が必要なので、

・個々のマスがstateとしてisRevealedを保持しているだけではダメで、複数のマスの情報を同時に参照できる上位コンポーネントが必要

ということも分かります。したがって、必須のコンポーネントについても、以下のように考察できますね。

盤面を管理する<Board />コンポーネントを設計し、その子コンポーネントとして各マスを表す<Cell />コンポーネントを作る。valueとisRevealedの値は、盤面の初期化時に<Board /> から各<Cell />に”配る”。

<Board />と<Cell />の関係性のイメージ図。複数ある<Cell />を<Board />が管理する

以上で、ゲームの最も重要なロジックについて実装の方針が立ちました。

シバセンセー
シバセンセー

なんとなく、マインスイーパーの実装が出来そうになってきたね。

まとめ

必須のstateは、周囲の爆弾の個数valueと、valueが見えているかどうかを表すisRevealedの2つ。

valueisRevealedは、盤面を管理す<Board />コンポーネント内部に持ち、各マスを表す<Cell />コンポーネントに配る

難しそうなところ つながっている0のマス

これまでの説明で、

・爆弾が周囲に無い(value = 0)マスを開けたとき、開けたマスの周囲にも同じようなマスがあれば一気に開ける

というマインスイーパー独特の挙動をどうするのかと思った方は鋭いですね。開いた範囲がパッと広がる、あの気持ちいいやつですね。

一見難しそうですが、このとき1個1個のマスがどう開いていくかに着目して考えると、

処理X: value = 0のマスを開けた時、周囲隣接8マスがvalue = 0であるかチェックする。仮にvalue = 0であればそのマスも開ける(isRevealed = trueにする)

  

処理Xを、チェック対象のマスについてvalue ≠ 0になるまで繰り返す。

といえます。

本題のReactではなくアルゴリズムの話になってしまうのですが、これは初歩的な 深さ優先探索(DFS)で実装可能です。

ダテネコ
ダテネコ

自力で考えるんじゃなくて、DFSを理解して使う方が近道になるんだぜ。

<Board />コンポーネントの実装の一部を掲載します。コード中のコメントを参考にしてください。

// 先に、移動方向をベクトルのような形で用意しておく
const dx = [1, 0, -1, 0, 1, 1, -1, -1];
const dy = [0, 1, 0, -1, 1, -1, -1, 1];

const Board = () => {
// ...
  function dfs(Graph, seen, h, w, x, y){
    seen[x][y] = true;
    
 // 今見ているマスの値が0ではないなら、これ以上周囲を探索しない
    if (Graph[x][y] !== 0 { return; }
    
 // 今見ているマスの周囲8方向を探索
    for ( let direction = 0; direction < 8; ++direction){
      const nx = x + dx[direction];
      const ny = y + dy[direction];
      
      if (nx < 0 || nx >= h || ny < 0 || ny >= w){
        continue;
      }
      if (seen[nx][ny]){ continue; }

      // 次に進むマスをセットして、再度関数を呼び出す(再帰)
      dfs(Graph, seen, h, w, nx, ny);
    }
    return seen;
  }
// ...
}
DFS実装のポイント

・(1・2 / 13行目)動く方向dx, dyを配列にしてループすることで、8方向を探索するループが簡潔に書ける

・(17行目)次の探索位置(nx, ny)を確認して、グリッドからはみ出たマスを探索してエラーにならないようにする

・(23行目)次の探索位置について関数dfsを再度呼び出すことで、「条件を満たすまで繰り返す」ことができる

マインスイーパーの動作では、0のマスだけでなく、0のマスの塊を囲むようなvalue= 1 〜 8のマスを1重だけ開ける必要があります。そのため、seen[x][y] = trueの後にGraph[x][y]の値を判定する順になっていることを押さえてください。

DFSの背景や応用までを丁寧に理解したい方は、以下の記事が大変分かりやすいです。

関数dfs()が返すseen配列は、0のマスの”島”を囲むような範囲について値がtrueになっています。

0のマスを開ける前のisRevealedseenの論理和をとり、その結果stateを更新しようという目論見で実装しています。

ここまでで、ゲームのコアとなるロジックについて、問題なく実装できそうだと確認できました。

次章で、他に必要そうなstateとコンポーネントを考えていきましょう。

完成イメージから、他に必要そうなstate, コンポーネントを考えよう

コアのロジックについて方針が立ったので、完成イメージを考えて、必要そうなコンポーネントとstateを挙げていきます。

コンポーネントから考えていきます。

コンポーネントの列挙

ゲーム本体の完成はこんな感じです。

必要なコンポーネント

<GamingHeader /> : ミニゲーム用ヘッダー

<Minesweeper />: ゲーム全体を管理する、マインスイーパー本体の最上位コンポーネント

<Timer />: ゲームの経過時間( = スコア)を表示するタイマー

<ControlButton /> : ゲームスタート・ニューゲームスタートなどを行う操作ボタン

<SelectOptions />: ゲームの難易度を変更するコンポーネント

<Board />: 盤面を管理する主要コンポーネント

<StatusMessage /> : ゲームクリア・ゲームオーバーなどのステータスに応じて変化するメッセージ

各マスを表す、<Cell /> は、<Board />の下位コンポーネントとしてつくる。

コンポーネント間の関係は以下のようになりますね。

簡略化したコンポーネントツリー
コマリブル
コマリブル

コンポーネントが多くて困った、実装できるかな

シバセンセー
シバセンセー

主要な部分は<Board /> とその周りのロジックだから、コンポーネントの多さにびっくりしなくても大丈夫だよ。

<GamingHeader /> は、他のミニゲームを作って追加したときに、ページ移動を実装するためのナビゲーション用コンポーネントです。マインスイーパー単体を作る際にはナビゲーション機能を実装しませんので、おまけ程度に思ってください。

stateの列挙

ミニゲームとして全体を協調させて機能させるために、

gameStatus: ゲームクリア・ゲームオーバー・ゲーム進行中などの状態

difficulty :選択中の難易度の情報(盤面の広さと爆弾数)

・経過時間

の状態をstateとして持ち、gameStatusdifficultyゲーム全体で共有することにします。

そのため、主要なロジックを持つ<Board /> の親コンポーネントとして<Minesweeper />をつくり、最上位コンポーネントとしてこれらのstateを管理させます

なぜこのようにするのか、次節に詳細を書きますが、納得できている人は飛ばしてください。

ゲーム全体で共有するべきstateの保持と更新は、useReducer()useContext()の組み合わせが使えそうですね(実際にそうしています)。

<Minesweeper />コンポーネントの必要性

実際の状況を考えてみましょう。

例えば、<StatusMessage />は、ゲームクリア、ゲームオーバー等のstateに応じたメッセージを表示するコンポーネントですが、ゲームクリア・ゲームオーバーの判定は<Board />が行うため、<StatusMessage />単体ではgameStatus を取得することができません。

もちろん、<Board />の下位コンポーネントに<StatusMessage />を押し込めば<Board />から<StatusMessage />propsとしてgameStatusを伝達できますが、

<StatusMessage /><Board />が管理している盤面の情報とは無関係であり、子コンポーネントにする必然性が無い

<StatusMessage /> 以外にもgameStatusを使用するコンポーネントが出てくると困る

ことを考えると、<Board /><StatusMessage /> は同じ階層に設置する方が簡潔実装になりそうです。

以上から、

<Board />がゲームの状態を判定→

<Minesweeper />で管理しているgameStatus を書き換える→

<Minesweeper /> から<StatusMessage />gameStatusを伝達

としておくのがベターです。

実際に、今回の実装では<StatusMessage />の他に、<ControlButton />gameStatusを利用するので、共通の親コンポーネントに持っておく方が良いのです。

gameStatusの伝達の様子

経過時間をstateで表す

次に、ゲームの経過時間(マインスイーパーのスコア)ですが、これは実装上の都合から、現在時刻を表すnow と、ゲームをスタートした瞬間の時刻startTimeの2つのstateを設定して、引き算で求めます(実装は非常に簡単です)。

※ 実装が気になってモヤモヤする人は、公式チュートリアルとほぼ同じなので先にご覧ください。

<Timer /> からstartTimeを取得する方法について考えましょう。

ゲームがスタートするのは、スタート前の状態で<ControlButton />が押されたタイミングです。すると、<Timer />単体では取得できません。

前節と同じ議論で、<Timer /><ControlButton />の共通の親コンポーネント<Minesweeper />で経過時間を計算し、ボタンが押された瞬間の経過時間で<Timer />を更新すればよいですね。

stateの列挙 まとめ

・ゲームの進行状況を表すgameStatusと、難易度(盤面の広さと爆弾数)を決めるdifficultyをstateとして管理する

・経過時間は、現在時刻 now 、ゲームスタート瞬間の時刻startTimeの2つのステートから都度計算する

・以上の3つの情報は、ゲーム内全体に共有するため<Minesweeper />で管理する

以上で、ゲームに必要なコンポーネント・stateの列挙が(ほぼ)終わりました。

実際のコード(Github)には上記で紹介していないコンポーネント・stateが2,3個あるのですが、純粋に実装上のテクニックとして変更・追加したものになります。

開発環境の構築

0からのモダンフロントエンド環境の構築方法については、本記事では書ききれませんので別記事に譲ります。

ここでは、マインスイーパー制作時の(筆者の)開発環境についての要点として、

・必要なパッケージ類 : package.json

・モジュールバンドラー : webpack5(webpack.config.js)

について記載・補足します。

※ 筆者の開発環境では、上記を🐳 Dockerコンテナ内で動作させて開発を行なっています。

package.json

・sass-loader : SASS(公式)をCSSに変換するためのローダー

・babel : Modern JS → ES5の変換を行うローダー

をwebpack経由で起動することで、npm run webpackorwebpack-dev-serverのコマンド1発でビルドできることを主眼に置いた環境です。

{
  "name": "webpack5-react-babel",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
  "build": "webpack",
  "dev-server": "webpack-dev-server",
  },
  "dependencies": {
    "@babel/cli": "^7.13.16",
    "@babel/core": "^7.14.2",
    "@babel/preset-env": "^7.14.2",
    "@babel/preset-react": "^7.13.13",
    "@emoji-mart/data": "^1.2.1",
    "@emoji-mart/react": "^1.1.1",
    "@fortawesome/fontawesome-svg-core": "^6.5.2",
    "@fortawesome/free-brands-svg-icons": "^6.5.2",
    "@fortawesome/free-regular-svg-icons": "^6.5.2",
    "@fortawesome/free-solid-svg-icons": "^6.5.2",
    "@fortawesome/react-fontawesome": "^0.2.2",
    "babel-loader": "^9.1.3",
    "css-loader": "^7.1.1",
    "emoji-mart": "^5.6.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.23.1",
    "sass": "^1.77.1",
    "sass-loader": "^14.2.1",
    "style-loader": "^4.0.0",
    "webpack": "^5.38.1",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^5.0.4"
  }
}
package.jsonまとめ

react本体

・react, react-dom:必須

・react-router–dom : 多ページ用。マインスイーパー単体の開発では不要

モジュールバンドラー

・webpack:必須

・webpack-cli : “scripts”に指定したコマンドから、webpackを起動させるために必要

・webpack-dev-server:ホットリロード対応の開発用サーバー起動のために必要

ローダー

・@bable/core:必須

・@babel/cli:コマンドからbabelを起動させるために必要

・@babel/preset-env, @balel/preset-react:babelをreactと共に使用するために必要。必須

・sass:必須

・sass-loader:SASS→CSSのローダー。スタイリングにSASSを使用するならf必要。

・style-loader :CSSをJSに埋め込むために必要なローダー。必須。

スタイリング(欲しいスタイルに応じてカスタマイズしてください)

・@emoji-mart/data:絵文字を使うために必要。絵文字データ本体。

・@emoji-mart/react:絵文字をreactコンポーネントとして記述するために必要。

・@fortawesome/fontawesome-svg-core, @fortawesome/free-brands-svg-icons, @fortawesome/free-regular-svg-icons, @fortawesome/free-solid-svg-icons

fontawesomeを使用するために必要なデータ本体。

・@fortawesome/react-fontawesome

fontawesomeを<FontAwesomeIcon />コンポーネントとして利用するために必要なライプラリ。

webpack5 + babel+scssはややレガシー環境と感じられる方も多いかと思いますが、2024年現在動作できております。マインスイーパーのビルドに関してbabelの遅さが問題になることはありませんので、カスタマイズできない方はこのままご使用いただいて大丈夫です。

Vite(+SWC)等をお使いの方は、ご自身の環境に合わせて読み替えをお願い致します。

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/app.js',
  output: {
    path: path.join(__dirname, 'build'),
    filename: 'bundle.js'
  },
  mode: 'development',
  module: {
  rules: [{
    loader: 'babel-loader',
    test: /.js$/,
    exclude: /node_modules/
  },{
    test: /.scss$/,
    use: [
    'style-loader',
    'css-loader',
    'sass-loader'
  ]
  }]
},
  devServer: {
    static: {
    directory: path.join(__dirname, 'build'),
  },
    host: '0.0.0.0',
    port: 8080
  },
  devtool: "source-map"
}
webpack.config.jsまとめ

entry, output, mode

 基本設定であり必須。

module

・loader: babelを使用

・test: ローダー(babel)の対象ファイルを.jsに設定

・exclude: ローダーから除外するファイル指定

→ SASSのローダーの設定も同様

devServer

・static : webpackでビルドしたファイルの場所を指定(webpack4〜設定が変更されているので、webpack3以前の方と異なります)

・host, port: 🐳 Docker使用時のみ必要な設定(後述)

dev-tools: “source-map”

開発中のバグ発生時に、ビルド前のコンポーネントのコードまで遡ってバグ発生位置を知らせてくれる機能。開発時ONを推奨。

devServerのhost, port設定ですが、筆者がDocker環境で開発を行なったため必要になった設定です。Docker環境はそれ自体は「閉じて」おり、外部アクセスを受け付けませんので、ローカルPCからビルド結果を確認するためにはポートの設定が必要です。

まとめ

設計編で、以下のことを確認できましたね。

・マインスイーパーのコアとなるゲームロジックは、<Board />コンポーネントとvalue, isRevealedstateで表現する。

<Board />の上位に<Minesweeper />を設計し、ゲーム全体で共有すべきgameState, difficultystateを管理する

・完成イメージ(デザイン)を各パーツに分解して考えれば、必要なコンポーネントやstateを列挙できる

設計編で、実装に必要なコアな情報は全て説明されています。

自信のある人は、Githubのソースコードと照らし合わせてここから手を動かしてみましょう。

以上でマインスイーパーの設計は終了です。お疲れ様でした。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA


error: Content is protected !!