LoginSignup
1
0

More than 1 year has passed since last update.

JavaScriptで作る変則リバーシ④

Last updated at Posted at 2022-05-29

はじめに

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

プログラムの土台部分については、下記の記事で紹介しています。

今回は前回作ったものをベースに、新たにシアン色の石山吹色の石を追加した、計6色の石を使った、3(+2)人対戦で遊べる変則リバーシを作りましたので、ご紹介したいと思います。

cyan.pngyamabuki.png

できたもの

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

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

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

シアン色の石と山吹色の石とは

"シアン色の石"と"山吹色の石"は私が思いついた、第五・第六の石です。(といっても、同じようなものは既にいろいろな方が、作られているとは思います)

このシアン色の石と山吹色の石は同じ性質で、これらの石は相手をひっくり返すことはできても、決してひっくり返されることはないという性質を備えた石とします。

こうすると、これらの石を扱うプレイヤーは、相手をひっくり返せばそれだけで勝ててしまうので、以下の扱いとします。

  • 直接の対戦相手としては登場させない
  • 決まった手数の時にだけ手を打つ

ということで、勝敗はあくまで、黒・白・灰の3プレイヤーで競い、シアン・山吹は灰プレイヤー以上にお邪魔キャラに特化したプレイヤーとなります。

はたして、これで面白いゲームになりますでしょうか!?
例によって思い付くままにやってみたいと思います。

盤面を変更する

シアンと山吹のプレイヤーが追加され、打たれる石も増えるので、やや盤面が今のままでは窮屈なようです。

そこで、今回は盤面のサイズを少し大きくしてみます。また、AIには全く影響ないのですが、ユーザーには厄介な目隠しマスを追加して、より難解な盤面を作ってみます。

board.js
const H = 0;  // 穴
const E = 1;  // 空きマス
const B = 2;  // 黒色の石
const W = 3;  // 白色の石
const A = 4;  // 灰色の石
const C = 5;  // シアン色の石
const Y = 6;  // 山吹色の石
const G = 7;  // 緑色の石
const BOARD = [
  H, H, H, H, H, H, H, H, H, H, H, H,
  H, H, H, H, H, E, E, H, H, H, H, H,
  H, H, H, H, E, E, E, E, H, H, H, H,
  H, H, H, E, E, E, E, E, E, H, H, H,
  H, H, E, E, E, G, C, E, E, E, H, H,
  H, E, E, E, Y, W, B, G, E, E, E, H,
  H, E, E, E, G, B, W, Y, E, E, E, H,
  H, H, E, E, E, C, G, E, E, E, H, H,
  H, H, H, E, E, E, E, E, E, H, H, H,
  H, H, H, H, E, E, E, E, H, H, H, H,
  H, H, H, H, H, E, E, H, H, H, H, H,
  H, H, H, H, H, H, H, H, H, H, H, H,
];
const BOARD_COLOR = [
  '*', '*', '*', '*', '*', '*', '*', '*', '*', '*', '*', '*',
  '*', '*', '*', '*', '*', '1', '2', '*', '*', '*', '*', '*',
  '*', '*', '*', '*', '1', '1', '2', '2', '*', '*', '*', '*',
  '*', '*', '*', '1', '5', '1', '2', '2', '2', '*', '*', '*',
  '*', '*', '1', '1', '1', '1', '2', '2', '6', '2', '*', '*',
  '*', '1', '1', '1', '1', '1', '2', '2', '2', '2', '2', '*',
  '*', '3', '3', '3', '3', '3', '4', '4', '4', '4', '4', '*',
  '*', '*', '3', '6', '3', '3', '4', '4', '4', '4', '*', '*',
  '*', '*', '*', '3', '3', '3', '4', '5', '4', '*', '*', '*',
  '*', '*', '*', '*', '3', '3', '4', '4', '*', '*', '*', '*',
  '*', '*', '*', '*', '*', '3', '4', '*', '*', '*', '*', '*',
  '*', '*', '*', '*', '*', '*', '*', '*', '*', '*', '*', '*',
];
const COLOR_CODE = {
  '0': 'green',
  '1': 'lightskyblue',
  '2': 'pink',
  '3': 'mediumaquamarine',
  '4': 'navajowhite',
  '5': 'white',
  '6': 'black',
  '*': '* no color *',
};

