用Pygame实现中国象棋AI:从规则引擎到Alpha-Beta剪枝
简介这份Python中国象棋游戏开发实例面向计算机专业学生、毕业设计者与游戏开发爱好者以pygame库实现图形界面与人机对战覆盖棋盘绘制、走法验证、将军检测、胜负判断、棋子类与AI决策等核心模块。压缩包共1017个文件、8.24MB以716个py源码为主体搭配37个pyd动态库、39个png图片、28个gif动画、wav音效等界面与资源文件并含部分依赖库文件目录涵盖主逻辑、棋子、AI等模块结构完整适合直接运行和二次开发。已有108人学习下载。通过该实例可系统掌握Python编程、pygame事件处理、棋盘规则编码以及Minimax、启发式评估等博弈树搜索算法的实际应用结合多类型资源便于理解完整工程组织与排错方法是毕业设计选题、课程实践和个人项目练手的高质量参考方案。1. 为什么中国象棋项目值得用pygame重写一遍中国象棋的棋面状态约有10^40种这个复杂度恰好落在“规则入手容易、AI搜索仍有深度”的区间。一个用Python加pygame实现的中国象棋图形界面项目不像井字棋那样十几行写完就没了下文也不像围棋那样一上来就要上深度学习它让开发者在棋盘建模、走法生成、博弈树搜索三条主线上都拿到足够的练习量。这也是计算机专业毕业设计里这个选题常年被选的原因之一。很多第一次接触这类项目的开发者会在棋盘坐标映射、炮的隔子吃判断、AI搜索时长这几个点上反复返工。这篇文章按实际开发顺序把界面渲染、规则引擎、人机对战AI决策和调参排错的完整链路拆开讲。适合正在选题的学生也适合想补一份完整pygameAI实践经历的开发人员。整个代码规模在2000行上下后续改成国际象棋或军棋的扩展成本也比较低。2. pygame窗口搭建与棋盘渲染2.1 棋盘坐标与像素坐标的映射中国象棋棋盘是9列10行但pygame的窗口坐标系以左上角为原点x轴向右、y轴向下。渲染前必须先把逻辑坐标row, col换算成像素坐标鼠标点击时再做反向换算。这一步是后面所有交互的基础。# 棋盘常量 BOARD_ROWS 10 BOARD_COLS 9 CELL_SIZE 57 # 格子边长像素 ORIGIN_X, ORIGIN_Y 60, 60 # 棋盘左上角锚点 def board_to_pixel(pos): row, col pos return ORIGIN_X col * CELL_SIZE, ORIGIN_Y row * CELL_SIZE def pixel_to_board(pos): x, y pos col round((x - ORIGIN_X) / CELL_SIZE) row round((y - ORIGIN_Y) / CELL_SIZE) if 0 row BOARD_ROWS and 0 col BOARD_COLS: return (row, col) return Noneboard_to_pixel负责给绘制棋子提供位置pixel_to_board负责把鼠标点击位置转回棋盘坐标。这里用round而不是int取整是为了让点击落在格子中心附近时即使偏差几个像素也能正确命中对应格子。如果调整窗口尺寸只需要改动CELL_SIZE和ORIGIN系列常量层级渲染逻辑不需要跟着动。2.2 游戏主循环与事件响应pygame程序的基础是一个持续运行的while循环每帧处理事件、更新状态、重绘画面。中国象棋是回合制AI思考期间不需要实时计算物理帧但主循环依然要用固定帧率驱动保证窗口响应不迟钝。class ChessApp: def __init__(self): pygame.init() self.screen pygame.display.set_mode((640, 700)) pygame.display.set_caption(中国象棋 - 人机对战) self.clock pygame.time.Clock() self.board Board() # 棋盘逻辑层 self.ai ChessAI(depth3) # AI搜索深度设为3 self.selected None # 当前选中的棋子 self.player_color red # 玩家执红先行 def handle_click(self, mouse_pos): pos pixel_to_board(mouse_pos) if pos is None: return clicked self.board.get_piece(pos) if self.selected is None: # 没有选中棋子时先确认点到了己方棋子 if clicked and clicked.side self.player_color: self.selected pos else: # 再点己方棋子是重新选择点其他位置是走棋 if clicked and clicked.side self.player_color: self.selected pos elif self.board.is_valid_move(self.selected, pos): self.board.move(self.selected, pos) self.selected None self.ai_turn() def run(self): while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() return if event.type pygame.MOUSEBUTTONDOWN: self.handle_click(event.pos) self.draw() pygame.display.flip() self.clock.tick(60)这个事件循环只做两件事检测窗口关闭处理鼠标点击。handle_click里用selected字段区分“选子”和“走子”两个状态这是棋盘类项目最常见的一段式状态机。ai_turn放在玩家走子完成之后触发注意不要在事件线程里直接跑耗时很长的AI搜索后面第4章会单独处理这个问题。2.3 棋子图片加载与选中高亮棋子资源用图片比用字体渲染更稳定。加载时统一缩放到格子尺寸避免绘制时频繁做缩放计算。棋子红方资源名黑方资源名帅/将red_king.pngblack_king.png车red_rook.pngblack_rook.png马red_knight.pngblack_knight.png炮red_cannon.pngblack_cannon.png相/象red_bishop.pngblack_bishop.png仕/士red_advisor.pngblack_advisor.png兵/卒red_pawn.pngblack_pawn.pngdef load_piece_images(): names (king, advisor, bishop, knight, rook, cannon, pawn) images {} for side in (red, black): for name in names: path fassets/{side}_{name}.png img pygame.image.load(path) img pygame.transform.smoothscale( img, (CELL_SIZE, CELL_SIZE)) images[(side, name)] img return imagespygame.image.load加载原图smoothscale在缩小时做抗锯齿滤波显示效果比直接scale更柔和。资源文件放在assets目录下打包或换机器演示时整个目录一起带过去。如果临时没有美术素材可以用pygame.font.SysFont渲染汉字替代但字体在不同操作系统上差异较大跨平台演示时容易出现布局偏移。选中高亮是在当前选中的格子上覆盖一个半透明矩形。半透明叠加需要用SRCALPHA创建一个带透明通道的Surfacefill里最后一个参数是透明度数值越大越明显。def draw_highlight(self, pos): x, y board_to_pixel(pos) rect pygame.Rect(x - CELL_SIZE // 2, y - CELL_SIZE // 2, CELL_SIZE, CELL_SIZE) overlay pygame.Surface(rect.size, pygame.SRCALPHA) overlay.fill((255, 255, 0, 60)) # 半透明黄色 self.screen.blit(overlay, rect.topleft)这个高亮函数在每帧draw流程中调用绘制顺序要在棋子之前否则会被棋子图片盖住。3. 棋子类与走法规则引擎3.1 面向对象的棋子抽象把每个棋子建模为一个对象属性包括阵营、名称、当前位置和存活状态行为是生成所有可走位置。基类用raise NotImplementedError强制子类实现get_possible_moves这是后面扩展新棋种时最省事的结构。class Piece: def __init__(self, side, name, row, col): self.side side # red 或 black self.name name self.row row self.col col self.captured False def get_possible_moves(self, board): raise NotImplementedErrorget_possible_moves接收board对象作为参数返回一组(row, col)元组。这里只判断“能不能走到”不真正落子。棋子移动和吃子的实际状态变更交给board.move执行这样设计是为了让AI在搜索时可以反复apply再undo而不会污染渲染层数据。棋子类移动特性关键规则Rook车直线任意距离不可越子Knight马日字形有蹩马腿Cannon炮直线任意距离吃子必须隔一个棋子King帅/将九宫内一步不可出九宫Advisor仕/士九宫内斜一步不可出九宫Bishop相/象田字形斜两步不可过河、有象眼Pawn兵/卒每次向前一步过河后可左右移动3.2 车马炮的走法生成实现车的逻辑最直接四个方向直线延伸遇到第一个棋子时根据阵营决定能不能吃然后停下来。这个过程天然实现了“不能越子”。class Rook(Piece): def get_possible_moves(self, board): moves [] for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): r, c self.row dr, self.col dc while 0 r BOARD_ROWS and 0 c BOARD_COLS: target board.get_piece((r, c)) if target is None: moves.append((r, c)) else: if target.side ! self.side: moves.append((r, c)) break r dr c dc return moves四个方向向量分别代表向上、向下、向左、向右。while循环沿一个方向持续延伸target为None说明是空位可以走遇到棋子后判断阵营并立即break保证不会越过第一个棋子继续收集位置。马的规则实现需要先判断马腿位置是否有棋子阻挡。class Knight(Piece): def get_possible_moves(self, board): moves [] # 每个元组: (马腿偏移, 目标偏移) legs ( ((0, -1), (-2, -1)), ((0, -1), (-2, 1)), ((0, 1), (2, -1)), ((0, 1), (2, 1)), ((-1, 0), (-1, -2)), ((-1, 0), (-1, 2)), ((1, 0), (1, -2)), ((1, 0), (1, 2)), ) for leg, move in legs: leg_r, leg_c self.row leg[0], self.col leg[1] if not (0 leg_r BOARD_ROWS and 0 leg_c BOARD_COLS): continue if board.get_piece((leg_r, leg_c)) is not None: continue # 蹩马腿 to_r, to_c self.row move[0], self.col move[1] if 0 to_r BOARD_ROWS and 0 to_c BOARD_COLS: target board.get_piece((to_r, to_c)) if target is None or target.side ! self.side: moves.append((to_r, to_c)) return moveslegs元组里每一项包含马腿偏移和目标偏移两两对应。先检查马腿位置是否越界或被占再计算目标位置最后判断目标处是空位还是敌方棋子。这个写法比八个if分支清晰也方便后续配位置表。炮的走法需要区分移动和吃子用jumped标志位表示当前是否已经跳过了一个棋子。class Cannon(Piece): def get_possible_moves(self, board): moves [] for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): r, c self.row dr, self.col dc jumped False while 0 r BOARD_ROWS and 0 c BOARD_COLS: target board.get_piece((r, c)) if not jumped: if target is None: moves.append((r, c)) else: jumped True else: if target is not None: if target.side ! self.side: moves.append((r, c)) break r dr c dc return movesjumped标志位是这段逻辑的核心。没有跳过棋子时炮只能走直线空位遇到第一个棋子后只把jumped置为True不收集这个位置。之后再遇到棋子时如果是敌方则记录为可吃子并break如果是己方则直接break。这样一次遍历就把“移动”和“隔子吃”两种行为都处理完了。3.3 将军检测与胜负判断将军检测的朴素实现是找到被将军一方的将/帅然后遍历对方所有棋子的走法集合看有没有棋子能攻击到将帅的位置。def is_in_check(self, side): king_pos None for r in range(BOARD_ROWS): for c in range(BOARD_COLS): p self.board[r][c] if p is not None and p.name king and p.side side: king_pos (r, c) break if king_pos: break if king_pos is None: return True for r in range(BOARD_ROWS): for c in range(BOARD_COLS): p self.board[r][c] if p is not None and p.side ! side: if king_pos in p.get_possible_moves(self): return True return False这里有一个明显的性能重复每判断一次将军就要全盘扫描并重新生成大量走法列表。在AI搜索深度的递归过程中这个函数会被调用成千上万次。工程上可以在棋盘对象里加一个版本号每次apply或undo后递增走法结果缓存按版本号失效。毕业设计阶段用朴素版本足够但要知道这里的计算开销在哪里。胜负判定在将军基础上再做一层过滤如果当前方被将军且遍历当前方所有棋子的所有合法走法后没有任何一步能解除将军状态就判负。这里的核心是“走一步之后检查is_in_check是否仍然为True”而不是简单判断有没有可走的位置。4. Minimax与Alpha-Beta剪枝AI落子核心4.1 评估函数决定AI的“棋感”让电脑选择走哪一步核心是把一个棋局映射成一个数值。这个数值就是评估函数。AI要做的就是在搜索深度内找出让这个数值对己方最大化的落子。基础版本只需要统计双方棋子的价值差。PIECE_VALUES { king: 0, advisor: 2, bishop: 2, knight: 4, rook: 6, cannon: 4, pawn: 1, } def evaluate(board): score 0 for r in range(BOARD_ROWS): for c in range(BOARD_COLS): piece board.get_piece((r, c)) if piece is None: continue value PIECE_VALUES[piece.name] if piece.side red: score value else: score - value return score正值表示红方占优负值表示黑方占优。这个评估函数只考虑了子力没有考虑位置价值所以AI会出现“用马换卒”这种在子力上占便宜、在位置上吃亏的棋。要提升棋力可以叠加一张10x9的位置价值表比如给过河卒加权重给正出动的车加权重。棋子基础价值帅/将0仕/士2相/象2马4车6炮4兵/卒14.2 Minimax递归与Alpha-Beta剪枝实现如果直接取每一步评估分最高的走法是贪心策略棋力大约只会吃子不会布局。真正的多步推演要模拟对手的回应这就是Minimax自己走棋的节点取子节点最大分数对手走棋的节点取子节点最小分数。def minimax(board, depth, is_red_turn, alpha, beta): if depth 0 or board.is_game_over(): return evaluate(board) moves board.get_all_moves(red if is_red_turn else black) if not moves: return evaluate(board) if is_red_turn: max_eval -float(inf) for move in moves: board.apply(move) val minimax(board, depth - 1, False, alpha, beta) board.undo(move) if val max_eval: max_eval val if val alpha: alpha val if beta alpha: break return max_eval else: min_eval float(inf) for move in moves: board.apply(move) val minimax(board, depth - 1, True, alpha, beta) board.undo(move) if val min_eval: min_eval val if val beta: beta val if beta alpha: break return min_eval递归的终止条件是深度归零或者游戏结束。apply和undo是配套的apply执行落子并记录被吃的棋子undo把棋盘恢复到apply之前的状态。红方节点维护alpha黑方节点维护beta一旦出现beta alpha说明当前分支的最优结果已经不会影响上一层决策直接break剪掉整个子树。这种剪枝不影响最终搜索结果只是把注定没用的分支提前放弃。4.3 走法排序与剪枝效率剪枝效果很大程度上取决于走法的搜索顺序。如果每次先搜到当前局面下最好的走法alpha和beta能很快收紧剪枝效果明显如果先搜到一堆烂棋alpha和beta迟迟不收敛几乎等于没剪。def order_moves(board, moves, side): def key(move): score 0 target board.get_piece(move.to_pos) if target is not None: score 10 * PIECE_VALUES[target.name] if (target is not None and PIECE_VALUES[target.name] PIECE_VALUES[board.get_piece(move.from_pos).name]): score 50 return -score return sorted(moves, keykey)排序依据是启发式规则优先走吃子的走法尤其是低价值棋子吃高价值棋子会被额外加权排在列表前部。排序本身有时间开销但和剪枝省下的指数级搜索量相比这个开销完全可以接受。深度3以上这一步几乎能减少一半以上的搜索节点。4.4 AI对外接口与线程安全AI搜索应该封装在独立类里不在游戏主循环内直接跑。深度3的搜索在纯Python中通常需要几百毫秒如果直接放进事件循环玩家会看到窗口卡死。class ChessAI: def __init__(self, depth3): self.depth depth def best_move(self, board): moves board.get_all_moves(red) best None best_score -float(inf) for move in moves: board.apply(move) score minimax(board, self.depth - 1, False, -float(inf), float(inf)) board.undo(move) if score best_score: best_score score best move return bestbest_move遍历当前红方所有合法走法逐个进入minimax搜索最终返回评分最高的那个move。这里假设AI固定执红如果要做执黑或者玩家自由选择颜色需要在外部传入side参数。minimax初始alpha是负无穷、beta是正无穷表示没有任何已知的上下界。5. 调参技巧与常见踩坑5.1 搜索深度与响应时间深度每增加一层搜索节点数大约乘以中国象棋的分支因子均值这个值通常在40到50之间。所以深度4比深度3慢一个数量级完全正常。搜索深度平均每步耗时实际棋力1小于0.05秒只会吃子新手水平20.1秒左右经常走出送子棋30.3到1秒能防守、会兑子42到5秒有明显战术配合515秒以上不推荐在纯Python中使用如果要把深度调到4以上不要只改depth参数必须配合走法排序和评估缓存否则开局阶段就会卡很久。一个折中的方案是动态深度在残局阶段棋子少、分支因子低可以自动把深度加1开局和中期保持在3到4层。5.2 多个高发Bug的排查顺序最常见的问题是点击棋子没反应。先确认pixel_to_board返回的坐标范围在正确区间如果窗口有系统标题栏干扰在开发阶段可以打印原始鼠标坐标和换算后的棋盘坐标对比一下。第二个高频问题是AI思考时窗口白屏。这是事件循环被阻塞的表现。简单处理可以在搜索前后切换状态文本复杂一点就用python的threading模块把搜索丢到后台线程UI线程只负责渲染“AI思考中”的提示。注意search结果需要通过队列或者事件回传到主线程不要直接操作pygame对象。第三个问题是apply和undo两个函数不对称导致棋盘状态错乱特别是被吃棋子的captured标志没有恢复。一个稳妥的设计是把每一步移动封装成Move对象。class Move: def __init__(self, from_pos, to_pos, piece, captured): self.from_pos from_pos self.to_pos to_pos self.piece piece self.captured captured def undo(self, move): from_r, from_c move.from_pos to_r, to_c move.to_pos self.board[from_r][from_c] move.piece if move.captured is not None: self.board[to_r][to_c] move.captured move.captured.captured False else: self.board[to_r][to_c] None move.piece.row, move.piece.col from_r, from_cmove.captured保存被吃棋子的引用undo时先恢复目标格再把captured标志改回来。递归搜索中这个函数会被反复调用必须做到无副作用。加一个自检函数也不麻烦固定摆几个局面后断言移动集合与手工推导结果一致后续改规则时跑一遍就能发现回归。本文还有配套的精品资源点击获取