☰
Pygame实战:从零复刻中国象棋,掌握规则引擎与AI搜索
2026/10/14 2:48:12 网站建设 项目流程

1. 为什么用 Pygame 复刻中国象棋是个值得动手的项目

很多人第一次接触 Pygame 都是从贪吃蛇、打砖块这类小游戏开始的,做完之后总觉得"不过瘾"——逻辑太简单,代码量太少,学不到真正有用的东西。而中国象棋这个题材,恰好卡在一个非常舒服的难度区间:规则清晰但分支复杂,界面朴素但对交互要求高,既不会像 RPG 那样庞大到劝退,也不会像猜数字那样三小时就写完。

我当初选这个题目,核心动机有三个。第一,棋盘是标准的 9×10 网格,天然适合用二维数组建模,这对训练"数据结构映射到界面坐标"的思维特别有效。第二,棋子走法规则明确,马走日、象走田、炮翻山,每一条都能翻译成独立的判断函数,是练习"规则引擎"设计的绝佳素材。第三,Pygame 的事件循环和绘制机制在这个项目里会被用到极致——鼠标点击选子、落子、重绘、判断胜负,整个链路完整且闭环。

从技术收益来看,做完这个项目你能掌握的东西包括:二维数组与屏幕坐标的双向转换、基于状态机的回合控制、走法合法性校验、简单 AI 的极小化极大搜索雏形、以及 Pygame 里字体渲染、图片加载、音效播放这些常规操作。这些技能迁移到任何棋类、战棋类、回合制策略游戏上都是通用的。

需要提前说明的是,本文附上的原码是一个可运行、可扩展的完整版本,不是那种只画个棋盘就结束的半成品。它包含完整的走法校验、将军判定、悔棋、重新开始,以及一个基于简单评估函数的电脑对手。代码结构我刻意做了分层,方便你后续替换成更强的 AI 或者加上网络对战。

提示:本文所有代码基于 Python 3.8+ 和 Pygame 2.x 编写,如果你用的是更老的版本,部分 API(比如pygame.draw.rect的圆角参数)可能不兼容,建议先升级环境。

2. 棋盘建模:从二维数组到屏幕像素的映射逻辑

2.1 为什么用 9×10 而不是 8×8

中国象棋的棋盘是 9 条竖线、10 条横线,交叉点落子,所以有效落子位置是 9×10=90 个点。这一点和围棋、五子棋一样是"点上下棋",而国际象棋是"格子里下棋"。这个差异直接决定了坐标系统的设计。

我见过不少新手一上来就用board[row][col]存棋子,然后绘制的时候用row * cell_size直接当像素坐标,结果发现棋子总是偏半格。原因就在于:棋盘线交点是坐标原点,而不是格子中心。正确的做法是让数组索引直接对应交点,绘制时用margin + col * grid_size计算像素位置,其中margin是棋盘边框留白。

# 棋盘参数定义 BOARD_COLS = 9 # 竖线数量 BOARD_ROWS = 10 # 横线数量 GRID_SIZE = 60 # 相邻交点间距(像素) MARGIN_X = 40 # 左侧留白 MARGIN_Y = 40 # 顶部留白 def board_to_pixel(row, col): """把棋盘数组坐标转成屏幕像素坐标(交点中心)""" x = MARGIN_X + col * GRID_SIZE y = MARGIN_Y + row * GRID_SIZE return x, y def pixel_to_board(x, y): """把鼠标点击的像素坐标反算成最近的棋盘交点""" col = round((x - MARGIN_X) / GRID_SIZE) row = round((y - MARGIN_Y) / GRID_SIZE) if 0 <= row < BOARD_ROWS and 0 <= col < BOARD_COLS: return row, col return None

这里用round而不是整除,是因为玩家点击时不可能精确点在交点上,取最近交点才符合直觉。实测下来,GRID_SIZE取 60 左右在 1080P 屏幕上视觉最舒服,太小了棋子挤在一起,太大了棋盘超出窗口。

