3
5

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

自己紹介

はじめまして、y-tetsuと申します。普段は車載機器のエンジニアとして働いております。3年ほど前にPythonとAIを学ぶぞ!!と、リバーシのプログラムを書き始めて以来、リバーシ作りにはまっています(あくまで日曜大工的に、気ままに作っているものですが)。

最近はJavaScriptに興味がわきまして、相変わらず新しい気持ちでリバーシ作りを始めています。せっかくなので何か変わった物をと思い、今回変則リバーシを作るに至りました。

ちなみに、リバーシを作るのは好きですが、自分でプレイする方はてんで弱いです(先読みのコツが未だにわかりません)。

皆様これから、よろしくお願いします。

はじめに

本記事は、これから数回に分けてご紹介していく変則リバーシの、土台となるプログラムの説明記事です。関連する記事の全体構成は以下のラインナップとなっています。

導入編

JavaScriptで作った簡単なリバーシ (※本記事)

変則ルール編

盤面がいつもとは違う変則リバーシ

3人対戦で遊べる変則リバーシ

4色の石を使った、3人対戦で遊べる変則リバーシ

6色の石を使った、3(+2)人対戦で遊べる変則リバーシ

7色の石を使った、3(+2)人対戦で遊べる変則リバーシ

今回は導入編ということで、ごく普通に動くJavaScriptで作った簡単なリバーシをご紹介したいと思います。

できたもの

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

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

ということで、お見せしたい結果も出揃いましたので、作ったものについて説明していきたいと思います。

ソフト構成について

まずは、ソフトの中身をざっくりとお見せします。

ディレクトリ構成

ディレクトリ構成は以下です。

colorful-reversi
│  index.html
│
├─css                // スタイルシート
│      style.css
│
├─image              // 画像ファイル
│      black.png
│      white.png
│
├─src                // JavaScriptモジュール
│      board.js
│      game.js
│      main.js
│      player.js
│      ui.js
└─test               // テストコード
       assert.js
       baord_test.js
       game_test.js
       player_test.js
       ui_test.js
       test.html

モジュール概要

JavaScriptの各モジュールの概要は以下です。

モジュール名 概要
main.js ゲームの初期設定および、ゲームの開始を行う
board.js 盤面の初期設定および、打てる手の取得や石を置いてひっくり返す処理を行う
game.js ゲームの進行や勝敗の判定を行う
player.js プレイヤー(ユーザーやコンピュータ)が、一手選んで打つ処理を行う
ui.js ゲーム画面の描画および、ユーザーのクリック操作(着手)を取得する処理を行う

全体イメージ

おおまかなプログラム処理の全体イメージを示します。灰色ハッチング部分はプログラム外の部分を表しています。また、細かな処理は省略しており、実際のプログラムと完全に一致しない部分もございますのでご注意ください。
program_image1.png

Webページの構成について

JavaScriptの処理内容を説明する前に、Webページの構成についても軽く触れておきます。

HTML

Webページの画面構成を決めています。また、CSSとJavaScriptの読み込みも行っています。

画面の構成は、上から順に
 ・手番とスコア
 ・盤面
 ・ソースリポジトリへのリンク
としています。

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>colorful-reversi</title>
    <link rel="stylesheet" type="text/css" href="./css/style.css">
</head>

<body>
    <p><span id="turn">---</span> <span id="score">---</span></p>
    <div id="ui_board"></div>
    <script src="src/board.js"></script>
    <script src="src/player.js"></script>
    <script src="src/game.js"></script>
    <script src="src/ui.js"></script>
    <script src="src/main.js"></script>
    <a href=https://github.com/y-tetsu/colorful-reversi>GitHub y-tetsu/colorful-reversi</a>
</body>

</html>

ゲームの進行に合わせて、各idを設定している要素の内容が、JavaScriptにより更新されます。

id名 内容
turn 手番を表示するテキスト
score 黒と白のスコア(石の数)を表示するテキスト
ui_board 盤面を表示するテーブル

