Pythonで書いた45行のリバーシプログラム

# はじめに
どうも、y-tetsuです。

本記事は、[オセロプログラム ～7行のC言語で書くコンピュータ対局～](https://uguisu.skr.jp/othello/7gyou.html)に衝撃を受けた筆者が、これをPythonで書き直してみる！！というものです。

https://uguisu.skr.jp/othello/7gyou.html

上記サイトのC言語のコードはたった7行×79文字なのですが、コンソール上でAIとリバーシ(オセロ)の対局ができる、という一品です。有難いことに、整形したコードも載せていただいています。

ぎっしりと趣向が凝らされたコードを紐解いてみたい、Python文法にもっと馴染んでおきたい、という個人的な思いが相まって、今回このような取り組みをしてみました。

コード行数の少なさだけを目指すなら、リスト内包表記を使って1行で書くようなトリッキーな方法も考えられます。ですが、今回はあくまで元のコードの意図を優先し、かつPythonの基本的(**Pythonic**)な書き方にもなるべく沿ったものとしたい、という思いがありました。

そこで、今回は以下の条件でコードを書くことにしました。
- Python 3.9以上
- ライブラリは未使用
- flake8に準拠

:::note
flake8とはPythonのコードチェッカーツールです。pep8の規約に違反しているなど、Pythonコードの問題点を示してくれます。今回はflake8で問題点が検出されない範囲でコードを書きました。
:::
https://flake8.pycqa.org/en/latest/

https://pep8-ja.readthedocs.io/ja/latest/

# できたもの
という事で、ささっと今回作ったコードをお見せしておきます。

```python
class ShortReversi:
    def __init__(self):
        self.dirs = [-10, -9, -8, -1, 1, 8, 9, 10]
        self.discs = ' - o x\n'
        self.board = [0 if i % 9 else 3 for i in range(91)]
        self.board[40] = self.board[50] = self.turn = 1
        self.board[41] = self.board[49] = 2

    def _check(self, board, move, flip=False):
        if not (ret := False) and not board[move]:
            for i in range(8):
                count = value = move + self.dirs[i]
                while board[value] == 3 - self.turn:
                    value += self.dirs[i]
                if count != value and board[value] == self.turn:
                    ret = value = move
                    while flip:
                        board[value] = self.turn
                        value += self.dirs[i]
                        if board[value] == self.turn:
                            break
        return ret

    def play(self, com1=False, com2=True):
        end = False
        while not (move := 0):
            for i in range(9, 82):
                if self._check(self.board, i, flip=False) and not move:
                    move = i
                print(self.discs[self.board[i]*2:][:2], end='')
            while move and not (end := False):
                if not com1 and self.turn == 1 or not com2 and self.turn == 2:
                    x, y = [int(i) for i in input().split()]
                    move = x + y * 9
                if self._check(self.board, move, flip=True):
                    break
            else:
                if end:
                    break
                end, _ = True, print('pass')
            self.turn = 3 - self.turn


if __name__ == '__main__':
    ShortReversi().play()
```

元のコードに用意されていた、`main`と`check`関数の構成はそのままに、盤面などの初期化を加えた、**クラス**を使った書き方に直しています。

なお、`main`は`play`に名称変更しました。その他も変数名やロジックなど微妙に書き換えてはいますが、概ね元のコードを再現したつもりです。

また、おまけで`play`メソッドの引数に応じて、対戦AIを変えられるよう改造もしてみました。


## 動かし方
以下を実行するとゲームが始まります。

### 実行コマンド
```
> python short_reversi.py
```
※あらかじめ[Python 3.9以上](https://www.python.org/downloads/)をインストールしておいてください。

https://www.python.org/downloads/

### 遊び方
先手が人、後手がAIとなっています。(デフォルト時)

自分の手番の時に、以下のとおりコマンドプロンプトにて、打ちたい場所をXY座標で指定してください。(例では"x=5 y=3"として"5 3"を入力、Y方向は上から下です)
![input1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/3da10fb5-a4fc-d930-0206-66a3e4cac716.png)

そうすると、座標の場所に自分のディスク(石)が置かれて、次にAIが手を打ちます。
![input2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/b1f6b603-c1b4-c883-7b62-16309536819d.png)

再び自分の手番となり、ゲームが決着するまでこれを繰り返します。


# コード説明
プログラム全体は`ShortReversi`クラスとしています。以下で実行しています。

```python
if __name__ == '__main__':
    ShortReversi().play()
```

## \_\_init\_\_
初期化処理は以下の通りです。クラスをインスタンス化した際に、リバーシを始める準備をします。

```python
    def __init__(self):
        self.dirs = [-10, -9, -8, -1, 1, 8, 9, 10]
        self.discs = ' - o x\n'
        self.board = [0 if i % 9 else 3 for i in range(91)]
        self.board[40] = self.board[50] = self.turn = 1
        self.board[41] = self.board[49] = 2
```

### ディスクの表現
ディスクは`self.discs`の文字列として保持しています。

|記号|意味|
|---|---|
|-|空きマス|
|o|先手のディスク|
|x|後手のディスク|

### 盤面の表現
盤面は91マスの1次元のリストです。格納された値が表現するものは、以下の通りです。

|値|意味|
|---|---|
|0|空きマス|
|1|先手のディスク|
|2|後手のディスク|
|3|壁|

2次元のイメージに当てはめると、9列×10行＋1の盤面となっていて、初期化後の値は下図のようになります。

![board.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/4255549b-5399-822a-756e-418e8ba73f64.png)

緑色のエリアはディスクが置ける部分で、灰色は探索を打ち切るための"壁"となっています。白色のエリアは空きマスとしていますが、実際にはディスクが置かれる事はなく、こちらも"壁"となっています。細かい部分で灰色の壁と白色の壁では、**盤面表示の際に改行出力に対応している**、という違いがあります。

当初は、参考にしたコードと同じく9*10=90マスのリストで作ろうとしたのですが、うまくいきませんでした。

ディスクが置けるエリアの一番右下隅に置こうとすると、右斜め下方向への探索の際、リストの領域外アクセスとなりPythonに怒られてしまいました…。そのため、1マス足して91マスに改変しています。

### 方向の表現
`self.dirs`にて盤面の上下左右斜め8方向の1マス進んだ時の移動量を表しています。

```python
self.dirs = [-10, -9, -8, -1, 1, 8, 9, 10]
```
![directions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653390/f9a735ed-ecab-a48d-0201-60b839dded82.png)

### 手番の表現
`self.dirs`にて現在の手番を表します。盤面の表現と同じく、1が先手で2が後手を表します。初期値を1の先手としています。
```python
self.board[40] = self.board[50] = self.turn = 1
```


## _check
`_check`メソッドは手が打てるかどうかの判定と、ディスクをひっくり返す処理の二役を担っています。

```python
    def _check(self, board, move, flip=False):
        if not (ret := False) and not board[move]:
            for i in range(8):
                count = value = move + self.dirs[i]
                while board[value] == 3 - self.turn:
                    value += self.dirs[i]
                if count != value and board[value] == self.turn:
                    ret = value = move
                    while flip:
                        board[value] = self.turn
                        value += self.dirs[i]
                        if board[value] == self.turn:
                            break
        return ret
```

引数に盤面(`board`)と手(`move`)、ひっくり返すかどうか(`flip`)を指定します。

戻り値(`ret`)は手が置ける場合に`True`そうでない場合に`False`を返します。

まず指定した場所が空きマスかどうかを判定し、空いていれば8方向をループで順に探索していきます。

```python
        if not (ret := False) and not board[move]:
            for i in range(8):
```
セイウチ演算子(`:=`)の部分は、戻り値の初期化(`False`を設定)を、空きマス判定と同時に行うための条件式です。(ここは行数を稼ぐために、やや無理した？感じに見えるかもしれません…)

```python
            for i in range(8):
                count = value = move + self.dirs[i]
                while board[value] == 3 - self.turn:
                    value += self.dirs[i]
                if count != value and board[value] == self.turn:
                    ret = value = move
```

各方向の探索時は、以下の条件を満たす場合に手が置けると判定します。
- 相手のディスクが連続している
- 相手ディスクの連続の最後は、自分のディスクとなっている

```python
                    while flip:
                        board[value] = self.turn
                        value += self.dirs[i]
                        if board[value] == self.turn:
                            break
```
`flip`引数に`True`が指定されていれば、ついでにディスクをひっくり返します。


## play
`play`メソッドはゲームのメインループ処理を行います。

```python
    def play(self, com1=False, com2=True):
        end = False
        while not (move := 0):
            for i in range(9, 82):
                if self._check(self.board, i, flip=False) and not move:
                    move = i
                print(self.discs[self.board[i]*2:][:2], end='')
            while move and not (end := False):
                if not com1 and self.turn == 1 or not com2 and self.turn == 2:
                    x, y = [int(i) for i in input().split()]
                    move = x + y * 9
                if self._check(self.board, move, flip=True):
                    break
            else:
                if end:
                    break
                end, _ = True, print('pass')
            self.turn = 3 - self.turn
```

`end`変数にてゲーム終了の判定を行います。手を打つたびにFalseとしていますが、一度パスが発生するとTrueを設定します。そして次の手番もパスの場合は、`break`で最初の`while`ループを抜けてゲーム終了となります。

以下の、メインループの記載は`move`を0で初期化しつつ無限ループさせることを意図して書いています。

```python
        while not (move := 0):
            for i in range(9, 82):
                if self._check(self.board, i, flip=False) and not move:
                    move = i
                print(self.discs[self.board[i]*2:][:2], end='')
```

最初の`for`文は盤面全体を探索し、打てる場所(`move`)の取得(ただし、最初に見つかった場所のみ)と、盤面のコンソールへの表示を行っています。

```python
                print(self.discs[self.board[i]*2:][:2], end='')
```

上記のコンソールの表示のロジックは、`self.discs`の文字列に配列スライスを2回適用して盤面に対応する文字を出力しています。

一回目のスライス`[self.board[i]*2:]`で、盤面の値の位置から最後までを取ります。次に、二回目のスライス`[:2]`で先頭から2文字分に整形しています。

盤面の状態に対する、コンソールへの標準出力の対応は以下となります。
|盤面の値|出力|
|---|---|
|0 |" -"|
|1 |" o"|
|2 |" x"|
|3 |"\n"|

なお、上記のとおり改行の出力も盤面の値で管理しているため、print文で改行されないよう`end=''`を指定しています。

打てる手が見つかった場合は、その手をAIが打つか、人が指定した手を打つかで、処理を切り替えています。

```python
                if not com1 and self.turn == 1 or not com2 and self.turn == 2:
                    x, y = [int(i) for i in input().split()]
                    move = x + y * 9
```

人が手を打つ場合は、`input`関数で標準入力からXY座標の2つの値を取得し、`int`型に直します。そしてその値を1次元のリストのインデックスに変換し、打つべき手としています。(人が入力した手が、ディスクを返せない場所の時は、再度入力を求める挙動となります)

手を打った後は、手番を交代して次のループへ移ります。

```python
            self.turn = 3 - self.turn
```

上記により、先手(1)の場合は後手(2)に、後手(2)の場合は先手(1)に切り替わります。

### playメソッドの引数
`play`メソッドの引数を変更すると、AIまたは人のプレイヤーを自由に変更できます。

|引数|用途|
|---|---|
|com1|True ⇒ 先手はAI、False ⇒ 先手は人|
|com2|True ⇒ 後手はAI、False ⇒ 後手は人|

例えば、AI同士対戦させる場合は、プログラムを以下に変更して下さい。
```python
if __name__ == '__main__':
    ShortReversi().play(True, True)
```

以上、作ったコードの説明でした。ここまで長々と読んでいただき、誠にありがとうございました。


# おわりに
元の7行のコードには遠く及びませんでしたが、無理に処理を1行に詰め過ぎないようなるべく気を付け、ようやく45行に収まったという感じでした。

セイウチ演算子(`:=`)を使って、変数の初期化をしつつループを回す部分は、あまりやらないだろうなと思いつつもコードを短くしたくて、やや罪悪感を感じていたりします…。他の箇所についても「こっちの方が**Pythonic**だよ」といったところがあれば、ぜひ教えていただけると大変嬉しく思います。

今回の取り組みで、元のC言語のコードをPythonで同じように書こうとするとどうなるのか？と考えていると、パズルを解いているような感覚で、想像以上に夢中になれました。他にも、標準ライブラリやnumpyなどを活用するとどうなるか？と考えるのも面白そうです。

機会があれば、Pythonで7*79文字に収まるリバーシプログラムにもチャレンジしてみたいと思います。(できるかどうかもわかりませんが)

今回の記事で紹介させていただいたコードは、以下でも公開しています。もし良かったら見てみて下さい。

https://github.com/y-tetsu/short_reversi

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