2017 04 04

マインスイーパーを作るの3

先日、台所用の洗剤が残り僅かになっていたので買ってきた。 特に拘りがある訳じゃないので、値段の割に量が多そうなものを買ってきた。 それがキュキュット詰替用。 約2回分だそうだ。

洗剤の1

これまで使っていた洗剤はキュキュットじゃないのだが、ボトルを並べてみると、謳い文句の通り、キュキュット詰替用は前のやつのちょうど2回分ぐらいありそうだった。

台所用洗剤ってどれもだいたい同じぐらいの量なんだろうか。 とすると、このサイズは、平均的な女の人の手の大きさとか体力を基準にしてるのだろうか。

詰め替える前にかなり減っているのは、父がもう使ってしまったから。 詰め替えるのではなく、新しい方をそのまま使おうとして大量に出てしまったのだろう。 そんな事態の再発防止のためにも早々に詰め替えておこうと思ったのだが、眺めているうちに、この2つのボトルのキャップが交換できそうなことに気がついた。

洗剤の2

キャップを交換してみた。

螺子的にはぴったりだった。 口の角度が怪しくなったが、右利きにはむしろ使いやすい角度になった気がする。 小さな空のボトルに2回詰め替えるはずだったのが、キャップを1回変えるだけになった。 しかも今後ずっとだ。

割とどうでもいいことだけど、ちょっとした感動があったよ。

minesweeper

昨日の続き。 今日で完成させるつもりで、今朝からずっとこればっかり考えていた。 その結果。

minesweeper.js