実行方法

  1. 以下のサイトにアクセスし、ソース一式をダウンロードする
    https://github.com/y-tetsu/colorful-reversi/tree/ch00
    (ダウンロード方法)
    サイトアクセス後、CodeメニューからDownload Zipを選択する。
    download.png

  2. 任意のフォルダにて1.のZIPを解凍する。

  3. 2.の中身のindex.htmlをブラウザで開く。

CSS

背景や盤面、テキストなどの書式を設定しています。1行1行かみしめるように、設定の内容をコメントで記載してみました。

style.css
body {                         /* (全体)                 */
    text-align: center;        /* テキスト中央寄せ       */
    color:#C0C0C0;             /* テキストの色           */
    background-color:#303030;  /* 背景の色               */
}

p {                            /* (手番とスコア)         */
    font-size: 2rem;           /* フォントサイズ         */
}

img {                          /* (石の画像)             */
    display: block;            /* 中央寄せ               */
    margin: auto;              /* 周囲の余白 : 自動調整  */
}

div#ui_board table {           /* (表全体)               */
    margin: auto;              /* 周囲の余白 : 自動調整  */
    color: #E0E0E0;            /* テキストの色           */
    background: #303030;       /* 背景色                 */
    font-size: 1.5rem;         /* フォントサイズ         */
}

div#ui_board td {              /* (表の要素)             */
    border:2px solid #303030;  /* 枠線 : サイズ 1本線 色 */
}

a:link {                       /* (リンク:未訪問)        */
    color: #F0F0F0;            /* テキストの色           */
}

a:visited {                    /* (リンク:訪問済み)      */
    color: #F0F0F0;            /* テキストの色           */
}

a:hover {                      /* (リンク:ホバー時)      */
    color: #FFFFFF;            /* テキストの色           */
    background: #0000DD;       /* 背景色                 */
}

a:active {                     /* (リンク:クリック時)    */
    color: #FF0000;            /* テキストの色           */
}

画像ファイル

石の表示には画像ファイルを用いました。JavaScriptを使ってtableタグの各要素に動的に追加・変更しています。

ファイル名 用途 画像
white.png 白の石 white.png
black.png 黒の石 black.png

JavaScriptモジュール

先に概要を示した各モジュールを組み合わせて、ゲームの進行とその内容に合わせた画面の表示(盤面の石の表示や手番、スコア)を行います。

テストコード

JavaScriptモジュールのテストコードです。簡易的なもので基本処理の動作確認をしています。詳細はおまけをご参照ください。

以上で、Webページを構成する個々の要素についての説明を終わります。

基本的な処理について

ここでは、ブラウザ上で遊べるリバーシを作るために必要となる、JavaScriptの基本的なプログラムの処理についてご説明したいと思います。

これは、次回以降に説明する変則リバーシの土台となるものです。私個人の理解しやすさを優先させて作ったため、処理の効率などあまり考慮できていません。また、知識不足による不備なども多々あるかと思いますが、今後も継続してより良くしていきたいと思います。

あわせて、今回プログラムを作りながら覚えた、初学者目線のJavaScriptの知識や、作ったときに考えた内容などを、以下の"メモ"や"制約事項"の形で残していきます。

メモ

制約事項

盤面

board_icon.png
盤面に関係する処理は、board.jsにまとめました。

盤面情報の表現

以下のような、リバーシで遊ぶ8x8マスの周りに"壁"をおいた、10x10マスの盤面を1次元の配列で表すことにしました。

board.js
const X = 0;  // 壁
const E = 1;  // 空きマス
const B = 2;  // 黒色の石
const W = 3;  // 白色の石

