写个小游戏是学习Python图形编程最直接的一条路而五子棋恰好是一个逻辑完整、复杂度适中的练手项目。这篇内容我打算把整个PyGame版五子棋的实现过程完整拆开从棋盘绘制、鼠标坐标换算、落子规则到胜负判断每一段都讲清楚代码怎么写、为什么这么写。不管你是刚学完Python基础想找个实战项目还是想在pygame里快速做出一个能玩的程序这篇都能用得上。先说结论PyGame版五子棋真正核心的难点不在“绘制”而在“坐标换算”和“胜负判断”这两块。很多新手写完界面发现棋子落在了两个格子之间或者明明五连了却判断不出来基本都是这两个地方出了问题。这篇文章会重点把这两个环节掰开揉碎讲透同时也整理了我自己调试过程中踩过的一些坑。1. 需求拆解写代码之前先想清楚这四点动手写之前我建议先把需求拆清爽。五子棋看起来简单真要做成一套完整流程其实包含四个独立的功能模块。第一是棋盘渲染。你得画出一个15乘15的围棋式棋盘包括网格线、星位、边距还有黑白棋子。这一层纯粹是“画图”代码上就是pygame的线条和圆形绘制没有太多逻辑难度但视觉参数需要提前定好。第二是交互输入。玩家在棋盘上点击某个位置程序要能判断这个点击对应的是哪一个交叉点然后决定能不能落子。这里最核心的是一个坐标换算函数把鼠标像素坐标转成棋盘格坐标处理不好就会出现在“两格之间落子”的怪现象。第三是状态管理。棋盘本质是一个15乘15的二维数组0表示空、1表示黑棋、2表示白棋。每一轮要记录当前该谁下、落子后是否切换回合、游戏是否已经结束。状态管理做得清晰后面做悔棋、判负、人机对战都会很轻松。第四是胜负判定。每次落子后要判断从当前落子点出发的横、竖、左斜、右斜四个方向是否存在连续五个同色棋子。这部分不复杂但边界条件和方向组合很容易漏。把需求这样拆开之后你会发现整个项目的实现路径非常清晰渲染层管画图交互层管鼠标逻辑层管数组算法层管判断。我的建议是写的时候也按这四个模块来组织代码而不是把所有内容都堆在main函数里。哪怕你只是写一个几百行的小游戏函数拆分带来的调试便利也值得这么做。2. 棋盘坐标系最容易出错的地方先把它解决掉2.1 基础参数怎么定我用的参数如下BOARD_SIZE为15标准五子棋棋盘CELL_SIZE设为40像素MARGIN边距设为30像素。窗口的棋盘区域宽度应该是CELL_SIZE * (BOARD_SIZE - 1) MARGIN * 2这个公式算出的是从左边缘到右边缘的完整跨度。为什么是(BOARD_SIZE - 1)而不是BOARD_SIZE因为15乘15的棋盘交叉点之间有14个格子。第一列从MARGIN开始第15列在MARGIN 14 * CELL_SIZE的位置。如果用15去乘棋盘就会整体向右多出一截。窗口底部我额外预留了INFO_HEIGHT用来显示“当前轮到哪一方”或者“游戏结束”。棋盘区域是正方形底部信息栏是长条矩形这样界面层次比较清楚。2.2 屏幕坐标与棋盘坐标的换算pygame的坐标系原点在窗口左上角x轴向右、y轴向下。鼠标点击位置通过event.pos拿到这是一个形如(x, y)的像素坐标。棋盘坐标则是我们定义的二维数组下标范围是0到14。从像素坐标转格子坐标最直观的思路是先减去边距MARGIN再除以CELL_SIZE最后取整。但取整方式是个细节坑。直接用int()截断当鼠标落在格子的左半区域时会跑到左边的格子上用round()在Python 3里又是银行家舍入遇到14.5这种边界值会得到14而不是15逻辑上容易懵。我推荐的做法是加半个格子再整除def pos_to_grid(mx, my): gx (mx - MARGIN CELL_SIZE // 2) // CELL_SIZE gy (my - MARGIN CELL_SIZE // 2) // CELL_SIZE if 0 gx BOARD_SIZE and 0 gy BOARD_SIZE: return gx, gy return None这样做的原理很朴素如果鼠标落在某个交叉点周围半个格子范围内就认为玩家想点这个交叉点。比如第一列交叉点的像素x坐标是30第二列是70当鼠标x落在50时(50 - 30 20) // 40 1正确落到第二列。反过来从棋盘坐标转像素坐标用于绘制棋盘线、棋子和星位公式是MARGIN gx * CELL_SIZE。这个就简单了没有取整问题。2.3 绘制细节与视觉调优棋盘格子是Burlywood木纹色即(222, 184, 135)网格线用黑色。绘制时画15条横线和15条竖线坐标从MARGIN开始每间隔CELL_SIZE取一个点。如果全部用1像素宽度效果比较干净但如果你觉得棋盘线太细可以给最外圈单独画粗线不过这属于锦上添花不影响核心逻辑。棋子半径我建议用17到18像素。半径太小显得空半径超过19又会和相邻棋子重叠。为了更有立体感黑棋可以在左上方向画一个小高光白棋可以加一圈灰色描边。加不加这些视觉细节不影响功能但会影响观感体验实测下来加上之后整体效果提升明显。星位的绘制也不难在(3,3)、(11,3)、(3,11)、(11,11) 和正中央(7,7)画几个实心小圆点半径4像素即可。这里要注意星位必须画在交叉点上不能画在格子中央否则会显得不专业。3. 胜负判断算法四个方向扫五连3.1 核心思路五子棋的胜利条件是横、竖、左斜、右斜四个方向中任意一个方向存在连续五个同色棋子。最容易想到的办法是落子后遍历整个棋盘对每个位置向四个方向检测是否有五连。这个方法可行但效率比较低而且代码写起来冗余。更合理的思路是只围绕“最后一颗落子”展开检测。因为胜利一定包含最新落下的这颗棋子所以只需要从当前棋盘坐标出发分别沿着四个方向向两边延伸数一数连续同色棋子的总数如果大于等于5就判定为胜利。3.2 方向枚举与代码实现四个基础方向向量可以定义为向右(1,0)、向下(0,1)、右下(1,1)、右上(1,-1)。每个方向向量乘以正负号就能得到正方向和反方向。def check_win(board, gx, gy, player): directions [(1, 0), (0, 1), (1, 1), (1, -1)] for dx, dy in directions: count 1 for sign in (1, -1): for step in range(1, 5): nx gx sign * dx * step ny gy sign * dy * step if 0 nx BOARD_SIZE and 0 ny BOARD_SIZE and board[nx][ny] player: count 1 else: break if count 5: return True return False这段代码里count初始值设为1因为落子点本身就算一个。然后分别向正方向和反方向延伸最多各延伸4步因为加上落子点本身已经能覆盖5个位置再多数下去没有意义。遇到空位、对方棋子或者越界就立即跳出循环。边界条件这块要特别留意。每延伸一步都要检查0 nx BOARD_SIZE和0 ny BOARD_SIZE否则就是数组越界。很多初学者在这里只判断了“等于对方棋子”就继续结果棋盘边缘放子时直接报错。3.3 为什么四个方向就够了有人可能会疑惑五子棋有8个方向为什么只检测4个因为每个方向被检测两次一次沿正方向一次沿反方向。比如(1,0)向右扫描加(-1,0)向左扫描合起来就是水平方向的完整检测。所以实际覆盖的是8个半轴但方向向量只有4个。这个压缩写法不仅让代码更短更重要的是把“连续长度”的计算逻辑统一了。无论你在哪个方向上的哪一侧延伸走的都是同一套循环不容易漏写。4. 完整可运行代码从初始化到主循环4.1 环境准备与初始化先安装pygame然后导入模块并初始化。用一个pygame.time.Clock对象把帧率锁定在60FPS避免程序空转时CPU占用飙升。这一步虽然小但实测下来不加tick的话风扇会一直高速转。import pygame import sys BOARD_SIZE 15 CELL_SIZE 40 MARGIN 30 INFO_HEIGHT 60 WIN_WIDTH CELL_SIZE * (BOARD_SIZE - 1) MARGIN * 2 WIN_HEIGHT WIN_WIDTH INFO_HEIGHT BOARD_COLOR (222, 184, 135) LINE_COLOR (0, 0, 0) BLACK_PIECE (0, 0, 0) WHITE_PIECE (255, 255, 255) EDGE_COLOR (120, 120, 120) INFO_BG (240, 240, 240) def main(): pygame.init() screen pygame.display.set_mode((WIN_WIDTH, WIN_HEIGHT)) pygame.display.set_caption(五子棋) clock pygame.time.Clock() board [[0] * BOARD_SIZE for _ in range(BOARD_SIZE)] current 1 game_over False winner 0 font pygame.font.SysFont(simhei, 28)棋盘数组用列表推导式生成15行每行15个0。这一步要特别注意不能用[[0] * BOARD_SIZE] * BOARD_SIZE因为后者创建的是同一个内部列表的15份引用改一个点会同步改掉整列。4.2 事件循环与落子逻辑事件循环的核心是处理QUIT事件和鼠标左键按下事件。点击时先转成棋盘坐标再判空位、落子、判断胜负、切换回合。while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if event.type pygame.MOUSEBUTTONDOWN and event.button 1 and not game_over: pos pos_to_grid(event.pos[0], event.pos[1]) if pos is None: continue gx, gy pos if board[gx][gy] ! 0: continue board[gx][gy] current if check_win(board, gx, gy, current): game_over True winner current else: current 3 - current3 - current这个写法很巧妙。当current为1时结果是2当current为2时结果是1刚好完成黑白双方切换比if-else更简洁。注意game_over判断放在最外层避免游戏结束后继续落子。如果你不加上这个锁定玩家就可以在胜负已分时继续往棋盘上乱下体验很怪。4.3 绘制与刷新绘制阶段调用三个函数先重画整个棋盘再遍历二维数组把有棋子的位置画出来最后在底部信息栏渲染文字。文字渲染用的是pygame的font模块渲染结果通过blit贴到屏幕上。draw_board(screen) for x in range(BOARD_SIZE): for y in range(BOARD_SIZE): if board[x][y] ! 0: draw_piece(screen, x, y, board[x][y]) pygame.draw.rect(screen, INFO_BG, (0, WIN_WIDTH, WIN_WIDTH, INFO_HEIGHT)) if game_over: text 黑棋获胜 if winner 1 else 白棋获胜 else: text 轮到黑棋 if current 1 else 轮到白棋 surf font.render(text, True, (0, 0, 0)) screen.blit(surf, (20, WIN_WIDTH 15)) pygame.display.flip() clock.tick(60)这里有一个我自己以前忽略的细节pygame的中文文字显示依赖系统字体pygame.font.SysFont(simhei, 28)在Windows上基本有效但某些Linux环境找不到黑体就会显示方块。稳妥的办法是准备一个ttf字体文件放在项目目录用pygame.font.Font(font.ttf, 28)加载。这篇示例我为了简洁用了SysFont实际项目中建议自带字体文件。完整的draw_board和draw_piece函数我用下面的写法def draw_board(screen): screen.fill(BOARD_COLOR) for i in range(BOARD_SIZE): x MARGIN i * CELL_SIZE pygame.draw.line(screen, LINE_COLOR, (x, MARGIN), (x, WIN_WIDTH - MARGIN), 1) y MARGIN i * CELL_SIZE pygame.draw.line(screen, LINE_COLOR, (MARGIN, y), (WIN_WIDTH - MARGIN, y), 1) star_points [(3, 3), (11, 3), (3, 11), (11, 11), (7, 7)] for sx, sy in star_points: cx MARGIN sx * CELL_SIZE cy MARGIN sy * CELL_SIZE pygame.draw.circle(screen, LINE_COLOR, (cx, cy), 4) def draw_piece(screen, gx, gy, player): cx MARGIN gx * CELL_SIZE cy MARGIN gy * CELL_SIZE color BLACK_PIECE if player 1 else WHITE_PIECE pygame.draw.circle(screen, color, (cx, cy), 18) if player 1: pygame.draw.circle(screen, (40, 40, 40), (cx - 3, cy - 3), 5) else: pygame.draw.circle(screen, EDGE_COLOR, (cx, cy), 18, 1)把上面这几段拼在一起就是一个能直接运行的双人对战五子棋。代码量不大但功能是完整的。5. 实测踩坑与排查清单5.1 棋子总落在格子之间这个问题的根源基本都在坐标换算。你如果用的是int()直接截断鼠标点在格子左半部分时会向左取整一格看起来就像棋子偏移了。我的解决办法是前面讲的“加半格偏移再整除”。另外如果你的MARGIN设置和鼠标点击区域不一致也会造成系统性偏移检查一下棋盘线画的位置和换算时用的MARGIN是不是同一个值。5.2 棋盘线条看起来发虚pygame画线时如果线的起始坐标是奇数像素位置而窗口缩放或字体渲染影响了坐标对齐容易出现两根半像素宽的模糊线。解决办法是保证MARGIN和CELL_SIZE都是整数并且所有交叉点坐标计算结果不带小数。棋盘尺寸本来就是整数计算这个坑相对少见但如果你的屏幕缩放比例不是100%窗口整体可能会被缩放导致模糊这是系统层面的问题代码层面不用管。5.3 胜负判断漏判斜线漏判斜线是最隐蔽的bug。很多人写了水平和垂直判断就以为万事大吉结果斜着五连根本不算赢。我在check_win里把所有方向统一成一个方向向量列表就是为了避免这种情况。提交测试的时候建议你专门在两条对角线上各摆一次五连验证(1,1)和(1,-1)这两个方向是否正确。5.4 游戏结束后还能落子这个问题纯粹是状态控制缺失。只要在事件处理时加上not game_over条件并在胜利后把game_over置为True就行。如果你还想做“胜负已分后按键重新开始”的功能就在这个基础上加一个R键重置逻辑。5.5 围棋坐标与数组下标混淆画棋盘和画棋子用的是像素坐标判断用的是格子坐标。如果你在判断时不小心把格子坐标当成像素坐标去操作就会出现落子在棋盘上显示正常但判断全部失效的诡异现象。我自己的习惯是命名上严格区分格子变量叫gx、gy像素变量叫cx、cy、px、py一眼就能看出谁是谁。下面整理一个常见的排查速查表现象可能原因解决思路棋子落在两格之间坐标换算取整方式不对加半格偏移再整除棋盘线模糊绘制坐标不是整数检查CELL_SIZE和MARGIN斜向五连不算赢check_win漏掉斜方向检查方向向量(1,1)和(1,-1)游戏结束后还能落子少了game_over状态锁事件处理前判断game_over修改棋盘后整列变化列表推导式写错避免用乘法复制嵌套列表中文显示为方块系统缺少对应字体用Font加载ttf文件6. 还能怎么扩展这个版本只是双人同屏对战。如果要让项目更进一步有两条路径值得考虑。一条是悔棋功能。维护一个落子历史栈每次落子把(gx, gy)压入栈中悔棋时pop出最近一步把棋盘对应位置置为0同时把current切换回上一步的玩家。这个功能逻辑简单但实际体验提升非常明显尤其在跟朋友对战时误触和手滑太常见了。一条是单机人机对战。实现方式从简到难有很多层最简单的可以做一个“随机落子”稍微进阶一点可以用权值评分法给每个空位按照四个方向上的棋子连续性打分选择得分最高的位置落子。再往上就是极小极大搜索加Alpha-Beta剪枝但五子棋搜索分支多不配合启发式评估容易算得慢。作为练手权值评分法是一个性价比很高的选择既不会涉及太复杂的数学又能做出像模像样的AI。如果你想更进阶还可以用socket把棋盘状态同步到另一台机器上做联机对战或者用pygame.mixer给落子加上音效。不过这些都属于锦上添花核心的棋盘渲染、坐标换算、胜负判断才是这个项目真正的骨架。如果把棋盘逻辑和pygame渲染分开写成两个独立模块你后面任何扩展都会更顺手。这也是我做完这个项目之后最深的体会哪怕只是一个小游戏把“逻辑”和“展示”解耦后续的每一次改动都会变得轻松很多。