2.2 棋子编码:用整数还是字符串

棋子的表示方式直接影响到后续走法判断的写法。我试过三种方案:

方案示例优点缺点
字符串"red_ma"可读性强比较时容易拼错,性能略低
元组(1, "ma")结构清晰嵌套访问麻烦
整数编码12运算快,便于 AI 评估可读性差,需要查表

最终我选了整数编码,因为后面写 AI 评估函数时需要对棋子价值做加减运算,整数最方便。具体规则是:红方棋子用 1-7,黑方用 8-14,空位用 0。这样判断阵营只需要piece < 8即可。

EMPTY = 0 # 红方 RED_JU = 1 # 车 RED_MA = 2 # 马 RED_XIANG = 3 # 象 RED_SHI = 4 # 士 RED_JIANG = 5 # 将 RED_PAO = 6 # 炮 RED_BING = 7 # 兵 # 黑方(统一加 7) BLACK_JU = 8 BLACK_MA = 9 # ... 以此类推 def is_red(piece): return 1 <= piece <= 7 def is_black(piece): return 8 <= piece <= 14 def same_side(p1, p2): if p1 == EMPTY or p2 == EMPTY: return False return (is_red(p1) and is_red(p2)) or (is_black(p1) and is_black(p2))

初始布局用一个 10 行 9 列的二维列表硬编码即可,注意黑方在上、红方在下,这是中国象棋的惯例。写的时候建议对着真实棋盘摆一遍,别凭记忆,我当初就把炮的位置写错了一行,调试了半天才发现。

2.3 棋盘绘制的那些细节坑

画棋盘看着简单,实际上有几个地方特别容易翻车。第一个是楚河汉界,中间那一行不能画竖线,而且要有"楚河汉界"四个字。第二个是九宫格的斜线,将在九宫格里走斜线,所以九宫格要画对角线。第三个是兵炮位置的十字标记,传统棋盘上这些点有特殊的角标。

def draw_board(screen, font): # 画横线(10 条,贯穿全宽) for r in range(BOARD_ROWS): start = board_to_pixel(r, 0) end = board_to_pixel(r, BOARD_COLS - 1) pygame.draw.line(screen, BLACK, start, end, 2) # 画竖线(9 条,中间断开楚河汉界) for c in range(BOARD_COLS): if c == 0 or c == BOARD_COLS - 1: # 最左最右两条线贯穿 start = board_to_pixel(0, c) end = board_to_pixel(BOARD_ROWS - 1, c) pygame.draw.line(screen, BLACK, start, end, 2) else: # 中间竖线分两段 start1 = board_to_pixel(0, c) end1 = board_to_pixel(4, c) start2 = board_to_pixel(5, c) end2 = board_to_pixel(BOARD_ROWS - 1, c) pygame.draw.line(screen, BLACK, start1, end1, 2) pygame.draw.line(screen, BLACK, start2, end2, 2) # 九宫格斜线 # 上方黑方九宫格 pygame.draw.line(screen, BLACK, board_to_pixel(0, 3), board_to_pixel(2, 5), 2) pygame.draw.line(screen, BLACK, board_to_pixel(0, 5), board_to_pixel(2, 3), 2) # 下方红方九宫格 pygame.draw.line(screen, BLACK, board_to_pixel(7, 3), board_to_pixel(9, 5), 2) pygame.draw.line(screen, BLACK, board_to_pixel(7, 5), board_to_pixel(9, 3), 2)

楚河汉界的文字用font.render渲染后blit到中间区域,注意要旋转 90 度或者横排,看个人审美。我建议横排居中,字号大一点,视觉上更有气势。

3. 走法规则引擎:每种棋子背后的判断逻辑

3.1 车马炮的走法实现差异

车(JU)的走法最好写:沿四个方向直线延伸,遇到第一个非空位置就停,如果是敌方棋子可以吃,己方则不能。核心是循环 + 边界检查 + 阻挡判断。

