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

{"filename":"Board.js","code":"\/\/ \u5148\u306b\u3001\u79fb\u52d5\u65b9\u5411\u3092\u30d9\u30af\u30c8\u30eb\u306e\u3088\u3046\u306a\u5f62\u3067\u7528\u610f\u3057\u3066\u304a\u304f\nconst dx = [1, 0, -1, 0, 1, 1, -1, -1];\nconst dy = [0, 1, 0, -1, 1, -1, -1, 1];\n\nconst Board = () => {\n\/\/ ...\n  function dfs(Graph, seen, h, w, x, y){\n    seen[x][y] = true;\n    \n    \/\/ \u4eca\u898b\u3066\u3044\u308b\u30de\u30b9\u306e\u5024\u304c0\u3067\u306f\u306a\u3044\u306a\u3089\u3001\u3053\u308c\u4ee5\u4e0a\u5468\u56f2\u3092\u63a2\u7d22\u3057\u306a\u3044\n    if (Graph[x][y] !== 0) { return; }\n    \n    \/\/ \u4eca\u898b\u3066\u3044\u308b\u30de\u30b9\u306e\u5468\u56f28\u65b9\u5411\u3092\u63a2\u7d22\n    for ( let direction = 0; direction < 8; ++direction){\n      const nx = x + dx[direction];\n      const ny = y + dy[direction];\n      \n      if (nx  0 || nx >= h || ny  0 || ny >= w){\n        continue;\n      }\n      if (seen[nx][ny]){ continue; }\n      \/\/ \u6b21\u306b\u9032\u3080\u30de\u30b9\u3092\u30bb\u30c3\u30c8\u3057\u3066\u3001\u518d\u5ea6\u95a2\u6570\u3092\u547c\u3073\u51fa\u3059\uff08\u518d\u5e30\uff09\n      dfs(Graph, seen, h, w, nx, ny);\n    }\n    return seen;\n  }\n\/\/ ...\n}","language":"javascript","id":1}
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発でビルドできることを主眼に置いた環境です。

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

{"filename":"webpack.config.js","code":"const path = require('path');\nmodule.exports = {\n  entry: '.\/src\/app.js',\n  output: {\n    path: path.join(__dirname, 'build'),\n    filename: 'bundle.js'\n  },\n  mode: 'development',\n  module: {\n  rules: [{\n    loader: 'babel-loader',\n    test: \/.js$\/,\n    exclude: \/node_modules\/\n  },{\n    test: \/.scss$\/,\n    use: [\n    'style-loader',\n    'css-loader',\n    'sass-loader'\n  ]\n  }]\n},\n  devServer: {\n    static: {\n    directory: path.join(__dirname, 'build'),\n  },\n    host: '0.0.0.0',\n    port: 8080\n  },\n  devtool: \"source-map\"\n}","language":"javascript","id":1}
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 !!