之前在做一个象棋启蒙练习的小工具时,我反复卡在“棋谱记录”和“局面评估”这两个环节上。网上的资料要么只讲棋盘绘制,要么直接甩一个完整引擎源码,对初学者非常不友好。这篇文章把整个实现过程整理成一套完整的实战方案:从零搭建一个带图形界面的象棋打谱工具,并接入本地 AI 引擎完成局面分析与最佳走法推荐。无论你是刚学 Python 的初学者,还是想了解象棋软件原理的开发者,都能照着做出来。
1. 象棋打谱与 AI 分析的基本概念
1.1 什么是打谱软件
传统意义上,棋手“打谱”是指研究前人留下的棋谱记录,在棋盘上一步步摆出来,理解每一步棋的意图。而打谱软件就是把这个过程搬到电脑上,用数字化的方式记录棋谱、回放棋谱,并且允许用户随时修改、保存和加载棋局。
一个基本的打谱软件需要具备以下能力:
- 绘制一个标准的 9 × 10 象棋棋盘。
- 用鼠标点击实现走棋。
- 校验走法是否符合象棋规则。
- 记录每一步着法,形成棋谱。
- 支持棋谱回放、前进、后退。
- 能够把棋局保存成文件,也能从文件加载继续研究。
棋谱的核心作用在于“重现”。软件不仅要记录“从哪走到哪”,还要记录完整的局面结构。这样用户关闭软件后重新打开,仍然能恢复到之前的研究进度。
1.2 AI 分析在象棋软件中的角色
AI 分析不是象棋软件必须的部分,但一旦加上,整个工具的价值会提升很多。它的核心是回答两个问题:
- 当前局面谁占优?优势有多大?
- 当前局面下最好的走法是什么?
在实现层面,AI 分析既可以是内置算法,也可以调用外部引擎。对于初学者来说,自己实现一个完整的象棋 AI 需要掌握博弈树搜索、Alpha-Beta 剪枝、评估函数等一系列知识,难度较高;更稳妥的方式是接入现成的开源象棋引擎,通过标准协议发送当前局面,引擎返回评估分数和建议走法。
目前比较常见的引擎通信协议有 UCCI(Universal Chinese Chess Interface)和 UCI(Universal Chess Interface,国际象棋常用)。国内流行的象棋巫师、象眼等引擎大多支持 UCCI 协议。我们的程序只需要把局面转换成引擎认识的格式,用子进程方式启动引擎,用标准输入输出和引擎通信即可。
1.3 本文的软件方案与技术选型
本文采用 Python 作为开发语言,原因有几点:
- 语法简单,初学者容易看懂。
- tkinter 是 Python 自带 GUI 库,不需要额外安装。
- 数据的组织与 JSON 转换方便,适合保存棋谱。
软件的结构设计拆成三层,层次清晰,便于后期扩展:
| 层次 | 功能 | 主要文件 |
|---|---|---|
| 界面层 | 棋盘绘制、点击交互、按钮控制 | board_gui.py |
| 规则层 | 走法生成、合法性校验、将军判断 | game_rules.py |
| 数据层 | 棋谱记录、FEN 序列化、引擎通信 | game_data.py |
虽然最终效果无法和商业象棋软件相比,但作为学习项目,它的完整度已经足够:能下棋、能打谱、能回放、能分析。
2. 环境准备与项目结构
2.1 开发环境说明
本文示例在 Windows 10/11 环境下完成,Python 版本以 3.10 及以上为例。由于 tkinter 是标准库组件,安装 Python 时默认会带上,不需要额外执行安装命令。
如果你使用的是 macOS 或 Linux,tkinter 可能需要单独安装。在 Ubuntu 上可以执行:
sudo apt-get install python3-tk在 macOS 上,如果运行import tkinter报错,可以尝试安装 python-tk:
brew install python-tk版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.2 创建项目结构
在磁盘上新建一个文件夹,命名可以叫chess_notation_studio,后续所有代码都放在这个目录下。推荐的项目结构如下:
chess_notation_studio/ ├── main.py # 程序入口 ├── game_rules.py # 走法与规则模块 ├── board_gui.py # 界面绘制与交互 ├── fen_manager.py # FEN 串解析与生成 ├── engine_client.py # AI 引擎通信模块 ├── data/ │ └── records/ # 棋谱保存目录 └── engines/ └── (放引擎程序文件)main.py负责启动窗口,board_gui.py负责界面,game_rules.py负责核心规则,fen_manager.py负责把局面转成标准化格式,engine_client.py负责和 AI 引擎做通信。
2.3 需要准备的辅助工具
- 一个支持语法高亮的代码编辑器,推荐 VS Code。
- 一个象棋 AI 引擎。初学者可以先去下载象棋巫师引擎,也可以在开源社区找支持 UCCI 协议的引擎。注意引擎文件是一个独立的可执行程序,我们只需要在程序里调用它。
本文所有代码以教育学习为目的,引擎使用仅限本地个人学习研究。
3. 棋盘数据结构与 FEN 格式设计
3.1 棋盘的坐标系统
中国象棋棋盘是 9 列 × 10 行,红方在下方,黑方在上方。为了方便程序表示,我采用二维数组来存储棋盘状态:
- 行坐标 0 到 9,对应从上到下的 10 行。
- 列坐标 0 到 8,对应从左到右的 9 列。
- 数组元素用字符串表示棋子:红帅
RK、红仕RA、红相RB、红马RN、红车RR、红炮RC、红兵RP;黑将以BK开头,其他棋子同理。
棋盘初始化时,按照标准布局填入对应棋子即可,空位置用None表示。
3.2 FEN 格式介绍
FEN(Forsyth-Edwards Notation)是一种用纯文本表示棋盘局面的格式。国际象棋的 FEN 已经被广泛使用,中国象棋也有社区定义了自己的 FEN 变体。
典型的中象 FEN 长这样:
rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1其中各段含义如下:
- 第 1 段:棋盘局面。红方用大写字母表示,黑方用小写字母表示。行之间用
/分隔,数字表示连续空位数。 - 第 2 段:轮到哪一方走棋,
w表示红方,b表示黑方。 - 第 3 段:是否允许王车易位,中国象棋一般用
-。 - 第 4 段:吃过路兵目标格,一般用
-。 - 第 5 段:半回合计数。
- 第 6 段:完整回合数。
FEN 串的价值在于跨平台交换局面数据。我们的软件不管内部怎么存储,和 AI 引擎通信时一定要转换为引擎认识的 FEN 格式。
3.3 棋盘初始化代码
在game_rules.py中,首先定义棋盘的数据结构:
# 文件路径:chess_notation_studio/game_rules.py # -*- coding: utf-8 -*- """ 象棋棋盘与基础规则模块 """ INIT_BOARD = [ ["BR", "BN", "BB", "BA", "BK", "BA", "BB", "BN", "BR"], [None, None, None, None, None, None, None, None, None], [None, "BC", None, None, None, None, None, "BC", None], ["BP", None, "BP", None, "BP", None, "BP", None, "BP"], [None, None, None, None, None, None, None, None, None], [None, None, None, None, None, None, None, None, None], ["RP", None, "RP", None, "RP", None, "RP", None, "RP"], [None, "RC", None, None, None, None, None, "RC", None], [None, None, None, None, None, None, None, None, None], ["RR", "RN", "RB", "RA", "RK", "RA", "RB", "RN", "RR"], ] # 棋子中文显示名 PIECE_NAMES = { "RK": "帅", "RA": "仕", "RB": "相", "RN": "马", "RR": "车", "RC": "炮", "RP": "兵", "BK": "将", "BA": "士", "BB": "象", "BN": "马", "BR": "车", "BC": "炮", "BP": "卒", } # 红方与黑方标识 RED = "R" BLACK = "B"这段代码的核心是把棋盘变成一个二维数组。第 0 行是黑方阵地,第 9 行是红方阵地。PIECE_NAMES字典用于显示棋子文字,方便在界面层调用。
3.4 局面转 FEN 的实现
在fen_manager.py中实现棋盘数组与 FEN 串的互相转换:
# 文件路径:chess_notation_studio/fen_manager.py # -*- coding: utf-8 -*- """ FEN 串解析与生成模块 """ from game_rules import INIT_BOARD PIECE_FEN_MAP = { "RK": "K", "RA": "A", "RB": "B", "RN": "N", "RR": "R", "RC": "C", "RP": "P", "BK": "k", "BA": "a", "BB": "b", "BN": "n", "BR": "r", "BC": "c", "BP": "p", } REVERSE_FEN_MAP = {v: k for k, v in PIECE_FEN_MAP.items()} def board_to_fen(board): """ 把二维棋盘数组转换为 FEN 字符串。 """ rows = [] for row in board: empty_count = 0 row_str = "" for cell in row: if cell is None: empty_count += 1 else: if empty_count > 0: row_str += str(empty_count) empty_count = 0 row_str += PIECE_FEN_MAP[cell] if empty_count > 0: row_str += str(empty_count) rows.append(row_str) return "/".join(rows) def fen_to_board(fen): """ 把 FEN 第一段转换为棋盘数组。 fen: 完整的 FEN 字符串或只保留局面部分 """ board_part = fen.strip().split()[0] board = [] rows = board_part.split("/") for row_str in rows: row = [] for ch in row_str: if ch.isdigit(): for _ in range(int(ch)): row.append(None) else: row.append(REVERSE_FEN_MAP[ch]) board.append(row) return board def board_to_full_fen(board, turn="w"): """ 生成完整的 FEN,包含轮到哪一方走棋等信息。 """ board_fen = board_to_fen(board) return f"{board_fen} {turn} - - 0 1"这里需要注意 FEN 转换时的空位处理逻辑。数字代表连续空位的数量,比如一行中第 1 列到第 3 列是空的,就写作3,而不是111。这个细节如果实现错误,引擎解析会出现问题。
4. 走法生成与规则校验
4.1 走子信息的数据结构
界面上用户点击两次完成一步棋:第一次点击选中棋子,第二次点击目标位置。程序需要判断这次移动是否合法。
我定义了一个Move数据结构:
# 继续写入 game_rules.py class Move: def __init__(self, from_row, from_col, to_row, to_col, piece): self.from_row = from_row self.from_col = from_col self.to_row = to_row self.to_col = to_col self.piece = piece def to_ucci(self): """ 转换为引擎识别的走法字符串。 例如:从 (9,4) 到 (7,4) 写作 h2h4(以 ANSI 坐标表示) """ return _pos_to_ucci(self.from_row, self.from_col) + _pos_to_ucci(self.to_row, self.to_col) def _pos_to_ucci(row, col): """ 棋盘坐标转 UCCI 坐标。 列坐标对应 a~i,行坐标从下往上数 0~9。 """ return chr(ord('a') + col) + str(9 - row)UCCI 协议中的坐标是从左到右a到i,从下到上0到9。比如红方帅的初始位置是第 9 行第 4 列,转换为 UCCI 坐标就是e0,这个细节在对接引擎时会用到。
4.2 马的走法
马的走法比较特殊,需要“蹩马腿”。马的移动是“日”字形:先横走一步再竖走两步,或者先竖走一步再横走两步。在(row, col)坐标系统中,马可以一次性移动(±2, ±1)或(±1, ±2),但需要检查蹩腿点。
def get_knight_moves(board, row, col, piece): """ 获取马的合法移动目标列表。 """ moves = [] directions = [ (-2, -1, -1, 0), (-2, 1, -1, 0), (2, -1, 1, 0), (2, 1, 1, 0), (-1, -2, 0, -1), (1, -2, 0, -1), (-1, 2, 0, 1), (1, 2, 0, 1) ] for d_row, d_col, leg_row, leg_col in directions: leg_pos = (row + leg_row, col + leg_col) if _is_inside(row + d_row, col + d_col) and board[leg_pos[0]][leg_pos[1]] is None: target = board[row + d_row][col + d_col] if target is None or not _same_side(piece, target): moves.append((row + d_row, col + d_col)) return moves对于初学者来说,最难理解的是directions列表。每一组四个数字的含义是:前两个是马的最终位移,后两个是马腿的位置位移。例如(-2, -1, -1, 0)表示马向上走 2 行、向左走 1 列,同时需要检查(-1, 0)位置是否为空。
4.3 炮的走法
炮的走法分为两种情况:
- 不吃子时,沿直线移动,中间不能有棋子阻挡。
- 吃子时,必须隔一个棋子(称为“炮架”),且目标位置有对方棋子。
def get_cannon_moves(board, row, col, piece): """ 获取炮的合法移动目标列表。 """ moves = [] directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] for d_row, d_col in directions: r, c = row + d_row, col + d_col jumped = False while _is_inside(r, c): if not jumped: if board[r][c] is None: moves.append((r, c)) else: jumped = True else: if board[r][c] is not None: if not _same_side(piece, board[r][c]): moves.append((r, c)) break r += d_row c += d_col return movesjumped标志变量用来区分“还没隔子”和“已经隔子”两个状态。如果没有隔子,目标位置为空就可以走;一旦隔了子,后面遇到第一个棋子才可能吃,吃不到就停止。
4.4 兵的走法
兵(卒)的规则:
- 未过河之前只能向前走一步。
- 过河之后可以向前、向左、向右走,但不能后退。
- 红方前进方向是行数减小,黑方前进方向是行数增大。
def get_pawn_moves(board, row, col, piece): """ 获取兵(卒)的合法移动目标列表。 """ moves = [] is_red = piece == "RP" forward = -1 if is_red else 1 # 向前 if _is_inside(row + forward, col): target = board[row + forward][col] if target is None or not _same_side(piece, target): moves.append((row + forward, col)) # 过河判断:红方到达第 5 行以上(row <= 4),黑方到达第 5 行以下(row >= 5) crossed = (is_red and row <= 4) or (not is_red and row >= 5) if crossed: for dc in (-1, 1): if _is_inside(row, col + dc): target = board[row][col + dc] if target is None or not _same_side(piece, target): moves.append((row, col + dc)) return moves4.5 合法走法统一入口
为了避免在界面层写大量 if 判断,我提供一个统一函数,根据棋子类型自动分派到对应的走法生成函数:
def get_legal_moves(board, row, col): """ 返回指定位置棋子的所有合法目标坐标列表。 """ piece = board[row][col] if piece is None: return [] if piece == "RR" or piece == "BR": return get_chariot_moves(board, row, col, piece) if piece == "RN" or piece == "BN": return get_knight_moves(board, row, col, piece) if piece == "RC" or piece == "BC": return get_cannon_moves(board, row, col, piece) if piece == "RP" or piece == "BP": return get_pawn_moves(board, row, col, piece) if piece == "RK" or piece == "BK": return get_king_moves(board, row, col, piece) if piece == "RA" or piece == "BA": return get_advisor_moves(board, row, col, piece) if piece == "RB" or piece == "BB": return get_elephant_moves(board, row, col, piece) return []文章中省略了车、将、仕、象的详细代码,它们的思路类似。车的走法沿四条直线扫描直到碰到棋子;将只能在九宫格内走一步;仕只能在九宫格内斜走一步;象走田字且需要检查“塞象眼”。
完整的规则模块已经可以在本地实现整局对弈。这是后续一切功能的基础,建议初学者先运行一个命令行测试,手动走几步,验证规则是否正确。
5. 图形界面与打谱交互实现
5.1 棋盘绘制与点击逻辑
图形界面使用 tkinter 的Canvas组件绘制。画布大小为 540 × 600,每个格子的尺寸设定为 60 像素,左侧和顶部各留一些边距。
# 文件路径:chess_notation_studio/board_gui.py # -*- coding: utf-8 -*- """ 象棋打谱界面模块 """ import tkinter as tk from tkinter import messagebox from game_rules import INIT_BOARD, PIECE_NAMES, get_legal_moves, Move from fen_manager import board_to_full_fen CELL_SIZE = 60 MARGIN_X = 30 MARGIN_Y = 30 BOARD_ROWS = 10 BOARD_COLS = 9 class ChessBoardGUI: def __init__(self, root): self.root = root self.root.title("象棋打谱与AI分析") self.canvas = tk.Canvas(root, width=540, height=620, bg="#E8C170") self.canvas.pack(side=tk.LEFT, padx=10, pady=10) self.board = [row[:] for row in INIT_BOARD] self.turn = "R" # 当前轮到哪一方:"R" 红方,"B" 黑方 self.selected = None # 当前选中的棋子坐标 self.legal_targets = [] # 当前选中棋子的合法落点 self.record_panel = tk.Frame(root) self.record_panel.pack(side=tk.RIGHT, fill=tk.Y, padx=10, pady=10) self.record_text = tk.Text(self.record_panel, width=22, height=26, state=tk.DISABLED) self.record_text.pack() self.moves_log = [] # 存储每一步的 Move 对象 self.current_step = -1 # 当前回放步数,-1 表示初始局面 self._draw_board() self.canvas.bind("<Button-1>", self._on_click) def _draw_board(self): """ 绘制棋盘网格、河界、九宫斜线以及棋子。 """ self.canvas.delete("all") # 绘制格子 for r in range(BOARD_ROWS): y = MARGIN_Y + r * CELL_SIZE self.canvas.create_line(MARGIN_X, y, MARGIN_X + 8 * CELL_SIZE, y) for c in range(BOARD_COLS): x = MARGIN_X + c * CELL_SIZE self.canvas.create_line(x, MARGIN_Y, x, MARGIN_Y + 9 * CELL_SIZE) # 绘制河界文字 self.canvas.create_text(MARGIN_X + 4 * CELL_SIZE, MARGIN_Y + 4.5 * CELL_SIZE, text="楚 河 汉 界", font=("楷体", 18), fill="#8B5A2B") # 绘制棋子 for r in range(BOARD_ROWS): for c in range(BOARD_COLS): piece = self.board[r][c] if piece: self._draw_piece(r, c, piece)在_draw_piece方法中,根据棋子颜色的不同绘制不同颜色的圆和文字:
def _draw_piece(self, row, col, piece): x = MARGIN_X + col * CELL_SIZE y = MARGIN_Y + row * CELL_SIZE color = "red" if piece.startswith("R") else "black" self.canvas.create_oval(x - 24, y - 24, x + 24, y + 24, fill="#F5DEB3", outline=color, width=2) self.canvas.create_text(x, y, text=PIECE_NAMES[piece], font=("楷体", 20, "bold"), fill=color)点击事件的逻辑分为两步选中和落子。第一次点击时,如果当前位置有当前回合的棋子,就选中它,同时高亮合法走法;第二次点击时,如果目标位置在合法走法列表中,就执行走棋。
def _on_click(self, event): col = round((event.x - MARGIN_X) / CELL_SIZE) row = round((event.y - MARGIN_Y) / CELL_SIZE) if not (0 <= row < BOARD_ROWS and 0 <= col < BOARD_COLS): return if self.selected is None: piece = self.board[row][col] if piece and piece.startswith(self.turn): self.selected = (row, col) self.legal_targets = get_legal_moves(self.board, row, col) self._highlight_targets() else: if (row, col) in self.legal_targets: self._do_move(self.selected[0], self.selected[1], row, col) self.selected = None self.legal_targets = [] self._draw_board()5.2 走棋与棋谱记录
每执行一步合法的走棋,就同步生成一条棋谱记录。这里使用 UCCI 标准坐标来记录起点和终点:
def _do_move(self, from_row, from_col, to_row, to_col): piece = self.board[from_row][from_col] move = Move(from_row, from_col, to_row, to_col, piece) self.board[to_row][to_col] = piece self.board[from_row][from_col] = None self.turn = "B" if self.turn == "R" else "R" self.moves_log.append(move) self.current_step += 1 self._append_record(move) self._draw_board()moves_log列表保存了整盘棋的完整走子过程,这就是“棋谱”在程序中的核心形态。record_text组件把每一步转换成人类可读的文字,比如“炮二平五”这种中文记谱法可以简单实现,也可以先用(行,列) -> (行,列)的方式展示。
中文象棋记谱法有一套完整的规则:炮二平五、马八进七、車一进一等。作为进阶功能,可以在move.to_chinese()方法中实现,这里先做简化处理。
5.3 回放功能实现
回放功能需要能够回到历史局面,而不是只靠撤销。为了方便,我设计了一个“回到初始局面,然后重新执行前 N 步”的方案。这样实现简单,代码容易理解:
def replay_to(self, step): """ 回放到第 step 步之后(step 从 0 开始)。 step = -1 表示回到初始局面。 """ self.board = [row[:] for row in INIT_BOARD] self.turn = "R" for i in range(step + 1): move = self.moves_log[i] self.board[move.to_row][move.to_col] = move.piece self.board[move.from_row][move.from_col] = None self.turn = "B" if self.turn == "R" else "R" self.current_step = step self._draw_board() def step_backward(self): if self.current_step >= 0: self.replay_to(self.current_step - 1) def step_forward(self): if self.current_step < len(self.moves_log) - 1: self.replay_to(self.current_step + 1)这个方法的时间复杂度是 O(n),对于一盘普通对局几百步来说完全够用。如果以后要支持上千步的大棋谱,可以考虑直接保存每一步的完整棋盘快照。
5.4 保存与加载棋谱
棋谱的保存采用 JSON 格式。每个棋谱对象包含初始 FEN、双方名称、每一步的走法序列。这里补充 FEN 的解析能力后,加载棋谱就等于按序列初始化棋盘,再逐条执行走法。
def save_record(self, filepath): """ 保存当前棋谱到 JSON 文件。 """ data = { "initial_fen": board_to_full_fen(INIT_BOARD, "w"), "moves": [m.to_ucci() for m in self.moves_log] } import json with open(filepath, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load_record(self, filepath): """ 从 JSON 文件加载棋谱。 """ import json with open(filepath, "r", encoding="utf-8") as f: data = json.load(f) self.moves_log = [] from fen_manager import fen_to_board self.board = fen_to_board(data["initial_fen"]) for move_str in data["moves"]: from_row = 9 - int(move_str[1]) from_col = ord(move_str[0]) - ord('a') to_row = 9 - int(move_str[3]) to_col = ord(move_str[2]) - ord('a') piece = self.board[from_row][from_col] move = Move(from_row, from_col, to_row, to_col, piece) self.moves_log.append(move) self.current_step = -1 self.replay_to(len(self.moves_log) - 1)这里使用了Load和Save的标准读写操作,并在加载完成后自动回放到最后一步,确保界面状态和棋谱数据一致。
6. 接入 AI 引擎实现局面分析
6.1 UCCI 协议简析
UCCI 协议是象棋引擎常用的通信协议。程序启动引擎后,通过标准输入发送命令,引擎通过标准输出返回结果。常见命令如下:
| 命令 | 作用 |
|---|---|
ucci | 启动引擎,引擎返回ucciok表示就绪 |
position fen <FEN> | 设置当前局面 |
go depth <深度> | 让引擎思考到指定深度 |
bestmove <走法> | 引擎思考完成后返回最佳走法 |
quit | 关闭引擎 |
引擎思考过程中会不断输出评估分数和主变化(principal variation),我们需要解析这些输出,提取score和pv字段。
6.2 引擎通信客户端
在engine_client.py中使用subprocess模块启动引擎。这里使用线程池的方式异步等待输出,避免界面卡死。
# 文件路径:chess_notation_studio/engine_client.py # -*- coding: utf-8 -*- """ 象棋引擎通信客户端 """ import subprocess import threading import queue class EngineClient: def __init__(self, engine_path): self.engine_path = engine_path self.process = None self.output_queue = queue.Queue() self.running = False def start(self): """ 启动引擎进程并初始化 UCCI 协议。 """ self.process = subprocess.Popen( [self.engine_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 ) self.running = True self.send_command("ucci") # 启动一个线程持续读取引擎输出 self.reader_thread = threading.Thread(target=self._read_output, daemon=True) self.reader_thread.start() def send_command(self, cmd): """ 向引擎发送一条命令。 """ if self.process and self.running: self.process.stdin.write(cmd + "\n") self.process.stdin.flush() def _read_output(self): """ 持续读取引擎输出,放入队列。 """ while self.running: line = self.process.stdout.readline() if line: self.output_queue.put(line.strip()) else: break def analyze(self, fen, depth=12): """ 分析当前局面,返回最佳走法。 fen: 完整 FEN 字符串 depth: 搜索深度 返回: (bestmove, score) 元组,score 为正数表示红方优势 """ self.send_command(f"position fen {fen}") self.send_command(f"go depth {depth}") bestmove = None score = 0 while True: try: line = self.output_queue.get(timeout=1) except queue.Empty: continue if line.startswith("info"): parts = line.split() for i, part in enumerate(parts): if part == "score": # 引擎输出的分数是相对于当前走棋方而言 score = int(parts[i + 1]) if part == "pv" and i + 1 < len(parts): # pv 后面第一个走法就是最佳走法 pass elif line.startswith("bestmove"): bestmove = parts[1] if "parts" in dir() else line.split()[1] break return bestmove, score def stop(self): self.running = False if self.process: self.send_command("quit") self.process.terminate()需要特别注意的是,不同引擎输出的info行格式可能有差异。有的引擎输出score cp 35,有的输出score mate 3。在商业引擎和开源引擎中,这个字段的语义并不完全一致。建议在接入具体引擎时,先手动运行引擎,输入几行命令,观察它的原始输出格式,再针对性地编写解析逻辑。
6.3 界面集成 AI 分析
在 GUI 上增加三个按钮:“AI 分析”、“自动走一步”、“清空分析”。点击“AI 分析”后,程序生成当前局面的 FEN,交给引擎客户端去分析,然后把引擎返回的走法标注在棋盘上。
def analyze_current_position(self): fen = board_to_full_fen(self.board, "w" if self.turn == "R" else "b") self.canvas.create_text(270, 590, text="AI 分析中...", font=("微软雅黑", 12), fill="blue") bestmove, score = self.engine_client.analyze(fen, depth=14) if bestmove: from_row = 9 - int(bestmove[1]) from_col = ord(bestmove[0]) - ord('a') to_row = 9 - int(bestmove[3]) to_col = ord(bestmove[2]) - ord('a') # 高亮最佳走法的起点和终点 self._highlight_bestmove(from_row, from_col, to_row, to_col) # 显示评估分数 self.canvas.create_text(270, 590, text=f"最佳走法: {bestmove} 分数: {score}", font=("微软雅黑", 12), fill="green")这里的score正负含义比较复杂。大多数 UCCI 引擎的score是相对于当前走棋方的优势,也就是说如果当前轮黑方走棋,score为正表示黑方优势。程序在展示时,如果需要统一为“红方视角”,要记住在显示前做符号转换。
6.4 主程序入口
在main.py中组装所有模块:
# 文件路径:chess_notation_studio/main.py # -*- coding: utf-8 -*- """ 象棋打谱与AI分析程序入口 """ import tkinter as tk from board_gui import ChessBoardGUI from engine_client import EngineClient def main(): root = tk.Tk() app = ChessBoardGUI(root) # 启动引擎,路径根据实际引擎文件位置调整 engine_path = "engines/engine.exe" app.engine_client = EngineClient(engine_path) app.engine_client.start() root.mainloop() app.engine_client.stop() if __name__ == "__main__": main()注意事项:如果你的系统里没有可用的 UCCI 引擎,程序会启动失败。为了健壮性,在ChessBoardGUI中可以用try...except包住引擎启动过程,失败时提示“未检测到引擎,但打谱功能仍然可用”。
7. 完整运行效果与联调验证
7.1 运行程序
在项目根目录执行:
python main.py预期效果:
- 出现一个窗口,左侧是棋盘,右侧是棋谱记录。
- 初始局面按标准象棋布局摆好。
- 鼠标点击红方棋子,再点击合法目标位置,棋子移动。
- 右侧棋谱记录每步走法。
- 切换到黑方后,黑方棋子可移动。
- 点击回放按钮,棋局可以逐步退回或前进。
- 点击 AI 分析,等待引擎思考后,输出最佳走法。
7.2 自动走棋联调
如果希望做一个完全自动的对弈演示,可以在界面层循环调用 AI 分析结果,让程序自动走棋。这样的效果和“象棋自动走棋软件”类似,但注意这只是在本机本地做研究演示,不要用于在线对战或作弊。
def auto_play_best_move(self): if self.game_over: return fen = board_to_full_fen(self.board, "w" if self.turn == "R" else "b") bestmove, score = self.engine_client.analyze(fen, depth=10) if bestmove: to_row = 9 - int(bestmove[3]) to_col = ord(bestmove[2]) - ord('a') from_row = 9 - int(bestmove[1]) from_col = ord(bestmove[0]) - ord('a') self._do_move(from_row, from_col, to_row, to_col) self.root.after(500, self.auto_play_best_move)after方法让程序每 500 毫秒自动执行一步,形成一个简单的引擎对战演示。初学者可以把红方和黑方分别接两个引擎,实现引擎互相对弈。
7.3 性能观察
引擎搜索深度与耗时直接相关。常见引擎在depth 10时通常在 1 到 3 秒内返回结果,depth 16则可能需要几十秒甚至更久。在实际使用中,建议把搜索深度设在 10 到 14 之间,兼顾速度与棋力。
8. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
tkinter导入报错 | Python 安装时未包含 tkinter | 检查 Python 版本,使用系统自带 Python 或重新安装 |
| 界面不显示棋盘 | Canvas尺寸设置问题 | 检查MARGIN_X和CELL_SIZE计算是否越界 |
| 棋子不能移动 | 走法生成函数有误 | 单独测试get_legal_moves返回的坐标列表 |
| 马走法异常 | 蹩马腿判断错误 | 打印马腿位置,核对leg_row和leg_col |
| 引擎启动失败 | 引擎路径错误或格式不支持 | 确认引擎文件存在,手动双击运行测试 |
| 引擎返回空结果 | 协议格式不匹配 | 在终端手动输入position fen和go depth测试 |
| 棋谱保存后加载不对 | FEN 解析错误 | 打印fen_to_board结果,对照原始局面 |
| 分支回放时走法不完整 | moves_log与current_step状态不同步 | 回放后重新设置current_step |
最有效的排查方式是“分模块测试”。写一个简单的命令行脚本,只测试规则模块,不加载 GUI。这样可以大大缩短排查时间:
# 文件路径:chess_notation_studio/test_rules.py # -*- coding: utf-8 -*- from game_rules import INIT_BOARD, get_legal_moves if __name__ == "__main__": board = [row[:] for row in INIT_BOARD] # 测试炮的走法,红炮位置在第 7 行第 1 列 moves = get_legal_moves(board, 7, 1) print("红炮合法走法:", moves)运行这个脚本后,观察红炮的合法落点。标准初始局面下,红炮可以横向移动,也可以沿纵向移动但不能吃子,只有在隔子情况下才能吃子。如果你发现列表里出现了错误坐标,那么问题一定在get_cannon_moves内部,而不是界面逻辑。
9. 最佳实践与工程建议
9.1 规则模块与界面模块彻底分离
这是整个项目最重要的架构约束。规则模块不应该依赖 tkinter,界面模块只负责展示和交互,不负责判断走法是否合法。这样做的好处是:
- 规则模块可以单独测试,不需要启动 GUI。
- 以后如果要迁移到 PyQt、Web 或者命令行,界面层可以整体替换,规则层不需要动。
- AI 引擎通信与界面解耦,可以并行开发。
9.2 棋谱数据格式要标准化
棋谱数据尽量使用 FEN 和 UCCI 坐标这种标准格式保存,而不是自己发明格式。好处是:
- 可以方便地和外部引擎交换数据。
- 可以加载别人保存的棋谱文件。
- 以后实现网络对战、棋谱分享更容易。
9.3 引擎进程管理要注意资源回收
引擎是一个独立的进程,如果程序退出时没有正确关闭,会留下僵尸进程。在EngineClient类中实现stop()方法,并在主程序finally块中调用。更稳妥的做法是使用contextlib.closing或自定义上下文管理器。
9.4 走棋前先做完整校验
界面层的点击事件触发走棋之前,必须调用规则层的方法确认目标位置在合法列表中。不要相信点击的网格坐标本身。这不仅是程序健壮性问题,也关系到后面接入引擎时会不会出现非法局面。
9.5 引擎输出的容错处理
不同引擎的输出格式差异很大。在解析info行时,不要假设字段顺序固定,应该按字段名逐个查找。另外,部分引擎支持多线程搜索,会输出多条info行,最后一行的分数才是最准确的,解析时持续更新即可。
9.6 数据备份与安全
打谱软件在保存棋谱文件时,建议采用“先写入临时文件,再替换原文件”的方式,防止保存过程中程序崩溃导致原文件损坏。对于重要的棋谱,可以加上自动备份功能,每次保存时生成xxx_bak.json副本。
10. 总结与扩展方向
本文从零实现了一款象棋打谱与 AI 分析软件,核心内容包括:
- 棋盘二维数组的数据结构设计。
- FEN 串的生成与解析。
- 马、炮、兵等棋子的走法生成与规则校验。
- tkinter 图形界面与鼠标交互。
- 棋谱记录、回放、保存与加载。
- 通过 UCCI 协议接入本地 AI 引擎,实现局面分析与最佳走法推荐。
对于初学者来说,这个项目最大的价值不是“做出一个完美软件”,而是理解一个完整应用的拆分方式:规则逻辑、界面渲染、数据持久化、外部进程通信,每一个模块都可以独立测试和演进。
如果继续做扩展,有几个不错的进阶方向:
- 实现完整的中文记谱法,比如“炮二平五”“马八进七”。
- 加入棋谱搜索功能,按开局名称或变例名称检索。
- 实现开局库读取,学习“象棋本地开局库文件”的格式。
- 接入在线棋谱库,研究大师对局。
- 加入局面评估图表,画出整盘棋的胜负走势曲线。
AI 部分也可以继续深入,比如自己实现一个简化版的搜索算法:先做一个仅包含“将军”“吃子”评估的贪心算法,再逐步加入 Alpha-Beta 剪枝和置换表。这样能从内部理解象棋 AI 的工作原理,而不仅仅停留在调用引擎协议这一层。
这个项目本身很适合反复重构。每学一个新知识点,都可以回来优化一部分代码。建议你把项目保存好,过一个月再打开,看看哪些地方能写得更好。技术能力的提升,往往就是在这样一次次“回头看”和“重写”中发生的。