PythonとTkinterでCPU対戦チェスを作った話|合法手・チェック判定・CPU思考まで実装

Pythonでチェスを作った

PythonとTkinterを使って、CPUと対戦できるチェスアプリを作りました。

最初は「8×8の盤面を表示して、駒をクリックで動かせればチェスっぽくなる」と思っていました。
でも実際に作ってみると、チェスはただ駒を動かすだけのゲームではありませんでした。

チェックされているときにどの手が指せるのか、キングが危険になる手を禁止できるのか、キャスリングやアンパッサンをどう扱うのか。
見た目よりも、ルール判定と状態管理がかなり大事なゲームでした。

今回作ったものは、次のような機能を持っています。

  • サイト上で遊べるWeb版はこちら
  • Python標準のTkinterで画面を作成
  • 8×8のチェス盤をCanvasに描画
  • 白はプレイヤー、黒はCPUとして対戦
  • 駒をクリックすると移動可能マスを表示
  • 駒の移動、駒取り、チェック判定に対応
  • チェックメイトとステイルメイトを判定
  • キャスリングに対応
  • アンパッサンに対応
  • ポーンのプロモーションに対応
  • CPU難易度をEasy / Normal / Hardから選択
  • Web版ではさらにExtra難易度を追加
  • 直前の手、選択中の駒、チェック中のキングを色で表示

全体の構成

コードは大きく分けて、ゲーム本体と画面表示の2つに分けました。

ChessGame
  盤面、手番、合法手、チェック判定、CPU思考などを担当

ChessApp
  Tkinterの画面、クリック処理、盤面描画、ボタンなどを担当

チェスのルールと画面描画を同じ場所に全部書いてしまうと、あとから修正しづらくなります。
そのため、ルールはChessGame、画面はChessAppに分けました。

たとえば、合法手の判定を直したいときはChessGameを見ればよく、見た目を変えたいときはChessAppを見ればよくなります。

盤面の持ち方

盤面は8×8の二次元リストで管理しています。

self.board = [
    ["bR", "bN", "bB", "bQ", "bK", "bB", "bN", "bR"],
    ["bP"] * 8,
    [None] * 8,
    [None] * 8,
    [None] * 8,
    [None] * 8,
    ["wP"] * 8,
    ["wR", "wN", "wB", "wQ", "wK", "wB", "wN", "wR"],
]

wKなら白のキング、bQなら黒のクイーンという意味です。
何もないマスはNoneにしています。

wK: 白キング
wQ: 白クイーン
wR: 白ルーク
wB: 白ビショップ
wN: 白ナイト
wP: 白ポーン

bK: 黒キング
bQ: 黒クイーン
bR: 黒ルーク
bB: 黒ビショップ
bN: 黒ナイト
bP: 黒ポーン

文字列だけで駒を表しているので、判定がかなりシンプルになります。
先頭の文字を見れば白か黒かがわかり、2文字目を見れば駒の種類がわかります。

駒の表示

駒の画像は使わず、Unicodeのチェス記号を使いました。

PIECE_SYMBOLS = {
    "wK": "♔",
    "wQ": "♕",
    "wR": "♖",
    "wB": "♗",
    "wN": "♘",
    "wP": "♙",
    "bK": "♚",
    "bQ": "♛",
    "bR": "♜",
    "bB": "♝",
    "bN": "♞",
    "bP": "♟",
}

画像を用意しなくてもチェスらしい見た目になるので、最初の実装としてはかなり便利です。

TkinterのCanvasに文字として描画しているだけなので、盤面の色やマスの大きさも調整しやすくなっています。

Moveクラスで手を管理する

チェスでは「どこからどこへ動いたか」だけでは情報が足りません。

駒を取ったのか、プロモーションしたのか、キャスリングなのか、アンパッサンなのか。
あとから盤面を更新するためには、手の情報をまとめて持っておく必要があります。

そこでMoveというdataclassを作りました。

@dataclass
class Move:
    start: tuple[int, int]
    end: tuple[int, int]
    piece: str
    captured: str | None = None
    promotion: str | None = None
    castle: str | None = None
    en_passant: bool = False
    pawn_double: bool = False

この形にしておくと、普通の移動も特殊な移動も同じMoveとして扱えます。
盤面更新やCPUの評価でも、手の情報をまとめて参照できるので便利でした。

合法手の考え方

チェスで一番大事だったのが、合法手の判定です。

単純に「駒が移動できる場所」を出すだけなら、そこまで難しくありません。
ビショップなら斜め、ルークなら縦横、ナイトならL字に動かせばいいだけです。