盤面を大きくした影響でブラウザの画面内に収まらなくなったので、描画も微調整します。
(ui.js)

-const BASE_IMAGE_SIZE = 40;
-const BASE_BOARD_SIZE = 10;
+const BASE_IMAGE_SIZE = 35;
+const BASE_BOARD_SIZE = 12;

ブラウザに描画した結果が以下になります。

黒と白のマスが目隠しマスで、同色の石が置かれていても分からないようになっています。

なお、シアン色の石と山吹色の石の追加方法は、第②弾でご紹介した5人対戦をご参照ください。

石の性質を追加する

相手にひっくり返されない性質は第③弾ですでに実装済みでした。ですので、今回は以下のようにひっくり返されない不変石を配列化して、複数設定できるようにするだけでOKでした。

(board.js)

-const PERMANENT = G;           // 不変石
+const PERMANENTS = [G, C, Y];  // 不変石

// 石を置く処理
// (引数)
//  turn  : プレイヤーの手番(色)
//  board : 盤面情報を格納した配列
//  index : 石を置く位置(マスを示す番号)
function putDisc(turn, board, index) {
  if (index === NO_MOVE) return [];
  const flippables = getFlippablesAtIndex(turn, board, index);
  board[index] = turn;                                                    // 手の位置にディスクを置く
  for (let flippable of flippables) {                                     // 相手のディスクをひっくり返す
-   if (board[flippable] !== PERMANENT) board[flippable] = turn;         // 不変石はひっくり返さない
+   if (!PERMANENTS.includes(board[flippable])) board[flippable] = turn;  // 不変石はひっくり返さない
  }
  return flippables.concat(index);
}

シアン色プレイヤーと山吹色プレイヤーの性格

参加プレイヤーが増えたこともあり、少しAIに性格をつけてみたいと思います。

  • シアン … なるべく少なく石が取れる手を選ぶ
  • 山吹色 … なるべく多く石が取れる手を選ぶ

ということで、追加したAIが以下。

(player.js)

const HUMAN = 'human';
const RANDOM = 'random';
+const MINIMUM = 'minimum';
+const MAXIMUM = 'maximum';
const MCS = 'mcs';

// プレイヤー
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 MINIMUM:
+       move = getMoveByMinimum(game);
+       break;
+     case MAXIMUM:
+       move = getMoveByMaximum(game);
+       break;
      case MCS:
        move = getMoveByMonteCarloSearch(game, MCS_SCHEDULE);
        break;
      default:
        break;
    }
    return putDisc(game.turn, game.board, move);
  }
}

+// なるべく少なく石が取れる手を返す
+// (引数)
+//  game  : ゲーム情報
+// (戻り値)
+//  return : コンピュータの手(マスを表す番号)
+function getMoveByMinimum(game) {
+  const turn = game.turn;
+  const board = game.board;
+  const legalMoves = getLegalMoves(turn, board);
+  return legalMoves.reduce((a, b) => getFlippablesAtIndex(turn, board, a).length < getFlippablesAtIndex(turn, board, b).length ? a : b);
+}
+
+// なるべく多く石が取れる手を返す
+// (引数)
+//  game  : ゲーム情報
+// (戻り値)
+//  return : コンピュータの手(マスを表す番号)
+function getMoveByMaximum(game) {
+  const turn = game.turn;
+  const board = game.board;
+  const legalMoves = getLegalMoves(turn, board);
+  return legalMoves.reduce((a, b) => getFlippablesAtIndex(turn, board, a).length > getFlippablesAtIndex(turn, board, b).length ? a : b);
+}

