はじめに
どうも、y-tetsuです。
本記事は、オセロプログラム ~7行のC言語で書くコンピュータ対局~に衝撃を受けた筆者が、これをPythonで書き直してみる!!というものです。
上記サイトのC言語のコードはたった7行×79文字なのですが、コンソール上でAIとリバーシ(オセロ)の対局ができる、という一品です。有難いことに、整形したコードも載せていただいています。
ぎっしりと趣向が凝らされたコードを紐解いてみたい、Python文法にもっと馴染んでおきたい、という個人的な思いが相まって、今回このような取り組みをしてみました。
コード行数の少なさだけを目指すなら、リスト内包表記を使って1行で書くようなトリッキーな方法も考えられます。ですが、今回はあくまで元のコードの意図を優先し、かつPythonの基本的(Pythonic)な書き方にもなるべく沿ったものとしたい、という思いがありました。
そこで、今回は以下の条件でコードを書くことにしました。
- Python 3.9以上
- ライブラリは未使用
- flake8に準拠
flake8とはPythonのコードチェッカーツールです。pep8の規約に違反しているなど、Pythonコードの問題点を示してくれます。今回はflake8で問題点が検出されない範囲でコードを書きました。
できたもの
という事で、ささっと今回作ったコードをお見せしておきます。
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以上をインストールしておいてください。
遊び方
先手が人、後手がAIとなっています。(デフォルト時)
自分の手番の時に、以下のとおりコマンドプロンプトにて、打ちたい場所をXY座標で指定してください。(例では"x=5 y=3"として"5 3"を入力、Y方向は上から下です)
そうすると、座標の場所に自分のディスク(石)が置かれて、次にAIが手を打ちます。
再び自分の手番となり、ゲームが決着するまでこれを繰り返します。
コード説明
プログラム全体はShortReversi
クラスとしています。以下で実行しています。
if __name__ == '__main__':
ShortReversi().play()
__init__
初期化処理は以下の通りです。クラスをインスタンス化した際に、リバーシを始める準備をします。
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の盤面となっていて、初期化後の値は下図のようになります。
緑色のエリアはディスクが置ける部分で、灰色は探索を打ち切るための"壁"となっています。白色のエリアは空きマスとしていますが、実際にはディスクが置かれる事はなく、こちらも"壁"となっています。細かい部分で灰色の壁と白色の壁では、盤面表示の際に改行出力に対応している、という違いがあります。
当初は、参考にしたコードと同じく9*10=90マスのリストで作ろうとしたのですが、うまくいきませんでした。
ディスクが置けるエリアの一番右下隅に置こうとすると、右斜め下方向への探索の際、リストの領域外アクセスとなりPythonに怒られてしまいました…。そのため、1マス足して91マスに改変しています。
方向の表現
self.dirs
にて盤面の上下左右斜め8方向の1マス進んだ時の移動量を表しています。
self.dirs = [-10, -9, -8, -1, 1, 8, 9, 10]
手番の表現
self.dirs
にて現在の手番を表します。盤面の表現と同じく、1が先手で2が後手を表します。初期値を1の先手としています。
self.board[40] = self.board[50] = self.turn = 1
_check
_check
メソッドは手が打てるかどうかの判定と、ディスクをひっくり返す処理の二役を担っています。
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方向をループで順に探索していきます。
if not (ret := False) and not board[move]:
for i in range(8):
セイウチ演算子(:=
)の部分は、戻り値の初期化(False
を設定)を、空きマス判定と同時に行うための条件式です。(ここは行数を稼ぐために、やや無理した?感じに見えるかもしれません…)
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
flip
引数にTrue
が指定されていれば、ついでにディスクをひっくり返します。
play
play
メソッドはゲームのメインループ処理を行います。
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で初期化しつつ無限ループさせることを意図して書いています。
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
)の取得(ただし、最初に見つかった場所のみ)と、盤面のコンソールへの表示を行っています。
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が打つか、人が指定した手を打つかで、処理を切り替えています。
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次元のリストのインデックスに変換し、打つべき手としています。(人が入力した手が、ディスクを返せない場所の時は、再度入力を求める挙動となります)
手を打った後は、手番を交代して次のループへ移ります。
self.turn = 3 - self.turn
上記により、先手(1)の場合は後手(2)に、後手(2)の場合は先手(1)に切り替わります。
playメソッドの引数
play
メソッドの引数を変更すると、AIまたは人のプレイヤーを自由に変更できます。
引数 | 用途 |
---|---|
com1 | True ⇒ 先手はAI、False ⇒ 先手は人 |
com2 | True ⇒ 後手はAI、False ⇒ 後手は人 |
例えば、AI同士対戦させる場合は、プログラムを以下に変更して下さい。
if __name__ == '__main__':
ShortReversi().play(True, True)
以上、作ったコードの説明でした。ここまで長々と読んでいただき、誠にありがとうございました。
おわりに
元の7行のコードには遠く及びませんでしたが、無理に処理を1行に詰め過ぎないようなるべく気を付け、ようやく45行に収まったという感じでした。
セイウチ演算子(:=
)を使って、変数の初期化をしつつループを回す部分は、あまりやらないだろうなと思いつつもコードを短くしたくて、やや罪悪感を感じていたりします…。他の箇所についても「こっちの方がPythonicだよ」といったところがあれば、ぜひ教えていただけると大変嬉しく思います。
今回の取り組みで、元のC言語のコードをPythonで同じように書こうとするとどうなるのか?と考えていると、パズルを解いているような感覚で、想像以上に夢中になれました。他にも、標準ライブラリやnumpyなどを活用するとどうなるか?と考えるのも面白そうです。
機会があれば、Pythonで7*79文字に収まるリバーシプログラムにもチャレンジしてみたいと思います。(できるかどうかもわかりませんが)
今回の記事で紹介させていただいたコードは、以下でも公開しています。もし良かったら見てみて下さい。
それでは皆様、よきリバーシ・ライフを!