1時間+αでマインスイーパーを作ってみた
この記事はjig.jp Advent Calendar 2023の12月12日(火)の記事です。
はじめに
こんにちは。私は、 jig.jp でウェブフロントエンド開発を行っている、Uと申します。
2023年3月23日、jig.jp のエンジニアブログに 『マインスイーパーを作って腕試し』 という記事が投稿されました。その記事によれば『マインスイーパーを何分で作れるかでプログラミング力がわかるらしい』ので、私も挑戦してみました。
要件
まず、マインスイーパーを作成するにあたって、レギュレーションを定めます。今回は、先述したエンジニアブログ記事や実際のマインスイーパーを参考に、以下の内容に定めました。
縦Hマス x 横Wマスからなる盤面に、全部でM個の爆弾がランダムに配置される
H、W、Mの各値は任意に(コード内で)設定可能な状態とする
爆弾のないマスを選択すると、選択マスの周囲8マスに存在する爆弾の数が表示される。この数が0の場合、その周囲8マスが自動的に開かれる
ゲームの最初に選択されたマス、及びその周囲8マスに地雷は設置されない
目印として設置できる旗は実装しなくともよい
実装
今回私は、一番使い慣れているJavaScriptフレームワーク「Angular」を用いて実装を行うことにしました。AngularはGoogle社が開発したオープンソースのJavaScriptフレームワークで、双方向データバインディングという機能によりWebアプリケーション開発に必要なソースコードの記述を大幅に減らすことができる、等といったメリットがあります。
これからどのような実装を行ったか紹介していきますが、開発速度を重視して作ったコードであるため、少々読みづらい部分や効率の悪い実装を行っている箇所がありますが、どうかご了承ください。
盤面データを生成し、それを表示させる
マインスイーパーで各マスが保持すべき要素は、「そこに地雷があるか」「そのマスが開かれているか(実装によっては「旗が立てられているか」も含む)」の2つです。「そのマスの周囲8マスに地雷がいくつあるか」という情報も、都度計算を行わせるのは手間がかかる為、これもマス情報に持たせることにしました。
interface Cell {
index: number
isMine: boolean
isOpened: boolean
arounds: number
seed: number
}
今回は、フィールドを長さ [ H x W ] の一次元配列で表現しました。これを高さHマス、幅Wマスの盤面に変換して表示していきます。
<div class="row" *ngFor="let y of getArray(height)">
<button class="cell" *ngFor="let x of getArray(width)"
[class.opened]="field[y * height + x].isOpened"
[disabled]="field[y * height + x].isOpened"
(click)="onClickCell(x, y)">
@if (field[y * height + x].isOpened) {
@if (field[y * height + x].isMine) {
💣
} @else if (field[y * height + x].arounds > 0) {
{{ field[y * height + x].arounds }}
}
}
</button>
</div>
@if は、Angular17で新たに使えるようになったフロー制御構文です。以前は *ngIf を用いて、各要素の表示・非表示を切り替えていましたが、 else-if のような追加の条件分岐を行う場合、複雑な条件式を用いる必要がありました。しかし Angular17 の @if / @else if を使うことにより、簡潔で分かりやすい実装を行うことができるようになりました。
自動オープン機能を実装する
クリックされたマスの arounds が 0 の時、その周囲8マスを自動でオープンする処理を実装していきます。自動でオープンした箇所の arounds が 0 ならば、更にその周囲もオープンするという仕様の都合上、再帰関数を使って実装したくなりますが、今回は敢えてそれを外して while 文での実装を試みます。
実際のコードを以下に記述します。
private openCells (x: number, y: number): void {
let depth: number = 0
let nextOpenCellsIdList: Position[] = [{ x, y }]
let openCellsIdList: Position[] = []
while (nextOpenCellsIdList.length > 0) {
openCellsIdList = nextOpenCellsIdList
nextOpenCellsIdList = []
while (openCellsIdList.length > 0) {
// 計算すべき範囲のIDを取得
const cpos: Position = openCellsIdList.shift() as Position
const idx: number = this.positionToIndex(cpos.x, cpos.y)
// 盤面領域外は計算しない
if (cpos.x < 0 || cpos.x >= this.width) continue
if (cpos.y < 0 || cpos.y >= this.height) continue
// 既に計算済みであれば再計算しない
if (this.field[idx].isOpened) continue
// 旗が立てられている個所は計算しない
if (this.field[idx].mark === 'flag') continue
// 実際に開く処理
this.field[idx].isOpened = true
this.field[idx].mark = 'open'
this.openedCnt++
// ゲームクリア判定 クリア判定が行われたらそこで終了
if (this.openedCnt + this.bombs === this.width * this.height) {
alert('ゲームクリア!')
return
}
if (this.field[idx].isMine) {
alert('ゲームオーバー!')
return
} else {
if (this.field[idx].around === 0) {
// 八近傍を探索リストに追加
nextOpenCellsIdList.push({ x: cpos.x - 1, y: cpos.y - 1 })
nextOpenCellsIdList.push({ x: cpos.x, y: cpos.y - 1 })
nextOpenCellsIdList.push({ x: cpos.x + 1, y: cpos.y - 1 })
nextOpenCellsIdList.push({ x: cpos.x - 1, y: cpos.y })
nextOpenCellsIdList.push({ x: cpos.x + 1, y: cpos.y })
nextOpenCellsIdList.push({ x: cpos.x - 1, y: cpos.y + 1 })
nextOpenCellsIdList.push({ x: cpos.x, y: cpos.y + 1 })
nextOpenCellsIdList.push({ x: cpos.x + 1, y: cpos.y + 1 })
}
}
}
depth ++
}
}
処理内容としては、
クリックされた箇所を探索候補リストに加える
探索候補リストが1件以上あれば、探索候補リストの内容を探索リストに加える
探索リストの先頭から、以下の処理を行う
フィールド外や探索済みの箇所であれば処理しない
指定箇所のマスを開く
そのマスが爆弾であれば、ゲームオーバー処理を行って終了
そのマスの arounds が 0 であれば、その周囲8マスを探索候補リストに加える
探索リストに登録されたマスを全て探索し終えたら、2に戻る
を行っています。
完成
以上を実装したものが、こちらです。クリックした点を中心に、爆弾が周囲に無いマスが自動で開かれていくのが分かります。(添付GIFでは探索順を分かりやすくするため、depthの浅い順にアニメーション表示をさせています)
終わりに
今回は Angular を使ってマインスイーパーを作りました。私の実装で、正常に遊べるようになるまでにかかった時間は1時間12分でした。フレームワークを用いることにより、大幅に開発の時間を短縮できました。 Angular17 には @if だけではなく @for のフロー制御構文もあるのですが、それも使ってみようと試みたところうまく動作しなかったので、今後の課題としていきたいです。
実装のアルゴリズムは人によってそれぞれです。他の人の実装と自分の実装を比較して、似通ったところを探したり、逆に自分では考えつかなかった実装方法を見つけたりして、話のタネにしてみるのも面白いと思います。皆さんも、是非お試し下さい。