【初心者向け】サークルで学んだことを生かしたマインスイーパーの作り方説明.ts
react を用いてマインスイーパーを作る
本記事について
前回の記事の引用です
僕は東洋大学の公認サークルである INIAD.ts に所属しており、現時点でオセロやマインスイーパーを制作し、オセロのオンライン対戦の開発に取り組んできました
その中で Typescript での開発方法やコードの書き方、教え方などの技術を向上させてきました
それらの成果の確認と記録のため
前回の記事を読んだうえでこの記事も読んでくださった方、ありがとうございます
今回僕の記事を初めて読む方、ぜひ前回の記事も読んでいただけるととてもうれしいです
また、いいねや 𝕏(旧 twitter)での拡散やコメントをしていただけると励みになります
また、本サークルに興味を持っていただけましたら、下記のリンクからメンバーの活動等も見ることができますので確認していただけると幸いです
マインスイーパーを作成するための準備
node.js のインストール
$ PS C:\Users\ユーザー名> node -v
v18.16.0
$ PS C:\Users\ユーザー名> npm -v
9.5.1
このような表示になっていれば大丈夫です
なっていない場合は前回の記事やその他の記事を参考にしてください
フレームワークをクローンする
今回も前回同様、フレームワークにサークルで用いた next-ts-starter を使用します
にアクセスし、code と書いてある緑の部分をクリックしてください
URL をコピーします
コマンドプロンプトを開き以下のコマンドを入力してください
$ git clone コピーしたurl minesweeper
$ cd minesweeper
$ code .
vscode が開いたと思いますので、vscode のターミナル上で以下のコマンドを実行してください
また、推奨された拡張機能は入れておいてください
$ npm i
$ npm run dev
自動的にフォルダが追加され、ターミナル上に様々なログが流れます
その中に localhost://3000 という部分があるので ctrl + クリック
します
おそらく規定のブラウザが立ち上がり、welcome to next.js
と表示されるはずです表示されたら成功です
GitHub のリポジトリを作成
前回の記事からの引用です
自身の GitHub にログインします
repository
→new
と進みます
リポジトリ名を入力し、既定の設定で作成します
リポジトリの URL をコピーします
リポジトリとローカル環境にあるフォルダを紐付けます
以下のように vscode のターミナルに入力してください$ git remote set-url origin コピーしたURL $ git init $ git add . $ git commit -m 'firstcommit' $ git push -u origin main(もしくはmaster)
- 以下ややコマンドラインの表示が異なりますがこれまでがあっているならば問題ないので適宜読み替えてください
自身のGitHub
→actions
→ 黄色いマークが回っている → 緑になったら成功です
マインスイーパーで遊ぶ
今回はこのデザインをベースに開発します
マインスイーパーを作る
注意
今回はだいぶ駆け足になっていますが、前回のオセロを自力で作りきったひとなら完成させられると思います
順次解説は増やしていくので、一回あきらめてしまっても、定期的に挑戦していただけると幸いです(𝕏(旧 twitter)にて発信します)
今回の例で、下記のサイトで紹介されている配列メソッドを多用しています(僕自身完璧に使いこなしているわけではないので、より良い実装がありましたら教えていただきたいです)
見た目を作る
vscode でothello/src/pages/index.page.tsx
を開きます
6 行目から 54 行目は今回も使わないので消します
vscode の右画面に./index.module.css を開きます
このようにすると作業の効率が上がります(個人差があります)
-
CSS スプライトを理解する
マインスイーパーには爆弾が周囲に何個あるかを数字で表示していますね
それぞれの数字に対して画像を割り当ててもよいのですが、CSS スプライトを用いると以下のようにメリットがありますCSS スプライトは、サイトの読み込みを速くする目的で使われている CSS の技法です。 具体的には、「サイト内の複数の画像をなるべく 1 枚の画像にまとめ、CSS で表示範囲を指定することによって表示させる」ことで画像の読み込みを減らし、サイトの読み込みが速くなるという仕組みです。
[1]この技法を用いて数字を表示していきます
まずはこの画像を名前をつけて保存してください
保存したこの画像をマインスイーパーのプロジェクトディレクトリ上にコピーして保存します
保存先とするフォルダを作成するため、以下のコマンドをターミナルに打ち込んでください$ mkdir assets $ mkdir assets/images
エクスプローラーを開き先ほど保存した画像をドラッグ&ドロップでコピーします
このようになっていることを確認してください
以下のようにファイルを書き換えてください
index.tsximport 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
が表示されたはずです -
CSS スプライトを tsx ファイルでうまく利用する
今は1
が表示されていますが、ほかの数字も表示させる必要があります
先ほど、はみ出ている部分は表示されていないと言いましたが、表示されていないだけで存在はしています
どうにかして表示領域を2
や3
などの右側の数字上にずらせばいいですねブラウザの開発者ツールを開きます
数字の要素を選択して CSS の属性を追加します
マウスホイールを動かして値を変えると表示される領域が変化したことがわかると思います
これを用いて表示する数字を変化させます例
index.tsxconst Home = () => { const number = 2; //任意の0~13の数字 return ( <div className={styles.container}> <div className={styles.number} style={{ backgroundPositionX: 30 - 30 * number }} /> </div> ); }; export default Home;
-
マインスイーパーの石が敷き詰められた部分を作る
css ファイルの他のクラスや、tsx ファイルの container を参考に空気を読んでクラス board を作ってください
ボードのサイズは基本を 30 * 9 で 270px とします -
セルごとの要素を作る
マインスイーパーの最初の状態であるクラス stone を作成します
立体感を出すために CSS の属性に{ boarder: outset 5px; }
を用いるといい感じになります
また、数字のセルは白い区切り線が入っているので追加します例
index.module.cssboard { 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; }
数字に枠をつけると中の画像がずれるのでサイズを変更しました
-
セルを増やす
セルを敷き詰めるロジックについては前回で詳しく解説したので今回は深く解説しません
今回ポイントとなるのは、石の時と数字の時で異なるクラスをあてる必要があることです
見た目と対応させる 2 次元配列はconst board = [...Array(9)].map((_, y) => [...Array(9)].map((_, x) => ((y + x + 1) % 13) - 1) );
を用いるとよいです
例
index.tsximport 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;
機能を付ける
-
今回の設計思想のお話
前回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)[][]
です -
-
ゲームとしての機能を追加する 1
今回必要な機能のうち-
左クリック初回に爆弾をランダムに 10 個配置
-
左クリック毎にクリックしたセルに対応する 2 次元配列の要素を 0 なら 1 に変更
-
右クリック毎にクリックしたセルに対応する 2 次元配列の要素を 0 → 2 → 3 → 0 と変更(旗 → はてな → 取り消し)
を追加します
それぞれの機能を実装したらboard.map(…
のboard
をそれぞれの配列に書き換えて動作確認をするとよいです例
index.tsximport { 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;
-
-
ゲームとしての機能をつける 2
今回必要な機能のうち- すべてのセルを確認してクリックされた履歴があったら周囲 8 セルのボムの開かれた数を
board
に代入 - 周囲 8 セルのボムの数が 0 だったなら周囲 8 セルに対して上記の動作を行う
を追加します
まずboard
を以下のように書き換えてくださいconst board = [...Array(9)].map(() => [...Array(9)].map(() => -1));
また、
return
の直前にindex.tsxuserInputs.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.tsxconst 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.tsxconst 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); } }) ); } };
- すべてのセルを確認してクリックされた履歴があったら周囲 8 セルのボムの開かれた数を
-
ゲームとしての機能をつける 3
今回必要な機能のうち- 爆弾をクリックした履歴があったら(負けていたら)爆弾を表示する
- 爆弾をクリックした履歴があったら(負けていたら)クリックできない
を追加します
負けたかはbombMap
とuserInputs
を 1 セルごとに比較して、bombMap
が 1 かつuserInputs
が 1 の場所を探せばよいです例
index.tsxconst isFailed = () => bombMap.flat().filter((bomb, index) => bomb === 1 && userInputs.flat()[index] === 1).length > 0;
この関数が
false
の時にはクリックしたときに何もせずにreturn
すればよいです例
index.tsxconst clickL = (x: number, y: number) => { if (isFailed()) return; /* 略 */ };
そして、
board
を作成するコードの直後に、board を上書きしてbomb
を表示すればよいです例
index.tsxif (isFailed()) { bombMap.forEach((row, j) => row.forEach((userInput, i) => { if (userInput === 1) { board[j][i] = 11; } }) ); }
これでひと先ず遊べる程度の機能ができたかと思います
さらに機能をつける
- 旗/はてなを表示する
- 数字をクリックしたときに周囲の旗の数と数字が同じなら空いていないセルをクリックしたことにする
- 盤面の周りに縁をつけ、顔文字でプレイ状況を表示する
- タイマー/残り爆弾数を表示する
- 中級や上級のサイズを作る
- 画面の大きさを変えるとそれにあった大きさに自動的に変わる
Discussion