67
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【javaScript】エモいマインスイーパ から学ぶ再帰関数

Last updated at Posted at 2021-05-09

プログラミングの学習のために同僚たちとともにマインスイーパを作る腕試しをしたところ、とてもエモい作品が上がってきたのでご紹介したいと思い、記事にしました。

clear.gif

以下のページで実際に動かすことができます。
MINESWEEPER

本記事の内容はエモいマインスイーパの紹介を通して、

  • 再帰関数の使い方
  • 背景色をじわじわ変えるCSSの方法

を解説します。

デザインコンセプト

エモいマインスイーパは、初期画面では真っ黒な背景です。
ゲームを進めていくごとに背景色が徐々に変化していきます。
黒→青→水色… と変化し、成功すると最後は美しいグラデーションでfinishします。
これは夜明けをイメージしています。マインスイーパは地雷除去のゲームなので、平和な世界にしたいというエモい希望が込められています。
背景色が変化する実装は後ほど解説します。

start play1 play2 clear game over

アルゴリズム、ロジックのわかりやすい解説

セルの生成と状態管理

ゲームボードはtableタグで、ひとつひとつのセルはtdタグになっています。
セルは、以下のような状態を持つ必要があります。

  • 爆弾があるか
  • 周辺の爆弾の数
  • 旗が立っているか
  • 開いているか、閉じているか

エモいマインスイーパはこれらの状態をDOMに保持して管理しています。つまり、tdタグのclass属性であったり、innerTextに数字を持っていたりします。

爆弾の生成

爆弾の場所はゲームごとにランダムに配置します。
以下の状態でゲームを開始することを例にします。
width: 10
height: 10
bomb: 10

セルは全部で 10 * 10 = 100個あり、どこか10箇所に爆弾を配置する必要があります。
セルへのアクセスは、以下のようにjQueryでtd配列のn番目、という方法で行いますので、0から99までの数字の中で重複のない10個を生成すれば良さそうです。

// n番目のセルを取得するコード
$('#gameBody td').eq(n);

⬇乱数を生成するには以下のように書きます。この関数は0以上1未満で均一な分布の乱数を返してくれます。

Math.random() // 結果:0.44185123205727495

⬇0から99までの数字にしたいため、全セルの数を掛けて、小数点以下は切り捨てます。

Math.floor(Math.random() * (width * height)) // 44

⬇この方法でループしながら乱数を生成し、乱数が重複しなければ、爆弾を配置します。

let bombIndexes = [];
while (bombIndexes.length < bomb) {
    let n = Math.floor(Math.random() * (width * height));
    if ($.inArray(n, bombIndexes) === -1) {
        $('#gameBody td').eq(n).addClass('is-bomb');
        bombIndexes.push(n);
    }
}

しかしマインスイーパには 最初にクリックしたセルには爆弾を配置しないようにして、プレーヤーが不利にならないようにする というルールがあります。

このままだとエモいマインスイーパは一手目から爆死する可能性があります。それもエモいですが向上心のある作者は以下のように改善しました。

let bombIndexes = [];

// 一手目のセルのindex($elには最初にクリックしたセルが格納されている)
let index = $('#gameBody td').index($el);

// 一手目のセルとその周辺8つのセルを excludeCells に格納
let excludeCells = getAroundCell(index);
excludeCells.push(index)

while (bombIndexes.length < bomb) {
    let n = Math.floor(Math.random() * (width * height))

    if ($.inArray(n, bombIndexes) === -1 && $.inArray(n, excludeCells) === -1) {
        bombIndexes.push(n);
    }
}

一手目のセルとその周辺8つのセルを除外しながら爆弾を生成しました。この処理を最初にクリックしたときに実施しています。

周辺の爆弾の数の求め方

周辺の爆弾の数を求めるには、周辺のセルにアクセスする必要があります。
エモいマインスイーパはjQueryによるDOM操作で周辺のセルにアクセスします。

// n番目のセルを取得するコード
$('#gameBody td').eq(n);

tdを配列で全部取得して、配列のn番目を取得しています。
周辺のセルは、上、下、左、右、左上、右上、左下、右下の8箇所あります。それぞれのnを求める必要があります。
起点となるセルのインデックス(i)とゲームボードの列数(w)のふたつを使いnを算出します。

// 左上
let tl = i - w - 1;
// 上
let t = i - w;
// 右上
let tr = i - w + 1;
// 左
let l = i - 1;
// 右
let r = i + 1;
// 左下
let bl = i + w - 1;
// 下
let b = i + w;
// 右下
let br = i + w + 1;

ただし、角や端のセルは周辺にセルが8箇所あるとは限りません。角かどうか、端かどうかを判定しながら存在する周辺セルのnのみを取得します。

let array = [tl, t, tr, l, r, bl, b, br];
if (i === 0) {
    // ボードの左上の角
    array = [r, b, br]
} else if (i === w - 1) {
    // ボードの右上の角
    array = [l, bl, b]
} else if (i === w * (h - 1)) {
    // ボードの左下の角
    array = [t, tr, r]
} else if (i === w * h - 1) {
    // ボードの右下の角
    array = [tl, t, l]
} else if (i < w - 1) {
    // ボードの1行目(上端)
    array = [l, r, bl, b, br]
} else if (i > w * (h - 1)) {
    // ボードの最優業(下端)
    array = [tl, t, tr, l, r]
} else if (i % w === 0) {
    // ボードの1列目(左端)
    array = [t, tr, r, b, br]
} else if (i % w === w - 1) {
    // ボードの最終列(右端)
    array = [tl, t, l, bl, b]
}