reduceを使って、最大・最小を求めています。

(game.js)

const FLIPPERS = {                     // 使用可能な全プレイヤー情報(石をひっくり返せる)
  [B]: {                               // 黒
    'player'   : new Player(HUMAN),    // 人が操作
    'opponents': [W, A, C, Y, G],      // 対戦相手 + 置き石
    'score'    : 0,                    // 黒の石の数
  },
  [W]: {                               // 白
    'player'   : new Player(MCS),      // コンピュータが操作(原始モンテカルロ探索)
    'opponents': [B, A, C, Y, G],      // 対戦相手 + 置き石
    'score'    : 0,                    // 白の石の数
  },
  [A]: {                               // 灰
    'player'   : new Player(RANDOM),   // コンピュータが操作(ランダム)
    'opponents': [B, W, C, Y, G],      // 対戦相手 + 置き石
    'score'    : 0,                    // 灰の石の数
  },
  [C]: {                               // シアン
-   'player'   : new Player(RANDOM),   // コンピュータが操作(ランダム)
+   'player'   : new Player(MINIMUM),  // コンピュータが操作(Minimum)
    'opponents': [B, W, A, Y, G],      // 対戦相手 + 置き石
    'score'    : 0,                    // シアンの石の数
  },
  [Y]: {                               // 山吹
-   'player'   : new Player(RANDOM),   // コンピュータが操作(ランダム)
+   'player'   : new Player(MAXIMUM),  // コンピュータが操作(Maximum)
    'opponents': [B, W, A, C, G],      // 対戦相手 + 置き石
    'score'    : 0,                    // 山吹の石の数
  },
};

盤面サイズが大きくなったので、白プレイヤーのAIも微調整しておきます。

(player.js)

const MCS_SCHEDULE = [
- [40, 34, 28, 22,  16,  13,  10,   6],  // 空きマス残り
- [20, 40, 60, 80, 120, 200, 400, 800],  // プレイアウト回数
+ [48, 40, 34, 28, 22,  16,  13,  10,   6],  // 空きマス残り
+ [10, 20, 40, 60, 80, 120, 200, 400, 800],  // プレイアウト回数
];

決まった手数の時にだけ打つプレイヤー

さて、ここからが本記事で一番重要な部分になります。

シアンプレイヤーと山吹色プレイヤーは、毎回入ってくると強すぎるので、決まった手数の時にだけ参加する、割り込みプレイヤーとします。

打ってくる手数は以下とします。

  • シアン … 10手目
  • 山吹色 … 30手目と40手目

game.jsには以下の変更を加えました。

  • 割り込みプレイヤーの設定(CUTINS)を追加
  • ゲームオブジェクト作成時の引数を追加
  • 開始からの手数の管理を追加
  • 割り込みプレイヤーの管理を追加

割り込みプレイヤーの設定(CUTINS)を追加

割り込みプレイヤーの割込みタイミングと、どのプレイヤーが割り込むかの設定内容です。

game.js
const NO_CUTIN = -1;
const CUTINS = {                       // 割り込みプレイヤー
  10: C,                               // 10手目でシアンが打つ
  30: Y,                               // 30手目で山吹色が打つ
  40: Y,                               // 40手目で山吹色が打つ
};

ゲームオブジェクト作成時の引数を追加

ゲームオブジェクト生成時、新たに割込みプレイヤー設定を引数で受け取るよう変更しました。

初手で割り込みが発生するケースの処置も合わせて追加しています。だいぶ複雑になってきましたね…。