ただし、それだけではチェスとして正しくありません。

自分のキングが取られる状態になる手は、指してはいけないからです。

そこで、処理を2段階に分けました。

疑似合法手
  駒の動きだけを見た移動候補

合法手
  その手を指しても自分のキングがチェックされない手

実装では、まずpseudo_moves_for_piece()で駒ごとの移動候補を作ります。
その後、is_legal_move()で仮の盤面に手を適用し、自分のキングがチェックされていないかを確認します。

def is_legal_move(self, move: Move) -> bool:
    next_board = self.board_copy()
    self.apply_move_to_board(next_board, move)
    return not self.in_check(move.piece[0], next_board)

この「一度仮に動かしてから確認する」という考え方が、チェス実装ではかなり重要でした。

チェック判定

チェック判定では、まず自分のキングの位置を探します。

king_square = self.find_king(color, use_board)

次に、相手の駒がそのマスを攻撃しているかを調べます。

return self.is_square_attacked(king_square, self.other(color), use_board)

ここで少し注意が必要なのが、ポーンです。

ポーンは前に進みますが、攻撃するマスは斜め前です。
そのため、移動できるマスと攻撃しているマスが違います。

この違いを扱うために、attacks_onlyという引数を用意しました。
チェック判定では「その駒が実際に移動できるか」ではなく、「そのマスを攻撃しているか」を見る必要があるからです。

チェックメイトとステイルメイト

手を指したあと、次の手番のプレイヤーがチェックされているかを確認します。

もしチェックされていて、さらに合法手がなければチェックメイトです。

チェック中 + 合法手なし = チェックメイト

チェックされていないのに合法手がなければステイルメイトです。

チェックなし + 合法手なし = ステイルメイト

この2つは似ていますが、結果がまったく違います。
チェスのゲームとして成立させるためには、ここを分けて判定する必要があります。

キャスリング

キャスリングは、実装してみると思ったより条件が多い特殊ルールでした。

主な条件は次の通りです。

  • キングがまだ動いていない
  • 対象のルークがまだ動いていない
  • キングとルークの間に駒がない
  • 現在チェックされていない
  • キングが通るマスが攻撃されていない
  • 移動後のマスも攻撃されていない

この条件を管理するために、キャスリング権利を持たせています。

self.castling_rights = {
    "wK": True,
    "wQ": True,
    "bK": True,
    "bQ": True,
}

キングやルークが動いたら、この権利を消します。
また、ルークが取られた場合も、その側のキャスリング権利を消す必要があります。

キャスリングは「キングを2マス動かすだけ」に見えますが、実際にはルークも同時に動かすので、盤面更新も特別扱いしています。

アンパッサン

アンパッサンも、チェス特有のややこしいルールです。

相手のポーンが2マス進んだ直後だけ、そのポーンを斜めに取ることができます。
そのため、2マス進んだポーンがあったときだけen_passant_targetを保存しています。

if move.pawn_double:
    start_row, start_col = move.start
    direction = -1 if move.piece[0] == "w" else 1
    self.en_passant_target = (start_row + direction, start_col)
else:
    self.en_passant_target = None

ポイントは「直後だけ」というところです。
次の手で使われなければ、アンパッサンの権利は消えます。

プロモーション

ポーンが一番奥まで進んだときは、今回は自動でクイーンに昇格するようにしました。

promotion=f"{color}Q" if next_row in (0, 7) else None

本来はクイーン、ルーク、ビショップ、ナイトから選べます。
ただ、まずはゲームとして遊べる状態を優先したので、今回は自動クイーンにしています。

画面にも「プロモーションは自動でクイーンになります」と表示しています。

CPUの考え方

CPUは3段階の難易度を用意しました。

CPU_LEVELS = {
    "Easy": "easy",
    "Normal": "normal",
    "Hard": "hard",
}

Easyはランダム性が強めです。
たまに駒を取る手を選びますが、基本的にはランダムに指します。

Normalは、手を点数化してよさそうな手を選びます。
評価には駒の価値を使っています。

PIECE_VALUES = {
    "P": 100,
    "N": 320,
    "B": 330,
    "R": 500,
    "Q": 900,
    "K": 0,
}

評価している内容は、たとえば次のようなものです。

  • 駒を取れる手は高評価
  • プロモーションは高評価
  • キャスリングは少し高評価
  • チェックをかける手は高評価
  • 中央に近いマスは少し高評価
  • 序盤のナイトやビショップの展開は少し高評価
  • 取られそうなマスに行く手は低評価

