一人二役の寂しいオセロから脱却すべく、オセロの対戦が務まるAIの作成を始めた。
しかし、一通り動きはするものの、このAIは形勢判断を全くせずにテキトーに手を選んでいるだけなので非常に弱い。
なので少しは歯ごたえのあるまともなAIを作ろうと思うのであった。
前回作成したAIは
という単純な実装でした。これには 二つ問題 があります。
つまり、 これらを解決すればまともなAIになるはず です。
しかし「手の価値」を正確に算出するのは非常に困難です。
ただ 「手の価値」はその手を指した後の盤面の価値から近似できる でしょう。
となると今度は「盤面の価値」をどう測るかという話になってきます。
一先ず今の段階では単純に
としておきましょう。
もう少しまともな算出方法はあると思いますが、
慣れない分野なのでまずは動くものを作ることを優先します。
そうすると残る問題は「先の先を読む」ことになります。
極端な話、「ゲーム開始からゲーム終了まで」を読めば、
勝てる可能性が少しでも残っている限り必ず勝つパーフェクトなAIが出来ます。
しかし、これではオセロで起き得る全ての局面を計算することになってしまい、
まともにゲームが遊べる実行速度ではなくなってしまいます。
何せ4×4という最小限の盤面ですら全局面を読むと待たされたというのに、
8×8の通常の盤面で全局面を読むことは実質的に不可能です。
なので
ことにしましょう。取り敢えずは N = 4 とします。
また、こうしておけば読む手の深さを調整することでAIの「腕前」を簡単に調整できます。
ゲーム木がある以上、N手先まで読むのは非常に簡単です。
N手先まで探索して見つかった末端のノードに対応する局面を一つづつ調べればいいのです。
まずは「ゲーム中のある局面から最大でN手先までを切り取る」関数 limitGameTreeDepth
を定義しましょう。
これがあれば
形勢判断の処理が
「最大でN手先までを考慮する」ことなく
「最後まで読み切る」形になるため実装が簡単になります。
function limitGameTreeDepth(gameTree, depth) {
return {
board: gameTree.board,
player: gameTree.player,
moves:
depth == 0
? []
: gameTree.moves.map(function (m) {
return {
isPassingMove: m.isPassingMove,
x: m.x,
y: m.y,
gameTreePromise: delay(function () {
return limitGameTreeDepth(force(m.gameTreePromise), depth - 1);
})
};
})
};
}
次に盤面の価値を算出する関数 scoreBoard
を定義しましょう。
今回の定義は石の数を数えるだけなので簡単ですね。
まあ「角に置かれた石は重要度を上げる」程度のコツは反映したいものですが、
話がややこしくなるのでそれは将来の楽しみに取っておきましょう。
function scoreBoard(board, player) {
var opponent = nextPlayer(player);
return sum($.map(board, function (v) {return v == player;})) -
sum($.map(board, function (v) {return v == opponent;}));
}
function sum(ns) {
return ns.reduce(function (t, n) {return t + n;});
}
次に「手の価値」 = 「次の局面の価値」を評価する関数 ratePosition
を定義しましょう。
オセロのような二人でプレイするゲームならば
「自分にとって都合の良い手」 = 「相手にとって都合の悪い手」
なので、ある局面の価値は
という考え方(= ミニマックス法)で定義できます。
function ratePosition(gameTree, player) {
if (1 <= gameTree.moves.length) {
var choose = gameTree.player == player ? Math.max : Math.min;
return choose.apply(null, calculateRatings(gameTree, player));
} else {
return scoreBoard(gameTree.board, player);
}
}
function calculateRatings(gameTree, player) {
return gameTree.moves.map(function (m) {
return ratePosition(force(m.gameTreePromise), player);
});
}
後は最も良い手を選ぶ関数 findTheBestMoveByAI
を修正すれば完成です。
なお、同じ価値の手が複数あった場合は、
最初に見つかったものを選ぶことにしましょう。
ゲームとして楽しむならランダムに選んでも良いのですが、
動作確認を簡単にするために
「同一局面に対して指す手は常に同じ」
ということにしておきます。
var AI_LEVEL = 4;
function findTheBestMoveByAI(gameTree) {
var ratings = calculateRatings(
limitGameTreeDepth(gameTree, AI_LEVEL),
gameTree.player
);
var maxRating = Math.max.apply(null, ratings);
return gameTree.moves[ratings.indexOf(maxRating)];
}
おお……「先の先を読んで最善手を選ぶ」のは難しいことだと思っていましたが、
案外簡単に実装することが出来ました。やりましたね。
それでは遊んでみましょう!
……う、うーん? 序盤の数手を過ぎると妙に反応速度が鈍くなりますね。
試しに先読みする手数を1に制限してみましょう。
うーん、これだとサクサク動いています。
となると 4手の先読みですら検討すべき局面が爆発的に増えている ということでは……
(実際に計算すると中盤の適当な局面から4手先にあり得る局面は大抵1〜2万くらい存在します)
今回の変更で少しは歯ごたえのあるAIにできたと思ったら、
快適に遊べない状態になってしまいました。
これではAIの強さの変化を実感する以前の問題です。
もっと高速化しなければ……!
と言う訳で次回は「AI高速化編」です。