game.js
// ゲームの管理
class Game {
  constructor(board, order, flippers, cutins) {
    this.board = board.concat();
    this.moveCount = 1;
    this.order = this.moveCount in cutins ? [cutins[this.moveCount]].concat(order) : order.concat();
    this.turnIndex = 0;
    this.cutInIndex = this.moveCount in cutins ? this.turnIndex : NO_CUTIN;
    this.turn = this.order[this.turnIndex];
    this.flippers = this.copyFlippers(flippers);
    this.player = this.flippers[this.turn].player;
    this.cutins = cutins;
    this.participants = [...new Set(order)];
    this.pass = 0;
    this.wait = WAIT_TIME;
    this.humanMove = NO_MOVE;
    this.updateScore();
    this.updatedDiscs = [];
    this.state = GAME_INIT;
  }

開始からの手数の管理を追加

手数は一手打つごとにインクリメントするのみです。

(game.js)

  // 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.put === NO_MOVE) return GAME_STOP;
+   this.moveCount++;
    this.setNextPlayer();
    return GAME_PLAY;
  }

割り込みプレイヤーの管理を追加

次のプレイヤーを設定するタイミングで、割り込みプレイヤーの管理を行います。

管理する内容としては

  • 1手打ち終わったあと、その手番に割り込みプレイヤーが割り込んでいたら、this.orderから削除
  • また、次回の手番で割り込みプレイヤーが割り込む場合は、そのプレイヤーをthis.orderに追加

を行っています。

(game.js)

  // 次のプレイヤーを設定する
  setNextPlayer() {
+   // 今回の割り込みプレイヤー削除
+   this.deleteCutInPlayer();
    // 手番を回す
    this.turnIndex++;
    if (this.turnIndex >= this.order.length) this.turnIndex = 0;
+   // 次回の割り込みプレイヤー追加
+   this.addCutInPlayer();
    // プレイヤー設定更新
    this.turn = this.order[this.turnIndex];
    this.player = this.flippers[this.turn].player;
  }

+ // 割り込みプレイヤーを削除する
+ deleteCutInPlayer() {
+   if (this.cutInIndex != NO_CUTIN) {
+     if (this.turnIndex >= this.cutInIndex) this.turnIndex--;
+     this.order.splice(this.cutInIndex, 1);
+     this.cutInIndex = NO_CUTIN;
+   }
+ }
+
+ // 割り込みプレイヤーを追加する
+ addCutInPlayer() {
+   if (this.moveCount in this.cutins) {
+     this.cutInIndex = this.turnIndex;
+     this.order.splice(this.cutInIndex, 0, this.cutins[this.moveCount]);
+   }
+ }

this.turnIndexは現在の手番の位置、this.cutInIndexは割込みプレイヤーが割り込んだ位置を表しています。

配列のspliceメソッドを使って、配列の途中へ値を追加したり、配列の途中の値を削除したりしています。

  • splice(変更する位置, 0, 追加する要素) … 追加
  • splice(変更する位置, 削除する要素の数) … 削除

割り込みプレイヤーの管理方法

以下に3手目にシアンが割り込む場合のイメージを示します。

1手目(黒の手番)

手番を回すorder配列に、B→W→Aの順番で手番を回すよう値がセットされています。1手目はturnIndexに0が格納されていることから、B(黒)が手を打ちます。
move1.png

2手目(白の手番)

1手目が終わると、moveCountturnIndexはインクリメントされ、次の手番のW(白)が手を打ちます。
move2.png

3手目(シアンの割り込み)

2手目が終わると、同様にmoveCountturnIndexがインクリメントされます。3手目にC(シアン)が割り込むよう設定されている場合は、ここでturnIndexの位置にシアンが割り込みます。
move3.png
次の手番で、割り込んだシアンを削除するために、その位置を示すcutInIndexturnIndexを代入しておきます。

このようにして、割り込みプレイヤーの追加削除を管理し、ゲーム終了の判定やパスのメッセージ表示をorder配列に基づいて処理する設計としました。

原始モンテカルロ探索の変更点

原始モンテカルロ探索のコードには、ゲームオブジェクト生成時の引数追加が変更になっています。そのほかには特に変更は不要でした。コードの内容は省略させていただきます。シアンプレイヤーと山吹プレイヤーの性格を決めましたが、プレイアウトの際はこれらを無視し、全プレイヤーがランダムに振る舞うものとして扱います。

