【Pygame】Minimax法で敵CPUを強化したオセロゲームを作成

PygameとMinimax法(ミニマックス法)で敵CPUを強化したオセロゲームを作成する方法をソースコード付きで詳しく解説します。

オセロゲームの敵CPUを強化

オセロゲームの敵CPU(白側)を強化するために、今回は「Minimax法」を用います。
Minimax法では、2人対戦型のゲームでN手先まで読みをする時、相手のターンでは自分にとって一番不利な手を選びます。
そして、自分のターンでは自分にとって一番有利な手を選びます。これをN手先まで繰り返し行い、最終的に自分のターンで最も有利になる手を選びます。

Minimax法のアルゴリズム

Minimax法のアルゴリズムを簡単に整理すると以下のとおりです。

手順 説明
①ゲームツリーの探索 ゲームツリーとは、現在のボード状態から可能なすべての手を展開していったものです。各ノード(点)はボードの状態を表し、枝(線)は手の選択を表します。
②再帰的な探索 ミニマックスアルゴリズムは、再帰的にゲームツリーを探索します。つまり、ある手を選んだ後、その次の手も同様に最適な手を探していきます。
③評価値の計算 各ノードでボードの状態を評価し、評価値を計算します。評価値は、白の石の数から黒の石の数を引いたものです。

相手のターンでは評価値が最小(Min)、自分のターンでは評価値が最大(Max)になるような手を選ぶことから、Minimax法と呼ばれています。これをオセローゲームに適用すると、敵CPU(白)とプレイヤー(黒)の処理は以下のようになります。

項目 説明
① 敵CPU(白)のターン 白が有利になるように評価値を最大化しようとします。つまり、白の石の数が増えるような手を選びます。具体的には、白のターンで白が置けるすべての位置を試し、それぞれの手について評価値を計算します。その中で最も高い評価値を持つ手を選びます。
(例)白が置ける位置A、B、Cがあるとします。それぞれの位置に置いた場合の評価値を計算します。位置Aの評価値が5、位置Bの評価値が3、位置Cの評価値が7だとすると、白は評価値が最も高い位置Cを選びます。
② プレイヤー(黒)のターン 黒が有利になるように評価値を最小化しようとします。つまり、白の石の数が減るような手を選びます。具体的には、黒が置けるすべての位置を試し、それぞれの手について評価値を計算します。その中で最も低い評価値を持つ手を選びます。
(例)黒が置ける位置D、E、Fがあるとします。それぞれの位置に置いた場合の評価値を計算します。位置Dの評価値が2、位置Eの評価値が4、位置Fの評価値が1だとすると、黒は評価値が最も低い位置Fを選びます。

評価値の計算

オセロゲームの場合、評価値としてよく用いられるのが数手先の「白の石数 – 黒の石数」です。例えば、深さが3なら敵CPU側(白)は3手先までのパターンを試し、3ターン後に「白の石数 – 黒の石数」が最も大きくなる次手を選びます。計算方法を整理すると以下のようになります。

計算手順 説明
① 3手先までのすべてのパターンを試す 現在のボード状態から、白と黒が交互に手を打つ3手先までのすべての可能な手を探索します。
② 評価値の計算 各終端ノード(3手先のボード状態)で、白の石の数から黒の石の数を引いた評価値を計算します。
③ 最適な手の選択 白のターンでは、評価値(白の石数 – 黒の石数)を最大化する手を選びます。CPUは自分の手を最大化しようとしますが、人が最適な手を選ぶことを前提にしているため、最小化プレイヤーの処理が必要です。黒のターンでは、プレイヤーが最適な手を選ぶと仮定し、評価値(白の石数 – 黒の石数)を最小化する手を選びます。敵CPUとプレイヤーの両者が最適な手を選ぶと仮定して、敵CPUは一手を選びます。

サンプルコード

Minimax法を用いたオセロゲームのサンプルコードです。


サンプルコードのポイント解説

CPUが「もしここに置いたらどうなるか」を考える際、実際の盤面を直接書き換えるとゲームが進行してしまいます。そのため、シミュレーションを行う前に盤面を丸ごとバックアップしています。

# 盤面の状態を一時保存(バックアップ)
board_backup = copy.deepcopy(self.board)

# 試しに置いてみる
self.board[x][y] = WHITE
self.flip_stones(x, y)  # 実際にひっくり返してシミュレーション

# 先読みを実行...

# 盤面を元の状態に完全に戻す
self.board = board_backup

