はじめに
以前ご紹介させていただいた、下記記事からの続編(第②弾)になります。
なお、プログラムの土台部分はこちら。
今回は前回作ったものをベースに、新たに灰色の石を追加した、3人対戦で遊べる変則リバーシを作りましたので、ご紹介したいと思います。
できたもの
以下のような、ブラウザで遊べるものを作りました。
自身が先手(黒)で、相手(白と灰)はコンピュータ、としました。
ソースコードなど一式は、以下に置いております。
ということで、お見せしたい結果も出揃いましたので
簡単ではありますが、作った上でのポイントを説明していきたいと思います。
灰色の石とは
"灰色の石"は私が思いついた、第三の石です。(といっても、同じようなものは既にいろいろな方が、作られているとは思います)
この灰色の石はもともとの黒と白の石と、扱いは同等とします。つまり、相手の石を自分の石で挟めば、挟まれた石は自分のものになるというルールに準拠したものとします。
以下に、石をひっくり返す時の挙動を示します。
(黒が相手を挟んだ場合)
(白が相手を挟んだ場合)
(灰色が相手を挟んだ場合)
こうすると、石をひっくり返す(reverse)というよりは、色を塗り替える(repaint)といった方が、あるいは自然なのかもしれませんね。ですが、ここは名のある"リバーシ"で通させてもらいたいと思います。
石の種類と盤面の初期設定は以下とします。黒、白、灰色の石を4つづつ置き、かつ、すぐには灰色が全滅してしまわないようばらしました。
const H = 0; // 穴
const E = 1; // 空きマス
const B = 2; // 黒色の石
const W = 3; // 白色の石
const A = 4; // 灰色の石
const BOARD = [
H, H, H, H, H, H, H, H, H, H,
H, H, H, E, E, E, E, H, H, H,
H, H, E, E, E, E, E, E, H, H,
H, E, E, E, B, A, E, E, E, H,
H, E, E, A, W, B, W, E, E, H,
H, E, E, W, B, W, A, E, E, H,
H, E, E, E, A, B, E, E, E, H,
H, H, E, E, E, E, E, E, H, H,
H, H, H, E, E, E, E, H, H, H,
H, H, H, H, H, H, H, H, H, H,
];
ブラウザに描画した結果が、以下になります。(灰色の石の描画処理は、以降の描画関連の変更でまとめて示します)
灰色の石の追加に伴い、board.jsのひっくり返せる石を取得する処理が変更になりました。
// ひっくり返せる石を取得する処理
// (引数)
// turn : プレイヤーの手番(色)
// board : 盤面情報を格納した配列
// index : 石を置く位置(マスを示す番号)
// (戻り値)
// flippables : ひっくり返せる石の位置(マスを示す番号)の配列
function getFlippablesAtIndex(turn, board, index) {
let flippables = [];
if (board[index] !== E) return flippables; // 空きマス以外はスキップ
- const opponent = turn === B ? W : B;
+ const opponents = getOpponentColors(turn);
for (let {x, y} of DIRECTION_XY) {
const dir = (GAME_BOARD_SIZE * y) + x;
let opponentDiscs = [];
let next = index + dir;
// 相手ディスクが連続しているものを候補とする
- while (opponent === board[next]) {
+ while (opponents.includes(board[next])) {
opponentDiscs.push(next);
next += dir;
}
これにともない、getOpponentColors
という、相手ディスクを返す関数も追加しました。
// 自身の対戦相手を返す
// (引数)
// turn : プレイヤーの手番(色)
// (戻り値)
// return : 対戦相手を格納した配列
function getOpponentColors(turn) {
return turn in FLIPPERS ? FLIPPERS[turn].opponents : [];
}
FLIPPERS
はgame.jsで定義した、石を返すプレイヤー情報をまとめたものです。(後述)
getOpponentColors
の戻り値の雰囲気としては、相手ディスクが連続している箇所を検索するときに
以前は、
- 自身が黒の場合は、白
- 自身が白の場合は、黒
を調べていましたが、今回はこれを
- 自身が黒の場合は、白か灰
- 自身が白の場合は、灰か黒
- 自身が灰の場合は、黒か白
となるよう変更しています。
灰色の石に関する処理としましては、以上になります。
3人対戦の実装
3人対戦を実現する上では、手番の回し方を決める必要があります。
今回は、黒→白→灰→黒→白→灰…の順番でゲームを進めるものとしました。
関連して、主に以下が変更となりました。
- 使用可能なプレイヤーを
FLIPPERS
連想配列にて管理し、灰色プレイヤーを追加 - 手番を回す順番を
ORDER
配列で管理する - スコア算出および勝敗判定、ゲーム終了判定のパス回数を
ORDER
配列に基づいて行う - ゲームオブジェクト作成時に、手番の配列と、プレイヤー情報の連想配列を入力とする
- 石の描画処理を
UI_PLAYER_INFO
連想配列にまとめて、灰色を追加
また、ゲーム性向上のため以下も変更しました。
- ランダムより強いコンピュータAI(原始モンテカルロ探索)を追加
プレイヤーの設定は以下としています。
- 黒は人が操作
- 白はコンピュータ(やや強い)
- 灰はコンピュータ(ランダム)
ゲーム管理の変更
ゲーム管理を変更した結果が以下です。ターンの順番や終了判定などが、こまごまと変わっています(差分だと多いので、コードそのままを載せました)。
const GAME_INIT = 0;
const GAME_PLAY = 1;
const GAME_STOP = 2;
const GAME_END = 3;
const GAME_TURN_END = 'End';
const DRAW = 'Draw';
const PASS = 'Pass';
const WIN = 'Win!';
const WAIT_TIME = 800; // ウェイト時間(ms)
const ORDER = [B, W, A]; // プレイヤーの順番
const FLIPPERS = { // 使用可能な全プレイヤー情報(石をひっくり返せる)
[B]: { // 黒
'player' : new Player(HUMAN), // 人が操作
'opponents': [W, A], // 白と灰をひっくり返せる
'score' : 0, // 黒の石の数
},
[W]: { // 白
'player' : new Player(MCS), // コンピュータが操作(原始モンテカルロ探索)
'opponents': [B, A], // 黒と灰をひっくり返せる
'score' : 0, // 白の石の数
},
[A]: { // 灰
'player' : new Player(RANDOM), // コンピュータが操作(ランダム)
'opponents': [B, W], // 黒と白をひっくり返せる
'score' : 0, // 灰の石の数
},
};
// ゲームの管理
class Game {
constructor(board, order, flippers) {
this.board = board.concat();
this.order = order.concat();
this.turnIndex = 0;
this.turn = this.order[this.turnIndex];
this.flippers = this.copyFlippers(flippers);
this.player = this.flippers[this.turn].player;
this.participants = [...new Set(order)];
this.pass = 0;
this.wait = this.getWaitTime();
this.humanMove = NO_MOVE;
this.updateScore();
this.updatedDiscs = [];
this.state = GAME_INIT;
}
// ゲームループ
loop() {
this.updatedDiscs = [];
this.state = this.play();
this.updateScore();
updateUi();
switch (this.state) {
case GAME_PLAY:
setTimeout(() => this.loop(), this.wait);
break;
case GAME_STOP:
break;
case GAME_END:
alert(this.getWinnerMessage());
break;
default:
break;
}
}
// 1手プレイ
play() {
if (this.isEnd()) return GAME_END;
if (this.indicatePass()) alert(this.getPassMessage());
this.pass = 0;
this.updatedDiscs = this.player.actMove(this);
if (this.updatedDiscs.length === 0) return GAME_STOP;
this.setNextPlayer();
return GAME_PLAY;
}
// 次のプレイヤーを設定する
setNextPlayer() {
this.turnIndex++;
if (this.turnIndex >= this.order.length) this.turnIndex = 0;
this.turn = this.order[this.turnIndex];
this.player = this.flippers[this.turn].player;
}
// スコアの更新
updateScore() {
for (let color of this.order) {
this.flippers[color].score = this.board.filter(e => e === color).length;
}
}
// 終了の判定
isEnd() {
for (let i=0; i<this.order.length; i++) {
if (!this.isPass()) return false;
}
this.turn = GAME_TURN_END;
return true;
}
// パスの判定
isPass() {
if (getLegalMoves(this.turn, this.board).length <= 0) {
this.setNextPlayer(this.turn);
this.pass++;
return true;
}
return false;
}
// パス通知の有無
indicatePass() {
const pass = (this.pass < this.order.length && this.pass > 0);
const human = this.participants.map(e => this.flippers[e].player.name).includes(HUMAN);
return pass && human;
}
// パス通知のメッセージを取得
getPassMessage() {
let pre = []
let index = this.turnIndex;
for (let i=0; i<this.pass; i++) {
index--;
if (index < 0) index = this.order.length - 1;
pre.push(getGameTurnText(this.order[index]));
}
return pre.reverse().join(' and ') + ' ' + PASS;
}
// 勝利プレイヤー通知のメッセージを取得
getWinnerMessage() {
const winner = this.getWinner();
return winner === DRAW ? winner : getGameTurnText(winner) + ' ' + WIN;
}
// 勝利プレイヤーの石を返す
getWinner() {
const scores = this.participants.map(e => this.flippers[e].score);
const max = Math.max(...scores);
if (scores.filter(e => e === max).length === 1) return this.participants[scores.indexOf(max)];
return DRAW;
}
// 待ち時間取得
getWaitTime() {
// ユーザー同士、コンピュータ同士の場合はウェイトなし
const players = [...new Set(this.participants.map(e => this.flippers[e].player.name))];
return players.includes(HUMAN) && (players.length > 1) ? WAIT_TIME : 0;
}
// プレイヤー情報のコピー
copyFlippers(flippers) {
let copy = {};
for (let key in flippers) {
copy[key] = {
'player' : new Player(flippers[key].player.name),
'opponents': flippers[key].opponents.concat(),
'score' : flippers[key].score,
};
}
return copy;
}
}
描画関連の変更
描画についても、灰色の石の表示処理が追加になっているほか、クリック時のプレイヤーの判定処理などにも変更が入りました。灰色の石の画像もフォルダに追加しています。
const UI_BOARD = 'ui_board';
const UI_PLAYER_INFO = {
[B]: {
'name': 'Black',
'img' : './image/black.png',
},
[W]: {
'name': 'White',
'img' : './image/white.png',
},
[A]: {
'name': 'Ash',
'img' : './image/ash.png',
},
};
// 盤面のテーブル作成
function createBoardTable() {
// ボードの子要素を一旦全削除
const uiBoard = document.getElementById(UI_BOARD);
removeChilds(uiBoard);
// ヘッダ含めた盤面をテーブルで作成
const table = document.createElement('table');
uiBoard.appendChild(table);
for (let y=0; y<BOARD_SIZE; y++) {
const tr = document.createElement('tr');
table.appendChild(tr);
for (let x=0; x<BOARD_SIZE; x++) {
const td = document.createElement('td');
tr.appendChild(td);
td.addEventListener('click', onBoardClicked);
td.setAttribute('id', UI_BOARD + (y * BOARD_SIZE + x));
}
}
// 盤面のサイズと背景色と形
for (let i=0; i<BOARD.length; i++) {
const square = document.getElementById(UI_BOARD + i);
square.width = 60;
square.height = 60;
const boardColor = BOARD_COLOR[i];
if (reversi.board[i] !== H && boardColor !== '*') {
square.style.backgroundColor = COLOR_CODE[boardColor];
}
}
// 盤面のヘッダー情報を追加
for (let i=0; i<PLAYABLE_SIZE; i++) {
// 上辺のヘッダ(アルファベット)
const topHeader = document.getElementById(UI_BOARD + (i + 1));
topHeader.textContent = String.fromCharCode('A'.charCodeAt(0) + i);
// 左辺のヘッダ(数字)
const leftHeader = document.getElementById(UI_BOARD + ((i + 1) * BOARD_SIZE));
leftHeader.textContent = i + 1;
}
// 石の初期配置
setupDiscs(PLAYABLE_INDEXS);
}
// 盤面の更新
function updateUi() {
setupDiscs(reversi.updatedDiscs);
const turn = document.getElementById('turn');
turn.textContent = getGameTurnText(reversi.turn);
const score = document.getElementById('score');
score.textContent = reversi.participants.map(e => reversi.flippers[e].score).join(' : ');
}
// 石を並べる
// (引数)
// indexs : 石を置く位置(マスを示す番号)の配列
function setupDiscs(indexs) {
for (let index of indexs) {
const square = document.getElementById(UI_BOARD + index);
const disc = reversi.board[index];
if (disc in UI_PLAYER_INFO) setImg(square, UI_PLAYER_INFO[disc].img);
}
}
// 画像を配置
// (引数)
// element : Document要素
// imgPath : 画像のパス
function setImg(element, imgPath) {
removeChilds(element); // 一旦、子要素削除
const img = document.createElement('img'); // 画像要素作成
img.src = imgPath; // 画像パス
img.width = 40; // 横サイズ(px)
img.height = 40; // 縦サイズ(px)
element.appendChild(img); // 画像追加
}
// 要素の子を削除
function removeChilds(element) {
for (let i=element.childNodes.length-1; i>=0; i--) {
element.removeChild(element.childNodes[i]);
}
}
// 手番の文字列を取得
function getGameTurnText(turn) {
return turn in UI_PLAYER_INFO ? UI_PLAYER_INFO[turn].name : GAME_TURN_END;
}
// マスをクリックした時の処理
function onBoardClicked(event) {
if (reversi.state !== GAME_STOP) return;
if (reversi.player.name === HUMAN) {
const index = Number(this.getAttribute('id').replace(UI_BOARD, ''));
const flippables = getFlippablesAtIndex(reversi.turn, reversi.board, index);
if (flippables.length > 0) {
reversi.humanMove = index;
reversi.loop();
}
}
}
AIの追加
ゲームを少し面白くするため、ランダムよりも強いAIの追加も行いました。
以下の理由から、今回は原始モンテカルロ探索を採用しました。
- 3人対戦ということで、2人対戦用のMinMax法やアルファ・ベータ法がそのまま使えなかった
- 盤面形状が様々に異なっても、強さに影響が出にくいものとしたかった
なるべく簡単に作れるものが良かった
また、探索数を終盤にかけて増やしていく仕組みを入れ、序盤に処理が重くならないようにしています。結果、序盤は手を抜き、終わりが近づくにつれ頑張りだすAIになりました。処理の高速化が実現できれば、もう少しAIを強くできそうですが、今後の課題としたいと思います。
const HUMAN = 'human';
const RANDOM = 'random';
const MCS = 'mcs';
const NO_MOVE = -1;
const MCS_SCHEDULE = [
[40, 34, 28, 22, 16, 13, 10, 6], // 空きマス残り
[20, 40, 60, 80, 120, 200, 400, 800], // プレイアウト回数
];
// プレイヤー
class Player {
constructor(name) {
this.name = name;
}
// 1手打つ
actMove(game) {
let move = NO_MOVE;
switch (this.name) {
case HUMAN:
move = getMoveByHuman(game);
break;
case RANDOM:
move = getMoveByRandom(game);
break;
case MCS:
move = getMoveByMonteCarloSearch(game, MCS_SCHEDULE);
break;
default:
break;
}
return putDisc(game.turn, game.board, move);
}
}
// 原始モンテカルロ探索で選んだ手を返す
// (引数)
// game : ゲーム情報
// schedule : 残り空きマス数に応じたプレイアウト数を格納した配列
// (戻り値)
// return : コンピュータの手(マスを表す番号)
function getMoveByMonteCarloSearch(game, schedule) {
const num = getPlayoutNum(game.board, schedule);
const turn = game.turn;
const randomFlippers = getRandomFlippers(game.flippers);
const legalMoves = getLegalMoves(turn, game.board);
if (legalMoves.length === 0) return NO_MOVE;
let results = [];
for (let move of legalMoves) {
let localBoard = game.board.concat();
putDisc(turn, localBoard, move);
let value = 0;
for (let i=0; i<num; i++) {
value += getPlayoutValue(game.turnIndex, localBoard, game.order, randomFlippers);
}
results.push(value);
}
return legalMoves[results.indexOf(Math.max.apply(null, results))];
}
// プレイアウト回数を返す
// (引数)
// board : 盤面情報を格納した配列
// schedule : 残り空きマス数に応じたプレイアウト数を格納した配列
// (戻り値)
// return : プレイアウト回数
function getPlayoutNum(board, schedule) {
const remain = board.filter(e => e === E).length;
let index = schedule[0].indexOf(Math.min.apply(null, schedule[0].filter(e => e >= remain)));
index = index === -1 ? 0 : index;
return schedule[1][index];
}
// ランダムなプレイヤー情報を取得
// (引数)
// flippers : プレイヤー情報を格納した連想配列
// (戻り値)
// return : flippersのプレイヤーをランダムに変えたもの
function getRandomFlippers(flippers) {
let randoms = {};
for (let key in flippers) {
randoms[key] = Object.assign({}, flippers[key]);
randoms[key].player = new Player(RANDOM);
}
return randoms;
}
// プレイアウト結果を取得
// (引数)
// index : プレイヤー手番の番号
// board : 盤面情報を格納した配列
// order : プレイヤーの順番を格納した配列
// flippers : プレイヤー情報を格納した連想配列
// (戻り値)
// return : プレイアウト結果(勝ち=1、それ以外=0)
function getPlayoutValue(index, board, order, flippers) {
const playout = new Game(board, order, flippers);
playout.turnIndex = index;
playout.setNextPlayer();
while (playout.play() === GAME_PLAY);
playout.updateScore();
return playout.getWinner() === order[index] ? 1 : 0;
}
ずいぶん込み入ったコードになってしまいましたが、仕組みとしては以下のイメージで作成しました。
- 打てる手の候補それぞれについて、一定回数ランダムにゲーム終了まで対戦(プレイアウト)させ、勝利数を集計
- 勝利数が一番多かった手を選ぶ
- 何回ランダムに対戦させるかは、空きマスの数に応じてあらかじめ決めておく
対戦結果
下記表のAIの組み合わせそれぞれで、100回づつ対戦させてみました。
意外とはっきり強くなっていそうです。(といっても、実際に対戦してみるとまだまだ普通に勝てるレベルでしたね…)
黒 | 白 | 灰 | 黒の勝率 | 白の勝率 | 灰の勝率 |
---|---|---|---|---|---|
モンテカルロ | ランダム | ランダム | 98% | 2% | 0% |
ランダム | モンテカルロ | ランダム | 1% | 98% | 1% |
ランダム | ランダム | モンテカルロ | 1% | 2% | 97% |
対戦シミュレータ
先ほどの対戦結果を出すにあたり、簡単なシミュレータを追加しました。
colorful-reversi
└─test
simulator.html
simulator.js
使い方
-
simulator.htmlをブラウザで開き、開発者ツールを起動します。
完成
以上で、今回作ろうと思っていた、3人対戦で遊べる変則リバーシが完成しました。
灰色プレイヤーはどちらかというと、黒プレイヤーVS白プレイヤーの対戦を乱すお邪魔キャラの想定で、今回は用意しております。遊ばれる際は、白プレイヤーを倒すことを目標にプレイしてみて下さい。
もちろん、ダウンロードしたソースコードを改変して、3プレイヤーとも"人"が操作するようにすることも、"コンピュータ"に操作させるようにすることも可能です。
完成したものをプレイしている様子が、以下になります。
(灰色プレイヤーが入ってくるタイミングが、なんとも慣れませんね)
おまけ
今回の変更により、ある程度手番の順序やプレイヤー追加を単純化しました。完成品には入れなかったのですが、試してみた結果を、2つ残しておきます。
灰色が黒と白の間にも割り込むケース
手番を回す順序を、黒→灰→白→灰→黒→…というのも試してみました。
const ORDER = [B, A, W, A]; // プレイヤーの順番
灰色プレイヤーのお邪魔率が倍!展開が早いです。
5人対戦
まだ見たことがなかったので、5人対戦も試してみました。灰色プレイヤーの同類として、新たにシアンプレイヤーと山吹プレイヤーを足してみます。(両プレイヤーともランダム扱い)
const H = 0; // 穴
const E = 1; // 空きマス
const B = 2; // 黒色の石
const W = 3; // 白色の石
const A = 4; // 灰色の石
const C = 5; // シアン色の石
const Y = 6; // 山吹色の石
const BOARD = [
H, H, H, H, H, H, H, H, H, H,
H, H, H, E, E, E, E, H, H, H,
H, H, E, E, C, E, E, E, H, H,
H, E, E, Y, B, A, C, E, E, H,
H, E, E, A, W, B, W, Y, E, H,
H, E, Y, W, B, W, A, E, E, H,
H, E, E, C, A, B, Y, E, E, H,
H, H, E, E, E, C, E, E, H, H,
H, H, H, E, E, E, E, H, H, H,
H, H, H, H, H, H, H, H, H, H,
];
const ORDER = [B, W, A, C, Y]; // プレイヤーの順番
const FLIPPERS = { // 使用可能な全プレイヤー情報(石をひっくり返せる)
[B]: { // 黒
'player' : new Player(HUMAN), // 人が操作
'opponents': [W, A, C, Y], // 対戦相手
'score' : 0, // 黒の石の数
},
[W]: { // 白
'player' : new Player(MCS), // コンピュータが操作(原始モンテカルロ探索)
'opponents': [B, A, C, Y], // 対戦相手
'score' : 0, // 白の石の数
},
[A]: { // 灰
'player' : new Player(RANDOM), // コンピュータが操作(ランダム)
'opponents': [B, W, C, Y], // 対戦相手
'score' : 0, // 灰の石の数
},
[C]: { // シアン
'player' : new Player(RANDOM), // コンピュータが操作(ランダム)
'opponents': [B, W, A, Y], // 対戦相手
'score' : 0, // シアンの石の数
},
[Y]: { // 山吹
'player' : new Player(RANDOM), // コンピュータが操作(ランダム)
'opponents': [B, W, A, C], // 対戦相手
'score' : 0, // 山吹の石の数
},
};
const UI_PLAYER_INFO = {
[B]: {
'name': 'Black',
'img' : './image/black.png',
},
[W]: {
'name': 'White',
'img' : './image/white.png',
},
[A]: {
'name': 'Ash',
'img' : './image/ash.png',
},
[C]: {
'name': 'Cyan',
'img' : './image/cyan.png',
},
[Y]: {
'name': 'Yamabuki',
'img' : './image/yamabuki.png',
},
};
自分が打った手の意味がわからなくなるくらい、目まぐるしい展開でした。
最後に
黒、白、灰色が入り乱れる、少し変わったリバーシを作ってみましたが、いかがでしたでしょうか。今回の3人対戦の場合、実際問題として何色の石が有利なのか、興味がわくところでもありますね。(リバーシの三体問題、あるのかないのか…、ないか…)
最後まで読んで下さり、ありがとうございました。
本記事の続編となる以下も、よかったら見てみてください。
それでは皆様、よきリバーシ・ライフを!