2011 11 05

スパイダーを作るの5

これまたいつものことだけど、それなりに一通り考えたつもりでも、実際に作ってみると見落としがが結構あるんだよな。 ま、それはそれで楽しいのだが。

ところで、spider の場合に問題になるのはテスト。 テストのためにゲームを実行しているはずが、ゲームに夢中になってテストが疎かになってしまうのだ。 割と頻繁に本末転倒。 って、これは spider じゃなくて俺の問題か。

さて、コード。

// カード供給元 function CardSet() { var parent = document.getElementById( 'field' ); var cards = []; for ( var i = 0; i < 2; i++ ) { // カード2セット for ( var mark in CARD.MARKS ) { for ( var n = 0; n < CARD.NUMBERS.length; n++ ) { cards.push( ( new Card( mark, n ) ).setParent( parent ) ); } } } this.getCards = function () { return cards.random(); }; this.getDummy = function () { return ( new Card() ).setParent( parent ); }; }

カードを供給するためのオブジェクト。

難易度最大で固定とするため、カードも ♥ ♠ ♦ ♣ 各13枚を2セットで固定している。 これを最初にまとめて作って、以後は新規作成せず使い回す。 カードの親要素を field にしているのは、列を親にすると管理が面倒になりそうだから。 ゲーム中、カードは頻繁に列を移動することになるからね。

getCards

シャッフルしたカードを返す。

getDummy

ダミーカード(マークも数字も無いカード)を1枚返す。

// 列 function Line( lineNo, dummyHeadCard, controller ) { var self = this; var POS = { left : 10 + lineNo * 70, top : 120, vGap : 24, nextTop : function () { return POS.top + ( myCards.length - 1 ) * POS.vGap; } }; var myCards = [ dummyHeadCard.setPosition( POS.left, POS.top ).close() ]; this.clear = function () { while ( myCards.length > 1 ) { var card = myCards.pop(); if ( card.isOpen() ) { card.close().removeEventListener( 'click', _select, false ); } } }; this.init = function ( cards ) { cards.each( function ( card ) { myCards.push( card.close().setPosition( POS.left, POS.nextTop() ).setZIndex( myCards.length ) ); } ); }; this.isEmpty = function () { return myCards.length === 1; }; this.isOpen = function ( no ) { return myCards[ myCards.length - no ].isOpen(); }; this.canAdd = function ( cards ) { if ( self.isEmpty() ) { return cards.length; } var nextNumber = myCards.tail().getNumber() - 1; return cards.length - cards.doWhile( function ( card ) { return card.getNumber() !== nextNumber; } ); }; this.add = function ( cards ) { cards.each( function ( card ) { card.setPosition( POS.left, POS.nextTop() ).setZIndex( myCards.length ); myCards.push( card.open().addEventListener( 'click', _select, false ) ); } ); if ( self.canGet(1).length === 13 ) { controller.fin( lineNo ); } }; this.canGet = function ( order ) { if ( self.isEmpty() ) { return []; } var selected = myCards.slice( order ? order : myCards.length - 1 ).reverse(); var getable = [ selected.shift() ]; selected.doWhile( function ( card ) { if ( card.isOpen() && getable[ 0 ].getMark() === card.getMark() && getable[ 0 ].getNumber() === card.getNumber() - 1 ) { getable.unshift( card ); return true; } return false; } ); return getable; }; this.get = function ( n ) { var cards = []; for ( var i = 0; i < n; i++ ) { cards.unshift( myCards.pop().removeEventListener( 'click', _select, false ) ); } if ( !myCards.tail().isOpen() ) { myCards.tail().open().addEventListener( 'click', _select, false ); } return cards; } this.focus = function ( focused ) { myCards.tail().focus( focused ); }; this.closeTop = function () { if ( myCards.tail().isOpen() ) { myCards.tail().close().removeEventListener( 'click', _select, false ); } }; function _select( e ) { controller.select( lineNo, this.getZIndex() ); } }

列を表現するオブジェクト。 何列目なのかを lineNo で指定する。

列内のカードに対し、列番号と列位置とで表示する座標を決める。 また、置いた順番と zIndex が一致するように設定し、これによってクリックされたカードの列内位置を取得する。

表向きのカードにはクリックイベントを設定し、イベント処理の中で、列番号と列内位置をコントローラーに通知する。 列の末尾(一番奥)にはダミーカードを置いて、列が空になったときの処理をさせる。

clear

列にある全てのカードを削除する。 削除するカードにイベントが設定されている場合は解除する。

init

ゲーム初期化時に実行する。 引数は、裏向きに初期配置するカード。

isEmpty

列が空(ダミーカードだけ)のとき true を返す。

isOpen

列の先頭から no 番目のカードが表向きのとき true を返す。

canAdd

列への追加候補として渡されたカードがこの列に置けるかどうかを判定し、判定結果として置ける枚数を返す。

列が空なら全て置ける。 空ではない場合は 「自分のカードの一番上 - 1 = 移動候補のカードのどれか」 が成り立てば、追加候補の先頭からその位置までのカードを置けるものとする。

add

列にカードを追加する。 このとき、カードの一番下を1として、連番になるように zIndex を設定する。