const BOARD = [
  X, X, X, X, X, X, X, X, X, X,
  X, E, E, E, E, E, E, E, E, X,
  X, E, E, E, E, E, E, E, E, X,
  X, E, E, E, E, E, E, E, E, X,
  X, E, E, E, W, B, E, E, E, X,
  X, E, E, E, B, W, E, E, E, X,
  X, E, E, E, E, E, E, E, E, X,
  X, E, E, E, E, E, E, E, E, X,
  X, E, E, E, E, E, E, E, E, X,
  X, X, X, X, X, X, X, X, X, X,
];

マスの状態を以下の4つ分用意して、盤面の状態を記憶します。

状態 定数名 意味
X 石が置けない
空きマス E 石が置ける
黒の石 B 黒の石が置かれている(他の石は直接置けない)
白の石 W 白の石が置かれている(他の石は直接置けない)

Xで外枠を囲うことで、ひっくり返せる石を探す際に、盤面の端で処理が簡単に止まるようにしています。

マスの番号

配列のインデックス(添え字)に対応するマスは以下の通りです。手を打つマスはこのインデックス(番号)にて処理しています。例えば、遊べる盤面の左上の角へはBOARD[11]でアクセスします。

ブラウザ表示

この配列の情報を、ブラウザへは以下のように表示します。Xで囲われた外枠部分は背景色とし、左の列と上の行に見出しを追加しています。(詳細は後述の盤面作成で紹介しています。)

const宣言されたものは定数を表し、プログラム実行中に書き換えることはできません。本記事では定数を、大文字と"_"(アンダーバー)のみで記載するものとします。

ひっくり返せる石を探す

指定したマスの位置から上下左右斜めの8方向について、相手の石を自分の石で挟んでいる箇所を返しています。ただし、指定した位置が空いていない場合は、空の配列を返しています。

8方向へ1マス探索を進める移動量は(X, Y)座標で定義しています。探索の際には以下にて

const dir = (size * y) + x;

「(size × Y方向の移動量) + X方向の移動量」を計算し、1次元配列での移動量に換算しています。

board.js
const DIRECTION_XY = [
  {'x': 0, 'y':-1},  // 上
  {'x': 1, 'y':-1},  // 右上
  {'x': 1, 'y': 0},  // 右
  {'x': 1, 'y': 1},  // 右下
  {'x': 0, 'y': 1},  // 下
  {'x':-1, 'y': 1},  // 左下
  {'x':-1, 'y': 0},  // 左
  {'x':-1, 'y':-1},  // 左上
];

// ひっくり返せる石を取得する処理
// (引数)
//  turn  : プレイヤーの手番(色)
//  board : 盤面情報を格納した配列
//  index : 石を置く位置(マスを示す番号)
// (戻り値)
//  flippables : ひっくり返せる石の位置(マスを示す番号)の配列
function getFlippablesAtIndex(turn, board, index) {
  let flippables = [];
  if (board[index] !== E) return flippables;  // 空きマス以外はスキップ
  const opponent = turn === B ? W : B;
  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 (opponent === board[next]) {
      opponentDiscs.push(next);
      next += dir;
    }
    // 連続が途切れた箇所が自ディスクの場合、候補を戻り値に追加
    if (board[next] === turn) {
      flippables = flippables.concat(opponentDiscs);
    }
  }
  return flippables;
}

分割代入を利用すると、連想配列の値を楽に取得することができます。

for (let {x, y} of DIRECTION_XY) {

打てる手を取得する

盤面の全マスについて、ひっくり返せる石が見つかった箇所すべてを打てる手(合法手)として返すようにしています。

board.js
// 打てる手を取得する処理
// (引数)
//  turn  : プレイヤーの手番(色)
//  board : 盤面情報を格納した配列
// (戻り値)
//  legalMoves : 打てる手(マスを示す番号)の配列
function getLegalMoves(turn, board) {
  let legalMoves = [];
  for (let i=0; i<board.length; i++) {
    const flippables = getFlippablesAtIndex(turn, board, i);
    if (flippables.length > 0) legalMoves.push(i);
  }
  return legalMoves;
}

変数宣言には、letvarがあります。

  • let … 変数が宣言された関数の外では使えない
  • var … 変数が宣言された関数の外でも使える

本記事では、letのみ使用し変数が書き換わる箇所をなるべく限定するものとします。また、変数名はcamelCaseのような先頭小文字で、以降の単語先頭は大文字のキャメルケースで統一するものとします。

石を置く

指定した位置と、ひっくり返せる石を全てプレイヤーの手番の石に置き換えます。また、置いた石とひっくり返した石の位置を返します。

board.js
// 石を置く処理
// (引数)
//  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;
  }
  return flippables.concat(index);
}