Hardでは、さらに相手の返し手も少し見ます。

if level == "hard":
    score -= self.best_reply_score(move, color) * 0.85

本格的なチェスAIではありませんが、完全ランダムよりはゲームらしくなります。
「その手を指したら相手に強い返しをされないか」を少し見るだけでも、CPUの雰囲気はかなり変わりました。

Web版で追加したExtra難易度

この記事のPython/Tkinter版では、CPU難易度をEasy / Normal / Hardの3段階にしています。
その後、サイト上で遊べるWeb版を作るときに、さらに強いExtra難易度を追加しました。

Web版のExtraでは、ただ評価関数で一番よさそうな手を選ぶだけではなく、数手先まで読む探索を入れています。

Easy:
  ランダム性が強め

Normal:
  駒得、中央、展開などを点数化して選ぶ

Hard:
  2手先まで読んで、相手の返しも少し考える

Extra:
  3手先まで読み、アルファベータ枝刈りで強い手を探す

Extraで入れた主な考え方は、次の通りです。

  • ミニマックス法で数手先まで読む
  • アルファベータ枝刈りで不要な探索を減らす
  • 駒取り、昇格、チェックを優先して読む
  • 駒の価値だけでなく、中央支配や駒の展開も評価する
  • キングの安全度や終盤の位置取りも少し評価する

これにより、NormalやHardよりも「ただ取れる駒を取る」だけではなく、次の展開を考えた手を指しやすくなりました。
本格的なチェスエンジンほど強いわけではありませんが、個人制作のブラウザゲームとしてはかなり遊びごたえが出たと思います。

画面表示で工夫したところ

TkinterのCanvasには、盤面だけでなくいろいろなヒントも描画しています。

  • 選択中の駒
  • 移動できるマス
  • 取れる駒
  • 直前の手
  • チェック中のキング
  • 座標ラベル

色は定数としてまとめました。

LIGHT_SQUARE = "#f0d9b5"
DARK_SQUARE = "#b58863"
HIGHLIGHT = "#f7ec6e"
MOVE_HINT = "#6aa84f"
CAPTURE_HINT = "#d9534f"
LAST_MOVE = "#9fc5e8"
CHECK_HINT = "#ff8a80"

チェスは合法手が多く、初心者だとどこに動けるかわかりづらいです。
クリックした駒の移動先を表示するだけで、かなり遊びやすくなりました。

クリック処理の流れ

プレイヤーが盤面をクリックしたときは、次のような流れで処理しています。

1. クリック位置から盤面の行・列を計算
2. 自分の手番か確認
3. すでに駒を選択している場合は、移動先として使えるか確認
4. 合法手なら手を実行
5. 盤面を再描画
6. CPUの手番なら少し待ってCPUを動かす

CPUは即座に動かすのではなく、少しだけ待ってから動かしています。

self.root.after(450, self.make_cpu_move)

この待ち時間があるだけで、CPUが考えているように見えます。
ゲームの手触りとしては、意外と大事な部分でした。

作っていて難しかったところ

一番難しかったのは、やはり合法手の判定です。

駒ごとの動きだけなら、比較的素直に書けます。
でも「その手を指した結果、自分のキングが危険にならないか」まで見ると、急にチェスらしい難しさが出てきます。

また、キャスリングとアンパッサンは普通の移動とは別の状態管理が必要です。
キングやルークが動いたか、ポーンが2マス進んだ直後かなど、盤面だけではわからない情報を持つ必要がありました。

作ってみて、チェスは盤面ゲームというより「状態管理のゲーム」だと感じました。

今後改善したいところ

今回の実装でも遊べる状態にはなっていますが、まだ改善できるところはあります。

  1. プロモーション時に昇格先を選べるようにする
  2. 棋譜を表示する
  3. 一手戻す機能を入れる
  4. 自分が黒でも遊べるようにする
  5. ドラッグ操作に対応する
  6. Python版にもWeb版のExtra難易度のような探索を入れる
  7. 持ち時間やタイマーを追加する
  8. PGN形式で保存できるようにする

特にPython版のCPUは、今は簡易的な評価関数で動いています。
Web版ではExtra難易度としてミニマックス法とアルファベータ枝刈りを入れたので、Python版にも同じ考え方を移植するとさらに強くできそうです。

実際のコード

下のコードが、今回作ったPythonチェスの中心部分です。
Tkinterだけで盤面を描画し、ChessGame側でチェスのルール、ChessApp側で画面操作を担当する構成にしています。

