Weighted Tic-Tac-Toe 重み付きの三目並べ 競技プログラミングの問題をゲームに の続きです。ゲームに感想戦をする機能を追加します。
VIDEO
感想戦をする機能を追加する
感想戦モードに入る処理と終了して次のゲームを開始する処理を示します。
感想戦を開始する
感想戦モードに入ったらゲーム開始ボタンを非表示にします。また先手後手を選択するボタンのみ操作可能とし、それ以外の操作はできないようにしたいので感想戦用の操作ボタンもいったん非表示にします。$moveTest.style.display = ‘none’ としておきながら $moveTest2.style.display = ‘block’ とするのは変かもしれませんが、これは次に感想戦用の操作ボタンを表示されたときに[戻る][最善手確認]ボタンが表示されるようにするためのものです(表示非表示の設定をここで決めてしまう)。
そのあとセルの表示と選択状態を保存している配列、着手の履歴を保存している配列を初期化します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function moveTestStart ( ) {
$ gameStart . style . display = 'none' ;
$ moveTest . style . display = 'none' ; // 感想戦用の操作ボタンを非表示に
$ moveTest2 . style . display = 'block' ;
$ startMessage . style . display = 'block' ;
soundPlayer . play ( ) ;
moves . length = 0 ; // 着手の履歴をクリア
isDiscussing = true ; // 感想戦モードのフラグをセット
selects [ 0 ] = [ 0 , 0 , 0 ] ; // どのセルも未選択に
selects [ 1 ] = [ 0 , 0 , 0 ] ;
selects [ 2 ] = [ 0 , 0 , 0 ] ;
for ( let row = 0 ; row < 3 ; row ++) {
for ( let col = 0 ; col < 3 ; col ++)
cells [ row ] [ col ] . style . backgroundColor = '#000' ; // セルの背景色をもとに戻す
}
$ navi . innerText ='' ;
}
感想戦を終了する
感想戦を終了する処理をしめします。
感想戦モードのフラグをクリアするとともに、クリックに反応しないようにignoreClickをセットします。そのあとセルの表示をリセットしスタートボタンを再表示させます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function moveTestEnd ( ) {
$ moveTest . style . display = 'none' ;
$ moveTest2 . style . display = 'none' ;
moves . length = 0 ;
isDiscussing = false ; // 感想戦モードのフラグをクリア
ignoreClick = true ; // クリックに反応させない
soundPlayer . play ( ) ;
$ gameStart . style . display = 'block' ;
for ( let row = 0 ; row < 3 ; row ++) {
for ( let col = 0 ; col < 3 ; col ++) {
cells [ row ] [ col ] . style . backgroundColor = '#000' ;
cells [ row ] [ col ] . innerText = '' ;
}
}
$ navi . innerText ='' ;
}
手順を戻せるようにする
感想戦モードもプレイヤーの手番では着手したい場所のセルをクリックして進めていきますが、途中で手順を戻したい場合やこの局面での最善手を知りたい場合があります。まず手順を戻す処理を示します。
終局してしまった場合とコンピュータの手番ではユーザーがクリックしても反応しなくしています。手番を戻すときもコンピュータの手番で手番を戻す処理がおきると困るので ignoreClick フラグで制御しています。その一方で感想戦モードで終局になった場合は手番を戻す処理ができないと困ります。
手番を戻すことができるのは終局している場合か ignoreClick == false の場合です。さらにもうひとつ条件があってプレイヤーとコンピュータがそれぞれ 1 回以上着手している(moves.length が 2 以上)場合です。これらの条件を満たす場合だけ着手を戻す処理をおこないます。
着手を戻す処理は moves から最後の要素をふたつ取り除き、対応するセルの背景色を元に戻すだけでよいです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function onMoveBack ( ) {
const gameFinished = getWinner0 ( selects ) ! = 0 | | isALLSelected ( selects ) ;
if ( ( ! ignoreClick | | gameFinished ) && moves.length >= 2){
soundPlayer.play();
ignoreClick = true ;
for ( let i = 0 ; i < 2 ; i ++) {
const last = moves . length - 1 ;
const lastMove = moves [ last ] ;
selects [ lastMove . Row ] [ lastMove . Col ] = 0 ;
cells [ lastMove . Row ] [ lastMove . Col ] . style . backgroundColor = '#000' ;
moves . length = last ;
await sleep ( 250 ) ;
}
$ navi . innerText = 'あなたの手番です。' ;
ignoreClick = false ;
}
else {
soundBad . play ( ) ;
}
}
最善手を表示する
最善手を表示する処理を示します。GetBestMove(selects)を実行すれば最善手にかんする情報が格納されたオブジェクトが返されるので、これをもとに文字列を生成して表示します。自分の手番ではないときにクリックするのは不正な操作です。この場合は効果音が鳴るだけで何も起きません。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function showBest ( ) {
if ( ! ignoreClick ) {
const best = GetBestMove ( selects , cellNumbers ) ;
const score1 = best . Scores [ 0 ] == inf ? '∞' : best . Scores [ 0 ] ;
const score2 = best . Scores [ 1 ] == inf ? '∞' : best . Scores [ 1 ] ;
const moveCount = best . MoveCount + 1 ;
if ( best . Winner == playerNumber ) {
const str1 = best . Row ! = -1 ? ` $ { moveCount } 手目の最善手は 上から $ { best . Row + 1 } 番目、左から $ { best . Col + 1 } 番目です。` : '終了。' ;
const str2 = ` $ { score1 } : $ { score2 } であなたの勝ちです。` ;
$ navi . innerText = str1 + str2 ;
}
else {
const str1 = best . Row ! = -1 ? ` $ { moveCount } 手目の最善手は 上から $ { best . Row + 1 } 番目、左から $ { best . Col + 1 } 番目ですが` : '終了。' ;
const str2 = ` $ { score1 } : $ { score2 } であなたは勝てません。` ;
$ navi . innerText = str1 + str2 ;
}
soundPlayer . play ( ) ;
}
else {
soundBad . play ( ) ;
}
}
問題を難しくする
マスにセットされる数はランダムに生成された -1000 ~ 999 の整数です。ところがこれだと極端に簡単な問題が出題されることがあるのでこれを改善します。また適当に問題をつくるとほとんどの問題が先手でないと勝てない問題となっています。後手を選択しないと勝てない問題が生成される確率をアップさせる方法も考えます。
どうやっても勝ててしまう?
以下のような関数を定義します。これは先手を選択した場合、勝つための初手の種類数を返す関数です。これが大きな数を返す問題はどうやっても勝てる問題とみなすことができます。またこの関数が 0 を返すのであれば勝つためには後手を選択しなければならない問題ということになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function checkNumbers ( numbers ) {
const selects0 = [ ] ;
selects0 [ 0 ] = [ 0 , 0 , 0 ] ;
selects0 [ 1 ] = [ 0 , 0 , 0 ] ;
selects0 [ 2 ] = [ 0 , 0 , 0 ] ;
const positions = getNextPositions ( selects0 ) ;
const winPositions = [ ] ;
for ( let i = 0 ; i < positions . length ; i ++) {
const row = positions [ i ] . Row ;
const col = positions [ i ] . Col ;
selects0 [ row ] [ col ] = 1 ;
const firstMove = GetBestMove ( selects0 , numbers ) ;
if ( firstMove . Winner == 1 ) // 勝ち
winPositions . push ( new Position ( row , col ) ) ;
selects0 [ row ] [ col ] = 0 ;
}
return winPositions ;
}
この関数を使って生成された問題をためしてみると後手を選択しないと勝てない問題はごくわずかな確率でしか生成されません。また先手を選択した場合、初手の最善手が 5 とおり以上など先手ならどうやっても勝てるというケースも多々あります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function gameStart ( ) {
moves . length = 0 ;
selects [ 0 ] = [ 0 , 0 , 0 ] ;
selects [ 1 ] = [ 0 , 0 , 0 ] ;
selects [ 2 ] = [ 0 , 0 , 0 ] ;
// テスト開始
let tryCount = 0 ;
const tryMaxCount = 800 ;
while ( true ) {
tryCount ++;
cellNumbers = createNumbers ( ) ;
const ret = checkNumbers ( cellNumbers ) ;
if ( tryCount > = tryMaxCount )
break ;
if ( ret . length == 0 ) {
console . log ( ` 後手必勝形:$ { tryCount } 回目` ) ;
break ;
}
console . log ( ` 先手必勝形:$ { ret . length } とおり` ) ;
}
// テスト終了
setCellNumbers ( ) ;
$ navi . innerText = '' ;
$ gameStart . style . display = 'none' ;
$ moveTest . style . display = 'none' ;
$ startMessage . style . display = 'block' ;
soundPlayer . play ( ) ;
}
function createNumbers ( ) {
const numbers = [ ] ;
numbers [ 0 ] = [ createNumber ( ) , createNumber ( ) , createNumber ( ) ] ;
numbers [ 1 ] = [ createNumber ( ) , createNumber ( ) , createNumber ( ) ] ;
numbers [ 2 ] = [ createNumber ( ) , createNumber ( ) , createNumber ( ) ] ;
let sum = 0 ;
for ( let i = 0 ; i < 3 ; i ++)
sum += numbers [ i ] [ 0 ] + numbers [ i ] [ 1 ] + numbers [ i ] [ 2 ] ;
// 総和が偶数になっている場合は奇数にする
if ( sum % 2 == 0 )
numbers [ 0 ] [ 0 ] ++;
return numbers ;
}
上記コードのテスト開始からテスト終了部分の実行結果です。ランダムに生成した数では後手が勝てる問題はなかなか生成されません。
230回超試行のすえ作成された後手が必ず勝つ問題
238を選択すれば勝てます。
裏で問題を作らせる
まず問題を難しくするためには先手を選択した場合の勝てる初手の種類が少ないものを探さなければなりません。初手の最善手の種類が少ない ⇔ 難しい問題 なのかと言われるとそうとも限らないのですが、ここはとりあえずそう考えます。
それから後手を選択しなければ勝てない問題の出題率を上げる方法も考えなければなりません。後手を選択しなければ勝てない問題が乱数で生成されるまで待っていると時間がかかるので、裏で問題を作らせて事前に生成された問題を出題させるという方法はどうでしょうか? これなら後手を選択しなければ勝てない問題の出題率を 5 割にすることができそうです。
まず問題を生成する関数 createProblems を定義します。
ここでは問題を 1000 個生成し、勝つためには先手を選択しなければならない問題で適切な初手が 3種類までのものと勝つためには後手を選択しなければならない問題をそれぞれ配列に格納しています。またそれぞれの問題のストックは 100 までとします。
この処理をすると他が動かなくなるのでループ内で10ミリ秒待機しています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const firstWinCellNumbers = [ ] ;
const secondWinCellNumbers = [ ] ;
async function createProblems ( ) {
let tryCount = 0 ;
const tryMaxCount = 1000 ;
while ( true ) {
await sleep ( 10 ) ;
tryCount ++;
const numbers = createNumbers ( ) ;
const ret = await getFirstMovesAsync ( numbers ) ; // 後述
if ( tryCount > = tryMaxCount )
break ;
if ( ret . length == 0 && secondWinCellNumbers.length < 100)
secondWinCellNumbers.push(numbers);
if ( ret . length < = 3 && firstWinCellNumbers.length < 100)
firstWinCellNumbers.push(numbers);
}
}
getFirstMovesAsync関数は生成された問題で勝つための先手の初手の種類数を返します。これも連続して処理をおこなうと UI が固まってしまうので 10 ミリ秒待機しながら処理をおこなっています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function getFirstMovesAsync ( numbers ) {
const selects0 = [ ] ;
selects0 [ 0 ] = [ 0 , 0 , 0 ] ;
selects0 [ 1 ] = [ 0 , 0 , 0 ] ;
selects0 [ 2 ] = [ 0 , 0 , 0 ] ;
const positions = getNextPositions ( selects0 ) ;
const winPositions = [ ] ;
for ( let i = 0 ; i < positions . length ; i ++) {
const row = positions [ i ] . Row ;
const col = positions [ i ] . Col ;
selects0 [ row ] [ col ] = 1 ;
const firstMove = GetBestMove ( selects0 , numbers ) ;
if ( firstMove . Winner == 1 )
winPositions . push ( new Position ( row , col ) ) ;
selects0 [ row ] [ col ] = 0 ;
await sleep ( 10 ) ;
}
return winPositions ;
}
新しく定義した関数を組み込む
ページが読み込まれたら新しく定義したcreateProblems関数で問題を生成します。
window . onload = ( ) => {
getCellElements ( ) ;
addEventListeners ( ) ;
initVolume ( ) ;
createProblems ( ) ;
}
スタートボタンがクリックされたら 2分の1の確率で後手を選択しなければ勝てない問題を出題します。ただしそのような問題が生成されていない場合は出題できないので secondWinCellNumbers が空でないことを確認します。
secondWinCellNumbers が空の場合は firstWinCellNumbers が空でなければここに格納されている問題を出題します。もしこの部分も空の場合(ページが読み込まれてすぐにスタートボタンをクリックした場合、そうなるかもしれない)は適当に問題を生成してこれを出題します。そのあと次回のプレイに備えて新しい問題を生成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function gameStart ( ) {
moves . length = 0 ;
selects [ 0 ] = [ 0 , 0 , 0 ] ;
selects [ 1 ] = [ 0 , 0 , 0 ] ;
selects [ 2 ] = [ 0 , 0 , 0 ] ;
// 作りためておいた問題(あれば)から出題する
if ( Math . random ( ) > 0.5 && secondWinCellNumbers.length > 0)
cellNumbers = secondWinCellNumbers.shift();
else if ( firstWinCellNumbers . length > 0 )
cellNumbers = firstWinCellNumbers . shift ( ) ;
else // ないならここでつくる
cellNumbers = createNumbers ( ) ;
// このあたりは同じ
setCellNumbers ( ) ;
$ navi . innerText = '' ;
$ gameStart . style . display = 'none' ;
$ moveTest . style . display = 'none' ;
$ startMessage . style . display = 'block' ;
soundPlayer . play ( ) ;
// 次の問題を最大 1000 個作成する
createProblems ( ) ;
}
VIDEO