2
0

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-22

はじめに

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

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

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

green.png

できたもの

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

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

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

緑色の石とは

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

この緑色の石は、状況に応じて何にでもなれるワイルドカードのようなもので、常に手番のプレイヤーが、最も多く石を取れる色として振る舞う性質を備えたものとします。

加えて、他の石に挟まれたとしてもひっくり返ることはなく、また、この石を打つことができるプレイヤーもいない(面倒なので用意しない)、単なる置き石の扱いとします。

それでは、緑色の石の性質がどのようなものか、具体的に示していきたいと思います。

石が置けるパターン

緑色の石がどのように振る舞うか、まずは簡単な例として以下の盤面でその様子を示したいと思います。
green4x4_1.png

手番は"黒"の場合で考えます(白や灰も色が変わるだけで同じ扱いとなります)。上記の盤面で、石が置ける場所は以下の黄色で示した①~⑤の箇所になります。
green4x4_2.png

それぞれの箇所に置いたときの結果と、石が置ける理由を以下にまとめます。

番号 置いた結果 置ける理由
緑を挟んでいる
灰を挟んでいる
緑が黒として振る舞うと、灰を挟んでいる
白を挟んでいる
緑が黒として振る舞うと、白を挟んでいる

上記から、緑色の石は以下の性質を備えたもの、と言い換えることができます。

  • 黒(手番)の石同士で、緑色の石を挟むことができる
  • 黒(手番)の石と緑色の石で、相手の石を挟むことができる

複数の緑色の石の振る舞い

冒頭で、緑色の石は手番のプレイヤーが、最も多く石を取れる色として振る舞うものと前置きしました。この特徴を端的に理解するための例として、以下の盤面を用いたいと思います。
green6x6_1.png

こちらも、手番は"黒"の場合で考えます。

上記盤面で、石が置ける場所は以下の黄色で示した①~③の箇所になります。
green6x6_2.png

それぞれの箇所に置いたときの結果を以下にまとめます。

番号 置いた結果 振る舞いの理由
緑が黒として振る舞うと、白と灰の計2石返せる
緑が緑のままとして振る舞うと、白、灰、白の計3石返せる
上側の緑が黒、下側の緑が緑のままとして振る舞うと、白、灰、白の計3石返せる

③の例のように、複数の緑の石がある場合、以下のようにそれぞれ異なる色として振る舞うことができます。

  • 上側の緑の石 → 黒として振る舞う
  • 下側の緑の石 → 緑のままとして振る舞う

このような緑色の石の振る舞いにより、手番のプレイヤーが、最も多く石を取れていることが分かるかと思います。

緑色の石の実装

作りたいものが決まったところで、さっそく実装に入っていきます。

緑色の石の実装には、大きく以下を行いました。

  • 石の描画を追加
  • 石の性質を追加

石の描画を追加する

灰色の石の時と同様、盤面を表す状態に"緑"を追加し、初期配置を変えます。あわせて、描画用の設定も追加します。

(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, 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, E, E, E, G, A, E, E, E, H,
+ H, E, E, A, W, B, G, E, E, H,
+ H, E, E, G, B, W, A, E, E, H,
+ H, E, E, E, A, G, 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,
];

(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',
  },
+ [G]: {
+   'name': 'Green',
+   'img' : './image/green.png',
+ },
};

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

石の性質を追加する

緑色の石の性質を追加するために、以下を行いました。

  • 手番の相手を返す処理に緑を追加
  • ひっくり返せる石を取得する処理に緑色の石を追加する
  • 石を置いてひっくり返す際に緑色の石は除外とする

手番の相手を返す処理に緑を追加

手番の"相手"の定義は、これまで以下としていました。

  • 自身が黒の場合は、白か灰(かシアンか山吹※以降は省略)
  • 自身が白の場合は、灰か黒
  • 自身が灰の場合は、黒か白