return array;

これで周辺のセルのnをすべて取得ことができました。
あとは周辺のセルにアクセスして、爆弾があれば集計変数に+1し、起点となるセルに数字を設定します。

爆弾がないセルを一気に開く

開いたセルに爆弾がない場合、上、下、左、右、左上、右上、左下、右下の8方向に数字or旗がないかを判定しならがセルを開いていき、数字or旗が見つかったらセルを開くのをやめる、という実装をします。
このようなロジックには再帰関数を利用することが多いと思います。

再帰関数とは

再帰関数とは自分自身を呼び出す関数のことです。
以下は再帰関数を利用する場合の例です。

function openCell(targetCell) {
	if (targetCellに数字or旗がある場合) {
		return;
	}

	targetCell.open();

	openCell(targetCellの上のcell);
	openCell(targetCellの下のcell);
	openCell(targetCellの左のcell);
	openCell(targetCellの右のcell);
	openCell(targetCellの左上のcell);
	openCell(targetCellの右上のcell);
	openCell(targetCellの左下のcell);
	openCell(targetCellの右下のcell);
}

openCell関数からopenCell関数が8回呼び出されています。こうすることで8方向に、数字 or 旗が見つかるまでセルを開き続けることができます。
もちろん再帰関数ではなく、ループを利用しても可能ですが、再帰関数の方がコードの重複が少なくシンプルに書くことができます。

再帰関数を利用する際には注意点があります。
必ず再帰呼び出しを抜けるためのreturnを書くこと

当たり前ですが再帰呼び出しはreturnしないと永遠に(※)呼び出し続けてしまいます。必ずreturnを書くことと、returnに入る条件をミスしないように気をつけましょう。

※実際には永遠にということはなく、メモリ内のスタックという領域を使い果たしてエラーになります。

相互再帰

エモいマインスイーパは再帰関数の一種である相互再帰が利用されています。相互再帰とは一つの関数内での循環した呼び出しではなく、複数の関数を組み合わせた状態での循環呼び出しです。
こちらが相互再帰を利用したコードです。

function clearCell($el) {
    if ($el.hasClass('is-flag')){
        // 旗が見つかれば終了
       return;
    }

    // セルを開ける
    $el.addClass('is-clear');

    if ($el.hasClass('is-bomb')) {
        // 爆弾が見つかればゲームオーバー
        return gameOver();
    }

    // これは後述
    setProgress();

    if ($el.hasClass('is-blank')) {
        // 数字も爆弾もないセルだったら、周辺のセルも調査
        clearBlanks($el);
    }
}

function clearBlanks($el) {
    let index = $('#gameBody td').index($el);
    // 周辺の存在するセルのindex配列
    let array = getAroundCell(index);

    $.each(array, function(){
        let $cell = $('#gameBody td').eq(this);
        if(!$cell.hasClass('is-clear')){
            // まだ開いていないセルの場合は開く
            clearCell($cell);
        }
    });
}

背景色が徐々に変わるCSS

bodyに付与しているクラスを付け替えることで背景色を変えています。
セルを開くたび、以下の関数が呼び出されます。

function setProgress() {
    clearCount += 1

    // 開いたセルの数 ÷ 爆弾のない全セルの数 × 100
    let progress = clearCount / (width * height - bomb) * 100;

    if (100 <= progress) {
        $('body').removeClass().addClass('is-morning');
        gameClear();
    } else if (90 <= progress) {
        $('body').removeClass().addClass('is-earlyMorning');
    } else if (80 <= progress) {
        $('body').removeClass().addClass('is-sunrise');
    } else if (70 <= progress) {
        $('body').removeClass().addClass('is-dayBreak');
    } else if (60 <= progress) {
        $('body').removeClass().addClass('is-lateNight');
    } else if (50 <= progress) {
        $('body').removeClass().addClass('is-midNight');
    } else if (40 <= progress) {
        $('body').removeClass().addClass('is-deepNight');
    } else if (30 <= progress) {
        $('body').removeClass().addClass('is-silentNight');
    } else if (20 <= progress) {
        $('body').addClass('is-night');
    } else {
        $('body').removeClass();
    }
}

進捗に応じてクラスを付け替えています。
色はCSSで以下のように設定されています。真夜中から朝にかけて、徐々に明るくなり、朝はlinear-gradientを利用して3色のグラデーション背景になっています。

body {
  width: 100%;
  min-height: 100vh;
  background: black;
}
body.is-night {
  background: #141418;
}
body.is-silentNight {
  background: #181620;
}
body.is-deepNight {
  background: #181621;
}
body.is-midNight {
  background: #12162b;
}
body.is-lateNight {
  background: #0d2246;
}
body.is-dayBreak {
  background: #0c3864;
}
body.is-sunrise {
  background: #0c5a8c;
}
body.is-earlyMorning {
  background: #498eb6;
}
body.is-morning {
  background: linear-gradient(180deg, #498eb6 0%, #b7dfc2 75%, #ffe1b2 100%);
}

これだけでは、クラスが付与された瞬間、急激に色が変わってしまうため、じわっと変化をするアニメーションを設定する必要があります。これもCSSで可能です。

* {
  transition: background 1s, color .6s, opacity .6s;
}

上記のCSSは、すべての要素に対して、

  • 背景(background)の変化に1秒をかける
  • 文字色(color)の変化に0.6秒をかける
  • 透明度(opacity)の変化に0.6秒をかける
    という指定になっています。
67
53
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
67
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?