简介:Python中国象棋源代码是一套面向Python初学者与游戏开发爱好者的完整小游戏项目,以中国象棋为场景,清晰展示了从界面绘制、棋子规则到人机对战的基础实现思路。压缩包共54个文件,以5个Python源码文件为核心,搭配大量png、gif、jpg图片素材,并附有字节码缓存pyc,整体仅299KB,轻量易用。项目按职责拆分为主程序、数据常量、棋子走法、电脑计算、按钮定义五个模块,界面图片与逻辑代码分离,便于逐段阅读和二次开发,尤其适合学习面向对象编程、事件驱动与简单AI算法。目前已有6640人学习使用,电脑走法仍有较大优化空间,读者可基于现有框架尝试升级搜索策略或评估函数,在实战中提升编程能力。下载后即可运行体验,也可作为课程设计或毕业设计的参考原型。 前几天整理代码仓库,翻出了两年前写的一版Python中国象棋。当时是为了练手,把整盘棋从棋盘绘制、规则判断到简单AI全部实现了一遍,代码量大概两千行出头。说不上多优雅,但胜在结构清晰,注释也写着“给未来的自己看”,今天重读还能顺畅读懂。这篇就把这套Python中国象棋源代码的核心思路、实现过程和踩过的坑拆开讲一遍,希望能给正在做棋类游戏练手项目的朋友一点参考。
1. 开局前的选择:Python能扛住中国象棋的复杂度吗
1.1 为什么拿中国象棋练手
棋类游戏一直是学编程时很喜欢练的项目,五子棋、黑白棋、国际象棋都很常见。但中国象棋有一套非常独特的规则体系:马走日但有绊马腿,象走田但怕塞象眼,炮要隔子打,将帅不能照面,士和相还不能出九宫和过河。这些规则如果放在一个不带界面、纯逻辑层面去实现,非常考验对状态、边界条件和数据结构的理解。
相比国际象棋,中国象棋的棋盘是10行9列,共有90个交叉点,棋子32枚,逻辑上不算特别复杂,但规则细节比五子棋丰富得多。用它来练手,能覆盖到二维数组遍历、基于位置的走法搜索、以及“在各种限制条件下判断一个局面是否合法”这一类通用问题。这些能力做任何游戏项目都能用上。
在整个项目过程中,我刻意把“规则”和“界面”分开。棋盘渲染、鼠标点击这些都属于表现层,规则引擎才是核心。只要规则层做对了,换界面只是换层皮。实践证明这个决定很划算,后来我不止一次把同一套规则模块搬到别的项目里。
1.2 Pygame、Tkinter 还是纯命令行
动手之前,我先列出了三种可能的实现路线:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 纯命令行 | 规则逻辑优先,不需要管图形细节 | 棋盘打印不直观,交互体验差 | 快速验证规则和AI |
| Tkinter | 标准库自带,无需额外安装 | 界面样式相对简陋,控件刷新不够顺滑 | 简单开发环境下的可视化 |
| Pygame | 绘制自由度极高,事件处理成熟,社区教程多 | 需要单独安装依赖,字体渲染要额外处理 | 做完整可玩的棋类游戏 |
我最终选了Pygame。它本质上就是“画布+事件循环”的结构,很适合做回合制棋类游戏:每一帧都可以根据当前棋盘状态把全部界面重新绘制一遍,而不是像传统GUI那样维护一堆控件的同步状态。鼠标点击选中棋子、高亮合法落点、走子后刷新画面,这套流程在Pygame里非常自然。
1.3 项目结构怎么分模块
整个项目没有用复杂的框架,就是最朴素的多文件组织:
chess_board.py:棋盘初始化、坐标转换、棋盘状态获取/修改chess_rules.py:所有棋子的走法生成、将军判断、胜负判断chess_ai.py:极小极大搜索、α-β剪枝、局面评估chess_gui.py:Pygame界面、鼠标事件、绘制逻辑main.py:启动入口,组装上面所有模块
这种拆分的好处是,规则模块不依赖任何一个界面函数,你可以单独写一个命令行测试脚本,快速跑几百个局面来验证走法是否合法。如果一开始就把界面和规则全部揉在一个文件里,后面排查问题的成本会成倍增长。
2. 棋盘表示:一套数据结构的取舍决定了后续所有代码怎么写
2.1 坐标体系与二维数组
中国象棋棋盘是10行9列,红方在下方,黑方在上方。我采用的坐标约定是:board[row][col],其中row从0到9,col从0到8。row越小越靠近黑方,row越大越靠近红方,col从左边0递增到右边8。
用二维数组是最直观的做法,读取某个位置的棋子就是一个下标索引,遍历走法时也是逐个坐标判断。有人喜欢用一维数组配合位移表来做,性能更好,但对中国象棋这种90格的小棋盘来说,二维数组可读性远大于那一点性能收益。
2.2 棋子编码方案
棋子的编码方式决定了代码的清晰度。我用数字表示棋子类型,空位用-1:
EMPTY = -1 RED_SHUAI = 0 RED_SHI = 1 RED_XIANG = 2 RED_MA = 3 RED_CHE = 4 RED_PAO = 5 RED_BING = 6 BLACK_JIANG = 10 BLACK_SHI = 11 BLACK_XIANG = 12 BLACK_MA = 13 BLACK_CHE = 14 BLACK_PAO = 15 BLACK_ZU = 16红方和黑方的编码相差10,这样判断棋子归属只需要一句:
def is_red(piece): return 0 <= piece <= 6 def is_black(piece): return piece >= 10 def is_same_side(piece_a, piece_b): return (0 <= piece_a <= 6 and 0 <= piece_b <= 6) or (piece_a >= 10 and piece_b >= 10)用一个整数而不是字符串存棋子的好处是判断逻辑简单、比较快,并且可以通过简单的整除运算映射到棋子名称字典上,绘制界面时用字典转成中文即可。
2.3 初始化棋局
初始化函数就是把阵型摆上去:
def init_board(): board = [[EMPTY] * 9 for _ in range(10)] for c in range(9): board[3][c] = RED_BING board[6][c] = BLACK_ZU board[0][0] = board[0][8] = BLACK_CHE board[0][1] = board[0][7] = BLACK_MA board[0][2] = board[0][6] = BLACK_XIANG board[0][3] = board[0][5] = BLACK_SHI board[0][4] = BLACK_JIANG board[2][1] = board[2][7] = BLACK_PAO board[9][0] = board[9][8] = RED_CHE board[9][1] = board[9][7] = RED_MA board[9][2] = board[9][6] = RED_XIANG board[9][3] = board[9][5] = RED_SHI board[9][4] = RED_SHUAI board[7][1] = board[7][7] = RED_PAO return board代码没有任何技巧性,就是把每个棋子的起始位置写清楚。这种“表面无聊”的代码恰恰是规则引擎的地基,一旦写错一个位置,后面所有测试都会错得莫名其妙。
3. 走法生成:中国象棋规则里的“魔鬼细节”全在这一层
3.1 车、兵、将的基础走法
车的走法最简单,从一个点出发,沿上下左右四个方向直线延伸,碰到任意棋子就停下,如果碰到的是对方棋子,可以吃掉。
兵的走法要分过河前和过河后。红兵初始在row=3,当row < 5时表示过河了(过了楚河汉界);黑卒初始在row=6,当row > 4时表示过河了。过河前只能向前走一格,过河后才能向左、向右走,但永远不能后退。
将和帅的走法是只能在九宫内移动一格,四个方向都要先判断目标位置是否在九宫范围内。九宫的坐标范围对红方是将帅在row 7..9, col 3..5,黑方是row 0..2, col 3..5,这个判断必须严谨。
3.2 绊马腿与塞象眼:校验前置位置的逻辑
马走日是8个方向,每个方向需要判定马腿是否有子。比如马从(r, c)要跳到(r-2, c-1),那马腿的位置就是(r-1, c)。所谓“绊马腿”,就是马腿位置不是空位时,这个方向不能走。
我最初写这部分时犯过一个低级错误:把马腿方向写错了,导致马能跳过棋子,看上去像是在飞。后来才发现问题出在我用“目标位置与当前位置的差值”反推马腿时,正负号搞反了。这里我直接把完整的方向表列出来:
MA_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), # 右偏下,马腿在正右侧 ]表中前两个值是目标相对偏移,后两个值是马腿相对偏移。有了这个表以后,写马走法只需要一个循环。
象走田的规则也是同理,四个大象眼位置就是目标位置和当前位置的中点。象的额外限制是不能过河,红相不能去到row < 5的区域,黑象不能去到row > 4的区域。
3.3 炮的“越子吃法”是唯一特殊逻辑
炮在空走时和车一样,沿直线走,不能跨越任何棋子。但是吃子时,必须且只能跨越一个棋子,这个被跨越的棋子叫“炮架”。也就是说,炮的走法要分两步判断:
- 找从当前位置沿某个方向往前的所有格子,从第一个非空格子开始,后面的第一个非空格子如果是敌方棋子,就是可以吃的位置。
- 中间所有格子都必须有且只有一个棋子作为炮架。
这个逻辑我一开始写成了一个单独的get_pao_moves函数,后来为了统一所有棋子的行为,我把它整合进一个通用的“沿方向扫描”函数里,用count_blocks计数中间棋子数。吃子判断就从“计数==1且目标为敌方”落在代码上。
3.4 将帅不能照面:这是全局规则不是单子规则
将帅不能照面的规则是指两个将帅如果在同一条竖线上,并且中间没有任何棋子,则该局面不合法。这条规则的本质是把“将帅”看作是可以沿竖线吃子的“假想车”,只不过吃子对象只能是对方的将帅。
我选择在每次执行走子后调用一个is_check函数时顺带检查这条规则。具体实现是:找到红帅和黑将的位置,如果列号相同,就统计它们之间所有棋子数量,如果为0,则当前走子不合法。这样就把“飞将”这种特殊情况也覆盖了。
4. 将军与胜负:让游戏知道什么时候该结束
4.1 将军检测的实现路径
判断一个局面中红方是否被将军,思路是:找到红帅的位置,遍历黑方所有棋子的所有合法走法,看是否存在一个走法的目标位置恰好是红帅的位置。如果有,说明红帅正被将军。
这里的关键点是要走“完整规则”的合法走法,而不是简化的走法。如果将军检测只用了简化走法而没检查绊马腿、炮架,就会出现明明马被别住腿却报将军的荒唐情况。
4.2 将死、困毙与胜负判定
将死的判断:当前局面下己方被将军,且己方所有棋子的所有合法走法都仍然会让己方将帅处于被将军的状态,那么就是被将死,对方胜。
困毙的判断:当前局面下己方没有被将军,但所有合法走法走完以后都会把自己送进被将军的局面,也就是无子可动。传统规则下,中国象棋的困毙算作输棋,这一点跟国际象棋的逼和不一样,实战中很容易被忽视。
我的判定逻辑是统一的:
def has_any_legal_move(board, turn): for each_piece in board: if piece belongs to turn: for each_move in get_moves(...): if is_move_legal(...): return True return False然后综合将军状态得出胜负结果。实现上不算复杂,但需要注意不能把“被将军”和“无合法走法”这两个状态混为一谈。
4.3 和棋:局面重复与自然限着
中国象棋里有很多和棋情况,最常见的是一将一杀、长捉、以及60回合自然限着。完整实现这些规则需要记录历史局面和步数。我在这个版本里做了简化:只记录过去若干步内是否出现三次相同局面,如果出现则判和。
history = [] def is_draw(history): if len(history) < 6: return False last = history[-1] count = 0 for item in history: if item == last: count += 1 return count >= 3为了做局面重复检测,我把棋盘状态编码成一个字符串:逐行逐列拼接棋子编号,中间加一个分隔符表示当前走子方。这样比较的时候只需要做字符串相等判断,简单可靠。
5. 简单AI:用极小球搜索加剪枝让电脑跟你下棋
5.1 从“纯随机走法”到“只看一步”的跳跃
最开始我的AI特别笨,只是从所有合法走法里随机挑一个。后来改成“贪心算法”,也就是对当前局面的所有合法走法都模拟执行一次,用评估函数计算走完后的分值,选分值最高的那步。
贪心算法能应付很弱的对手,但它看不见“被将军”和“丢大子”这类后续变化。比如你走出一步吃掉对方一个炮,但对方下一步能直接将军抽车,贪心算法完全意识不到。这就引出了极小极大搜索。
5.2 极小极大搜索和α-β剪枝
极小极大搜索的思想是:假设双方都会走出对自己最有利的棋。到我的回合,我会选择让评估值最大的走法;到对方的回合,他会选择让我评估值最小的走法。交替递归若干层,就是国际象棋和象棋AI的基本盘。
加上α-β剪枝之后,效率提升非常明显。剪枝的思路是:如果当前分支已经能够推断出对手不会让这个局面发生(因为有一个更好的选择),那就不用再深入搜索这个分支了。
def minimax(board, depth, alpha, beta, maximizing): if depth == 0: return evaluate(board) if maximizing: best = -INF for move in legal_moves: new_board = make_move(...) val = minimax(new_board, depth - 1, alpha, beta, False) best = max(best, val) alpha = max(alpha, val) if beta <= alpha: break return best else: best = INF for move in legal_moves: new_board = make_move(...) val = minimax(new_board, depth - 1, alpha, beta, True) best = min(best, val) beta = min(beta, val) if beta <= alpha: break return best在我那台普通笔记本上,不加剪枝的4层搜索有时就要等好几秒,加上剪枝之后基本能在1秒内出结果。这个差别实战体验非常明显。
5.3 局面评估函数:给每种棋子的价值定个价
评估函数是最能体现个人风格的地方。我这个版本用的是最经典的“棋子分值累加法”:
| 棋子 | 分值 |
|---|---|
| 将/帅 | 10000 |
| 车 | 600 |
| 马 | 400 |
| 炮 | 300 |
| 相/象 | 200 |
| 士/仕 | 200 |
| 兵/卒(未过河) | 100 |
| 兵/卒(已过河) | 200 |
评估方法是遍历整个棋盘,红方棋子加分,黑方棋子减分,最后返回一个总分。AI作为红方时最大化这个分数,作为黑方时最小化这个分数。另外我又给兵加了一点“过河奖励”,让AI更愿意把兵往前推进。这种评估函数非常朴素,但配合3到4层的搜索,已经能下出有模有样的棋,至少不会比刚学会规则的新手差。
6. Pygame界面:把命令行棋盘搬到屏幕上
6.1 画棋盘与画棋子
画棋盘时要注意,中国象棋棋盘的中间是楚河汉界,没有纵向直线贯穿。所以绘制竖线时要分成上下两段,中间留空。我先把10条横线画完整,再将左右两侧的竖线从第1条画到第4条、从第6条画到第9条,正中间的第5条竖线整体不画。然后补上九宫的两条斜线,棋盘就成型了。
棋子是一个实心圆加上中文字。文字渲染在Pygame里坑最大,默认字体不支持中文,直接渲染会显示成方块。解决方法是加载一个中文字体文件:
font_path = "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" font = pygame.font.Font(font_path, 30)这是一个很典型的环境坑。如果没提前准备字体文件,程序在其他机器上跑起来就是一片方框。后来我干脆在代码里写了一个字体自动查找函数,优先用常见字体路径,找不到再降级到默认字体。
6.2 鼠标点选与合法落点提示
交互逻辑用状态机来组织:
SELECT状态:等待玩家点击。点击到己方棋子,记录选中棋子,进入HIGHLIGHT状态。HIGHLIGHT状态:计算选中棋子的所有合法走法,在棋盘上画小圆点标记。点击合法落点,执行走子,进入WAIT_AI状态;点击其他己方棋子,切换选中;点击空白处或者对方棋子且不是合法落点,取消选择,回到SELECT状态。WAIT_AI状态:调用AI函数返回一步棋,执行后回到SELECT状态。
这个状态机虽然简单,但很重要。如果没有状态管理,鼠标事件会出现各种奇怪的冲突。比如你选中一个兵,又点了一下自己的车,结果反而把兵走到车的位置上去了,这种bug都是状态混乱导致的。
6.3 翻转子棋盘:解决红黑视角问题
还有一个细节是红方在下、黑方在上的视角问题。如果玩家选择执黑,棋盘需要整体翻转180度。直接在绘制时对坐标做一次变换即可:
def transform_pos(row, col, is_flipped): if not is_flipped: return row, col return 9 - row, 8 - col规则引擎始终按红方视角存储棋盘,只是界面绘制和鼠标点击坐标在进入/离开引擎时做一次变换。这个方案简单可靠,不会把规则逻辑搞乱。
7. 排错实录:棋类程序最容易藏bug的几个地方
7.1 走法生成器的验证方法
我验证走法生成正确性的笨办法是:写一个play_both_sides脚本,让AI自己跟自己下,每走一步就把棋盘打印出来,人眼盯着看。这种方法虽然原始,但非常有效。连续跑几十盘之后,哪种棋子的走法哪里有问题,基本都能暴露出来。
更有系统性的做法是设计一些“固定局面测试”,每个测试只摆少数几个棋子,验证特定规则。比如棋盘中只放一个马,周围摆上各种障碍,断言马的合法走法集合是否等于预期。
7.2 我踩过的三个真实bug
第一个是象棋的“绊马腿”判断没写好,导致马可以跳过棋子。原因是方向表里的马腿坐标写错,修复成本不大,但排查花了一个晚上。
第二个是炮吃子判断把“隔着棋子”和“隔着多个棋子”搞混了。我发现电脑的炮经常会穿过两个棋子来吃子。修复方案是数出路径上的全部棋子数量,吃子时要求严格等于1,空走时要求严格执行0,缺一个条件都会出错。
第三个是最隐蔽的,AI走出一步后自己的帅暴露在对方车的攻击线上,也就是说AI没有检查“走子后自己是否处于被将军状态”。这个问题在只有一层贪心时不容易暴露,因为评估函数分不出“被将军”和“没被将军”的区别。后来我在is_move_legal里强制加上了“模拟走子后检查己方将帅是否安全”这一步,问题才彻底解决。
7.3 性能优化:别让Python慢到不能玩
Python的执行速度不算快,但象棋搜索的优化空间很大。我做的第一个优化是走法生成时避免重复分配大量列表,尽量复用对象。第二个优化是评估函数只在搜索到叶子节点时才计算,而不是在每个节点都全盘扫一遍。第三个优化是深度设置为4层,最多5层,超过5层等待时间就不太能接受了。
另外我注意到一个有意思的现象:kào剪枝之后,搜索速度跟走法顺序有相当大的关系。如果先搜索看上去比较好的走法(比如吃大子的走法),剪枝效率会明显提高。我在代码里对走法列表按“棋子分值从大到小”排了个序,据说这个技巧在更专业的引擎里叫“杀手走法启发”,简单实现一下就有可感知的提速效果。
8. 拿到源代码之后还能往哪些方向改
8.1 棋谱保存与复盘功能
目前AI和人对弈结束后,我只会打印一句“红方胜”之类的文字。改进方向是做一个走法记录列表,每步记录一个类似于马8进7的字符串,存储到文件里。在对弈结束后,可以像看棋谱一样一步步回放。
关键还是坐标转换。我的内部坐标是(row, col),要转成“马8进7”这样的中国象棋记法,需要按照棋子的移动方向和起始列号来推算,这个转换逻辑本身也是一个值得写的小模块。
8.2 更聪明的AI:位置估值与开局库
我的评估函数只算了棋子分值,没有考虑位置因素。比如车占中路、马在河沿、炮在底线,位置的好坏其实差异很大。改进方案是给每种棋子做一张“位置分值表”,遍历棋子时把位置分也加起来,这样AI会更有大局观。
更进一步是加入开局库。把前几步的常见开局写在文件里,AI开局阶段直接从开局库里查着走,不仅能避开自己薄弱的中局,还能省下搜索时间留给中后盘。
8.3 网络对战与自定残局
还有一个团队朋友建议过的玩法:用socket通信把走法序列透传给对端,做一个简单的网络对战版本。两边各自维护棋盘,只传输走法的起点和终点坐标,界面层自己负责渲染,不需要同步整个棋盘状态。这样做的好处是网络只传输轻量数据,不容易出现状态不一致的问题。
自定残局功能则在规则引擎基础上加一个“从指定局面开始”的入口,测试某个残局下AI能不能找出杀棋,这是锻炼评估函数和搜索深度的好场景。
说实话,这套Python中国象棋源代码的整体难度并不高,但它非常完整地覆盖了规则、搜索、界面、交互、调试这几个经典模块。如果你已经掌握Python基础语法,想找一个稍有点挑战的练手项目,这个方向很值得一试。个人体会是,把走法生成器耐心写完、把测试用例补上,后面做AI和界面都会顺利很多。等你跑通了自己写的AI对战之后,再回头看那些看似烦琐的规则实现,会发现它们其实都是最值回票价的代码。
本文还有配套的精品资源,点击获取