カードを追加したら、先頭から13枚揃っているかどうかを確認する。 揃っている場合は、コントローラーに通知する。

canGet

列から削除可能なカードを返す。

削除できるのは、カードの先頭から同じマークで連番の続く範囲。 引数 order を指定した場合は、列の先頭からその位置までを検査対象とする。 order は、列のカードに設定した zIndex と同じで、1が一番奥を意味する。

get

先頭から n 枚のカードを削除し、削除したカードを返す。 このとき、削除するカードに設定されているイベントは解除する。 また、削除後の列の先頭のカードが裏向きだった場合は、表向きにしてイベントを設定する。

focus

列の先頭のカードの選択中表示を切り替える。

closeTop

列の先頭のカードが表向きの場合、裏向きにしてイベントを解除する。 一手戻すとき、カードを裏向きにする必要がある場合に呼ばれる。

// 未配カード用 function Yet( controller ) { var POS = { left : 640, top : 10 }; var myCards = []; this.clear = function () { if ( myCards.length ) { myCards.tail().removeEventListener( 'click', controller.deal, false ); myCards = []; } } this.init = function ( cards ) { cards.each( function ( card ) { myCards.push( card.setPosition( POS.left, POS.top ).close().setZIndex( myCards.length ) ); } ); myCards.tail().addEventListener( 'click', controller.deal, false ); }; this.get = function ( n ) { myCards.tail().removeEventListener( 'click', controller.deal, false ); var cards = myCards.slice( myCards.length - n ); myCards.splice( myCards.length - n, n ); if ( myCards.length ) { myCards.tail().addEventListener( 'click', controller.deal, false ); } return cards; }; }

列に配っていないカードを持たせるオブジェクト。

裏向きに積んだカードの一番上に、クリックイベントを設定する。 このイベント処理の中で、コントローラーのカードを配る処理を呼ぶ。

clear

残っている全てのカードを削除する。 カードの先頭に設定しているイベントは解除する。

init

ゲーム初期化時に実行する。 引数は、裏向きに初期配置するカード。 カードの先頭にイベントを設定する。

get

先頭から n 枚のカードを取り出す。 取り出したカードの先頭に設定していたイベントは解除する。 取り出した後、まだ残りがあれば、その先頭にイベントを設定する。

コントローラーのカードを配る処理の中で呼ばれるのだが、呼び出しが相互になってしまうのが微妙。

// 完了カード用 function Fin( controller ) { var POS = { left : 10, top : 10, hGap : 30, nextLeft : function () { return POS.left + POS.hGap * Math.floor( myCards.length / 13 ); } }; var myCards = []; this.clear = function () { myCards = []; }; this.add = function ( cards ) { cards.reverse().each( function ( card ) { card.setPosition( POS.nextLeft(), POS.top ).setZIndex( myCards.length ); myCards.push( card ); } ); } }

13枚揃ったカードを置いておくオブジェクト。 何をするでもない、ただカードを保持しておくだけ。

clear

持っている全てのカードを削除する。

add

カードを追加する。

ここのカードは全て表向きなのだが、渡されるカードはどこかの列で13枚揃ったものなので、既に表向きになっているはず。

// 勝敗管理 function Score() { var win = new _score( 'success' ); var lose = new _score( 'fail' ); win.set( win.get() ); lose.set( lose.get() ); this.win = function () { win.set( win.get() + 1 ); }; this.lose = function () { lose.set( lose.get() + 1 ); }; this.clear = function () { win.set( 0 ); lose.set( 0 ); }; function _score( id ) { var key = 'spider_' + id; this.get = function () { return parseInt( window.localStorage[ key ] || 0 ); }; this.set = function ( value ) { document.getElementById( id ).firstChild.nodeValue = window.localStorage[ key ] = value; }; } }

勝敗を管理するオブジェクト。

勝敗の記録を保持するのに、折角なので HTML5 の localStrage を使ってみた。 localStorage は key-value タイプで、key という名前で値 value を保存するには

window.localStrage[ key ] = value;

また、保存した key に対応する値を取得するには

value = window.localStrage[ key ];

とまあ簡単で使いやすい。 key が重複しないように気をつける必要があるが、それはまあ頭に 「spider_」 とか付けておけばいいだろう。 欠点は、ちょっと古いブラウザ(主にIE)が対応していないことだが、それはすぐに問題無くなるはず。 だよな?

win

勝ち数を1増やす。

lose

負け数を1増やす。

clear

勝ち負けともに0にする。

