1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScriptで作る変則リバーシ②

Last updated at Posted at 2022-05-16

はじめに

以前ご紹介させていただいた、下記記事からの続編(第②弾)になります。

なお、プログラムの土台部分はこちら。

今回は前回作ったものをベースに、新たに灰色の石を追加した、3人対戦で遊べる変則リバーシを作りましたので、ご紹介したいと思います。

ash.png

できたもの

以下のような、ブラウザで遊べるものを作りました。
自身が先手(黒)で、相手(白と灰)はコンピュータ、としました。

ソースコードなど一式は、以下に置いております。

ということで、お見せしたい結果も出揃いましたので
簡単ではありますが、作った上でのポイントを説明していきたいと思います。

灰色の石とは

"灰色の石"は私が思いついた、第三の石です。(といっても、同じようなものは既にいろいろな方が、作られているとは思います)

この灰色の石はもともとの黒と白の石と、扱いは同等とします。つまり、相手の石を自分の石で挟めば、挟まれた石は自分のものになるというルールに準拠したものとします。

以下に、石をひっくり返す時の挙動を示します。

(黒が相手を挟んだ場合)

(白が相手を挟んだ場合)

(灰色が相手を挟んだ場合)

こうすると、石をひっくり返す(reverse)というよりは、色を塗り替える(repaint)といった方が、あるいは自然なのかもしれませんね。ですが、ここは名のある"リバーシ"で通させてもらいたいと思います。

石の種類と盤面の初期設定は以下とします。黒、白、灰色の石を4つづつ置き、かつ、すぐには灰色が全滅してしまわないようばらしました。

board.js
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という、相手ディスクを返す関数も追加しました。

board.js
// 自身の対戦相手を返す
// (引数)
//  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(原始モンテカルロ探索)を追加

プレイヤーの設定は以下としています。

  • 黒は人が操作
  • 白はコンピュータ(やや強い)
  • 灰はコンピュータ(ランダム)

ゲーム管理の変更

ゲーム管理を変更した結果が以下です。ターンの順番や終了判定などが、こまごまと変わっています(差分だと多いので、コードそのままを載せました)。

game.js
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;
  }
}

描画関連の変更

描画についても、灰色の石の表示処理が追加になっているほか、クリック時のプレイヤーの判定処理などにも変更が入りました。灰色の石の画像もフォルダに追加しています。

ui.js
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を強くできそうですが、今後の課題としたいと思います。

player.js
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

使い方

  1. simulator.htmlをブラウザで開き、開発者ツールを起動します。

  2. 下図のテキストボックスに対戦回数を入力し、startボタンを押してシミュレーションを開始します。
    simulator1.png

  3. 対戦が終わると以下のように結果が表示されます。
    simulator2.png
    (処理に時間がかかるため、対戦回数は様子を見ながら増やしてください)

完成

以上で、今回作ろうと思っていた、3人対戦で遊べる変則リバーシが完成しました。

灰色プレイヤーはどちらかというと、黒プレイヤーVS白プレイヤーの対戦を乱すお邪魔キャラの想定で、今回は用意しております。遊ばれる際は、白プレイヤーを倒すことを目標にプレイしてみて下さい。

もちろん、ダウンロードしたソースコードを改変して、3プレイヤーとも"人"が操作するようにすることも、"コンピュータ"に操作させるようにすることも可能です。

完成したものをプレイしている様子が、以下になります。
(灰色プレイヤーが入ってくるタイミングが、なんとも慣れませんね)

おまけ

今回の変更により、ある程度手番の順序やプレイヤー追加を単純化しました。完成品には入れなかったのですが、試してみた結果を、2つ残しておきます。

灰色が黒と白の間にも割り込むケース

手番を回す順序を、黒→→白→→黒→…というのも試してみました。

game.js
const ORDER = [B, A, W, A];          // プレイヤーの順番

灰色プレイヤーのお邪魔率が倍!展開が早いです。

5人対戦

まだ見たことがなかったので、5人対戦も試してみました。灰色プレイヤーの同類として、新たにシアンプレイヤーと山吹プレイヤーを足してみます。(両プレイヤーともランダム扱い)

board.js
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,
];

game.js
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,                   // 山吹の石の数
  },
};
ui.js
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人対戦の場合、実際問題として何色の石が有利なのか、興味がわくところでもありますね。(リバーシの三体問題、あるのかないのか…、ないか…)
最後まで読んで下さり、ありがとうございました。

本記事の続編となる以下も、よかったら見てみてください。

それでは皆様、よきリバーシ・ライフを!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?