盤面は「リストのリスト」の2次元配列です。通常のコピーでは中身のリストまで複製されないため、deepcopy を使って全データを別物として複製します。石を置くだけでなく、flip_stones を実行して相手の石をひっくり返した状態を作ることで、Minimax法の正しい評価値が求まります。

Minimax法の処理

def minimax(self, depth, is_maximizing):
    # 探索の限界(3手先)に達したら、その時の盤面を評価する
    if depth == 3 or not self.has_valid_move():
        return self.evaluate_board()

    if is_maximizing:
        # CPU(白)の番:自分の利益を最大化するスコアを選ぶ
        max_eval = float('-inf')
        # ...(全マス探索)
        return max_eval
    else:
        # プレイヤー(黒)の番:CPUの利益を最小化すると想定
        min_eval = float('inf')
        # ...(全マス探索)
        return min_eval

minimax の中で minimax を呼び出すことで、1手、2手、3手……と深く潜っていきます。depth(深さ)の値を大きくすると「より深読み」を予測しますが、計算量が増えて動作が重くなります。

石が置けるかどうかの判定(is_valid_move)

オセロの根幹ルールである「相手の石を挟めるか」を全8方向についてis_valid_moveメソッドでチェックします。

# 8方向(上下左右、斜め)の移動量を定義
directions = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]

for dx, dy in directions:
    nx, ny = x + dx, y + dy
    # 隣が相手の石であることを確認
    if 0 <= nx < BOARD_SIZE and 0 <= ny < BOARD_SIZE and self.board[nx][ny] == opponent:
        while 0 <= nx < BOARD_SIZE and 0 <= ny < BOARD_SIZE:
            nx += dx
            ny += dy
            # 自分の石にたどり着けば「挟めている」のでTrue
            if self.board[nx][ny] == self.turn:
                valid = True
                break

1つのマスに対して8方向を順番に見ていき、1方向でも挟める条件を満たせば「有効な一手」となります。

石をひっくり返す処理(flip_stones)

石を置いた際、実際に挟まれた石を自分の色に変えます。

pieces_to_flip = []  # 相手の石を一時的に溜める
# ...(探索ループ)
if 0 <= nx < BOARD_SIZE and 0 <= ny < BOARD_SIZE and self.board[nx][ny] == self.turn:
    for px, py in pieces_to_flip:
        self.board[px][py] = self.turn  # 確定した石をすべて自分の色に

探索の途中でいきなり色を変えるのではなく、自分の石が見つかって「挟めていることが確定」してから、リストに保存した相手の石を一気に塗り替えます。

### 座標計算とクリック判定

マウスでクリックした画面上の座標(ピクセル)を、盤面のインデックス(0〜7)に変換します。

```python
x, y = event.pos
x //= GRID_SIZE  # 例:座標150px // 75px = インデックス2
y //= GRID_SIZE

GRID_SIZEは、画面幅(例:600)をマスの数(例:8)で割った値(例:75)です。整数の除算(//)で小数点を切り捨てることで、正確にどのマスがクリックされたかを算出します。

パスとゲーム終了の管理

オセロでは「置けない場合はパス」「両者置けなくなったら終了」というルールがあります。

# 片方が置けない場合
if not game.has_valid_move():
    # 相手も置ける場所がないかチェック(完全終了の判定)
    if not any(game.is_valid_move(x, y) for x in range(BOARD_SIZE) for y in range(BOARD_SIZE)):
        print(game.game_end())  # 勝敗表示
        break
    game.turn = WHITE if game.turn == BLACK else BLACK  # パス

any関数の活用で盤面全体をスキャンし、1つでも True があれば処理を続行します。全滅(どこにも置けない)状態を判定するのに非常に便利な関数です。

関連ページ

Pygameの使い方については以下ページで解説しています。

【Pygame超入門】使い方とサンプルゲームを解説
Pygameで2Dゲームを簡単に制作する方法を入門者向けに解説します。

Python全般については以下ページで解説しています。

【Python超入門】基礎から応用例まで幅広く解説
PythonについてPythonは、統計処理や機械学習、ディープラーニングといった数値計算分野を中心に幅広い用途で利用されているプログラミング言語です。他のプログラミング言語と比較して「コードが短くて読みやすい、書きやすい」「ライブラリが豊...
記事の監修者
西住技研

プログラミング言語「Python」を研究、仕事、趣味でデータ分析や作業自動化などに活用してきたノウハウを情報発信しています。
筆者の詳しいプロフィールやお問合せはこちらのページまで。
YoutubeX(旧Twitter)でも情報発信中です!

西住技研をフォローする
PyGame

コメント