def get_ju_moves(board, row, col): moves = [] directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] piece = board[row][col] for dr, dc in directions: r, c = row + dr, col + dc while 0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS: target = board[r][c] if target == EMPTY: moves.append((r, c)) else: if not same_side(piece, target): moves.append((r, c)) break # 遇到任何棋子都停止延伸 r += dr c += dc return moves

马(MA)的坑在于蹩马腿。很多人只判断了"日"字形的终点,忘了检查马腿位置是否有棋子。马腿是马当前位置沿直线方向相邻的那个点,比如马从 (r,c) 走到 (r-2,c-1),马腿是 (r-1,c)。

def get_ma_moves(board, row, col): moves = [] piece = board[row][col] # (目标偏移, 马腿偏移) patterns = [ ((-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 (dr, dc), (lr, lc) in patterns: r, c = row + dr, col + dc leg_r, leg_c = row + lr, col + lc if not (0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS): continue if board[leg_r][leg_c] != EMPTY: # 蹩马腿 continue if not same_side(piece, board[r][c]): moves.append((r, c)) return moves

炮(PAO)的走法最特殊:移动时和车一样,吃子时必须隔一个棋子(炮架)。这个"隔一个"的判断需要单独处理,不能和车共用逻辑。

def get_pao_moves(board, row, col): moves = [] piece = board[row][col] directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] for dr, dc in directions: r, c = row + dr, col + dc # 第一阶段:找炮架之前,空格都能走 while 0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS and board[r][c] == EMPTY: moves.append((r, c)) r += dr c += dc # 第二阶段:找到炮架后,继续找第一个棋子 if 0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS: r += dr c += dc while 0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS: if board[r][c] != EMPTY: if not same_side(piece, board[r][c]): moves.append((r, c)) break r += dr c += dc return moves

3.2 象士将的九宫与过河限制

象(XIANG)走"田"字,限制有两个:不能过河,以及象眼不能被堵。红象只能在 row 5-9 活动,黑象只能在 row 0-4。象眼是田字中心那个点。

士(SHI)只能在九宫格内走斜线,红方九宫是 row 7-9、col 3-5,黑方是 row 0-2、col 3-5。

将(JIANG)在九宫格内走直线,另外还有一个特殊规则叫将帅照面——如果两个将之间没有任何棋子,那么当前走子方可以直接"飞将"吃掉对方。这个规则在判断将军时特别重要,很多新手版本都漏了。

def get_xiang_moves(board, row, col): moves = [] piece = board[row][col] is_red_side = is_red(piece) patterns = [(-2, -2), (-2, 2), (2, -2), (2, 2)] for dr, dc in patterns: r, c = row + dr, col + dc eye_r, eye_c = row + dr // 2, col + dc // 2 if not (0 <= r < BOARD_ROWS and 0 <= c < BOARD_COLS): continue # 不能过河 if is_red_side and r < 5: continue if not is_red_side and r > 4: continue # 象眼被堵 if board[eye_r][eye_c] != EMPTY: continue if not same_side(piece, board[r][c]): moves.append((r, c)) return moves

3.3 将军判定与合法走法过滤

判断某一步是否合法,不能只看棋子本身的走法,还要看走完之后自己的将是否被将军。这是新手最容易忽略的一步,导致程序允许玩家走出"自杀"的棋。

正确流程是:先按棋子规则生成候选走法,然后对每个候选走法做一次"模拟执行",执行后检查己方将是否处于被攻击状态,如果被攻击则这个走法非法。