from __future__ import annotations

from dataclasses import dataclass
import copy
import random
import tkinter as tk


LIGHT_SQUARE = "#f0d9b5"
DARK_SQUARE = "#b58863"
HIGHLIGHT = "#f7ec6e"
MOVE_HINT = "#6aa84f"
CAPTURE_HINT = "#d9534f"
LAST_MOVE = "#9fc5e8"
CHECK_HINT = "#ff8a80"

PIECE_SYMBOLS = {
    "wK": "♔",
    "wQ": "♕",
    "wR": "♖",
    "wB": "♗",
    "wN": "♘",
    "wP": "♙",
    "bK": "♚",
    "bQ": "♛",
    "bR": "♜",
    "bB": "♝",
    "bN": "♞",
    "bP": "♟",
}

PIECE_VALUES = {
    "P": 100,
    "N": 320,
    "B": 330,
    "R": 500,
    "Q": 900,
    "K": 0,
}

CPU_LEVELS = {
    "Easy": "easy",
    "Normal": "normal",
    "Hard": "hard",
}


@dataclass
class Move:
    start: tuple[int, int]
    end: tuple[int, int]
    piece: str
    captured: str | None = None
    promotion: str | None = None
    castle: str | None = None
    en_passant: bool = False
    pawn_double: bool = False