比較演算子の=====は異なります。

console.log(1 == 1);     // true
console.log(1 === 1);    // true
console.log("1" == 1);   // true
console.log("1" === 1);  // false

==は型変換して一致を試みますが、===は型の違いを厳密にチェックします。!=!==の違いも同様です。(本記事ではすべて===および!==を使うものとします)

ゲーム進行

game_icon.png
ゲームの進行や勝敗判定はgame.jsにまとめました。

初期化処理

ボードの初期状態の取得など、ゲーム開始前に準備する処理です。

game.js
const BLACK = new Player(HUMAN);   // 人が操作
const WHITE = new Player(RANDOM);  // コンピュータが操作(ランダム)
const PASS_END = 2;                // 終了判定用のパス累積回数
const WAIT_TIME = 800;             // ウェイト時間(ms)
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!';

// ゲームの管理
class Game {
  constructor(board, turn, black, white) {
    this.board = board.concat();
    this.turn = turn;
    this.black = black;
    this.white = white;
    this.player = this.turn === B ? this.black : this.white;
    this.pass = 0;
    this.wait = this.getWaitTime();
    this.humanMove = NO_MOVE;
    this.updateScore();
    this.updatedDiscs = [];
    this.state = GAME_INIT;
  }

  // 待ち時間取得
  getWaitTime() {
    // ユーザー同士、コンピュータ同士の場合はウェイトなし
    const isNotSamePlayer = (this.black.name !== this.white.name);
    const human = (this.black.name === HUMAN || this.white.name === HUMAN);
    return (isNotSamePlayer && human) ? WAIT_TIME : 0;
  }

ゲームループ

setTimeout()を使って、再帰的にloop()を呼ぶことで、ゲームを進行させています。

game.js
  // ゲームループ
  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;
  }

loop()が呼ばれるタイミングは、ゲーム開始時ユーザークリック時の2箇所あります。

ユーザーとコンピュータ、もしくはユーザー同士が対戦する際は、以下のようにゲームが進行します。
game_flow1.png

また、コンピュータ同士が対戦する際は、以下になります。
game_flow2.png

setTimeout()は、一定時間経過後に特定の処理を一度だけ実行する際に使います。時間の指定はミリ秒(1秒=1000ミリ秒)単位です。

setTimeout(() => "処理", "一定時間");

本記事のloop()からコールしているsetTimeout()の引数には以下を指定しています。

  • 処理this.loop()(自分自身)
  • 一定時間this.wait

一定時間の切り替えは、ユーザーとコンピュータが対戦する際にはWAIT_TIME(800ms)、それ以外は0msを設定しています。対戦の際、コンピュータがちょっと待ってから打ち返すようにして、遊びやすくすることを狙ったものです。

手番を回す

手番は、以下の処理で黒→白→黒→白→…と、現在の手番を交互に入れ替えています。

game.js
  // 次のプレイヤーを設定する
  setNextPlayer() {
    this.turn = this.turn === B ? W : B;
    this.player = this.turn === B ? this.black : this.white;
  }

?は3項演算子といいます。
if文で書き換えると、以下と同じ意味になります。

  if (this.turn === B) {
    this.turn = W;
  }
  else {
    this.turn = B;
  }

コンパクトに1行で書けますが、入れ子を多用するとコードが読みにくくなるため、注意が必要です(偉そうに言えませんが)。

スコアの更新