def is_king_in_check(board, is_red_side): """判断某一方的将是否被将军""" king_pos = find_king(board, is_red_side) if king_pos is None: return True # 将没了,视为被将死 kr, kc = king_pos enemy = not is_red_side # 遍历所有敌方棋子,看能否攻击到将的位置 for r in range(BOARD_ROWS): for c in range(BOARD_COLS): piece = board[r][c] if piece == EMPTY: continue if is_red(piece) == enemy: continue if (kr, kc) in get_piece_moves(board, r, c): return True return False def get_legal_moves(board, row, col): """过滤掉会导致自己被将军的走法""" piece = board[row][col] if piece == EMPTY: return [] raw_moves = get_piece_moves(board, row, col) legal = [] for tr, tc in raw_moves: # 模拟走子 captured = board[tr][tc] board[tr][tc] = piece board[row][col] = EMPTY if not is_king_in_check(board, is_red(piece)): legal.append((tr, tc)) # 撤销 board[row][col] = piece board[tr][tc] = captured return legal

这个"模拟-检查-撤销"的模式在棋类编程里非常经典,虽然每次都要复制或修改棋盘,但逻辑清晰不易出错。性能上对于 90 个格子的棋盘完全够用,不需要做额外优化。

4. 交互与回合控制:鼠标事件驱动的状态机

4.1 选子、落子、取消选择的完整流程

Pygame 的交互核心是事件循环。中国象棋的鼠标操作有三种状态:未选中、已选中、等待落子。用一个变量selected记录当前选中的棋子位置,None表示未选中。

点击逻辑是这样的:如果当前没选中任何棋子,点击己方棋子就选中它,并高亮显示所有合法走法;如果已经选中了棋子,点击合法目标位置就执行走子,点击己方另一个棋子就切换选中,点击非法位置就取消选中。

selected = None legal_targets = [] while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: pos = pixel_to_board(*event.pos) if pos is None: selected = None legal_targets = [] continue row, col = pos piece = board[row][col] if selected is None: # 未选中状态:只能选当前回合方的棋子 if piece != EMPTY and is_red(piece) == current_turn_is_red: selected = (row, col) legal_targets = get_legal_moves(board, row, col) else: sr, sc = selected if (row, col) in legal_targets: # 执行走子 make_move(board, sr, sc, row, col) selected = None legal_targets = [] current_turn_is_red = not current_turn_is_red # 检查胜负 check_game_over() elif piece != EMPTY and is_red(piece) == current_turn_is_red: # 切换选中 selected = (row, col) legal_targets = get_legal_moves(board, row, col) else: selected = None legal_targets = []

高亮合法走法用半透明圆圈或者小圆点,画在目标位置中心。我试过用红色圆圈,视觉上最清楚,但要注意别和棋子颜色冲突,建议用亮绿色或黄色。

4.2 悔棋功能的实现思路

悔棋看着简单,实际上要处理"人机对战"和"双人对战"两种场景。双人对战悔一步就行,人机对战要悔两步(电脑一步、玩家一步),否则悔完还是电脑走。

实现上用一个历史栈,每次走子前把(board快照, current_turn)压栈,悔棋时弹栈恢复。棋盘快照用[row[:] for row in board]深拷贝,别用board.copy(),那是浅拷贝,二维列表会出问题。

history = [] def make_move(board, sr, sc, tr, tc): history.append(([r[:] for r in board], current_turn_is_red)) board[tr][tc] = board[sr][sc] board[sr][sc] = EMPTY def undo(): global current_turn_is_red if not history: return snapshot, turn = history.pop() for r in range(BOARD_ROWS): for c in range(BOARD_COLS): board[r][c] = snapshot[r][c] current_turn_is_red = turn

人机对战时,悔棋按钮点一次要连续弹两次栈,并且要判断栈里是否还有足够的历史。如果玩家刚开局还没走,栈是空的,这时候点悔棋要直接忽略。

4.3 胜负判定与将死检测

中国象棋的结束条件有三种:将死(对方无任何合法走法且被将军)、困毙(对方无任何合法走法但没被将军,也算输)、认输。程序里主要处理前两种。

