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

@paseri3739 さんの「[JavaScriptでオセロを作った(オブジェクト指向](https://qiita.com/paseri3739/items/58345da8262215b8af43)」を拝見してクラス構成がちょっと気になったので、[クリーンアーキテクチャ](https://qiita.com/search?q=%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%81%E3%83%A3)を参考にして自分なりにクラス設計と実装を行ってみました。
インタフェースを使ってないので、クリーンアーキテクチャとは言えないかもしれませんが。
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: 駒

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

## クラス図

```plantuml
skinparam nodesep 80
skinparam ranksep 60

package Devices/Web/UI/DB {

class jQuery {
}

class Node {
}

package Presenters/Controllers {

class OthelloWeb {
  + othello: Othello

  + constructor(othello: Othello):
  + start(): void
  - _show(): void
  - _addClickEvent(): void
  - _getClickedCellIndex($cell): Object{row: number, col: number}
}

class OthelloCUI {
  + othello: Othello
  + ROWS: string
  + COLS: string

  + constructor(othello: Othello): void
  + start(): void
  - _show(): void
  - _input(prompt: string): Promise
}

package UseCases {

class Othello {
  - _board: Board
  - _black: Player
  - _white: Player
  + player: Player
  - _TURN: Map<Player, Player>
  + isOver: boolean

  + constructor(board: Board, black: Player, white: Player): void
  + [Symbol.iterator](): Iterator<Iterator<Cell>>
  + put(row: number, col: number): boolean
  - _turn(): void
  + winner(): Player
}

package Entities {

enum Disc {
  + NONE: str
  + BLACK: str
  + WHITE: str
}


class Cell {
  + row: number
  + col: number
  - _disk: Disk

  + constructor(row: number, col: number): void
  + get disk(): Disc
  + set disk(disk: Disc): void
  + reverse(): void
}

class Board {
  - _cells: Disc[][]

  + constructor(): void
  + [Symbol.iterator](): Iterator<Iterator<Cell>>
  + putablePositions(disk: Disc): Iterator<{row: number, col:number}>
  + put(row: number, col: number, disk: Disc): boolean
  - _reversiblePositions(row: number, col: number, disc: Disc)
}

class Player {
  + name: string
  + disk: Disc

  + constructor(name: string, disk: Disc): void
  + toString(): string
  + isPass(board: Board): boolean
  + put(board: Board): boolean
}

}

}

}

}

jQuery --> OthelloWeb
Node --> OthelloCUI
OthelloWeb "1" o--> "1" Othello
OthelloCUI "1" o--> "1" Othello
Othello "1" o-> "2" Player
Othello "1" o--> "1" Board
Player "2" -> "1" Board
Player "0..1" o--> "1" Disc
Board "1" *--> "64" Cell
Cell "0..64" o-> "1" Disc
```

# 実装

```js: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アプリ

https://codepen.io/shiracamus/pen/MWdMojb

## 端末アプリ

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)の番です
縦の数字と横の英字を入力してください >
```