完成

以上で、今回作ろうと思っていた、6色の石を使った、3(+2)人対戦で遊べる変則リバーシが完成しました。

完成したものをプレイしている様子が、以下になります。
(目隠しマス、あとあと厄介になるかも!?)

おまけ

対戦相手がさらに増え、展開も早くなり相手がどこに打ったか目が追い付かなくなってきました。そこで、手を打った場所のマスをハイライトして目印を付けてみようと思います。

主に以下を変更しました。

  • ハイライトの色を追加
  • putDiscの戻り値を変更
  • ハイライトの色塗り処理を追加

ハイライトの色を追加

淡いラベンダー色をハイライト時に使うようにしてみました。

(board.js)

const COLOR_CODE = {
  '0': 'green',
  '1': 'lightskyblue',
  '2': 'pink',
  '3': 'mediumaquamarine',
  '4': 'navajowhite',
  '5': 'white',
  '6': 'black',
+ '7': '#DED5FA',
  '*': '* no color *',
};

putDiscの戻り値を変更

putDiscの戻り値は、これまで打った手とひっくり返した石を合わせたものを配列で返していました。今回これをハイライトに対応するため、打った手putとひっくり返した石flippedを区別して連想配列で返すよう変更しました。これにより打った手の位置を特定できるようになりました。

(board.js)

// 石を置く処理
// (引数)
//  turn  : プレイヤーの手番(色)
//  board : 盤面情報を格納した配列
-//  return : 更新された石
+//  index : 石を置く位置(マスを示す番号)
// (戻り値)
//  return : 置いた石、ひっくり返した石
function putDisc(turn, board, index) {
- if (index === NO_MOVE) return [];
+ if (index === NO_MOVE) return {'put': NO_MOVE, 'flipped': []};
  const flippables = getFlippablesAtIndex(turn, board, index);
  board[index] = turn;                                                    // 手の位置にディスクを置く
  for (let flippable of flippables) {                                     // 相手のディスクをひっくり返す
    if (!PERMANENTS.includes(board[flippable])) board[flippable] = turn;  // 不変石はひっくり返さない
  }
- return flippables.concat(index);
+ return {'put': index, 'flipped': flippables};
}

ハイライトの色塗り処理を追加

打った手のハイライト処理は以下になります。

  • 前回の手のハイライトを元のマスの色に戻す
  • 今回の手をハイライトする

(ui.js)

+let prePut = NO_MOVE;

// 盤面の更新
function updateUi() {
- setupDiscs(reversi.updatedDiscs);
+ updateDiscs();
  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(' : ');
}

+// 石を更新
+function updateDiscs() {
+  const {put, flipped} = reversi.updatedDiscs;
+  if (put === NO_MOVE) return;
+  // 前回の手のハイライトを消す
+  if (prePut !== NO_MOVE) {
+    const square = document.getElementById(UI_BOARD + prePut);
+    square.style.backgroundColor = COLOR_CODE[BOARD_COLOR[prePut]];
+  }
+  // 石を置く
+  setupDiscs(flipped.concat(put));
+  // 今回の手をハイライト
+  document.getElementById(UI_BOARD + put).style.backgroundColor = COLOR_CODE['7'];
+  prePut = put;
+}

結果

ハイライトに対応した結果を以下に示します。

色が多すぎて、あまり目立ってないですね。まあ、ないよりはマシかなという事で良しさせて下さい。

最後に

ユーザーを邪魔する要素が増えた、より先の展開が見えにくいリバーシを作ってみましたが、いかがでしたでしょうか。ランダム性が強いですが、このゲームならではの勝つための定石はあるのか、興味がわくところでもありますね。(結局、お邪魔キャラ次第だと思いますが…)
最後まで読んで下さり、ありがとうございました。

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

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

1
0
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
0