写个小游戏是学习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渲染分开写成两个独立模块,你后面任何扩展都会更顺手。这也是我做完这个项目之后最深的体会:哪怕只是一个小游戏,把“逻辑”和“展示”解耦,后续的每一次改动都会变得轻松很多。