1手進めるごとにスコアを更新します。盤面の石を毎回数えて算出しています。

game.js
  // スコアの更新
  updateScore() {
    this.blackScore = this.board.filter(e => e === B).length;
    this.whiteScore = this.board.filter(e => e === W).length;
  }

パスと終了の判定

打つ場所が見つからない場合はパス、また黒と白が連続でパス(2回)となった場合は、ゲーム終了と判定しています。また、プレイヤーにユーザーが含まれる(コンピュータ同士ではない)場合に、パスの通知(alert)を行います。

game.js
  // 終了の判定
  isEnd() {
    if (this.isPass() && this.isPass()) {
      this.turn = GAME_TURN_END;
      return true;
    }
    return false;
  }

  // パスの判定
  isPass() {
    if (getLegalMoves(this.turn, this.board).length <= 0) {
      this.setNextPlayer(this.turn);
      this.pass++;
      return true;
    }
    return false;
  }

  // パス通知の有無
  indicatePass() {
    const pass = (this.pass < PASS_END && this.pass > 0);
    const human = (this.black.name === HUMAN || this.white.name === HUMAN);
    return pass && human;
  }

  // パス通知のメッセージを取得
  getPassMessage() {
    const pre = this.turn === B ? W : B;
    return getGameTurnText(pre) + ' ' + PASS;
  }

勝敗判定

ゲーム終了時、スコア(取得した石の数)が多い方を勝ちと判定しています。

game.js
  // 勝利プレイヤー通知のメッセージを取得
  getWinnerMessage() {
    const winner = this.getWinner();
    return winner === DRAW ? winner : getGameTurnText(winner) + ' ' + WIN;
  }

  // 勝利プレイヤーの石を返す
  getWinner() {
    let winner = DRAW;
    if (this.blackScore > this.whiteScore) winner = B;
    if (this.whiteScore > this.blackScore) winner = W;
    return winner;
  }

文末の;(セミコロン)は省略可能です。省略した場合は\n(改行コード)を自動的に;とみなす挙動となります。本記事では、プログラムの意図を明確にするため、必ず;を入れるものとします。

手を打つ

player_icon.png
手を打つ処理は、player.jsにまとめました。
ユーザーが手を打つ処理と、コンピュータが手を打つ処理に対応しました。

1手打つ

1手打つ処理です。game.jsから呼ばれます。

HUMANRANDOMgame.jsにて、黒と白のプレイヤーの指定に使用します。

player.js
const HUMAN = 'human';
const RANDOM = 'random';
const NO_MOVE = -1;

// プレイヤー
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;
      default:
        break;
    }
    return putDisc(game.turn, game.board, move);
  }
}

ユーザーが選んだ手を返す

ユーザーの手番でこの処理が呼ばれます。

player.js
// ユーザーが選んだ手を返す
// (引数)
//  game  : ゲーム情報
// (戻り値)
//  humanMove : ユーザーの手(マスを表す番号)
function getMoveByHuman(game) {
  const move = game.humanMove;
  game.humanMove = NO_MOVE;
  return move;
}

ランダムに選んだ手を返す

打てる手の中から、ランダムに選んで手を決めるAIの処理です。とても弱いです。

player.js
// ランダムに選んだ手を返す
// (引数)
//  game  : ゲーム情報
// (戻り値)
//  return : コンピュータの手(マスを表す番号)
function getMoveByRandom(game) {
  const legalMoves = getLegalMoves(game.turn, game.board);
  const randomIndex = Math.floor(Math.random() * legalMoves.length);
  return legalMoves[randomIndex];
}

Math.random()は0以上1未満の擬似乱数を返します。(1は含まれません)

// コメントは出力例(毎回異なります)
console.log(Math.random());  // 0.10136633785614158
console.log(Math.random());  // 0.3146966178083439

また、Math.floor()は端数を切り捨てた整数を返します。

画面の描画

ui_icon.png
ゲーム画面の描画関係の処理は、ui.jsにまとめました。