今回はこれに緑を追加して、以下のように変更しています。

  • 自身が黒の場合は、白か灰か
  • 自身が白の場合は、灰か黒か
  • 自身が灰の場合は、黒か白か
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),  // コンピュータが操作(ランダム)
    'opponents': [B, W, A, Y, G],     // 対戦相手 + 置き石
    'score'    : 0,                   // シアンの石の数
  },
  [Y]: {                              // 山吹
    'player'   : new Player(RANDOM),  // コンピュータが操作(ランダム)
    'opponents': [B, W, A, C, G],     // 対戦相手 + 置き石
    'score'    : 0,                   // 山吹の石の数
  },
};

ひっくり返せる石を取得する処理に緑色の石を追加する

緑色の石の性質を決める、本記事でもっとも重要な部分になります。

先の節で説明した、手番の相手を返す処理に緑の石を追加したことにより、手番の石で挟んでいる石については、すでに最大限取得できる作りになっています。しかし、手番の石で挟んでいない場合については何も処理をしていません。

このままでは、手番の石と緑色の石で相手を挟んでいる場合に、石をひっくり返すことができませんね。

そこで、相手の石が手番の石で挟まれていない場合に、以下の処理を加えました。

  1. ひっくり返せる石の候補を後ろからたどって、最初の緑色の石を探す
  2. 先頭から緑色の石が見つかったところまでの石を、ひっくり返せる石として結果に格納する

プログラム中では、この石の性質を変化石(WILDCARD)と定義し、緑色の石を割り当てる設計としました。
(board.js)

const WILDCARD = G;   // 変化石

// ひっくり返せる石を取得する処理
// (引数)
//  turn  : プレイヤーの手番(色)
//  board : 盤面情報を格納した配列
//  index : 石を置く位置(マスを示す番号)
// (戻り値)
//  flippables : ひっくり返せる石の位置(マスを示す番号)の配列
function getFlippablesAtIndex(turn, board, index) {
  let flippables = [];
  if (board[index] !== E) return flippables;  // 空きマス以外はスキップ
  const opponents = getOpponentColors(turn);
  const size = Math.sqrt(board.length);
  for (let {x, y} of DIRECTION_XY) {
    const dir = (size * y) + x;
    let opponentDiscs = [];
    let next = index + dir;
    // 相手ディスクが連続しているものを候補とする
    while (opponents.includes(board[next])) {
      opponentDiscs.push(next);
      next += dir;
    }
    // 連続が途切れた箇所が自ディスクの場合、候補を戻り値に追加
    if (board[next] === turn) {
      flippables = flippables.concat(opponentDiscs);
    }
+   else {
+     while (opponentDiscs.length) {
+       // 候補をpopし、変化石を探す
+       if (board[opponentDiscs.pop()] === WILDCARD) {
+         // 変化石が見つかったら、残りの候補を戻り値に追加
+         flippables = flippables.concat(opponentDiscs);
+         break;
+       }
+     }
+   }
  }
  return flippables;
}

以下の盤面で、矢印の位置に黒の石を置いた場合を考えてみます。
flip1.png

プログラムは、置いた場所の1マス先から相手の石が連続する間、ひっくり返せる候補としてその石を記憶していきます。
flip2.png

今回の例の場合、盤面の端まで探しても黒がなかったので、以下の黄色枠が候補として、配列に格納されています。
flip3.png

以前は、ここで候補を破棄していました。(挟めないことが分かったので)

今回はここから黄色枠の配列を後ろにたどる処理を追加しています。具体的には、候補の配列の中身がなくなるまでpopして緑色の石を探しています。popして得られた位置に緑色の石があれば、その時点で残っている候補がひっくり返せる石となるわけです。

この例の場合の処理結果としては、以下のオレンジ枠がひっくり返せる石となります。popで後ろから探すことで、割と簡単に石を最大限取る仕様が満たせましたね。
flip4.png

石を置いてひっくり返す際に緑色の石は除外とする

ひっくり返らない石については、これを不変石(PERMANENT)と定義して、変化石同様に緑の石を割り当てました。

(board.js)

const PERMANENT = G;  // 不変石

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

完成

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

思い付くままに作ってみましたが、意外とそれなりに動くものになりましたね。ただただルールが複雑なだけの代物ですが、これもソフトウェアならではとして大目に見ていただければと思います。あとは見た目の色合いとかもいい感じにしたいですが、自分にはセンスが…。