class ChessGame:
    def __init__(self) -> None:
        self.reset()

    def reset(self) -> None:
        self.board = [
            ["bR", "bN", "bB", "bQ", "bK", "bB", "bN", "bR"],
            ["bP"] * 8,
            [None] * 8,
            [None] * 8,
            [None] * 8,
            [None] * 8,
            ["wP"] * 8,
            ["wR", "wN", "wB", "wQ", "wK", "wB", "wN", "wR"],
        ]
        self.turn = "w"
        self.selected: tuple[int, int] | None = None
        self.legal_targets: list[tuple[int, int]] = []
        self.en_passant_target: tuple[int, int] | None = None
        self.castling_rights = {
            "wK": True,
            "wQ": True,
            "bK": True,
            "bQ": True,
        }
        self.last_move: Move | None = None
        self.status = "白の手番です"
        self.game_over = False

    @staticmethod
    def inside(row: int, col: int) -> bool:
        return 0 <= row < 8 and 0 <= col < 8

    @staticmethod
    def other(color: str) -> str:
        return "b" if color == "w" else "w"

    def piece_at(self, square: tuple[int, int], board=None) -> str | None:
        row, col = square
        use_board = board if board is not None else self.board
        return use_board[row][col]

    def find_king(self, color: str, board=None) -> tuple[int, int]:
        use_board = board if board is not None else self.board
        target = f"{color}K"
        for row in range(8):
            for col in range(8):
                if use_board[row][col] == target:
                    return (row, col)
        raise ValueError("King not found")

    def is_square_attacked(self, square: tuple[int, int], by_color: str, board=None) -> bool:
        use_board = board if board is not None else self.board
        for row in range(8):
            for col in range(8):
                piece = use_board[row][col]
                if piece is None or piece[0] != by_color:
                    continue
                for move in self.pseudo_moves_for_piece((row, col), board=use_board, attacks_only=True):
                    if move.end == square:
                        return True
        return False

    def in_check(self, color: str, board=None) -> bool:
        use_board = board if board is not None else self.board
        king_square = self.find_king(color, use_board)
        return self.is_square_attacked(king_square, self.other(color), use_board)

    def pseudo_moves_for_piece(self, square: tuple[int, int], board=None, attacks_only: bool = False) -> list[Move]:
        use_board = board if board is not None else self.board
        row, col = square
        piece = use_board[row][col]
        if piece is None:
            return []

        color = piece[0]
        kind = piece[1]
        moves: list[Move] = []

        if kind == "P":
            direction = -1 if color == "w" else 1
            start_row = 6 if color == "w" else 1
            next_row = row + direction

            if not attacks_only and self.inside(next_row, col) and use_board[next_row][col] is None:
                moves.append(Move(square, (next_row, col), piece, promotion=f"{color}Q" if next_row in (0, 7) else None))
                jump_row = row + direction * 2
                if row == start_row and use_board[jump_row][col] is None:
                    moves.append(Move(square, (jump_row, col), piece, pawn_double=True))

            for delta_col in (-1, 1):
                capture_row = row + direction
                capture_col = col + delta_col
                if not self.inside(capture_row, capture_col):
                    continue
                target = use_board[capture_row][capture_col]
                if attacks_only:
                    moves.append(Move(square, (capture_row, capture_col), piece))
                elif target is not None and target[0] != color:
                    moves.append(Move(square, (capture_row, capture_col), piece, captured=target, promotion=f"{color}Q" if capture_row in (0, 7) else None))
                elif self.en_passant_target == (capture_row, capture_col):
                    captured_piece = use_board[row][capture_col]
                    if captured_piece is not None and captured_piece[0] != color and captured_piece[1] == "P":
                        moves.append(Move(square, (capture_row, capture_col), piece, captured=captured_piece, en_passant=True))

        elif kind == "N":
            for dr, dc in [(-2, -1), (-2, 1), (-1, -2), (-1, 2), (1, -2), (1, 2), (2, -1), (2, 1)]:
                nr, nc = row + dr, col + dc
                if self.inside(nr, nc):
                    target = use_board[nr][nc]
                    if target is None or target[0] != color:
                        moves.append(Move(square, (nr, nc), piece, captured=target))

        elif kind in ("B", "R", "Q"):
            directions = []
            if kind in ("B", "Q"):
                directions.extend([(-1, -1), (-1, 1), (1, -1), (1, 1)])
            if kind in ("R", "Q"):
                directions.extend([(-1, 0), (1, 0), (0, -1), (0, 1)])

            for dr, dc in directions:
                nr, nc = row + dr, col + dc
                while self.inside(nr, nc):
                    target = use_board[nr][nc]
                    if target is None:
                        moves.append(Move(square, (nr, nc), piece))
                    else:
                        if target[0] != color:
                            moves.append(Move(square, (nr, nc), piece, captured=target))
                        break
                    nr += dr
                    nc += dc

        elif kind == "K":
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    if dr == 0 and dc == 0:
                        continue
                    nr, nc = row + dr, col + dc
                    if self.inside(nr, nc):
                        target = use_board[nr][nc]
                        if target is None or target[0] != color:
                            moves.append(Move(square, (nr, nc), piece, captured=target))

            if not attacks_only:
                moves.extend(self.castling_moves(color, board=use_board))

        return moves

    def castling_moves(self, color: str, board=None) -> list[Move]:
        use_board = board if board is not None else self.board
        row = 7 if color == "w" else 0
        if use_board[row][4] != f"{color}K" or self.in_check(color, use_board):
            return []

        moves: list[Move] = []
        enemy = self.other(color)

        if self.castling_rights[f"{color}K"]:
            if use_board[row][5] is None and use_board[row][6] is None and use_board[row][7] == f"{color}R":
                if not self.is_square_attacked((row, 5), enemy, use_board) and not self.is_square_attacked((row, 6), enemy, use_board):
                    moves.append(Move((row, 4), (row, 6), f"{color}K", castle="king"))

        if self.castling_rights[f"{color}Q"]:
            if use_board[row][1] is None and use_board[row][2] is None and use_board[row][3] is None and use_board[row][0] == f"{color}R":
                if not self.is_square_attacked((row, 3), enemy, use_board) and not self.is_square_attacked((row, 2), enemy, use_board):
                    moves.append(Move((row, 4), (row, 2), f"{color}K", castle="queen"))

        return moves

    def legal_moves_from(self, square: tuple[int, int]) -> list[Move]:
        piece = self.piece_at(square)
        if piece is None or piece[0] != self.turn:
            return []
        return [move for move in self.pseudo_moves_for_piece(square) if self.is_legal_move(move)]

    def is_legal_move(self, move: Move) -> bool:
        next_board = self.board_copy()
        self.apply_move_to_board(next_board, move)
        return not self.in_check(move.piece[0], next_board)

    def all_legal_moves(self, color: str) -> list[Move]:
        current_turn = self.turn
        self.turn = color
        found: list[Move] = []
        for row in range(8):
            for col in range(8):
                piece = self.board[row][col]
                if piece is not None and piece[0] == color:
                    found.extend(self.legal_moves_from((row, col)))
        self.turn = current_turn
        return found

    def choose_cpu_move(self, color: str, level: str = "normal") -> Move | None:
        moves = self.all_legal_moves(color)
        if not moves:
            return None

        if level == "easy":
            captures = [move for move in moves if move.captured]
            if captures and random.random() < 0.35:
                return random.choice(captures)
            return random.choice(moves)

        best_score = -10_000
        best_moves: list[Move] = []

        for move in moves:
            score = self.score_move(move, color)
            if level == "hard":
                score -= self.best_reply_score(move, color) * 0.85

            if score > best_score:
                best_score = score
                best_moves = [move]
            elif abs(score - best_score) < 0.001:
                best_moves.append(move)

        return random.choice(best_moves)

    def score_move(self, move: Move, color: str) -> float:
        opponent = self.other(color)
        next_board = self.board_copy()
        self.apply_move_to_board(next_board, move)
        score = random.uniform(-0.2, 0.2)

        if move.captured:
            score += PIECE_VALUES[move.captured[1]] + PIECE_VALUES[move.piece[1]] * 0.04
        if move.promotion:
            score += PIECE_VALUES[move.promotion[1]] - PIECE_VALUES["P"]
        if move.castle:
            score += 45
        if self.in_check(opponent, next_board):
            score += 65

        end_row, end_col = move.end
        distance_from_center = abs(3.5 - end_row) + abs(3.5 - end_col)
        score += (7 - distance_from_center) * 4

        if move.piece[1] in ("N", "B") and move.start[0] in (0, 7):
            score += 18
        if move.piece[1] == "Q" and move.start[0] in (0, 7):
            score -= 16
        if self.is_square_attacked(move.end, opponent, next_board):
            score -= PIECE_VALUES[move.piece[1]] * 0.28

        return score

    def best_reply_score(self, move: Move, color: str) -> float:
        next_game = copy.deepcopy(self)
        next_game.play_move(move)
        opponent = self.other(color)
        replies = next_game.all_legal_moves(opponent)
        if not replies:
            return -10_000
        return max(next_game.score_move(reply, opponent) for reply in replies)

    def board_copy(self):
        return copy.deepcopy(self.board)

    def apply_move_to_board(self, board, move: Move) -> None:
        start_row, start_col = move.start
        end_row, end_col = move.end
        piece = board[start_row][start_col]
        board[start_row][start_col] = None

        if move.en_passant:
            board[start_row][end_col] = None

        if move.castle == "king":
            board[end_row][5] = board[end_row][7]
            board[end_row][7] = None
        elif move.castle == "queen":
            board[end_row][3] = board[end_row][0]
            board[end_row][0] = None

        board[end_row][end_col] = move.promotion if move.promotion else piece

    def update_castling_rights(self, move: Move) -> None:
        start_row, start_col = move.start
        end_row, end_col = move.end
        piece = move.piece

        if piece == "wK":
            self.castling_rights["wK"] = False
            self.castling_rights["wQ"] = False
        elif piece == "bK":
            self.castling_rights["bK"] = False
            self.castling_rights["bQ"] = False
        elif piece == "wR":
            if (start_row, start_col) == (7, 0):
                self.castling_rights["wQ"] = False
            elif (start_row, start_col) == (7, 7):
                self.castling_rights["wK"] = False
        elif piece == "bR":
            if (start_row, start_col) == (0, 0):
                self.castling_rights["bQ"] = False
            elif (start_row, start_col) == (0, 7):
                self.castling_rights["bK"] = False

        if move.captured == "wR":
            if (end_row, end_col) == (7, 0):
                self.castling_rights["wQ"] = False
            elif (end_row, end_col) == (7, 7):
                self.castling_rights["wK"] = False
        elif move.captured == "bR":
            if (end_row, end_col) == (0, 0):
                self.castling_rights["bQ"] = False
            elif (end_row, end_col) == (0, 7):
                self.castling_rights["bK"] = False

    def play_move(self, move: Move) -> None:
        self.apply_move_to_board(self.board, move)
        self.update_castling_rights(move)
        self.last_move = move

        if move.pawn_double:
            start_row, start_col = move.start
            direction = -1 if move.piece[0] == "w" else 1
            self.en_passant_target = (start_row + direction, start_col)
        else:
            self.en_passant_target = None

        self.turn = self.other(self.turn)
        self.selected = None
        self.legal_targets = []

        if self.in_check(self.turn):
            enemy_moves = self.all_legal_moves(self.turn)
            if enemy_moves:
                self.status = f"{'白' if self.turn == 'w' else '黒'}はチェックです"
            else:
                self.status = f"チェックメイト: {'白' if self.other(self.turn) == 'w' else '黒'}の勝ち"
                self.game_over = True
        else:
            enemy_moves = self.all_legal_moves(self.turn)
            if enemy_moves:
                self.status = f"{'白' if self.turn == 'w' else '黒'}の手番です"
            else:
                self.status = "ステイルメイト"
                self.game_over = True

    def select_square(self, square: tuple[int, int]) -> bool:
        if self.game_over:
            return False

        piece = self.piece_at(square)

        if self.selected is not None:
            moves = self.legal_moves_from(self.selected)
            for move in moves:
                if move.end == square:
                    self.play_move(move)
                    return True

        if piece is not None and piece[0] == self.turn:
            self.selected = square
            self.legal_targets = [move.end for move in self.legal_moves_from(square)]
        else:
            self.selected = None
            self.legal_targets = []

        return False