盤面サイズと遊べる範囲のマス

盤面のサイズはBOARD配列の要素数から求めています。

定数名 意味
BOARD_SIZE 外枠を含んだ全盤面の、1辺のサイズです。
PLAYABLE_SIZE 外枠を除いた盤面の、1辺のサイズです。
PLAYABLE_INDEXS 外枠を除いた盤面の、マスの番号をすべて含んだ配列です。
board.js
const BOARD_SIZE = Math.sqrt(BOARD.length);
const PLAYABLE_SIZE = BOARD_SIZE - 2;
const BOARD_ELEMENT_NUM = BOARD_SIZE * BOARD_SIZE;
const PLAYABLE_START = BOARD_SIZE + 1;
const PLAYABLE_END = (BOARD_SIZE + 1) * PLAYABLE_SIZE;
const PLAYABLE_INDEXS = getPlayableIndexs();

// ゲームで遊べる範囲の全ての盤面位置を取得
// ゲームで遊べる範囲の全ての盤面位置を取得
function getPlayableIndexs() {
  const all = Array(BOARD_ELEMENT_NUM).fill().map((_, i) => i)
  const limited = all.filter(e => (e >= PLAYABLE_START && e <= PLAYABLE_END));
  return limited.filter(e => (e % BOARD_SIZE !== 0 && (e + 1) % BOARD_SIZE !== 0));
}

PLAYABLE_INDEXSには、下図の緑色のマス(遊べる範囲のマス)の番号をすべて格納しています。

BOARD配列のサイズは必ずN×Nの正方形の形のサイズとし、外側を"壁"で囲うようにして下さい。

PLAYABLE_INDEXSは以下の手順で作成しています。

  1. Arrayで空(fill)の要素を100個持った配列を作る。
  2. 1.から、mapでそれぞれの要素をインデックス番号と同じもの(0~99)にする。
  3. 2.からfilterでインデックス番号11~88のみに絞り、他は除外する。
  4. 3.から灰色のマスの左端(20,30,...80)と、右端(19,29,...79)をfilterで除外し、残りを最終の戻り値とする

初期化

ゲーム開始前の初期化処理です。

ui.js
const UI_BOARD = 'ui_board';
const BLACK_IMG = './image/black.png';
const WHITE_IMG = './image/white.png';

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

盤面作成

ゲーム画面のリバーシの盤面は、HTMLのtable(テーブル)タグを使って実現しています。

ui.js
// 盤面のテーブル作成
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;
    if (reversi.board[i] !== X) {
        square.style.backgroundColor = 'green';
    }
  }
  // 盤面のヘッダー情報を追加
  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);
}

クリック時の手を受け付ける

テーブルクリック時の処理です。ユーザーの手番でかつ、打てる手(石を返せる場所)の場合に入力を受け付け、ゲームを進行させます。humanMoveにユーザーが打った手を記憶しています。

ui.js
// マスをクリックした時の処理
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();
    }
  }
}

この関数は、先のcreateBoardTable()実行時に、テーブルの各マスのイベントリスナーに登録されています。これによりユーザーのクリック時に、処理が呼び出されるようになっています。

盤面の更新

ゲームが1手進行するたびに、盤面の更新処理を行っています。
更新する内容は、石の配置と、手番、スコアです。石の表示には画像ファイルを用いています。

ui.js
// 盤面の更新
function updateUi() {
  setupDiscs(reversi.updatedDiscs);
  const turn = document.getElementById('turn');
  turn.textContent = getGameTurnText(reversi.turn);
  const score = document.getElementById('score');
  score.textContent = reversi.blackScore + ' : ' + reversi.whiteScore;
}

// 石を並べる
// (引数)
//  indexs  : 石を置く位置(マスを示す番号)の配列
function setupDiscs(indexs) {
  for (let index of indexs) {
    const square = document.getElementById(UI_BOARD + index);
    switch (reversi.board[index]) {
      case B:
        setImg(square, BLACK_IMG);
        break;
      case W:
        setImg(square, WHITE_IMG);
        break;
      default:
        break;
    }
  }
}