判断逻辑是:轮到某一方走时,遍历它所有棋子的所有合法走法,如果总数为 0,则这一方输。如果此时它被将军,叫将死;没被将军,叫困毙,规则上同样判负。

def has_any_legal_move(board, is_red_side): for r in range(BOARD_ROWS): for c in range(BOARD_COLS): piece = board[r][c] if piece == EMPTY: continue if is_red(piece) != is_red_side: continue if get_legal_moves(board, r, c): return True return False def check_game_over(): global game_over, winner if not has_any_legal_move(board, current_turn_is_red): game_over = True winner = "黑方" if current_turn_is_red else "红方" # 弹出提示

这里有个性能细节:has_any_legal_move只要找到一个合法走法就能返回,不需要遍历完,所以实际开销很小。但get_legal_moves内部会做模拟走子,如果棋盘上棋子多,每次判断都要跑几十次模拟,实测在普通电脑上单次判定在 10ms 以内,完全不影响体验。

5. 电脑对手:从随机走子到极小化极大搜索

5.1 先让电脑会走棋:随机 + 优先级

最简陋的 AI 就是随机选一个合法走法,但这样体验极差,电脑会像个傻子一样乱走。稍微好一点的做法是给走法打分:能吃子的走法优先,能将军的走法优先,靠近对方将的走法优先。

PIECE_VALUE = { EMPTY: 0, RED_JU: 900, RED_MA: 400, RED_XIANG: 200, RED_SHI: 200, RED_JIANG: 10000, RED_PAO: 450, RED_BING: 100, BLACK_JU: 900, BLACK_MA: 400, BLACK_XIANG: 200, BLACK_SHI: 200, BLACK_JIANG: 10000, BLACK_PAO: 450, BLACK_BING: 100, } def evaluate_move(board, sr, sc, tr, tc): score = 0 target = board[tr][tc] if target != EMPTY: score += PIECE_VALUE[target] * 10 # 吃子加权 # 靠近对方将加分 return score

这个版本已经能下出"看起来像那么回事"的棋了,但依然很弱,因为它只看一步。

5.2 极小化极大搜索的极简实现

要让电脑真正有威胁,必须让它能"往前看几步"。极小化极大(Minimax)是棋类 AI 的入门算法,核心思想是:我走一步,假设对方走最有利于他的一步,我再走最有利于我的一步……如此递归,选出让最终局面评分最高的走法。

配合 Alpha-Beta 剪枝,可以把搜索效率提升好几倍。我实测在深度 3 层的情况下,普通笔记本上单步思考时间在 0.5 秒左右,深度 4 层会到 2-3 秒,体验上深度 3 比较平衡。

def minimax(board, depth, alpha, beta, is_maximizing, ai_is_red): if depth == 0: return evaluate_board(board, ai_is_red), None best_move = None if is_maximizing: max_eval = -float('inf') for move in get_all_moves(board, ai_is_red): sr, sc, tr, tc = move captured = board[tr][tc] board[tr][tc] = board[sr][sc] board[sr][sc] = EMPTY eval_score, _ = minimax(board, depth - 1, alpha, beta, False, ai_is_red) board[sr][sc] = board[tr][tc] board[tr][tc] = captured if eval_score > max_eval: max_eval = eval_score best_move = move alpha = max(alpha, eval_score) if beta <= alpha: break return max_eval, best_move else: min_eval = float('inf') for move in get_all_moves(board, not ai_is_red): sr, sc, tr, tc = move captured = board[tr][tc] board[tr][tc] = board[sr][sc] board[sr][sc] = EMPTY eval_score, _ = minimax(board, depth - 1, alpha, beta, True, ai_is_red) board[sr][sc] = board[tr][tc] board[tr][tc] = captured if eval_score < min_eval: min_eval = eval_score best_move = move beta = min(beta, eval_score) if beta <= alpha: break return min_eval, best_move

evaluate_board是局面评估函数,最简单的写法是遍历所有棋子,把红方棋子价值相加减去黑方棋子价值相加,再根据位置微调(比如兵过河后价值翻倍,马炮在中心位置加分)。