完成したものをプレイしている様子が、以下になります。
(今回の見せ場は、ひっくり返す時に自分の石が緑色の石を突き抜けていくところですね)

おまけ

本題とはそれるのですが、ユーザビリティ向上のため、以下も対応したので残しておきます。

  • ウィンドウサイズに応じた盤面のリサイズ

ウィンドウサイズに応じて盤面をリサイズする

いままでは盤面のサイズを固定で描画していましたが、ブラウザ基準とは異なりスマホやタブレットで表示した際に、ややサイズが小さくなっていたのが不満に思っていました。やはり画面サイズに合わせて、なるべく大きく表示させたいところです。

ということで、今回はその対応を入れてみました。

ウィンドウをリサイズする処理は以下としました。

  1. 基準のウィンドウサイズと、その時の石の画像サイズを決める
  2. 実際のウィンドウサイズの高さと幅の1.25倍のうち短い方を取ってくる
  3. 1.のサイズに対する2.のサイズの比に比例して、石の画像のサイズを計算しなおす
  4. 3.に対して、ボードの一辺が10を基準に現在のボードサイズに反比例して、石の画像のサイズを計算しなおす
  5. テーブルサイズは石の画像の1.5倍とする

もともとのゲーム画面のサイズが縦長なため、縦と横をそのままの割合でサイズ調整すると、スマホなど縦長の画面の時には、横幅が余っていました。そのため、2.で幅を1.25倍して横幅に対しては大きめにサイズ調整するよう合わせこみました。

このリサイズ処理は、以下のタイミングで呼ぶようにしています。

  • ウィンドウサイズ変更時(window.onresize)
  • ゲーム初期化時
  • ゲーム画面更新時

(main.js)

let reversi = null;

// ゲームの開始
function start() {
  reversi = new Game(BOARD, ORDER, FLIPPERS);
  initUi();
  reversi.loop();
}

+window.onresize = resizeUi;
start();

(ui.js)

+const BASE_UI_SIZE = 930;
+const BASE_IMAGE_SIZE = 40;
+const BASE_BOARD_SIZE = 10;
+const MIN_IMAGE_SIZE = 25;
+const WIDTH_ASPECT = 1.25;
+
+let imageSize = BASE_IMAGE_SIZE * (BASE_BOARD_SIZE / BOARD_SIZE);
+let tableSize = imageSize * 1.5;

// 盤面の初期設定
function initUi() {
  createBoardTable();  // 盤面のテーブルを作成
  updateUi();          // ゲーム情報を反映
+ resizeUi();          // 盤面のリサイズ
}

// 盤面のテーブル作成
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;
+   square.width = tableSize;
+   square.height = tableSize;
    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 resizeUi(){
+  const innerWidth = window.innerWidth * WIDTH_ASPECT;
+  const innerHeight = window.innerHeight;
+  const uiSize = innerWidth < innerHeight ? innerWidth : innerHeight;
+  imageSize = BASE_IMAGE_SIZE * (uiSize / BASE_UI_SIZE) * (BASE_BOARD_SIZE / BOARD_SIZE);
+  imageSize = imageSize < MIN_IMAGE_SIZE ? MIN_IMAGE_SIZE : imageSize;
+  tableSize = imageSize * 1.5;
+  for (let i=0; i<BOARD.length; i++) {
+    // マス
+    let square = document.getElementById(UI_BOARD + i);
+    square.width = tableSize;
+    square.height = tableSize;
+    // 石
+    const disc = reversi.board[i];
+    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)
+ img.src = imgPath;                          // 画像パス
+ img.width = imageSize;                      // 横サイズ(px)
+ img.height = imageSize;                     // 縦サイズ(px)
  element.appendChild(img);                   // 画像追加
}

無事、スマホでも大きめに表示されるようになりましたね。

ブラウザのサイズ変更にもついて来てくれてます。よかった、よかった。

最後に

緑色の変わった石が加わった、より戦略性が求められるリバーシを作ってみましたが、いかがでしたでしょうか。緑色の石をどのように使うと勝ちやすいのか、興味がわくところでもありますね。(端っこや確定石狙いはやはり変則ルールでも安定感ありますね)
最後まで読んで下さり、ありがとうございました。

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

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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?