先日、台所用の洗剤が残り僅かになっていたので買ってきた。 特に拘りがある訳じゃないので、値段の割に量が多そうなものを買ってきた。 それがキュキュット詰替用。 約2回分だそうだ。
これまで使っていた洗剤はキュキュットじゃないのだが、ボトルを並べてみると、謳い文句の通り、キュキュット詰替用は前のやつのちょうど2回分ぐらいありそうだった。
台所用洗剤ってどれもだいたい同じぐらいの量なんだろうか。 とすると、このサイズは、平均的な女の人の手の大きさとか体力を基準にしてるのだろうか。
詰め替える前にかなり減っているのは、父がもう使ってしまったから。 詰め替えるのではなく、新しい方をそのまま使おうとして大量に出てしまったのだろう。 そんな事態の再発防止のためにも早々に詰め替えておこうと思ったのだが、眺めているうちに、この2つのボトルのキャップが交換できそうなことに気がついた。
キャップを交換してみた。
螺子的にはぴったりだった。 口の角度が怪しくなったが、右利きにはむしろ使いやすい角度になった気がする。 小さな空のボトルに2回詰め替えるはずだったのが、キャップを1回変えるだけになった。 しかも今後ずっとだ。
割とどうでもいいことだけど、ちょっとした感動があったよ。
昨日の続き。 今日で完成させるつもりで、今朝からずっとこればっかり考えていた。 その結果。
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)がある。 あと、オブジェクトという使い方ではないが、ロード完了時の初期処理も。
以下、それぞれがやっていること。
ここでやっていることは次の三つ。
盤面の実体となるテーブルを作る。
サイズ固定なので直接HTML中に書いてもいいのだが、せっかく行と列を定数として定義しているのだから、それらを使うようにした。
どのセルがクリックされたかを判定するのを楽にするために、行位置と列位置をHTML5のuser-dataとして設定している。
盤面の最小幅を設定する。
これはiPhoneなどの小さい画面で、テーブルが表示しきれない場合にセルが潰れないようにするため。
セルの幅は何もしないと画面の表示幅によって変わるが、高さはセル内で折り返しが発生しない限り変化しない。 そして今回は折り返しは発生しない。 なのでレンダリング時に決定されたセルの高さ(ピクセル単位)を取得し、これと同じだけセルの幅が確保できるようにテーブルの最小幅を設定している。
ついでに、誤操作で選択開始となってしまうことを防ぐために、選択開始のイベントをキャンセルしておく。
コントローラーを生成して初期化処理を実行する。
コントローラーについては後述。
ゲームの進行を管理する。
具体的には、ゲームの開始/進行/終了のためのイベントリスナーの設定や解除と実行。
イベントリスナーを設定するのは、操作モードを指定するためのラジオボタンと、盤面のテーブル。 盤面の個々のセルではなくテーブルにしたのは、その方が管理が楽そうだったから。
イベント処理をDOM2ではなくDOM0でやっているのも同じ理由。 イベント処理を付けたり外したりするのにDOM0の方が簡単だったから。
以下、もうちょっと具体的な説明。
ゲーム開始。
仮想マップを初期化し、盤面に初回クリック用のイベントリスナーを設定する。
初回クリックのイベント処理。
経過時間の起点となる時刻を取得した後、下請けに、仮想マップへの初回クリック通知と、2回目以降のクリックの準備をさせる。
2回目以降のクリックの開モードのイベント処理。
下請けに、仮想マップへの開モードクリック通知をさせる。
2回目以降のクリックの旗モードのイベント処理。
下請けに、仮想マップへの旗モードクリック通知をさせる。
_first, _open, _flag の各処理の共通下請け。 上位含めて割と酷いネーミングだが気にしない。 やってることは以下の通り。
仮想マップが通知を受けて返す結果は、爆死か完遂かその他。
終了時の処理。
終了メッセージの表示設定、イベントリスナーの解除、モード選択のラジオボタンの初期化を行う。
いかにもメッセージを表示するだけのような名前だが、もうちょっと働いている。
最初は本当にメッセージを表示するだけだった。 しかし一連の終了処理の中でメッセージ表示処理を呼び出すと、記述の順番は盤面の表示更新が先でメッセージ表示が後なのに、実行される画面表示は何故かメッセージが先で盤面の表示更新が後になるんだよな。 なんでそうなるのかは後で調べるとして、とりあえずの対策としてメッセージ表示を非同期にした。
非同期にしたことで結果表示に若干の間が空くことになったのだが、意外にこれが終了時の演出としては良い感じだった。 で、最初は50msぐらいだったのが、いろいろ試しているうちにだんだん伸びて、最終的には1秒になった。
この1秒の間に次のゲームが始められないように、メッセージを閉じる処理の中で次のゲームを開始するようにした結果がこれ。
各セルの情報を持つ。 コントローラーからの通知を受けてこの情報を更新し、結果を盤面に反映する。
ここで情報とは以下の各値。
最初の3つはゲーム開始時に決まり、その後は変化しない。 最後の一つはゲームの進行によって変化する。
以下、もうちょっと具体的な説明。
仮想マップを構築する。
仮想マップは、盤面のtableのtrとtdに対応する2次元配列とし、その個々の要素に上記情報を持たせている。
ゲーム開始のための仮想マップ初期化(閉じる,爆弾無し)と、初期化の結果の画面反映。
ゲーム開始の初回クリックの処理。
最初にクリックしたところには爆弾を置かない。 逆に言うと、最初のクリックまで爆弾の配置を決定できない。 なのでここで爆弾を配置する。
クリックされたセルを開く処理。
クリックされたセルの状態で処理を分ける。
クリックされたセルに旗を立てるか抜くかする処理。
これまたクリックされたセルの状態で処理を分ける。
セルを開く処理の下請け。 むしろ本体か。 指定されたセルから連鎖的に開けるセルを開く。
仮想マップを画面表示に反映する。 具体的には以下の通り。
画面表示は、旗を立てるとか抜くとかは一つのセルしか更新する必要が無いので、必要な部分のみ更新するのが効率的。 しかし全部更新しても大した量じゃ無いので、処理の見通しがいいことを優先して、何か一つでも更新があった場合は全更新している。 仮想マップという形にしているのも、この方が処理の見通しが良さそうだと思ったから。
遠い記憶では、最初にクリックした時にそれなりの面積が開くことが多かったように思う。 しかし俺のこれは、セルが一つしか開かないことの方が圧倒的に多い。 初回クリックに対して 「そのセルに爆弾が無い」 という以上の配慮が有るのかもしれないな。 例えば、そのセルを中心とする周囲8セルにも爆弾が無いとか。
そうするなら、VMap 内の _getPlacableCells で指定のセルだけ除外しているのを、条件に合わせて除外対象を増やしてやればいい。 しかしそこまで配慮する意味が見出せないのでスルー。
とまあプログラムは特に問題無く動いているのだが。
クリアできない
爆弾の数を10個ぐらいにして、最初のクリックでいきなりクリアとか、何度かクリックした末のクリアとか、ちゃんとクリア時の動作確認はできてはいる。 で、爆弾の数を本来の99個にしてテストプレイをするのだが、これが悉く途中で爆死。 爆弾の数が増えると 「ここかここのどちらかに爆弾があるはずなのだが、推論では決められないので運任せ」 というパターンが発生して、そこで必ずハズレを選んでしまうのだ。
つまり悪いのはプログラムじゃなくて俺の運。
一応の完成後に1時間ぐらいテストプレイをしているのだが、その間に一度もクリアなし。 自分で作ったものではあるのだが、何秒で爆死とか出てくるとかなりイラっとするな。
まあ、ちょっとイラッとさせるつもりで作っているので、これはこれでテストになってるんだけどさ。