// 画像を配置
// (引数)
//  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) {
  if (turn === B) return 'Black';
  if (turn === W) return 'White';
  return GAME_TURN_END;
}

今回は石の画像を更新するために、1手毎に毎回、石が更新された各マスの子ノードを全削除してから、画像を追加しなおす処理としています。他にもっと良い方法が見つかれば、修正したいと思います。

石の表示をテーブルの中央に持ってくるために、CSSに以下を記述しています。

style.css
img {                          /* (石の画像)             */
    display: block;            /* 中央寄せ               */
    margin: auto;              /* 周囲の余白 : 自動調整  */
}

発見するまでに意外と苦労しました。(最初はテーブルの設定で何とかしようとしていて、うまくいきませんでしたね)

プログラムの開始

main.png
プログラムの開始はmain.jsにまとめました。
初期化とゲームループを呼び出す、start()を、最初に一度呼ぶのみです。

main.js
let reversi = null;

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

start();

本記事のコードでは、プログラム簡単化のためゲーム終了後、画面は止まったままとなります。再度遊びたい場合はブラウザを更新してページを読み込み直してください。

ゲーム終了などのタイミングでstart()を呼ぶよう改造すると、繰り返し遊べるものにすることもできます。

基本的な処理の説明は、以上になります。

完成

長々と説明してしまいましたが、以上で今回作ろうと思っていた、JavaScriptで作った簡単なリバーシが完成しました。

完成したものをプレイしている様子が、以下になります。(いたって普通ですね)

おまけ

テストコードについての補足を残しておきます。今回はパッケージのインストールを一切不要とした簡素な構成としたかったため、既存のフレームワークは用いず自前でテストを用意することにしました。

アサート関数

以下のような、簡単なアサート関数を用意してテストに利用しています。

assert.js
// assertEqual
function assertEqual(first, second, name) {
  let result = '  OK   (' + name + ')';
  first = JSON.stringify(first);
  second = JSON.stringify(second);
  if (first != second) {
    result = '* NG * (' + name + ')\n' + 'first != second' + '\n' + 'first = ' + first + '\n' + 'second = ' + second;
    total = '* NG * (total)';
  }
  console.log(result);
};

// assertIncludes
function assertIncludes(first, second, name) {
  let result = '  OK   (' + name + ')';
  first = JSON.stringify(first);
  second = second.map(e => JSON.stringify(e));
  if (!second.includes(first)) {
    result = '* NG * (' + name + ')\n' + 'second not includes first' + '\n' + 'first = ' + first + '\n' + 'second = ' + second;
    total = '* NG * (total)';
  }
  console.log(result);
};
  • assertEqual … 第一引数と第二引数の値が一致していなかったらテストをNGとする
  • assertIncludes … 第一引数が第二引数に含まれていなかったらテストをNGとする

配列や連想配列などの一致確認も行えるよう、引数をJSON形式のテキストに変換し比較する方式としました。

実行方法

test.htmlをブラウザで開き、開発者ツールを起動すると以下の赤枠のようにコンソールにテスト結果が表示されます。(Edgeの例)
test.png

最後に

ブラウザで動く簡単なリバーシを作ってみましたが、いかがでしたでしょうか。ここから変則ルールを追加しながらJavaScript習得の糧にしていきたいと思います。(はじめは3つ!?と戸惑った、比較演算子の===!==にも慣れてきましたね)
最後まで読んで下さり、ありがとうございました。

本記事の続編となる以下からは、いよいよ変則リバーシの実装に踏み込んでいきます。よかったら見てみてください。

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

参考記事

JavaScriptでテストコードを"簡単"に書くための考え方を大変参考にさせていただきました。
https://qiita.com/standard-software/items/559d871794bfa38651f4

3
5
2

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
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?