资讯详情 Pygame中国象棋实战:逻辑与渲染分离的走法校验与交互实现
📅 2026/10/14 11:35:14
1. 项目缘起与整体设计思路1.1 为什么选择用Pygame做中国象棋用Python做桌面小游戏Pygame几乎是绕不开的一个库。它轻量、文档全、社区活跃装完就能跑不需要折腾复杂的图形管线。而中国象棋这个题材对练手来说非常“对味”——棋盘是标准的9x10网格棋子走法规则明确没有物理引擎、没有实时碰撞检测逻辑层和渲染层可以分得很干净。我当初动手写这个项目核心动机有三个。第一想找一个规则完整但不至于太复杂的棋类来练手围棋规则太深、五子棋又太浅象棋刚好卡在中间。第二Pygame的坐标系统和象棋棋盘的交叉点布局天然契合用像素坐标换算棋盘坐标非常直观。第三象棋的走法校验涉及大量条件判断是练习“状态机思维”的好素材。这个项目最终能实现的功能包括完整的棋盘与棋子绘制、鼠标点击选子与落子、每种棋子的合法走法校验、吃子逻辑、回合切换、将帅照面检测、以及一个简易的胜负判定。代码量控制在五百行左右适合有一定Python基础、想入门游戏开发的朋友直接拿来读、拿来改。1.2 整体架构逻辑与渲染分离写游戏最容易犯的错就是把“数据”和“画面”搅在一起。比如在绘制棋子的函数里顺手改棋盘状态或者在判断走法的函数里直接操作屏幕坐标。这样写前期爽后期改一个规则要翻遍整个文件。我在这个项目里采用的是逻辑层与渲染层分离的思路。棋盘状态用一个二维列表board来存每个元素代表一个棋子或者空位所有走法校验、吃子、回合切换都只操作这个二维列表完全不碰Pygame的任何东西。渲染层则负责把board里的数据翻译成屏幕上的图形每一帧重新画一遍。这样做的好处很实在调试规则的时候我可以直接在终端里打印board看状态不用启动图形界面想换一套棋子皮肤只改渲染层逻辑一行不动。对于新手来说这种分层意识比具体代码更重要。1.3 棋盘坐标与像素坐标的换算象棋棋盘是9列10行棋子落在交叉点上不是格子里。这一点和很多人的直觉不同。所以坐标换算要特别小心。我定义棋盘坐标(row, col)其中row从0到9对应从上到下的10条横线col从0到8对应从左到右的9条竖线。屏幕上的像素坐标则通过一个偏移量加间距来算MARGIN 40 # 棋盘边缘留白 CELL 60 # 相邻交叉点间距 def board_to_pixel(row, col): x MARGIN col * CELL y MARGIN row * CELL return x, y反过来鼠标点击的像素坐标要换算回棋盘坐标用四舍五入找最近的交叉点def pixel_to_board(x, y): col round((x - MARGIN) / CELL) row round((y - MARGIN) / CELL) return row, col注意这里用round而不是整除是因为玩家点击时很难精确点到交叉点正中心取最近的交叉点才符合手感。如果点击位置离所有交叉点都太远应该判定为无效点击这个容差我一般设成CELL * 0.4。2. 核心数据结构与棋子规则解析2.1 用二维列表表示棋盘棋盘状态我用一个10行9列的二维列表表示空位用None有棋子的地方存一个字典或者元组包含颜色和类型board [[None for _ in range(9)] for _ in range(10)] board[0][0] {color: black, type: ju} # 黑方车 board[9][4] {color: red, type: shuai} # 红方帅用字典而不是纯字符串是为了后续扩展方便。比如以后想加“棋子是否已经移动过”这种状态直接往字典里加字段就行不用改结构。初始布局我写了一个函数init_board()来填充红方在下、黑方在上中间第4、5行是空的楚河汉界。这里有个细节象棋的“炮”和“兵”位置容易记错我建议直接对着真实棋盘摆一遍把坐标一个个写死别想着用循环生成容易出错还不好调试。2.2 每种棋子的走法校验这是整个项目的核心也是最容易写乱的地方。我的做法是给每种棋子写一个独立的校验函数输入是起点、终点和当前棋盘输出是布尔值。车ju走直线中间不能有棋子。判断方法是先确认起点终点在同一行或同一列然后沿着路径逐格检查是否有阻挡。马ma走“日”字但有“蹩马腿”的规则。先判断位移是不是(±1, ±2)或(±2, ±1)然后检查马腿位置也就是长边方向相邻的那一格是否有棋子。象xiang走“田”字有“塞象眼”规则而且不能过河。位移必须是(±2, ±2)中间那格不能有棋子且终点必须在己方半场。士shi走斜线一格且只能在九宫格内。九宫格对红方是row 7-9, col 3-5对黑方是row 0-2, col 3-5。将/帅jiang/shuai走直线一格同样限制在九宫格内。另外还有“将帅照面”的特殊规则这个我单独处理。炮pao走直线但吃子和移动规则不同。移动时中间不能有棋子吃子时起点和终点之间必须恰好有一个棋子炮架。兵/卒bing/zu未过河只能向前一格过河后可以向前或左右一格永远不能后退。def is_valid_move(board, start, end): sr, sc start er, ec end piece board[sr][sc] if piece is None: return False if board[er][ec] is not None and board[er][ec][color] piece[color]: return False t piece[type] if t ju: return check_ju(board, start, end) elif t ma: return check_ma(board, start, end) # ... 其余棋子类似实操心得写校验函数时先写“位移形状对不对”再写“路径有没有阻挡”最后写“终点是不是自己人”。这个顺序能帮你快速定位bug——如果棋子走法完全不对是形状判断错了如果能走但穿过了别的棋子是阻挡判断漏了。2.3 将帅照面与胜负判定“将帅照面”是象棋里一个容易被忽略但必须实现的规则如果红帅和黑将在同一列上且中间没有任何棋子那么当前走棋的一方不能走出导致这种局面的棋实际上规则是造成照面的一方判负但简化实现里通常直接禁止这种走法。我的处理方式是在每次走子后检查找到红帅和黑将的位置如果它们在同一列且中间无子就判定刚走棋的一方违规回退这步棋。更严谨的做法是在走子前预判但回退法实现起来更简单对新手友好。胜负判定则是在走子后检查对方的将/帅是否还在棋盘上。如果被吃了游戏结束。这里要注意正规象棋里将帅不能被吃而是被“将死”但简化版直接吃子判定胜负也完全能玩适合练手阶段。3. 完整实操流程与关键代码实现3.1 环境准备与项目骨架先把环境搭起来。Python版本建议3.8以上Pygame用pip install pygame装最新版就行。我实测下来Pygame 2.x在Windows和macOS上都很稳Linux下如果缺SDL库可能需要额外装一下系统依赖。项目文件结构我建议保持简单一个文件搞定xiangqi/ main.py # 主程序包含所有逻辑 assets/ # 可选放棋子图片和音效新手阶段别急着拆模块一个文件写到五百行左右完全可控。等你想加AI或者网络对战了再拆成board.py、rules.py、render.py也不迟。主程序的骨架是这样的import pygame import sys pygame.init() screen pygame.display.set_mode((620, 700)) pygame.display.set_caption(中国象棋) clock pygame.time.Clock() board init_board() selected None current_turn red while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() elif event.type pygame.MOUSEBUTTONDOWN: handle_click(event.pos) draw_board(screen, board) draw_pieces(screen, board) pygame.display.flip() clock.tick(60)这个主循环是Pygame游戏的标准写法处理事件、更新状态、绘制画面、控制帧率。clock.tick(60)保证游戏跑在60帧不会因为循环太快吃满CPU。3.2 棋盘与棋子的绘制棋盘绘制分几步先画背景色再画横线和竖线然后画九宫格的斜线最后画“楚河汉界”的文字。横线有10条竖线有9条但竖线在楚河汉界处是断开的除了最左和最右两条边线。这个细节很多人第一次写会漏掉画出来就是9条通到底的竖线看着不对劲。def draw_board(screen, board): screen.fill((240, 200, 140)) # 木色背景 # 画横线 for r in range(10): y MARGIN r * CELL pygame.draw.line(screen, (0,0,0), (MARGIN, y), (MARGIN8*CELL, y), 2) # 画竖线中间断开 for c in range(9): x MARGIN c * CELL if c 0 or c 8: pygame.draw.line(screen, (0,0,0), (x, MARGIN), (x, MARGIN9*CELL), 2) else: pygame.draw.line(screen, (0,0,0), (x, MARGIN), (x, MARGIN4*CELL), 2) pygame.draw.line(screen, (0,0,0), (x, MARGIN5*CELL), (x, MARGIN9*CELL), 2)棋子我用圆形加文字来画红方红字、黑方黑字选中时加一个高亮圈。文字用pygame.font.SysFont加载系统字体中文显示一般没问题如果遇到方块乱码就指定一个明确的中文字体路径。注意Pygame默认字体不一定支持中文。稳妥的做法是用pygame.font.Font(字体文件路径, 字号)加载一个确定支持中文的字体文件比如思源黑体或者系统自带的黑体。3.3 鼠标交互与走子流程鼠标交互的完整流程是这样的点击时先换算棋盘坐标然后分三种情况处理。第一种当前没有选中棋子。如果点击位置有当前回合方的棋子就选中它记录selected (row, col)。如果点的是空位或对方棋子忽略。第二种已经选中了棋子再次点击同一个位置取消选中。第三种已经选中了棋子点击别的位置。这时候调用is_valid_move校验合法就执行走子、切换回合、检查胜负不合法就给出提示比如播放一个错误音效或者让棋子闪一下然后保持选中状态或者取消选中。def handle_click(pos): global selected, current_turn row, col pixel_to_board(*pos) if not in_board(row, col): return if selected is None: piece board[row][col] if piece and piece[color] current_turn: selected (row, col) else: if (row, col) selected: selected None elif is_valid_move(board, selected, (row, col)): move_piece(board, selected, (row, col)) selected None current_turn black if current_turn red else red check_game_over()move_piece函数负责把起点棋子移到终点如果终点有子就覆盖掉吃子。这里要注意移动前最好先备份棋盘状态万一走完发现将帅照面可以回退。3.4 回合切换与状态提示回合切换本身很简单就是一个字符串在red和black之间翻转。但为了让玩家知道现在轮到谁我建议在窗口顶部或者侧边显示当前回合方比如“红方走棋”或“黑方走棋”。另外选中棋子时给个视觉反馈非常重要。我的做法是在选中的棋子上画一个黄色圆环同时在所有合法落子点画小圆点。这个“合法落子提示”功能对新手玩家特别友好实现起来也不难遍历棋盘所有位置对每个位置调用is_valid_move返回True就画个半透明小圆点。def draw_hints(screen, board, selected): if selected is None: return for r in range(10): for c in range(9): if is_valid_move(board, selected, (r, c)): x, y board_to_pixel(r, c) pygame.draw.circle(screen, (0, 200, 0), (x, y), 8)这个功能加上之后整个游戏的体验会提升一个档次强烈建议加上。4. 常见问题与排查技巧实录4.1 棋子走法不对怎么排查走法校验是bug重灾区。我踩过的坑包括马的蹩腿判断写反了方向、象的过河判断用了错误的行号范围、炮的吃子判断把“恰好一个炮架”写成了“至少一个”。排查这类问题我的方法是写单元测试。不用搞复杂的测试框架直接在文件末尾写几个断言就行def test(): b init_board() # 车从(9,0)走到(9,4)应该合法假设路径无阻挡 assert is_valid_move(b, (9,0), (9,4)) True # 马从(9,1)走到(7,2)应该合法 assert is_valid_move(b, (9,1), (7,2)) True # 象不能过河 assert is_valid_move(b, (9,2), (5,4)) False print(all tests passed)每次改完规则跑一遍比在图形界面里手动点半天高效得多。4.2 中文显示乱码的解决Pygame里显示中文乱码九成是字体问题。pygame.font.SysFont(None, 36)用的是默认字体很多系统上不支持中文。解决办法是明确指定字体文件font pygame.font.Font(C:/Windows/Fonts/simhei.ttf, 36) # Windows # font pygame.font.Font(/System/Library/Fonts/PingFang.ttc, 36) # macOS如果不想依赖系统路径可以把一个开源中文字体文件放进assets目录用相对路径加载这样换电脑也能跑。4.3 点击响应不准的调整点击响应不准通常是坐标换算的容差没设好。如果容差太小玩家点偏一点就没反应容差太大又会误触到相邻的交叉点。我的经验是容差取CELL * 0.4比较合适。另外pixel_to_board换算完之后要检查row和col是否在合法范围内因为玩家可能点到棋盘外面。def in_board(row, col): return 0 row 9 and 0 col 8还有一个细节如果点击位置离最近的交叉点距离超过容差应该返回None而不是强行取整否则会出现“点空白处却选中了远处棋子”的诡异情况。4.4 常见问题速查表问题现象可能原因解决方法棋子能穿过其他棋子路径阻挡判断漏写检查车、炮的路径逐格扫描逻辑马走法不对蹩马腿方向判断错误确认马腿是长边方向相邻格象过河了半场范围行号写错红方row 5-9黑方row 0-4中文显示方块字体不支持中文指定中文字体文件路径点击没反应容差太小或坐标越界调整容差并加范围检查将帅照面没判缺少照面检测走子后检查同列中间是否无子游戏卡顿主循环没限帧加clock.tick(60)4.5 几个提升体验的小技巧第一个技巧是走子音效。Pygame加载音效很简单pygame.mixer.Sound(move.wav).play()一行搞定。落子声、吃子声、将军提示音加上之后沉浸感完全不一样。第二个技巧是悔棋功能。实现方式是维护一个历史栈每次走子前把棋盘深拷贝一份压栈悔棋时弹出恢复。深拷贝用copy.deepcopy(board)别用浅拷贝否则改一个影响一片。第三个技巧是走子动画。不用搞复杂的缓动简单让棋子从起点平滑移动到终点就行用pygame.time.get_ticks()算插值。这个属于锦上添花新手可以先跳过。第四个技巧是棋盘翻转。如果做双人对战可以让黑方视角的棋盘翻转180度这样双方都从自己这边看棋盘。实现上就是渲染时把行列都反过来算逻辑层完全不用动。5. 后续扩展方向与个人体会5.1 从双人对战到人机对战现在这个版本是双人轮流操作想加AI的话最直接的路子是极小极大搜索加Alpha-Beta剪枝。象棋的状态空间比围棋小得多搜索深度做到4到6层配合一个简单的子力价值评估函数车9分、马4分、炮4.5分、兵1分这种就能做出一个“会下棋”的电脑对手。评估函数是AI强弱的关键。除了子力价值还要考虑位置价值比如兵过河之后加分、马在中心加分、将帅安全度等等。这块展开能写一整篇这里就不铺开了。5.2 网络对战的思路想做联机对战核心是把“走子”这个动作抽象成一条消息通过socket在两端同步。服务端维护权威棋盘状态客户端只负责发送走子请求和接收状态更新。Pygame本身不带网络功能需要配合socket或者websockets库。这个方向复杂度陡增建议先把单机版打磨好再考虑。5.3 我个人的几点体会写这个象棋项目最大的收获不是学会了Pygame的API而是理解了规则引擎和表现层分离的价值。一开始我也把逻辑和渲染混在一起写改一个走法规则要重启游戏点半天才能验证。后来拆开之后规则用断言测、界面用眼睛看效率完全不是一个量级。另外象棋的走法校验看着简单真写起来边界条件特别多。蹩马腿、塞象眼、炮翻山、将帅照面每一个都有坑。我的建议是一个棋子一个函数写完立刻测别攒着一起调否则出了问题根本不知道是哪儿的锅。最后这个项目最好的学习方式不是照着抄一遍而是自己先写一版卡住了再回头看参考实现。抄代码只能得到一份能跑的代码自己踩过坑才能得到能迁移的经验。棋盘摆在那里规则是死的但你怎么组织代码、怎么分层、怎么调试这些才是真正带走的东西。