var C = { NUM : { ROWS : 16 + 2 // ダミー + 有効16行 + ダミー , COLS : 30 + 2 // ダミー + 有効30列 + ダミー , MINES : 99 } , STS : { HIDE : 0 , OPEN : 1 , FLAG : 2 } , CHR : { HIDE : "" , MINE : "●" , SAFE : "" , FLAG : "▽" } , CSS : { HIDE : "hide" , OPEN : "open" , FLAG : "hide" } , RTN : { DEAD : -1 , ALIVE : 0 , ACOMP : 1 } }; function VMap() { var vm = ( function () { var tbl = [], r, c; for ( r = 0; r < C.NUM.ROWS; r++ ) { tbl[ r ] = []; for ( c = 0; c < C.NUM.COLS; c++ ) { tbl[ r ][ c ] = { row : r // 自分の行位置 , col : c // 自分の列位置 , mine : 0 // 自分の爆弾の数 0 or 1 , near : 0 // 周囲の爆弾の数 0 .. 8 , sts : ( // 状態 HIDE or FLAG or OPEN r === 0 || r === C.NUM.ROWS - 1 || c === 0 || c === C.NUM.COLS - 1 ? C.STS.OPEN // 最外周はダミーで開いた状態 : C.STS.HIDE // 内側は隠した状態 ) }; } } return tbl; } )(); this.init = function () { var r, c; for ( r = 1; r < C.NUM.ROWS - 1; r++ ) { for( c = 1; c < C.NUM.COLS - 1; c++ ) { vm[ r ][ c ].mine = 0; vm[ r ][ c ].near = 0; vm[ r ][ c ].sts = C.STS.HIDE; } } return _show(); }; this.first = function ( r, c ) { var cells = _getPlacableCells( r, c ) , mines = C.NUM.MINES , mc; while ( mines ) { // 爆弾をセットする候補のセルからランダムに選んで爆弾をセット。 mc = ( cells.splice( Math.floor( Math.random() * cells.length ), 1 ) )[ 0 ]; vm[ mc.row ][ mc.col ].mine = 1; // 隣接セルの「周囲の爆弾の数」を1増やす。 vm[ mc.row - 1 ][ mc.col - 1 ].near++; vm[ mc.row - 1 ][ mc.col ].near++; vm[ mc.row - 1 ][ mc.col + 1 ].near++; vm[ mc.row ][ mc.col - 1 ].near++; vm[ mc.row ][ mc.col + 1 ].near++; vm[ mc.row + 1 ][ mc.col - 1 ].near++; vm[ mc.row + 1 ][ mc.col ].near++; vm[ mc.row + 1 ][ mc.col + 1 ].near++; mines--; } _openCell( r, c ); return _show(); }; function _getPlacableCells( r0, c0 ) { var cells = [], r; for ( r = 1; r < C.NUM.ROWS - 1; r++ ) { cells = cells.concat( vm[ r ].slice( 1, -1 ) ); } cells.splice( ( r0 - 1 ) * ( C.NUM.COLS - 2 ) + ( c0 - 1 ), 1 ); return cells; } this.open = function ( r, c ) { if ( vm[ r ][ c ].sts !== C.STS.HIDE ) { return C.RTN.ALIVE; } if ( vm[ r ][ c ].mine ) { _openAll(); } else { _openCell( r, c ); } return _show(); }; this.flag = function ( r, c ) { if ( vm[ r ][ c ].sts === C.STS.OPEN ) { return C.RTN.ALIVE; } vm[ r ][ c ].sts = vm[ r ][ c ].sts === C.STS.FLAG ? C.STS.HIDE : C.STS.FLAG; return _show(); }; function _openCell( r, c ) { if ( vm[ r ][ c ].sts === C.STS.HIDE ) { vm[ r ][ c ].sts = C.STS.OPEN; if ( vm[ r ][ c ].near === 0 ) { _openCell( r - 1, c - 1 ); _openCell( r - 1, c ); _openCell( r - 1, c + 1 ); _openCell( r , c - 1 ); _openCell( r , c + 1 ); _openCell( r + 1, c - 1 ); _openCell( r + 1, c ); _openCell( r + 1, c + 1 ); } } } function _openAll() { var r, c; for ( r = 1; r < C.NUM.ROWS - 1; r++ ) { for ( c = 1; c < C.NUM.COLS - 1; c++ ) { vm[ r ][ c ].sts = C.STS.OPEN; } } } function _show() { var trs = document.getElementById( "board" ).getElementsByTagName( "tr" ) , tds , r, c , sums = { flag : 0, rest : 0 }; for ( r = 1; r < C.NUM.ROWS - 1; r++ ) { tds = trs[ r ].getElementsByTagName( "td" ); for ( c = 1; c < C.NUM.COLS - 1; c++ ) { switch ( vm[ r ][ c ].sts ) { case C.STS.FLAG: tds[ c ].innerHTML = C.CHR.FLAG; sums.flag++; break; case C.STS.OPEN: tds[ c ].innerHTML = vm[ r ][ c ].mine ? C.CHR.MINE : vm[ r ][ c ].near ? vm[ r ][ c ].near : C.CHR.SAFE; tds[ c ].className = C.CSS.OPEN; break; default: tds[ c ].innerHTML = C.CHR.HIDE; tds[ c ].className = C.CSS.HIDE; sums.rest++; } } } document.getElementById( "countFlag" ).innerHTML = sums.flag; document.getElementById( "countRest" ).innerHTML = sums.rest; switch ( sums.flag + sums.rest ) { case 0: return C.RTN.DEAD; case C.NUM.MINES: return C.RTN.ACOMP; default: return C.RTN.ALIVE; } } } function Ctrl( vm ) { var self = this , board = document.getElementById( "board" ) , rdoOpen = document.getElementById( "rdoOpen" ) , rdoFlag = document.getElementById( "rdoFlag" ) , divMsg = document.getElementById( "msg" ) , startSec; this.init = function () { vm.init(); board.onclick = _first; }; function _first( e ) { startSec = _getSec( "floor" ); _base( e, vm.first, _prepareSecond ); } function _open( e ) { _base( e, vm.open ); } function _flag( e ) { _base( e, vm.flag ); } function _base( e, fMap, fNxt ) { if ( e.target.tagName.toUpperCase() === "TD" ) { switch ( fMap( _getInt( e, "row" ), _getInt( e, "col" ) ) ) { case C.RTN.DEAD: _gameOver( "爆死" ); break; case C.RTN.ACOMP: _gameOver( "クリア" ); break; default: if ( fNxt ) { fNxt(); } } } } function _gameOver( msg ) { _showMessage( msg ); board.onclick = null; rdoOpen.onchange = null; rdoFlag.onchange = null; rdoOpen.disabled = true; rdoFlag.disabled = true; rdoOpen.checked = true; } function _prepareSecond() { board.onclick = _open; rdoOpen.onchange = function () { board.onclick = _open }; rdoFlag.onchange = function () { board.onclick = _flag }; rdoOpen.disabled = false; rdoFlag.disabled = false; } function _getInt( e, key ) { return parseInt( e.target.dataset[ key ], 10 ); } function _getSec( f ) { return Math[ f ]( ( new Date() ).getTime() / 1000 ); } function _showMessage( msg ) { var sec = _getSec( "ceil" ) - startSec; setTimeout( function () { divMsg.onclick = function () { divMsg.style.display = "none"; divMsg.onclick = null; self.init(); }; divMsg.innerHTML = sec + " 秒で" + msg; divMsg.style.display = "block"; }, 1000 ); } } window.onload = function () { ( function () { var s = "<table id='board'><tbody>" , r, c; for ( r = 0; r < C.NUM.ROWS; r++ ) { s = s + "<tr>"; for ( c = 0; c < C.NUM.COLS; c++ ) { s = s + "<td data-row='_R_' data-col='_C_'></td>".replace( /_R_/, r ).replace( /_C_/, c ); } s = s + "</tr>" } s = s + "</tbody></table>"; document.getElementById( "body" ).innerHTML = s; } )(); ( function () { var board = document.getElementById( "board" ) , fstActCell = board.getElementsByTagName( "tr" )[ 1 ].getElementsByTagName( "td" )[ 1 ] , cellHeight = parseInt( ( window.getComputedStyle( fstActCell, "" ) ).height, 10 ) , cellSpacing = parseInt( ( window.getComputedStyle( board, "" ) ).borderSpacing, 10 ); board.style.minWidth = ( ( cellHeight + cellSpacing ) * C.NUM.COLS + cellSpacing ) + "px"; board.onselectstart = function ( e ) { e.preventDefault(); e.stopPropagation(); }; } )(); ( new Ctrl( new VMap() ) ).init(); };

主要なオブジェクトとして、盤面に対応する情報を持つ仮想マップ(VMap)と、イベントの取り回しを行うコントローラー(Ctrl)がある。 あと、オブジェクトという使い方ではないが、ロード完了時の初期処理も。

以下、それぞれがやっていること。

ロード完了時の初期処理

ここでやっていることは次の三つ。

  1. 盤面の実体となるテーブルを作る。

    サイズ固定なので直接HTML中に書いてもいいのだが、せっかく行と列を定数として定義しているのだから、それらを使うようにした。

    どのセルがクリックされたかを判定するのを楽にするために、行位置と列位置をHTML5のuser-dataとして設定している。

  2. 盤面の最小幅を設定する。

    これはiPhoneなどの小さい画面で、テーブルが表示しきれない場合にセルが潰れないようにするため。

    セルの幅は何もしないと画面の表示幅によって変わるが、高さはセル内で折り返しが発生しない限り変化しない。 そして今回は折り返しは発生しない。 なのでレンダリング時に決定されたセルの高さ(ピクセル単位)を取得し、これと同じだけセルの幅が確保できるようにテーブルの最小幅を設定している。

    ついでに、誤操作で選択開始となってしまうことを防ぐために、選択開始のイベントをキャンセルしておく。

  3. コントローラーを生成して初期化処理を実行する。

    コントローラーについては後述。

コントローラー(Ctrl)

ゲームの進行を管理する。

具体的には、ゲームの開始/進行/終了のためのイベントリスナーの設定や解除と実行。

イベントリスナーを設定するのは、操作モードを指定するためのラジオボタンと、盤面のテーブル。 盤面の個々のセルではなくテーブルにしたのは、その方が管理が楽そうだったから。

イベント処理をDOM2ではなくDOM0でやっているのも同じ理由。 イベント処理を付けたり外したりするのにDOM0の方が簡単だったから。

以下、もうちょっと具体的な説明。

init

ゲーム開始。

仮想マップを初期化し、盤面に初回クリック用のイベントリスナーを設定する。

_first

初回クリックのイベント処理。

経過時間の起点となる時刻を取得した後、下請けに、仮想マップへの初回クリック通知と、2回目以降のクリックの準備をさせる。

_open

2回目以降のクリックの開モードのイベント処理。

下請けに、仮想マップへの開モードクリック通知をさせる。

_flag

2回目以降のクリックの旗モードのイベント処理。

下請けに、仮想マップへの旗モードクリック通知をさせる。

_base

_first, _open, _flag の各処理の共通下請け。 上位含めて割と酷いネーミングだが気にしない。 やってることは以下の通り。

  1. クリックされたセルの位置を取得し、上位から渡された仮想マップへの通知処理を実行する。
  2. 通知の結果を受けて、ゲームの継続か終了かを判定する。
  3. ゲーム終了の場合、終了処理を実行する。
  4. ゲーム継続の場合、継続用の処理が指定されていれば実行する。

仮想マップが通知を受けて返す結果は、爆死か完遂かその他。

_gameOver

終了時の処理。

終了メッセージの表示設定、イベントリスナーの解除、モード選択のラジオボタンの初期化を行う。

_showMessage

いかにもメッセージを表示するだけのような名前だが、もうちょっと働いている。

  1. 経過時間の終点となる時刻を取得して経過時間(秒)を算出し、成功/失敗のメッセージと併せて表示する。
  2. 表示しているメッセージのクリックで、メッセージの消去とゲームの初期化処理を実行するように、イベントリスナーを設定する。

最初は本当にメッセージを表示するだけだった。 しかし一連の終了処理の中でメッセージ表示処理を呼び出すと、記述の順番は盤面の表示更新が先でメッセージ表示が後なのに、実行される画面表示は何故かメッセージが先で盤面の表示更新が後になるんだよな。 なんでそうなるのかは後で調べるとして、とりあえずの対策としてメッセージ表示を非同期にした。

非同期にしたことで結果表示に若干の間が空くことになったのだが、意外にこれが終了時の演出としては良い感じだった。 で、最初は50msぐらいだったのが、いろいろ試しているうちにだんだん伸びて、最終的には1秒になった。

この1秒の間に次のゲームが始められないように、メッセージを閉じる処理の中で次のゲームを開始するようにした結果がこれ。

仮想マップ(VMap)

各セルの情報を持つ。 コントローラーからの通知を受けてこの情報を更新し、結果を盤面に反映する。

ここで情報とは以下の各値。

最初の3つはゲーム開始時に決まり、その後は変化しない。 最後の一つはゲームの進行によって変化する。

以下、もうちょっと具体的な説明。

オブジェクト生成時の処理

仮想マップを構築する。

仮想マップは、盤面のtableのtrとtdに対応する2次元配列とし、その個々の要素に上記情報を持たせている。

init

ゲーム開始のための仮想マップ初期化(閉じる,爆弾無し)と、初期化の結果の画面反映。

first

ゲーム開始の初回クリックの処理。

最初にクリックしたところには爆弾を置かない。 逆に言うと、最初のクリックまで爆弾の配置を決定できない。 なのでここで爆弾を配置する。

  1. クリックされたセルを除いて、仮想マップに爆弾を配置する。 同時に周囲の爆弾の数も設定する。
  2. クリックされたセルを開く処理を呼び出す。
  3. 仮想マップを画面表示に反映し、結果を返す。
open

クリックされたセルを開く処理。

クリックされたセルの状態で処理を分ける。

  1. 閉じた状態でない場合、何もしない。
  2. 閉じた状態でそこに爆弾が有る場合、全セルを開き、画面表示に反映し、結果を返す。
  3. 閉じた状態でそこに爆弾が無い場合、そのセルから連鎖的に開けるセルを開き、画面表示に反映し、結果を返す。
flag

クリックされたセルに旗を立てるか抜くかする処理。

これまたクリックされたセルの状態で処理を分ける。

  1. 既に開いている場合、何もしない。
  2. 閉じている場合、旗が立ってなければ旗を立て、旗が立っていれば旗を抜き、画面表示に反映し、結果を返す。
_openCell

セルを開く処理の下請け。 むしろ本体か。 指定されたセルから連鎖的に開けるセルを開く。

  1. 指定されたセルが閉じた状態でない場合、何もしないで終了。
  2. 指定されたセルを開く。
  3. 周囲の爆弾の数が0の場合、隣接する8つのセルそれぞれに対してこの _openCell を再帰実行する。
_show

仮想マップを画面表示に反映する。 具体的には以下の通り。

  1. 仮想マップの持つ状態に応じて、画面表示の要素であるテーブルのセルにスタイル定義やセル内の文字を設定する。
  2. 各セルへの設定のついでに旗の数や残りの数を集計しておき、その結果も画面表示する。
  3. 集計値から爆死か完遂か継続かを判定し、判定結果を返す。

画面表示は、旗を立てるとか抜くとかは一つのセルしか更新する必要が無いので、必要な部分のみ更新するのが効率的。 しかし全部更新しても大した量じゃ無いので、処理の見通しがいいことを優先して、何か一つでも更新があった場合は全更新している。 仮想マップという形にしているのも、この方が処理の見通しが良さそうだと思ったから。

そしてテスト

遠い記憶では、最初にクリックした時にそれなりの面積が開くことが多かったように思う。 しかし俺のこれは、セルが一つしか開かないことの方が圧倒的に多い。 初回クリックに対して 「そのセルに爆弾が無い」 という以上の配慮が有るのかもしれないな。 例えば、そのセルを中心とする周囲8セルにも爆弾が無いとか。

そうするなら、VMap 内の _getPlacableCells で指定のセルだけ除外しているのを、条件に合わせて除外対象を増やしてやればいい。 しかしそこまで配慮する意味が見出せないのでスルー。

とまあプログラムは特に問題無く動いているのだが。

クリアできない

爆弾の数を10個ぐらいにして、最初のクリックでいきなりクリアとか、何度かクリックした末のクリアとか、ちゃんとクリア時の動作確認はできてはいる。 で、爆弾の数を本来の99個にしてテストプレイをするのだが、これが悉く途中で爆死。 爆弾の数が増えると 「ここかここのどちらかに爆弾があるはずなのだが、推論では決められないので運任せ」 というパターンが発生して、そこで必ずハズレを選んでしまうのだ。

つまり悪いのはプログラムじゃなくて俺の運。

一応の完成後に1時間ぐらいテストプレイをしているのだが、その間に一度もクリアなし。 自分で作ったものではあるのだが、何秒で爆死とか出てくるとかなりイラっとするな。

まあ、ちょっとイラッとさせるつもりで作っているので、これはこれでテストになってるんだけどさ。