画面側は、Tkinterでステータス表示、CPU難易度、リスタートボタン、盤面Canvasを作っています。
クリックされた座標を盤面の行と列に変換し、合法手ならChessGameに手を渡して進めます。

class ChessApp:
    def __init__(self) -> None:
        self.root = tk.Tk()
        self.root.title("Python Chess")
        self.root.configure(bg="#1f1f1f")

        self.game = ChessGame()
        self.human_color = "w"
        self.cpu_color = "b"
        self.cpu_thinking = False
        self.cpu_level_var = tk.StringVar(value="Normal")

        self.square_size = 84
        self.board_pixels = self.square_size * 8
        self.margin = 36
        self.canvas_size = self.board_pixels + self.margin * 2

        top = tk.Frame(self.root, bg="#1f1f1f")
        top.pack(fill="x", padx=12, pady=(12, 0))

        self.status_var = tk.StringVar(value=self.game.status)
        tk.Label(
            top,
            textvariable=self.status_var,
            bg="#1f1f1f",
            fg="#f3f3f3",
            font=("Yu Gothic UI", 14, "bold"),
            anchor="w",
        ).pack(side="left", expand=True, fill="x")

        tk.Label(
            top,
            text="CPU Level",
            bg="#1f1f1f",
            fg="#f3f3f3",
            font=("Yu Gothic UI", 10, "bold"),
        ).pack(side="left", padx=(10, 6))

        level_menu = tk.OptionMenu(top, self.cpu_level_var, *CPU_LEVELS.keys())
        level_menu.configure(
            bg="#2b2b2b",
            fg="white",
            activebackground="#3a3a3a",
            activeforeground="white",
            relief="flat",
            highlightthickness=0,
            font=("Yu Gothic UI", 10, "bold"),
        )
        level_menu.pack(side="left", padx=(0, 10))

        tk.Button(
            top,
            text="最初から",
            command=self.restart,
            bg="#2f6f3e",
            fg="white",
            activebackground="#3f8a50",
            activeforeground="white",
            relief="flat",
            padx=14,
            pady=8,
            font=("Yu Gothic UI", 11, "bold"),
        ).pack(side="right")

        tk.Label(
            self.root,
            text="プロモーションは自動でクイーンになります",
            bg="#1f1f1f",
            fg="#c7c7c7",
            font=("Yu Gothic UI", 10),
        ).pack(pady=(6, 8))

        self.canvas = tk.Canvas(
            self.root,
            width=self.canvas_size,
            height=self.canvas_size,
            bg="#1f1f1f",
            highlightthickness=0,
        )
        self.canvas.pack(padx=12, pady=(0, 12))
        self.canvas.bind("<Button-1>", self.handle_click)

        self.draw()

    def restart(self) -> None:
        self.game.reset()
        self.cpu_thinking = False
        self.status_var.set(self.game.status)
        self.draw()

    def square_to_canvas(self, row: int, col: int) -> tuple[int, int, int, int]:
        x1 = self.margin + col * self.square_size
        y1 = self.margin + row * self.square_size
        return x1, y1, x1 + self.square_size, y1 + self.square_size

    def handle_click(self, event: tk.Event) -> None:
        if self.cpu_thinking or self.game.game_over or self.game.turn != self.human_color:
            return

        col = (event.x - self.margin) // self.square_size
        row = (event.y - self.margin) // self.square_size
        if not (0 <= row < 8 and 0 <= col < 8):
            return

        moved = self.game.select_square((row, col))
        self.status_var.set(self.game.status)
        self.draw()

        if moved and not self.game.game_over and self.game.turn == self.cpu_color:
            self.schedule_cpu_move()

    def schedule_cpu_move(self) -> None:
        self.cpu_thinking = True
        self.game.selected = None
        self.game.legal_targets = []
        self.status_var.set(f"CPU thinking... ({self.cpu_level_var.get()})")
        self.draw()
        self.root.after(450, self.make_cpu_move)

    def make_cpu_move(self) -> None:
        if self.game.game_over or self.game.turn != self.cpu_color:
            self.cpu_thinking = False
            self.status_var.set(self.game.status)
            self.draw()
            return

        level = CPU_LEVELS.get(self.cpu_level_var.get(), "normal")
        move = self.game.choose_cpu_move(self.cpu_color, level)
        if move is not None:
            self.game.play_move(move)

        self.cpu_thinking = False
        self.status_var.set(self.game.status)
        self.draw()

    def draw(self) -> None:
        self.canvas.delete("all")
        self.draw_board()
        self.draw_labels()
        self.draw_pieces()

    def draw_board(self) -> None:
        checked_king = None
        if self.game.in_check(self.game.turn):
            checked_king = self.game.find_king(self.game.turn)

        for row in range(8):
            for col in range(8):
                x1, y1, x2, y2 = self.square_to_canvas(row, col)
                color = LIGHT_SQUARE if (row + col) % 2 == 0 else DARK_SQUARE
                self.canvas.create_rectangle(x1, y1, x2, y2, fill=color, outline=color)

                if self.game.last_move and ((row, col) == self.game.last_move.start or (row, col) == self.game.last_move.end):
                    self.canvas.create_rectangle(x1, y1, x2, y2, fill=LAST_MOVE, stipple="gray50", outline="")

                if checked_king == (row, col):
                    self.canvas.create_rectangle(x1, y1, x2, y2, fill=CHECK_HINT, stipple="gray50", outline="")

                if self.game.selected == (row, col):
                    self.canvas.create_rectangle(x1, y1, x2, y2, outline=HIGHLIGHT, width=4)

                if (row, col) in self.game.legal_targets:
                    piece = self.game.board[row][col]
                    if piece is None:
                        cx = (x1 + x2) / 2
                        cy = (y1 + y2) / 2
                        self.canvas.create_oval(cx - 10, cy - 10, cx + 10, cy + 10, fill=MOVE_HINT, outline="")
                    else:
                        self.canvas.create_rectangle(x1 + 6, y1 + 6, x2 - 6, y2 - 6, outline=CAPTURE_HINT, width=4)

    def draw_labels(self) -> None:
        for col in range(8):
            letter = chr(ord("a") + col)
            x = self.margin + col * self.square_size + self.square_size / 2
            self.canvas.create_text(x, self.margin / 2, text=letter, fill="#dddddd", font=("Yu Gothic UI", 11, "bold"))
            self.canvas.create_text(x, self.canvas_size - self.margin / 2, text=letter, fill="#dddddd", font=("Yu Gothic UI", 11, "bold"))

        for row in range(8):
            number = str(8 - row)
            y = self.margin + row * self.square_size + self.square_size / 2
            self.canvas.create_text(self.margin / 2, y, text=number, fill="#dddddd", font=("Yu Gothic UI", 11, "bold"))
            self.canvas.create_text(self.canvas_size - self.margin / 2, y, text=number, fill="#dddddd", font=("Yu Gothic UI", 11, "bold"))

    def draw_pieces(self) -> None:
        for row in range(8):
            for col in range(8):
                piece = self.game.board[row][col]
                if piece is None:
                    continue
                x1, y1, x2, y2 = self.square_to_canvas(row, col)
                cx = (x1 + x2) / 2
                cy = (y1 + y2) / 2
                self.canvas.create_text(
                    cx,
                    cy + 3,
                    text=PIECE_SYMBOLS[piece],
                    font=("DejaVu Sans", 46),
                    fill="#1f1f1f" if piece[0] == "b" else "#fffdf7",
                )

    def run(self) -> None:
        self.root.mainloop()


if __name__ == "__main__":
    ChessApp().run()

まとめ

PythonとTkinterでチェスを作ると、GUI、ルール判定、状態管理、CPU思考をまとめて学べます。

今回特に勉強になったのは、次の部分です。

  • 盤面を二次元リストで管理する方法
  • 駒ごとの移動候補を作る方法
  • 仮の盤面で合法手を判定する方法
  • チェック、チェックメイト、ステイルメイトの判定
  • キャスリングやアンパッサンの状態管理
  • Tkinter Canvasでゲーム画面を描く方法
  • 評価関数で簡単なCPUを作る方法

最初はシンプルなチェス盤のつもりでしたが、作っていくうちにかなり本格的なルールまで入れることになりました。

チェスはルールが多いぶん、実装できたときの達成感が大きいです。
Pythonでゲームロジックを学びたい人には、かなりいい題材だと思います。