Reactの公式チュートリアルを読み終わったけど、個別に説明されているトピックの定着に不安がある、具体的にどんな感じでHooksを使うのかしっくりこない。
そんな人に最適な実践形式のチュートリアルとして、Reactで出来るマインスイーパーのつくりかたを紹介します。
チュートリアルは、設計編(本記事)と実装編(後日投稿)に分けています。自信のある人は、設計編とGithubのコードを照らし合わせながら自力で実装できると、より良いですね。
記事の内容を理解することで、バニラReactのSPAでできることの大半を網羅できるかと思います。
・コンポーネント・stateの設計
・少し複雑なstateの管理
・イベントハンドラの扱い
・React v18以降のHooks : useReducer()
, useContext()
, useEffect()
, useRef()
・レイアウト・スタイリング
実装編の詳細な部分は、React完全初心者には難しいかと思います。ミニゲームとはいえ、コンポーネントのコードだけで450行程度あるので、全て理解しようとすると疲れてしまうかもしれません。
その場合、適宜公式チュートリアルを見ながら進めることをお勧めします。
・Classコンポーネントを用いたレガシーなコーディング
・TypeScriptを使用した開発
・Next.jsを用いたSSR
・外部API繋ぎ込み
スクラッチから作る際には、最初に主要なロジックは何であり、どのように実装するべきか方針を立てましょう。
これを明確にしておかないと、どこから手をつけていいか分からなくなってしまいます(筆者が最初そうでした)。
まず、マインスイーパーのルールのコアな部分を確認し、実装の方針を立てられるように適宜言い換えていきましょう。
・グリッド上のマス目があり、開いたマスの周囲8マスにある爆弾の数が表示される
・爆弾マスは、初期でランダムに配置されている
・爆弾マスを選択してしまうとゲームオーバー
・爆弾マス以外の全てのマスを開けられればゲームクリア
そうすると、ゲームを作る上で必須のstateとして、
value
: 各マスに割り当てられた、周囲の爆弾の数
isRevealed
: マスの値(value)が見えているかどうか
の2つを設計する必要がありそうです。
ユーザーがマスを開けた時、そのマスが爆弾マスかどうか判定する必要がありますが、例えば、
・爆弾マスはvalue
= -1
・それ以外のマスはvalue
= 爆弾の個数(0含む)
と分けておけば、マスの種類もvalue
に含む形で表せますね。
以上から、ゲームの最も重要なロジックは、
マスのクリックイベントの中に、value
によって分岐する処理を実装する。value
= -1 だとゲームオーバー、それ以外ならゲームを続行する
とすれば良さそうです。
また、ユーザーがマスを開ける度に、(ゲームオーバーにならない限り)ゲームのクリア判定が必要です。判定には開いたマスの個数が必要なので、
・個々のマスがstateとしてisRevealed
を保持しているだけではダメで、複数のマスの情報を同時に参照できる上位コンポーネントが必要
ということも分かります。したがって、必須のコンポーネントについても、以下のように考察できますね。
盤面を管理する<Board />
コンポーネントを設計し、その子コンポーネントとして各マスを表す<Cell />
コンポーネントを作る。valueとisRevealedの値は、盤面の初期化時に<Board />
から各<Cell />
に”配る”。
以上で、ゲームの最も重要なロジックについて実装の方針が立ちました。
なんとなく、マインスイーパーの実装が出来そうになってきたね。
・必須のstateは、周囲の爆弾の個数value
と、value
が見えているかどうかを表すisRevealed
の2つ。
・value
とisRevealed
は、盤面を管理する<Board />
コンポーネント内部に持ち、各マスを表す<Cell />
コンポーネントに配る
これまでの説明で、
・爆弾が周囲に無い(value
= 0)マスを開けたとき、開けたマスの周囲にも同じようなマスがあれば一気に開ける
というマインスイーパー独特の挙動をどうするのかと思った方は鋭いですね。開いた範囲がパッと広がる、あの気持ちいいやつですね。
一見難しそうですが、このとき1個1個のマスがどう開いていくかに着目して考えると、
処理X: value
= 0のマスを開けた時、周囲隣接8マスがvalue
= 0であるかチェックする。仮にvalue
= 0であればそのマスも開ける(isRevealed
= trueにする)
処理Xを、チェック対象のマスについてvalue
≠ 0になるまで繰り返す。
といえます。
本題のReactではなくアルゴリズムの話になってしまうのですが、これは初歩的な 深さ優先探索(DFS)で実装可能です。
自力で考えるんじゃなくて、DFSを理解して使う方が近道になるんだぜ。
<Board />コンポーネントの実装の一部を掲載します。コード中のコメントを参考にしてください。
・(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のマスを開ける前のisRevealed
とseen
の論理和をとり、その結果でstateを更新しようという目論見で実装しています。
ここまでで、ゲームのコアとなるロジックについて、問題なく実装できそうだと確認できました。
次章で、他に必要そうなstateとコンポーネントを考えていきましょう。
コアのロジックについて方針が立ったので、完成イメージを考えて、必要そうなコンポーネントとstateを挙げていきます。
コンポーネントから考えていきます。
ゲーム本体の完成はこんな感じです。
・<GamingHeader />
: ミニゲーム用ヘッダー
・<Minesweeper />
: ゲーム全体を管理する、マインスイーパー本体の最上位コンポーネント
・<Timer />
: ゲームの経過時間( = スコア)を表示するタイマー
・<ControlButton />
: ゲームスタート・ニューゲームスタートなどを行う操作ボタン
・<SelectOptions />
: ゲームの難易度を変更するコンポーネント
・<Board />
: 盤面を管理する主要コンポーネント
・<StatusMessage />
: ゲームクリア・ゲームオーバーなどのステータスに応じて変化するメッセージ
各マスを表す、<Cell />
は、<Board />
の下位コンポーネントとしてつくる。
コンポーネント間の関係は以下のようになりますね。
コンポーネントが多くて困った、実装できるかな
主要な部分は<Board />
とその周りのロジックだから、コンポーネントの多さにびっくりしなくても大丈夫だよ。
<GamingHeader />
は、他のミニゲームを作って追加したときに、ページ移動を実装するためのナビゲーション用コンポーネントです。マインスイーパー単体を作る際にはナビゲーション機能を実装しませんので、おまけ程度に思ってください。
ミニゲームとして全体を協調させて機能させるために、
・gameStatus
: ゲームクリア・ゲームオーバー・ゲーム進行中などの状態
・difficulty
:選択中の難易度の情報(盤面の広さと爆弾数)
・経過時間
の状態をstateとして持ち、gameStatus
とdifficulty
はゲーム全体で共有することにします。
そのため、主要なロジックを持つ<Board />
の親コンポーネントとして<Minesweeper />
をつくり、最上位コンポーネントとしてこれらのstateを管理させます。
なぜこのようにするのか、次節に詳細を書きますが、納得できている人は飛ばしてください。
ゲーム全体で共有するべきstateの保持と更新は、useReducer()
とuseContext()
の組み合わせが使えそうですね(実際にそうしています)。
実際の状況を考えてみましょう。
例えば、<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
を利用するので、共通の親コンポーネントに持っておく方が良いのです。
次に、ゲームの経過時間(マインスイーパーのスコア)ですが、これは実装上の都合から、現在時刻を表すnow
と、ゲームをスタートした瞬間の時刻startTime
の2つのstateを設定して、引き算で求めます(実装は非常に簡単です)。
※ 実装が気になってモヤモヤする人は、公式チュートリアルとほぼ同じなので先にご覧ください。
<Timer />
からstartTime
を取得する方法について考えましょう。
ゲームがスタートするのは、スタート前の状態で<ControlButton />
が押されたタイミングです。すると、<Timer />
単体では取得できません。
前節と同じ議論で、<Timer />
と<ControlButton />
の共通の親コンポーネント<Minesweeper />
で経過時間を計算し、ボタンが押された瞬間の経過時間で<Timer />
を更新すればよいですね。
・ゲームの進行状況を表すgameStatus
と、難易度(盤面の広さと爆弾数)を決めるdifficulty
をstateとして管理する
・経過時間は、現在時刻 now
、ゲームスタート瞬間の時刻startTime
の2つのステートから都度計算する
・以上の3つの情報は、ゲーム内全体に共有するため<Minesweeper />
で管理する
以上で、ゲームに必要なコンポーネント・stateの列挙が(ほぼ)終わりました。
実際のコード(Github)には上記で紹介していないコンポーネント・stateが2,3個あるのですが、純粋に実装上のテクニックとして変更・追加したものになります。
0からのモダンフロントエンド環境の構築方法については、本記事では書ききれませんので別記事に譲ります。
ここでは、マインスイーパー制作時の(筆者の)開発環境についての要点として、
・必要なパッケージ類 : package.json
・モジュールバンドラー : webpack5(webpack.config.js)
について記載・補足します。
※ 筆者の開発環境では、上記を🐳 Dockerコンテナ内で動作させて開発を行なっています。
・sass-loader : SASS(公式)をCSSに変換するためのローダー
・babel : Modern JS → ES5の変換を行うローダー
をwebpack経由で起動することで、npm run webpack
orwebpack-dev-server
のコマンド1発でビルドできることを主眼に置いた環境です。
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)等をお使いの方は、ご自身の環境に合わせて読み替えをお願い致します。
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
, isRevealed
stateで表現する。
・<Board />
の上位に<Minesweeper />
を設計し、ゲーム全体で共有すべきgameState
, difficulty
stateを管理する
・完成イメージ(デザイン)を各パーツに分解して考えれば、必要なコンポーネントやstateを列挙できる
設計編で、実装に必要なコアな情報は全て説明されています。
自信のある人は、Githubのソースコードと照らし合わせてここから手を動かしてみましょう。
以上でマインスイーパーの設計は終了です。お疲れ様でした。