// コントローラー function Controller() { var self = this; var cardSet = new CardSet(); var yet = new Yet( this ); var fin = new Fin( this ); var score = new Score(); var lines = []; for ( var l = 0; l < 10; l++ ) { lines[ l ] = new Line( l, cardSet.getDummy(), this ); } var lastLine = -1; var lastOrder = 0; var history = []; var win = false; this.clearScore = function () { confirm( 'これまでの勝敗を無しにする' ) && score.clear(); }; this.clear = function () { lines.each( function ( line ) { line.clear(); } ); fin.clear(); yet.clear(); if ( win ) { win = false; } else { score.lose(); } return self; }; this.init = function () { yet.init( cardSet.getCards() ); for ( var i = 0; i < lines.length; i++ ) { lines[ i ].init( yet.get( i < 4 ? 5 : 4 ) ); } return self; }; this.deal = function () { if ( lines.all( function ( line ) { return !line.isEmpty(); } ) ) { if ( lastLine !== -1 ) { lines[ lastLine ].focus( false ); lastLine = -1; lastOrder = 0; } lines.each( function ( line ) { line.add( yet.get( 1 ) ); } ); history = []; } else { alert( '空列があるとカードを配れない' ); } }; this.select = function ( lineNo, order ) { if ( lastLine === -1 ) { lastLine = lineNo; lastOrder = order; lines[ lineNo ].focus( true ); } else if ( lastLine === lineNo ) { lines[ lineNo ].focus( false ); lastLine = -1; lastOrder = 0; } else { lines[ lastLine ].focus( false ); var movable = lines[ lineNo ].canAdd( lines[ lastLine ].canGet( lastOrder ) ); if ( movable > 0 ) { history.push( { from : lastLine, to : lineNo, moved : movable, opened : lines[ lastLine ].isOpen( movable + 1 ) } ); lines[ lineNo ].add( lines[ lastLine ].get( movable ) ); } lastLine = -1; } } this.fin = function( lineNo ) { fin.add( lines[ lineNo ].get( 13 ) ); if ( self.isFinished() ) { alert( '完成' ); score.win(); win = true; } } this.isFinished = function () { return lines.all( function ( line ) { return line.isEmpty(); } ); } this.back = function () { if ( lastLine !== -1 ) { lines[ lastLine ].focus( false ); lastLine = -1; } if ( history.length ) { var command = history.pop(); if ( !command.opened ) { lines[ command.from ].closeTop(); } lines[ command.from ].add( lines[ command.to ].get( command.moved ) ); } }; }

ゲームの進行とカードの移動を制御する。 コントロールを訳して制御と言ってるけど、実際にやってることは各種オブジェクト間の仲介に近い。

コントローラー生成時、仲介される側である列や未配カードなどのオブジェクトを生成する。

clearScore

これまでの勝敗の記録をクリアする。 一応確認する。

clear

各オブジェクトを一旦空にする。

新規ゲーム開始前のクリア処理なので、呼ばれるのは、勝った後か現状を破棄してやり直しかのどちらか。 なので、直前の結果が勝利でない場合は、やり直しによる呼び出しとして負け数を1増やす。

init

列と未配カードの初期配置。

初期配置する数は決まっているのだが、わざわざカードを全て一旦未配として、そこから各列に必要な数を取って置いている。

deal

空き列が無い場合、未配から各列に1枚ずつカードを配る。 このとき、選択状態があれば解除する。 履歴スタックも空にする。

空き列がある場合は、その旨メッセージ表示する。

select

列のカードがクリックされたときのイベント処理で呼び出される。 引数として列番号と列内位置が渡される。

列が選択されていない場合は、今回の列を選択状態とする。

列が選択されていて、かつその列が今回と同じなら、選択状態を解除する。

その他の場合、選択列の先頭から選択位置までで、今回の列に移動できるカードがあるかどうかを調べる。 移動できるカードがあった場合は、その最大分を移動する。 併せて履歴スタックにもカードの移動記録を積んでおく。 移動できるカードが有ろうが無かろうが、選択状態は解除する。

fin

13枚揃った場合に呼ばれる。

引数で指定された列番号の列の先頭から13枚を、完了分に移動する。

isFinished

完成、つまり全列が空になっていれば true を返す。

back

戻す操作で呼ばれる。

選択状態を解除し、履歴スタックに移動記録が有れば1つ取り出して、逆の移動を行う。

// 初期処理 window.onload = function () { var controller = new Controller(); controller.init().deal(); document.getElementById( 'score' ).addEventListener( 'click', controller.clearScore, false ); document.getElementById( 'back' ).addEventListener( 'click', controller.back, false ); document.getElementById( 'init' ).addEventListener( 'click', function () { if ( controller.isFinished() || confirm( 'このゲームを捨てて新しく始める' ) ) { controller.clear().init().deal(); } }, false ); };

最後に、ロードイベントとして実行する全体の初期処理。 コントローラーを生成してゲーム実行のための準備をするとともに、メニューの各項目用のイベント処理を設定する。

以上でとりあえず動くのだが、完成したときの挙動がちょっと不自然。 まず完成のダイアログが出て、それから最後のカードが移動するのだ。 処理の順番は、カードの移動の方が先になるように書いたつもりなのだが。

あと、ブラウザのリロードは、やり直しとして負けでカウントすべきなのだが、今は放置。 なので、できないと思ったらリロードして、勝ち数だけを積み上げることが出来てしまう。 まあ、こんなので勝率を上げても虚しいだけだが。

そして何より、カードの移動のアニメーションが無い。 アニメーションなどしないでシュパッと移動する方が好みなのだが、それはそれとして、アニメーションを実装しないと何かちょっと負けた気がする。

ということで、明日はカード移動のアニメーションを考える。