2
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

オセロゲームをクリーンアーキテクチャで設計・実装してみた

Last updated at Posted at 2024-07-06

@paseri3739 さんの「JavaScriptでオセロを作った(オブジェクト指向」を拝見してクラス構成がちょっと気になったので、クリーンアーキテクチャを参考にして自分なりにクラス設計と実装を行ってみました。
インタフェースを使ってないので、クリーンアーキテクチャとは言えないかもしれませんが。
Webだけじゃなく、端末でも遊べるようにしてみました。

質問や改善点などがありましたらコメントお願いいたします。

クラス設計

Devices/Web/UI/DB

WebフレームワークにはjQueryを使用。マウスで操作する。
端末(コンソール、ターミナル)ではNode.jsで起動。標準入出力を使用する。
棋譜やハイスコアなどの記録は行わないのでDBは使わない。

Presenters/Controllers

Devices/Web/UI/DB(表示や入力)と Use Case の間のデータ変換を行う。

OthelloWeb

  • Webアプリのために、HTMLでオセロ盤を表示したり、クリックイベントに応じてOthelloを操作する

OthelloCUI

  • 端末アプリのために、端末画面にオセロ盤を表示したり、キー入力に応じてOthelloを操作する

Use Cases

使い方を定義。

Othello: オセロゲーム

  • オセロ盤、黒駒のプレイヤー、白駒のプレイヤーが必要
  • 先行は黒駒プレイヤー
  • プレイヤーは交互にプレイする
  • プレイヤーはオセロ盤上で相手の駒をひっくり返せる位置に駒を置く
  • 相手の駒がひっくり返せなければパスする(相手の番になる)
  • 両者がパスしたらゲーム終了
  • オセロ盤上の駒数の多いプレイヤーが勝ち

Entities

MVC (Model-View-Controller) の Model を定義。
UI (View-Controller) や DB に左右されない。

Player: プレイヤー

  • 表示名を持つ
  • 黒駒か白駒が割り当てられる
  • オセロ盤に自駒を置く
  • 駒が置けなければパスする
  • オセロ盤上の自駒を数えられる

Board: オセロ盤

  • オセロゲームで1つ使う
  • オセロ盤には8x8=64個のマス目(Cell)が存在する
  • 初期状態は、中央に4駒配置
  • 相手の駒をひっくり返せる場所に駒を置ける
  • 駒を置くと挟んだ相手の駒をひっくり返す

Cell: オセロ盤の1マス

  • 駒がなければ駒を置ける
  • 駒があれば駒をひっくり返せる

Disc: 駒

  • 片面が黒色、片面が白色
  • 駒はひっくり返せる

クラス図

実装

othello.js
"use strict";

/**
 * オセロの駒
 */
const Disc = Object.freeze({
  NONE: ".",
  BLACK: "x",
  WHITE: "o",
});

/**
 * オセロの駒の裏側
 */
const REVERSE = Object.freeze({
  [Disc.WHITE]: Disc.BLACK,
  [Disc.BLACK]: Disc.WHITE,
});

/**
 * 駒をひっくり返す8方向
 */
const DIRECTIONS = Object.freeze([
  [-1, -1],  // [dy, dx]
  [-1, 0],
  [-1, 1],
  [0, -1],
  [0, 1],
  [1, -1],
  [1, 0],
  [1, 1],
]);

/**
 * オセロ盤のマス目
 */
class Cell {
  /**
   * セルを初期化する
   * @param {number} row
   * @param {number} col
   */
  constructor(row, col) {
    this.row = row;
    this.col = col;
    this._disc = Disc.NONE;
  }

  /**
   * 表示文字列を返す
   * @returns {string} 表示文字列
   */
  toString() {
    return this._disc;
  }

  /**
   * セル上の駒を取得する
   * @returns {Disc} disc
   */
  get disc() {
    return this._disc;
  }

  /**
   * セルに駒(disc)を置く
   * セルに駒があれば例外発生
   * @param {Disc} disc
   */
  set disc(disc) {
    if (this._disc !== Disc.NONE) {
      throw new Error("disc exists.");
    }
    this._disc = disc;
  }

  /**
   * ひっくり返す
   * セルに駒がなければ例外発生
   */
  reverse() {
    if (!REVERSE[this._disc]) {
      throw new Error("no disc exists.");
    }
    this._disc = REVERSE[this._disc];
  }
}

/**
 * オセロ盤
 */
class Board {
  /**
   * 8x8のセルを作り、初期状態の駒配置にする
   */
  constructor() {
    this._cells = Array(8)
      // @ts-ignore
      .fill()
      .map((_, row) =>
        Array(8)
          // @ts-ignore
          .fill()
          .map((_, col) => new Cell(row, col))
      );
    this._cells[3][3].disc = Disc.BLACK;
    this._cells[4][4].disc = Disc.BLACK;
    this._cells[3][4].disc = Disc.WHITE;
    this._cells[4][3].disc = Disc.WHITE;
  }

  /**
   * 表示用に盤面のCellを行毎にイテレートする
   * 変更できないようにObject.freezeしたCellをイテレートする
   * @returns {Iterator<Iterator<Cell>>} 盤面行のイテレータ
   */
  [Symbol.iterator]() {
    return this._cells.map(Object.freeze)[Symbol.iterator]();
  }

  /**
   * discを置ける座標を取得する
   * @param {Disc} disc
   * @returns {Iterator<{row: number, col: number}>} 座標イテレータ
   */
  *putablePositions(disc) {
    for (const cells of this._cells) {
      for (const { row, col } of cells) {
        for (const _ of this._reversiblePositions(row, col, disc)) {
          yield { row, col };
        }
      }
    }
  }

  /**
   * 座標(row, col)にdiscを置く
   * @param {number} row
   * @param {number} col
   * @param {Disc} disc
   * @returns {boolean} 置けたらtrue、置けなければfalse
   */
  put(row, col, disc) {
    let reversed = false;
    for (const reverse of this._reversiblePositions(row, col, disc)) {
      this._cells[reverse.row][reverse.col].reverse();
      reversed = true;
    }
    if (reversed) {
      this._cells[row][col].disc = disc;
    }
    return reversed;
  }

  /**
   * discを置ける(相手の駒をひっくり返せる)座標を取得する
   * @param {number} row
   * @param {number} col
   * @param {Disc} disc
   * @returns {Iterator<{row: number, col: number}>} 座標イテレータ
   */
  *_reversiblePositions(row, col, disc) {
    if (this._cells[row]?.[col]?.disc !== Disc.NONE) {
      return;
    }
    for (const [dy, dx] of DIRECTIONS) {
      let y = row + dy;
      let x = col + dx;
      const path = [];
      while (x >= 0 && x < 8 && y >= 0 && y < 8) {
        const cell = this._cells[y][x];
        if (cell.disc === REVERSE[disc]) {
          path.push({ row: y, col: x });
        } else if (cell.disc === disc) {
          if (path.length > 0) {
            yield *path;
          }
          break;
        } else {
          break;
        }
        y += dy;
        x += dx;
      }
    }
  }

  /**
   * discの個数を数える
   * @param {Disc} dic
   * returns {number} discの個数
   */
  count(disc) {
    let counter = 0;
    for (const cells of this._cells) {
      for (const cell of cells) {
        if (cell.disc === disc) {
          counter++;
        }
      }
    }
    return counter;
  }
}

class Player {

  /**
   * プレイヤーの初期化
   * @param {string} name
   * @param {Disc} disc
   */
  constructor(name, disc) {
    this.name = name;
    this.disc = disc;
  }

  /**
   * 表示文字列を返す
   * @returns {string} 表示文字列
   */
  toString() {
    return `${this.name}(${this.disc})`;
  }

  /**
   * パスするか確認
   * @param {Board} board
   * @returns {boolean} パスするならtrue、しないならfalse
   */
  isPass(board) {
    for (const _ of board.putablePositions(this.disc)) {
      return false;  // 自駒を置ける場所があるのでパスしない
    }
    return true;  // 自駒を置ける場所がなかったのでパスする
  }

  /**
   * board上の座標(row, col)に自駒を置く
   * @param {Board} board
   * @param {number} row
   * @param {number} col
   * returns {number} 駒を置けたらtrue、置けなかったらfalse
   */
  put(board, row, col) {
    return board.put(row, col, this.disc);
  }

  /**
   * board上の自駒を数える
   * @param {Board} board
   * returns {number} 自駒の数
   */
  count(board) {
    return board.count(this.disc);
  }
}

class Othello {

  /**
   * オセロゲーム初期化
   * @param {Board} board
   * @param {Player} black
   * @param {Player} white
   */
  constructor(board, black, white) {
    this._board = board
    this._black = black;
    this._white = white;
    this.player = black;
    this._TURN = Object.freeze({ [black]: white, [white]: black });
    this.isOver = false;
  }

  /**
   * 表示用に盤面のCellを行毎にイテレートする
   * @returns {Iterator<Iterator<Cell>>} 盤面行のイテレータ
   */
  [Symbol.iterator]() {
    return this._board[Symbol.iterator]();
  }

  /**
   * 座標(row, col)に駒を置いてプレイヤーを交代する
   * @param {number} row
   * @param {number} col
   * @returns {boolean} 置けたらtrue、置けなかったらfalse
   */
  put(row, col) {
    if (this.isOver) {
      return false;
    }
    if (!this.player.put(this._board, row, col)) {
      return false;
    }
    this._turn();
    return true;
  }

  /**
   * プレイヤーを交代する
   */
  _turn() {
    this.player = this._TURN[this.player];
    if (this.player.isPass(this._board)) {
      this.player = this._TURN[this.player];
      if (this.player.isPass(this._board)) {
        this.isOver = true;
      }
    }
  }

  /**
   * 勝者を取得する
   * @returns {Player} 勝者、引き分けの場合はnull
   */
  winner() {
    const blackCount = this._black.count(this._board);
    const whiteCount = this._white.count(this._board);
    if (blackCount > whiteCount) {
      return this._black;
    } else if (whiteCount > blackCount) {
      return this._white;
    } else {
      return null;
    }
  }
}

/**
 * Web版オセロ入出力クラス
 */
class OthelloWeb {

  constructor(othello) {
    this._othello = othello;
    this._classNames = { [Disc.BLACK]: "black", [Disc.WHITE]: "white" };
    this._addClickEvent();
  }

  /**
   * クリックイベントを追加する
   */
  _addClickEvent() {
    $("#board")
    .off("click")
    .on("click", "td", (event) => {
      const $cell = $(event.currentTarget);
      const { row, col } = this._getClickedCellIndex($cell);
      this._othello.put(row, col);
      this._show();
      if (this._othello.isOver) {
        alert(`Game Over! Winner: ${this._othello.winner() ?? "DRAW"}`);
      }
    });
  }

  /**
   * クリックされたセルのインデックスを取得する
   * @param {JQuery} $cell
   * @returns {{row: number, col: number}}
   */
  _getClickedCellIndex($cell) {
    const $tr = $cell.closest("tr");
    return { row: $tr.index(), col: $cell.index() };
  }

  start() {
    this._show();
  }

  /**
   * オセロ盤を表示する
   */
  _show() {
    const $board = $("#board").empty();
    for (const cells of this._othello) {
      const $tr = $("<tr>").appendTo($board);
      for (const { disc } of cells) {
        const $td = $("<td>").appendTo($tr);
        if (disc !== Disc.NONE) {
          $("<div>").addClass(this._classNames[disc]).appendTo($td);
        }
      }
    }
  }
}


/**
 * CUI飯オセロ入出力クラス
 */
class OthelloCUI {

  constructor(othello) {
    this._othello = othello;
    this.ROWS = "12345678";
    this.COLS = "ABCDEFGH";
  }

  async start() {
    while (!this._othello.isOver) {
      this._show();
      while (true) {
        console.log(`${this._othello.player}の番です`);
        const pos = await this._input("縦の数字と横の英字を入力してください > ");
        if (pos.length != 2) {
          continue;
        }
        const [row, col] = [...pos.toUpperCase()].sort();
        if (this._othello.put(this.ROWS.indexOf(row), this.COLS.indexOf(col))) {
          break;
        }
        this._show();
        console.log("そこには置けません");
      }
    }
    this._show();
    console.log(`Game Over! Winner: ${this._othello.winner() ?? "DRAW"}`);
  }

  /**
   * オセロ盤を表示する
   */
  _show() {
    console.log(" ", this.COLS);
    for (const cells of this._othello) {
      console.log(this.ROWS[cells[0].row], cells.join(""));
    }
  }

  /**
   * 標準入力から1行読み込む
   * @returns {string} 読み込んだ行
   */
  async _input(prompt) {
    const readInterface = require("readline").createInterface({
      input: process.stdin,
      output: process.stdout
    });
    return new Promise(resolve => {
      readInterface.question(prompt, line => {
        readInterface.close();
        resolve(line);
      });
    });
  }
}

function main(uiClass) {
    const board = new Board();
    const black = Object.freeze(new Player("BLACK", Disc.BLACK));
    const white = Object.freeze(new Player("WHITE", Disc.WHITE));
    const othello = new Othello(board, black, white);
    const ui = new uiClass(othello);
    ui.start();
}

main(OthelloCUI);  // 端末(Node.js)で遊ぶ場合はこちらを使う
// $(() => main(OthelloWeb));  // Web(jQuery)で遊ぶ場合はこちらを使う

遊び方

Webアプリ

端末アプリ

Node.jsで遊べます。

$ node othello.js
  ABCDEFGH
1 ........
2 ........
3 ........
4 ...xo...
5 ...ox...
6 ........
7 ........
8 ........
BLACK(x)の番です
縦の数字と横の英字を入力してください > f4
  ABCDEFGH
1 ........
2 ........
3 ........
4 ...xxx..
5 ...ox...
6 ........
7 ........
8 ........
WHITE(o)の番です
縦の数字と横の英字を入力してください >
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