资讯详情

Python+Pygame五子棋开发实战:从棋盘建模到AI对战完整教程

📅 2026/10/13 8:28:21 | 华诺云谱 👁 阅读
Python+Pygame五子棋开发实战:从棋盘建模到AI对战完整教程
1. 项目拆解从需求到落地的整体思路很多新手学Python写到函数、类之后就卡住了总觉得语法都认识但一动手就不知道怎么写一个完整的程序。选五子棋作为练手项目非常合适它规则清晰、逻辑直观、界面反馈即时而且刚好能把Python基础语法、Pygame图形库、事件循环、AI算法这些知识点串成一条完整的链路。我做这个pygame版五子棋项目时的目标很明确不搞花哨的动画特效不追求复杂的AI棋力核心是把一局能玩起来的五子棋做扎实——棋盘能画、棋子能下、五连能判、结果能显示。真正动手之后才发现光是“判断胜负”这一块就有不少值得琢磨的细节。先看整个项目的模块划分。一个标准的pygame五子棋程序至少要拆成四块棋盘数据模型、游戏状态管理、图形渲染层、胜负判定逻辑。数据模型负责记录每一步落子状态管理控制玩家轮次和游戏结束条件渲染层负责把棋盘和棋子画到屏幕上判定逻辑是游戏的核心裁判。这四块的职责必须清晰分开。如果混在一起写前期可能感觉进展很快但一加功能就乱成一锅粥。比如加了AI对战之后如果落子逻辑跟渲染逻辑耦合在一起AI思考时屏幕就会卡顿如果不把判定独立成模块后续想换规则比如三手交换、五手两打这类专业规则就得推倒重来。我的建议是先按这个结构想清楚再动笔写代码。1.1 棋盘的数据建模从二维数组到落子映射棋盘是整个游戏的核心数据结构。标准五子棋是15×15的交叉点落子不是格子落子这个细节新手特别容易搞错。第一次做的时候我就踩过这个坑把棋子画到了格子中央看起来像国际象棋但真实棋盘上棋子应该落在横竖线的交叉点上。我用的是一个二维列表来表示棋盘状态每个位置有三个取值0表示空、1表示黑子、2表示白子。# 棋盘状态15x15初始全空 board [[0 for _ in range(15)] for _ in range(15)]这里有个小细节[[0] * 15] * 15这种写法看起来差不多但实际每一行都是同一个列表对象的引用改一行会连带其他行变值。我之前就是因为这个bug排查了很久棋子在屏幕上乱跳最后加打印才发现问题。建议创建二维数组时一定要用列表推导式不要用乘法。数据模型准备好之后要考虑的是像素坐标和棋盘索引的映射关系。pygame的坐标系是左上角为原点x轴向右y轴向下而棋盘索引是行和列。我设置棋盘的起点坐标是start_x 30,start_y 30每格间距是cell_size 40那么索引(row, col)对应的像素坐标就是px start_x col * cell_size py start_y row * cell_size反过来像素坐标转索引就是四舍五入取整col round((mouse_x - start_x) / cell_size) row round((mouse_y - start_y) / cell_size)这里要特别注意边界检查如果鼠标点击位置距离棋盘太远映射出来的索引可能超出0-14的范围直接访问列表就会越界报错。所以每次鼠标事件都要先判断坐标是否在有效范围内再做索引转换。1.2 游戏状态机轮次、落子与结束条件五子棋的游戏流程是一个典型的状态机黑棋落子 - 判定胜负 - 若未分胜负则轮到白棋 - 白棋落子 - 判定胜负 - 若未分胜负则轮到黑棋如此循环。我用三个变量管理状态current_player记录当前轮到黑还是白game_over记录游戏是否结束winner记录胜方颜色。每次落子之后更新棋盘、切换玩家、执行胜负判定。这里有个容易忽略的点胜负判定之后如果已经分出胜负就必须锁定棋盘不允许继续落子。否则玩家在游戏结束后还能乱点体验会很奇怪。def handle_click(pos): if game_over: return # 坐标换算 落子 判定这样一个入口判断就能保证游戏流程不会被破坏。还有一个小体验优化已经落子的位置不允许重复落子。这个逻辑放在落子之前判断如果对应位置的值非空就直接return等于这次点击被忽略。没有这一步玩家可以把自己的棋子覆盖掉整个游戏就乱套了。2. Pygame渲染实战从窗口创建到棋子绘制Pygame作为2D游戏框架API设计非常直白核心就是三个环节初始化窗口、处理事件、绘制画面。五子棋这个项目的渲染工作量不大但“怎么画得好看”恰恰是很多入门教程忽略的部分。初始化这部分比较固定设置窗口大小、标题、背景色就行import pygame pygame.init() screen pygame.display.set_mode((660, 660)) pygame.display.set_caption(五子棋) background_color (238, 187, 115) # 木纹黄窗口尺寸我选660像素稍微比棋盘区域大一点。棋盘起点30、间距4015个交叉点之间的总跨度是14×40560加上左右各30的边距棋盘区域是620×620。留出的40像素边距正好放玩家信息和按钮区域。2.1 绘制网格与星位别小看了这些基础图元绘制网格线是渲染的基础。用pygame.draw.line画15条横线和15条竖线循环就行。关键是棋盘边缘的粗线处理标准棋盘最外圈的线应该比其他线更粗这个细节不做的话棋盘看起来会有点“虚”。我的做法是对于第0条和第14条线用3像素宽度中间的线用1像素宽度。这样画出来的棋盘有实体感更接近真实棋盘的视觉效果。for i in range(15): # 横线 if i 0 or i 14: width 3 else: width 1 pygame.draw.line(screen, (0, 0, 0), (start_x, start_y i * cell_size), (start_x 14 * cell_size, start_y i * cell_size), width) # 竖线同理只改坐标方向的参数星位就是棋盘上那五个小黑点天元和四个星一方面装饰另一方面是棋手定位的参考。画法是在指定交叉点画小实心圆半径为4像素。五个点的坐标分别是(7,7)、(3,3)、(11,3)、(3,11)、(11,11)。2.2 棋子绘制圆润的渐变效果是怎么做到的棋子绘制是所有渲染里最影响观感的环节。最朴素的画法是在交叉点画一个实心圆黑子画黑色圆白子画白色圆。但实际效果非常扁平看起来很廉价。我参照了网上一些开源项目的做法用多层圆叠加来模拟光泽感。黑子先画一个半径14的深色圆做基底然后在左上角偏移2像素画一个半径10的稍微亮一点的圆形成高光。白子则是用浅灰做阴影、纯白做主体、再点上一个小高光。def draw_piece(screen, x, y, color): if color 1: # 黑子 pygame.draw.circle(screen, (0, 0, 0), (x, y), 14) pygame.draw.circle(screen, (50, 50, 50), (x - 2, y - 2), 9) else: # 白子 pygame.draw.circle(screen, (180, 180, 180), (x, y), 14) pygame.draw.circle(screen, (255, 255, 255), (x - 2, y - 2), 11) pygame.draw.circle(screen, (255, 255, 255), (x - 4, y - 4), 4)这里的高光偏移是固定值(-2, -2)因为窗口不会缩放所以不需要动态计算。如果以后要支持窗口缩放这些偏移量就得按半径比例来算。还有一个容易被忽略的细节棋子圆心的位置必须是交叉点的像素坐标而不是格子的中心。交叉点的坐标用前面说的映射公式算出来然后传给draw_piece。如果坐标对不上棋子会跟网格线错位看起来特别难受。2.3 事件循环与重绘机制刷新率的选择pygame的主循环结构基本固定但每个游戏的“刷新策略”不一样。五子棋是回合制游戏不像动作游戏那样需要每秒60帧的重绘。我采用的策略是只在棋子改动后调用update()加一个脏标记来触发重绘。简单讲就是这样running True clock pygame.time.Clock() while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if handle_click(event.pos): dirty True if dirty: draw_board(screen) draw_all_pieces(screen) pygame.display.flip() dirty False clock.tick(30)dirty标记的作用是避免每帧都重复绘制整块棋盘。15×15的棋子数量不算多全量重绘性能上也没压力但养成这种习惯对做更复杂的pygame项目很有帮助。clock.tick(30)是控制循环帧率没有这行的话while循环会以极快的速度空转CPU占用率直接拉满电脑风扇呼呼转。如果用pygame.display.update()而不是flip()也可以update()可以只更新指定区域flip()是全窗口更新。五子棋这个量级用哪个都行但如果你做了按钮、提示框可以尝试用update(rect列表)只刷新变化区域效率更高。3. 胜负判定算法八方向扫描的核心逻辑五子棋的胜负判定是程序的核心逻辑这部分写得好不好直接影响代码的扩展性。最直接的想法是每次落子后检查所有方向是否有连续5颗同色棋子。新手容易把判定写得非常啰嗦——写一个检查水平方向的长函数再复制粘贴改成垂直方向、两个斜对角线四个方向四个函数代码膨胀还容易写错。我的做法是统一成“方向向量”模式。棋子的八个邻居方向可以抽象成八组向量坐标偏移方向行偏移列偏移水平左0-1水平右01垂直上-10垂直下10左上斜-1-1右下斜11右上斜-11左下斜1-1判断胜负时以刚落的棋子为起点沿着某个方向的正向数同色棋子数量再沿反方向数同色棋子数量两个数量相加再加1当前棋子本身如果大于等于5就赢了。3.1 方向向量统一扫描法一份代码覆盖所有情况写代码时先把四个大方向定义成元组列表然后两层循环外层遍历四个方向内层做正反两次扫描。def check_win(board, row, col, player): directions [(0, 1), (1, 0), (1, 1), (1, -1)] for dr, dc in directions: count 1 # 当前落子本身 # 正向扫描 r, c row dr, col dc while 0 r 15 and 0 c 15 and board[r][c] player: count 1 r dr c dc # 反向扫描 r, c row - dr, col - dc while 0 r 15 and 0 c 15 and board[r][c] player: count 1 r - dr c - dc if count 5: return True return False这里一个关键设计是用(1, -1)而不是四个方向都单独写把“右上到左下”的对角线扫描拆成正向和反向两个过程。这样四个方向只需要4组向量而不是8组循环体内的逻辑完全一致。还需要注意边界条件。while循环里的判断0 r 15 and 0 c 15是必须的否则扫到棋盘边缘时会越界访问列表直接抛IndexError。我见过有人在循环里写了一堆if判断来检查边界完全没有必要像上面这样把边界写进while条件里就够了干净利落。3.2 终止条件的边界处理为什么必须在落子后立刻判定判断胜负的时机也值得注意。我选择在每次落子并更新棋盘之后立刻调用check_win而不是等下一轮事件触发时才判断。这样做的原因有两个一是逻辑上更自然因为胜负结果依赖“当前这一步棋”二是避免一个隐蔽的bug——如果玩家获胜后又轮到对手点击游戏状态已经变了再来判断就容易错乱。def place_piece(row, col, player): board[row][col] player if check_win(board, row, col, player): return win return continue这里返回值的处理也有讲究。place_piece返回两个状态——“win”或“continue”上层拿到状态后决定是否更新游戏结束标记。如果你直接返回布尔值那“获胜”时返回True其他时候返回False看起来没问题但后续扩展比如判断平局、判断禁手时布尔值就不够用了。建议一开始就用状态字符串或枚举值而不是布尔值后续扩展会简单很多。平局的情况也要考虑。棋盘共225个位置全部填满还没有五连就是平局。实现的思路是在每次落子后统计棋盘空位数量如果为0就宣布平局。这个功能前期可以不做但加AI之后会用到AI要考虑和棋的可能性。3.3 判定性能分析225个交叉点的极限情况可能有人会问每次落子后都扫描四个方向最坏情况下要循环多少步满盘时最后一步落下正反扫描每条方向最多扫描14步因为15路棋盘对角线最长也是15格四个方向加起来也就几十次循环。对Python来说这点计算量连0.1毫秒都用不到完全不用担心性能。但如果你做一个更大规模的棋盘比如19路或者加入AI搜索算法判定函数会被调用几万次这个时候就值得做一点优化。比如只存放“五个子力点”而不用扫描整个棋盘——我们下次再仔细展开。4. 体验升级悔棋、AI对战的实现要点基础版五子棋做完之后很多人会想着加功能。悔棋和AI对战是最常见的两个方向。悔棋功能的实现比想象中简单用一个栈保存每一步的落子坐标悔棋时弹出栈顶将棋盘对应位置清零。因为棋盘数据模型本身就支持随机访问赋值这个功能加上去前后不到10行代码。move_stack [] # 栈数据结构 # 落子时 move_stack.append((row, col)) # 悔棋时 if move_stack: row, col move_stack.pop() board[row][col] 0 current_player 3 - current_player # 玩家切换回上一步这里注意悔棋之后还要把current_player切回上一步的玩家。如果黑棋走了一步之后白棋悔棋那悔棋后应该还是白棋走但这一步其实是白棋之前走的……这个逻辑容易绕晕。我的建议是用一个简单的规则悔棋只回退一步悔棋后的current_player等于被移除棋子的颜色。这样逻辑永远自洽。AI对战这部分就复杂了。最简单的AI是“随机落子”——检查棋盘空位随机选一个下。这个实现起来10分钟搞定但棋力基本为零跟它下棋很无聊不适合作为最后的成品。稍微进阶一些的AI可以用“打分法”或者“极小化极大搜索”。五子棋的评估函数可以统计每个空位在四个方向上的“连子数”比如某个空位落黑子可以形成活三三个连续黑子且两端有空位就给这个位置很高的分数如果可以形成冲四四个连续黑子其中一端被堵分数次之。遍历所有空位算一遍取分数最高的位置落子。这个方案对付新手足够用了而且代码量不大。如果追求更强的棋力就要引入递归搜索树一个简单的alpha-beta剪枝算法大概需要200行代码。我建议初次实现直接做打分法AI先把评估函数调好棋力已经能超过平均水平了。5. 常见问题与排查技巧实录写这个项目的过程中我遇到了不少问题有些是代码逻辑的坑有些是pygame库的坑。这里整理几个最典型的自己再遇到可以少走弯路。问题现象可能原因解决方案棋盘显示错位线不完整循环里range范围写成15而不是14画线循环是for i in range(15)但终点坐标计算用14 * cell_size不要用15棋子不跟随鼠标位置坐标映射时用了“格子中心”而不是交叉点确保px start_x col * cell_size不要额外加半个格子点击无反应事件类型写错或按钮判断错误先打印event内容定位再检查event.button是否为1左键游戏结束还能继续下缺少game_over状态检查在handle_click开头判断if game_over: return图片无法加载路径问题pygame默认路径是相对当前工作目录用os.path.join拼接绝对路径或者把图片文件放到脚本同目录窗口内存占用持续增大主循环中每帧创建新Surface但没有销毁检查是否在循环内重复调用pygame.image.load加载应放在循环外棋子画出来边缘锯齿明显没有开启抗锯齿pygame.draw.circle不是抗锯齿的想平滑可以用gfxdraw模块绘制带AA的圆以上是表格快速看接下来详细说三个我踩过最深、坑得最久的5.1 隐藏Bugcheck_win误判非本子的同色子这是一个逻辑陷阱很容易在写扫描函数时踩进去。假设当前落子位置是(7,7)扫描方向是(1,-1)即以(7,7)为中心向左上方向延展。很多初学者会写成while board[r][c] player: count 1 r dr c dc这个写法在正常边界内能跑但有个隐蔽问题如果棋盘上有一个“断头”比如当前位置是黑子但沿着方向数了几个黑子之后出现了空格再往后又有黑子此时while循环在空格处已经停了count只统计了连续长度这个逻辑是对的。但如果反向扫描起始时r, c指向了一个非本方的空位那反向循环就完全不会执行count会停在1。这个判断并没有错——但如果你误把“向两边各数一次”写成了“向两侧各数一步再数”就有问题。我出现这个bug是在一次重构之后我当时把反向扫描的初始坐标写成了r, c row dr, col dc又往回走了一步导致反向扫描永远从落子的同一侧邻居开始形成了重复计数五颗连子会被算成六颗。排查半天才意识到正反扫描的起点符号反了。核心教训正反扫描一定要一个加方向向量、一个减方向向量并且用落子位置为基准。5.2 坐标转换Bug棋子显示在两条线中间这个问题非常经典。如果你把棋盘的起点设成了start_x 30格子间距40那么第0列交叉点的x坐标是30第1列是70第2列是110。但很多人在绘制“格子线”时习惯了取“划线的左端为index乘以间隔”比如for col in range(15): pygame.draw.line(..., (col * cell_size, 0), ...)这样第0条线的位置是x0与棋子的x30错开了。正确做法是画线时横线坐标用start_y row * cell_size、纵线坐标用start_x col * cell_size。也就是所有绘图坐标都统一从起点偏移量计算而不是从0开始。画完之后再画棋子时才不会错位。5.3 性能优化不必对每一帧重绘整个棋盘如果你在代码里这样写while running: draw_board(...) for 每个棋子: draw_piece(...) pygame.display.flip()这个写法本身没问题在15×15棋盘上每帧画几十个圆pygame完全跟得上。但问题是如果你把加载字体、创建Surface等耗资源的操作也写进循环里帧率就会明显下降。一个简单的优化方法把静态元素棋盘网格线和星位预先绘制到一张Surface上每次重绘时先blit这张静态背景再画棋子。这样省掉了重复绘制网格线的开销。我实测下来这个改动在棋盘子数量多的时候帧率稳定了不少。6. 代码结构总结与扩展方向最终我把整个项目拆成了四个文件main.py负责主循环和事件处理board.py管理棋盘数据模型和胜负判定render.py负责所有绘制逻辑ai.py负责AI落子计算。这样每个文件都在300行以内职责明确后续扩展起来非常舒服。如果对pygame还不太熟悉先从单一main.py文件开始写也没问题等代码超过300行之后再考虑拆分这样不会一开始就被模块化设计吓住。我的建议是先跑通再优化最后才是模块化。后续可以扩展的方向包括加入双人联机局域网Socket通信、加入AI难度选择、加入音效、打包成可执行文件用PyInstaller。每个方向都能再写一篇长文。最后说一句个人的体会五子棋虽然规则简单但它包含了一个完整的游戏程序所需的几乎全部核心组件——状态管理、用户输入、图形渲染、规则判定、可选AI。把这个项目吃透再去写贪吃蛇、俄罗斯方块、甚至更复杂的策略游戏都会感觉底气足了很多。很多游戏的骨架其实是相通的五子棋这个“小项目”的价值远比它看起来的要大。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