[Python] オセロゲーム（GUI）を作成した

# はじめに
Pythonでオセロゲームを作成しました。特徴は以下の通りです。

- Tkinterを使用したGUIアプリ
- 人（先手）とCPU（後手）で対戦
- CPU（後手）の打ち手はアルファ・ベータ法により決定

![オセロゲーム外観](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1330349/23252b34-982a-9286-6e9d-304e8fb00f04.png)

# 環境
- Python 3.9.2
- Tkinter

# 参考サイト
オセロ盤の各セルの評価値については、以下のサイトを参考にさせていただきました。
https://qiita.com/shiracamus/items/f03127819fff0f1a4349

# コード
2021/04/21
コメント欄にて、1クラスの責務が重すぎるとの指摘をいただき、コードを修正しました。
2021/06/06
※クラスの分け方に問題があるとのコメントをいただいています。以下のコードを参考にする際は、その点に注意をお願いいたします。

`DEPTH_MAX`の値を増加させれば、ゲームの難易度を上げることができます。ただし、CPU（後手）の思考時間が長くなります。

```Othello.py
#!/usr/bin/env python3

from tkinter import *
from tkinter import ttk
import copy
import traceback
import sys

# オセロ盤の行数（列数）
ROW_NUM = 8
# 石（先手）
STONE_1ST = '○'
# 石（後手）
STONE_2ND = '●'
# 相手の石
STONE_OPPONENT = {STONE_1ST: STONE_2ND, STONE_2ND: STONE_1ST}
# フォント指定
FONT_FAMILY = ''
FONT_SIZE = 30
# セルの前景色
FOREGROUND_COLOR = 'black'
# セルの背景色
BACKGROUND_COLOR = 'green'

# 検索方向
DIRECTIONS = ((-1, -1), (-1, 0), (-1, 1),
              (0, -1), (0, 1),
              (1, -1), (1, 0), (1, 1))

# 各セルの評価値
EVALUATION_BASE = (
    (45, -11,  4, -1, -1,  4, -11,  45),
    (-11, -16, -1, -3, -3, -1, -16, -11),
    (4,  -1,  2, -1, -1,  2,  -1,   4),
    (-1,  -3, -1,  0,  0, -1,  -3,  -1),
    (-1,  -3, -1,  0,  0, -1,  -3,  -1),
    (4,  -1,  2, -1, -1,  2,  -1,   4),
    (-11, -16, -1, -3, -3, -1, -16, -11),
    (45, -11,  4, -1, -1,  4, -11,  45))

# αβ法による探索の深さ
DEPTH_MAX = 5


class BoardUI(ttk.Frame):
    def __init__(self, objTk=None):
        super().__init__(objTk)
        # 先手
        self.player1 = PlayerHuman(STONE_1ST, '先手（あなた）')
        # 後手
        self.player2 = PlayerCpu(STONE_2ND, '後手（CPU）')
        # 石の配置を初期化
        self.clear_position_list()
        # 石の配置（表示用）を初期化
        self.clear_disp_list()
        # オセロ盤を表示
        self.display_board()

    def clear_position_list(self):
        """
        石の配置を初期化
        """
        self.position_list = [[''] * ROW_NUM for i in range(ROW_NUM)]
        self.position_list[ROW_NUM // 2 - 1][ROW_NUM // 2 - 1] = STONE_1ST
        self.position_list[ROW_NUM // 2][ROW_NUM // 2] = STONE_1ST
        self.position_list[ROW_NUM // 2 - 1][ROW_NUM // 2] = STONE_2ND
        self.position_list[ROW_NUM // 2][ROW_NUM // 2 - 1] = STONE_2ND

    def clear_disp_list(self):
        """
        石の配置（表示用）を初期化
        """
        self.disp_list = [[''] * ROW_NUM for i in range(ROW_NUM)]
        for x in range(ROW_NUM):
            for y in range(ROW_NUM):
                self.disp_list[x][y] = StringVar()
                self.disp_list[x][y].set('')

    def update_board(self):
        """
        石の配置を画面に反映
        """
        for x in range(ROW_NUM):
            for y in range(ROW_NUM):
                self.disp_list[x][y].set(self.position_list[x][y])

    def display_board(self):
        """
        オセロ盤を表示
        """
        # 情報表示ラベルの表示内容
        self.display_var = StringVar()
        self.display_var.set('石を置きたい場所をクリックしてください。')

        # 情報表示ラベル
        info_label = ttk.Label(
            self, textvariable=self.display_var
        )

        info_label.grid(
            row=0, column=0, columnspan=ROW_NUM, sticky=(N, S, E, W)
        )

        # フォント指定
        style = ttk.Style()
        style.theme_use('default')
        style.configure('myButton.TButton', font=(FONT_FAMILY, FONT_SIZE),
                        foreground=FOREGROUND_COLOR, background=BACKGROUND_COLOR)

        for x in range(ROW_NUM):
            for y in range(ROW_NUM):
                button = ttk.Button(self,
                                    textvariable=self.disp_list[x][y],
                                    style='myButton.TButton',
                                    command=lambda idx_x=x, idx_y=y: self.on_click_board_cell(
                                        idx_x, idx_y)
                                    )
                button.grid(row=x+1, column=y, sticky=(N, S, E, W))
        self.grid(row=0, column=0, sticky=(N, S, E, W))

        # 石の配置を画面に反映
        self.update_board()

        # 横方向の引き伸ばし設定
        for y in range(ROW_NUM):
            self.columnconfigure(y, weight=1)

        # 縦方向の引き伸ばし設定
        self.rowconfigure(0, weight=0)  # 情報表示欄
        for x in range(1, ROW_NUM + 1):
            self.rowconfigure(x, weight=1)

        # ウィンドウ自体の引き伸ばし設定
        self.master.rowconfigure(0, weight=1)
        self.master.columnconfigure(0, weight=1)

    def on_click_board_cell(self, idx_x, idx_y):
        """
        オセロ盤クリック時
        """
        if is_othello_end(boardUI=self):
            # オセロ終了後にオセロ盤をクリックした場合
            # 石の配置を初期化
            self.clear_position_list()
            # 石の配置を画面に反映
            self.update_board()
            self.display_var.set('石を置きたい場所をクリックしてください。')
            return

        # 石が打てる位置（先手）
        able_position_list = get_able_position_list(
            self.position_list, self.player1.stone)

        if not [idx_x, idx_y] in able_position_list:
            self.display_var.set('この位置には石を打つことができません。')
            return

        try:
            # 先手の石を打つ
            self.position_list = put_stone(
                idx_x, idx_y, copy.deepcopy(self.position_list), self.player1.stone)
            # 石の配置を画面に反映
            self.update_board()

            if is_othello_end(boardUI=self):
                # オセロ終了時
                on_othello_end(boardUI=self)
                return

            if get_able_position_num(self.position_list, self.player2.stone) == 0:
                # 後手が石を打つ位置がない場合
                self.display_var.set(
                    '{0}は石を打てないためパス。石を打ちたい場所をクリックしてください。'.format(self.player2.name))
                return

            # 後手の局面開始
            self.player2.think(boardUI=self)

        except Exception as e:
            print(traceback.format_exc())


class PlayerBase:
    def __init__(self, stone, name):
        self.stone = stone
        self.name = name


class PlayerHuman(PlayerBase):
    pass


class PlayerCpu(PlayerBase):
    def think(self, boardUI):
        """
        後手の局面
        """
        boardUI.display_var.set('{0}思考中...'.format(boardUI.player2.name))
        # 先手がパスした回数
        pass_num = 0

        while(True):
            # αβ法により次手を探索
            x_next, y_next, evaluation = get_next_position(
                boardUI, copy.deepcopy(boardUI.position_list),
                boardUI.player2.stone, depth=0, alpha=-sys.maxsize, beta=sys.maxsize)
            # 後手の石を打つ
            boardUI.position_list = put_stone(
                x_next, y_next, copy.deepcopy(boardUI.position_list), boardUI.player2.stone)
            # 石の配置を画面に反映
            boardUI.update_board()

            if is_othello_end(boardUI):
                # オセロ終了時
                on_othello_end(boardUI)
                return

            if get_able_position_num(boardUI.position_list, boardUI.player1.stone) > 0:
                # 先手が石を打つ位置がある場合
                break

            pass_num += 1

        if pass_num > 0:
            # 先手がパスした場合
            boardUI.display_var.set('{0}は石を打つ位置がなかったため{1}回パス。石を置きたい場所をクリックしてください。'.format(
                boardUI.player1.name, pass_num))
        else:
            boardUI.display_var.set('石を置きたい場所をクリックしてください。')


def get_able_position_list(position_list, stone_put):
    """
    石が打てる位置を取得
    """
    # 石が打てる位置
    able_position_list = []
    for x in range(ROW_NUM):
        for y in range(ROW_NUM):
            if position_list[x][y] != '':
                # 既に石がある場合
                continue
            num = 0
            for d_x, d_y in DIRECTIONS:
                # 石を打つことで反転される石の数を取得
                num += get_turn_over_num(x, y,
                                         d_x, d_y, position_list, stone_put)
            if num > 0:
                able_position_list.append([x, y])
    return able_position_list


def get_turn_over_num(x, y, d_x, d_y, position_list, stone_put):
    """
    石を打つことで反転される石の数を取得
    """
    # 反転される石の数
    num = 0
    for i in range(1, ROW_NUM):
        if not (0 <= (x + d_x * i) <= ROW_NUM - 1) or \
                not (0 <= (y + d_y * i) <= ROW_NUM - 1):
            return 0
        stone = position_list[x + d_x * i][y + d_y * i]
        if stone == '':
            return 0
        elif stone == stone_put:
            return num
        num += 1
    return 0


def put_stone(x, y, position_list, stone_put):
    """
    石を打つ
    """
    for d_x, d_y in DIRECTIONS:
        # 石を打つことで反転される石の数を取得
        num = get_turn_over_num(x, y,
                                d_x, d_y, position_list, stone_put)
        if num > 0:
            for i in range(1, num + 1):
                position_list[x + d_x * i][y + d_y * i] = stone_put
    # 打った石
    position_list[x][y] = stone_put
    return position_list


def get_able_position_num(position_list, stone_put):
    """
    石が打てる位置の数を取得
    """
    able_position_list = get_able_position_list(
        position_list, stone_put)
    return len(able_position_list)


def get_next_position(boardUI, position_list, stone_put, depth, alpha, beta):
    """
    αβ法により次手を探索
    """
    if get_able_position_num(position_list, stone_put) == 0:
        # 石を打つ位置がない場合
        # x_next, y_next, evaluation
        return -1, -1, 0
    # 石が打てる位置
    able_position_list = get_able_position_list(
        position_list, stone_put)
    # 評価値
    evaluation_list = []
    for x, y in able_position_list:
        # 石を打つ
        position_list2 = put_stone(
            x, y, copy.deepcopy(position_list), stone_put)
        if depth == DEPTH_MAX or \
                get_able_position_num(position_list2, STONE_OPPONENT[stone_put]) == 0:
            # 石を打つ位置がない場合
            # 評価値
            evaluation_list += [get_evaluation(
                position_list2, boardUI.player2.stone)]
        else:
            depth += 1
            # 再帰的に呼び出す
            x_next, y_next, evaluation = get_next_position(
                boardUI, position_list2, STONE_OPPONENT[stone_put], depth, alpha, beta)
            # 評価値
            evaluation_list += [evaluation]
        if stone_put == boardUI.player1.stone:
            # 先手の石を打った場合（後手の局面の場合）
            if evaluation_list[-1] < alpha:
                # αカット
                break
            if evaluation_list[-1] < beta:
                # β値を更新
                beta = evaluation_list[-1]
        else:
            # 後手（CPU）の石を打った場合（先手の局面の場合）
            if evaluation_list[-1] > beta:
                # βカット
                break
            if evaluation_list[-1] > alpha:
                # α値を更新
                alpha = evaluation_list[-1]
    # 評価値
    evaluation = 0.0
    idx = 0
    if stone_put == boardUI.player1.stone:
        # 先手の局面なら評価値が最小の手を選択
        if len(evaluation_list) > 0:
            idx = evaluation_list.index(min(evaluation_list))
    else:
        # 後手（CPU）の局面なら評価値が最大の手を選択
        if len(evaluation_list) > 0:
            idx = evaluation_list.index(max(evaluation_list))
    evaluation = evaluation_list[idx]
    x_next, y_next = able_position_list[idx]
    return x_next, y_next, evaluation


def get_evaluation(position_list, stone_put):
    """
    評価値を取得
    """
    evaluation = 0.0
    for x in range(ROW_NUM):
        for y in range(ROW_NUM):
            if position_list[x][y] == stone_put:
                # 自分の石の場合
                evaluation += EVALUATION_BASE[x][y]
            elif position_list[x][y] == STONE_OPPONENT[stone_put]:
                # 相手の石の場合
                evaluation -= EVALUATION_BASE[x][y]
    return evaluation


def is_othello_end(boardUI):
    """
    オセロ終了判定
    """
    # 空白数
    empty_num = 0
    # 先手の石の数
    stone_1st_num = 0
    # 後手の石の数
    stone_2nd_num = 0
    empty_num, stone_1st_num, stone_2nd_num = get_stone_num(boardUI)
    if empty_num == 0:
        return True
    elif stone_1st_num == 0:
        # 空白はあるが、全て後手の石になっている場合
        return True
    elif stone_2nd_num == 0:
        # 空白はあるが、全て先手の石になっている場合
        return True
    else:
        return False


def on_othello_end(boardUI):
    """
    オセロ終了時
    """
    # 空白数
    empty_num = 0
    # 先手の石の数
    stone_1st_num = 0
    # 後手の石の数
    stone_2nd_num = 0
    empty_num, stone_1st_num, stone_2nd_num = get_stone_num(boardUI)
    boardUI.display_var.set('{0}の石の数：{1}、{2}の石の数：{3}'.format(
        boardUI.player1.name,
        stone_1st_num,
        boardUI.player2.name,
        stone_2nd_num))


def get_stone_num(boardUI):
    """
    石の数を取得する
    """
    # 空白数
    empty_num = 0
    # 先手の石の数
    stone_1st_num = 0
    # 後手の石の数
    stone_2nd_num = 0
    for x in range(ROW_NUM):
        for y in range(ROW_NUM):
            if boardUI.position_list[x][y] == '':
                empty_num += 1
            elif boardUI.position_list[x][y] == boardUI.player1.stone:
                stone_1st_num += 1
            elif boardUI.position_list[x][y] == boardUI.player2.stone:
                stone_2nd_num += 1
    return empty_num, stone_1st_num, stone_2nd_num


def main():
    """
    メイン処理
    """
    objTk = Tk()
    objTk.title('オセロ')
    objTk.geometry('500x500')
    BoardUI(objTk)
    objTk.mainloop()


if __name__ == '__main__':
    main()

```