def evaluate_board(board, ai_is_red): score = 0 for r in range(BOARD_ROWS): for c in range(BOARD_COLS): piece = board[r][c] if piece == EMPTY: continue value = PIECE_VALUE[piece] # 兵过河加分 if piece == RED_BING and r < 5: value += 50 if piece == BLACK_BING and r > 4: value += 50 if is_red(piece): score += value else: score -= value return score if ai_is_red else -score

5.3 让 AI 不卡顿:搜索深度与线程处理

深度 3 的搜索在 Python 里大概要遍历几万个节点,单步耗时 0.3-0.8 秒。如果直接在主线程里跑,界面会卡住,玩家点击没反应。解决办法有两个:降低深度或者开线程。

我推荐开一个后台线程跑 AI 计算,主线程继续渲染界面,计算完成后通过队列把结果传回来。这样即使 AI 思考 2 秒,界面依然流畅,玩家能看到"电脑思考中"的提示。

import threading import queue ai_result_queue = queue.Queue() def ai_think_async(board_snapshot, depth, ai_is_red): def worker(): score, move = minimax(board_snapshot, depth, -float('inf'), float('inf'), True, ai_is_red) ai_result_queue.put(move) t = threading.Thread(target=worker, daemon=True) t.start()

主循环里每帧检查ai_result_queue是否为空,非空就取出走法执行。注意传给线程的棋盘必须是深拷贝,否则主线程和子线程同时改同一个列表会出问题。

6. 原码结构与二次开发建议

6.1 代码分层与文件组织

完整原码我建议拆成三个文件,别全塞一个.py里,否则改起来痛苦:

  • constants.py:棋盘尺寸、棋子编码、颜色、字体路径等常量
  • rules.py:走法生成、将军判定、胜负检测等纯逻辑
  • main.py:Pygame 初始化、事件循环、绘制、AI 调度

这样分层的好处是,rules.py不依赖 Pygame,可以单独写单元测试。我当初把规则和界面混在一起,后来想加个"棋谱记录"功能,改得焦头烂额,重构了一次才清爽。

6.2 几个可以立刻上手的扩展方向

棋谱记录与复盘:每次走子把(from, to)存进列表,游戏结束后可以逐步回放。这个功能对学习象棋的人特别有用。

难度选择:把 AI 搜索深度做成可配置,简单模式深度 1,普通深度 2,困难深度 3。再配合一点随机扰动,让低难度 AI 偶尔走"臭棋",体验更自然。

音效与动画:走子、吃子、将军分别配不同音效,棋子移动加一个 0.2 秒的平移动画,整体质感会提升一个档次。Pygame 的pygame.mixer加载 wav 或 ogg 都很方便。

网络对战:把走子信息通过 socket 发给另一台机器,双方各自维护棋盘状态。这个稍微复杂,但核心就是"本地走子后广播,收到广播后同步执行"。

6.3 调试棋类程序的几个实用技巧

第一,写一个棋盘打印函数,把二维数组用字符画出来,调试规则时比看界面快得多。第二,给每种棋子写独立的测试用例,比如马在角落有几种走法、炮隔一个子能不能吃,跑一遍就知道规则对不对。第三,用固定棋局测试将军判定,手动摆几个经典残局,验证程序判断是否和人类一致。

注意:AI 搜索时如果发现程序突然变慢,八成是评估函数里做了重复计算。把PIECE_VALUE这种常量字典提到函数外面,别在循环里反复创建。

最后说一句,这个项目的价值不在于代码有多复杂,而在于它把"规则建模、状态管理、搜索算法、界面交互"这四件事串成了一条完整的链路。你把这四个环节都亲手实现一遍,再去看其他棋类或者策略游戏,会发现底层思路是相通的。原码我放在附件里,直接下载就能跑,改坏了也不怕,重新解压一份就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询