
1. 项目概述为什么用Pygame做五子棋1.1 从零开始的核心需求拆解提到用Python做游戏很多人第一反应就是Pygame。五子棋作为最经典的棋类游戏之一规则简单、胜负明确特别适合用来练习图形界面开发、事件驱动编程和基础算法。这个项目的核心目标不是做出一个花里胡哨的商业作品而是让你理解一个程序是如何从空窗口变成能玩的对战游戏。先拆一下需求需要一个15x15的棋格棋盘黑白双方轮流在交叉点落子先连成五子的一方获胜。听起来简单但真正写起来涉及到的东西一点都不少窗口初始化、绘制棋盘网格、检测鼠标点击位置并换算成棋盘坐标、存储每一格的状态黑棋、白棋、空、每次落子后判断四个方向是否有五连子、处理游戏结束后的重置逻辑。我的做法是先把这些功能拆成独立的小模块哪怕后面要扩展成AI对战或网络联机基础架构都不用推翻。实际写下来的代码量大概在200行左右对于刚接触Pygame的人来说正好处于能看懂、能跑通、能改着玩的最佳范围。1.2 用Pygame而非其他框架的理由有人会问为什么不直接写纯命令行版本命令行版本当然能验证逻辑但你很难体验到鼠标点一下棋子就落上去的即时反馈而这种反馈恰恰是游戏开发里最迷人的部分。Pygame在图形界面方面足够轻量不需要安装Qt、Tkinter那样复杂的工具包也不用像Web游戏那样去折腾HTML、CSS和JavaScript。我之前对比过几个方案Tkinter虽然自带但绘制速度慢动画效果也弱做双人对战的互动体验会打折扣pyglet上手门槛比Pygame高文档也偏少。Pygame最友好的地方在于它的API非常直白处理图像、声音和事件都是一行调用就能搞定社区里的浩如烟海的教程和示例能让新手少走很多弯路。另外Pygame基于SDL开发跨平台性能稳定Windows、macOS、Linux都能跑这对我来说很重要因为同一个项目经常要在不同机器上演示。用Pygame做五子棋本质上就是用最小成本掌握游戏开发的核心循环初始化、更新状态、绘制画面、处理事件这个循环是几乎所有游戏的基础骨架。2. 环境准备搞定Pygame安装与项目初始化2.1 pip install pygame 的安装与验证动手写代码之前先把环境打通。Python版本我建议3.8以上太老的版本有些Pygame新特性用不了。打开终端直接执行pip install pygame如果你的机器上同时装了Python 2和Python 3记得用pip3 install pygame。安装完成后我习惯用一行代码快速验证import pygame print(pygame.version.ver)能正常输出版本号说明安装成功。这一步看起来简单但遇到问题的人不少。最常见的报错是pip版本太旧或者系统里缺少编译依赖。如果是Linux系统可能还需要先装libsdl2-mixer-2.0-0这类库。Windows用户基本上用官方Python安装包不会遇到什么编译问题macOS用户如果装了Homebrew先brew install sdl2也能省掉不少麻烦。我还遇到过一种情况明明pip install pygame成功了但import的时候提示找不到模块。这多半是pip装到了当前Python环境之外的解释器上尤其是用IDE创建了虚拟环境但忘了切换。解决方案很简单在项目所在虚拟环境里重新执行一次pip install就行。2.2 创建第一个Pygame窗口核心代码环境就绪后我们来创建一个最基本的窗口。这里不是最终的五子棋代码但它是所有Pygame项目的起点理解了它后面的东西就顺理成章了。import pygame import sys pygame.init() screen pygame.display.set_mode((750, 750)) pygame.display.set_caption(五子棋对战) clock pygame.time.Clock() while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() screen.fill((210, 180, 140)) # 木色背景 pygame.display.flip() clock.tick(60)这段代码做了四件事初始化Pygame、创建一个750x750像素的窗口、设置标题、进入主循环。主循环里最关键的是事件处理pygame.event.get()会返回当前队列中的所有事件比如鼠标移动、按键、关闭窗口等。pygame.QUIT对应右上角的关闭按钮所以一定要处理否则窗口关不掉。screen.fill用RGB颜色刷背景五子棋用木色底是很经典的搭配。clock.tick(60)把帧率限制在60FPS避免程序高速空转消耗CPU。这个框架就是整个游戏的地基后面所有功能都是在这个循环里不断添加代码。3. 棋盘与棋子绘制五子棋的界面基础3.1 棋盘网格的绘制逻辑与坐标换算标准的五子棋棋盘是15x15条线交叉点有15x15个对应225个落子位置。我采用的窗口大小是750x750留出上下左右的边距这样每个格子的跨度可以统一计算。我实际用的参数是边距50像素每格直径设为44像素这样棋盘的视觉中心正好落在窗口中央。计算格间距的核心代码很简单BOARD_SIZE 15 MARGIN 50 GRID_SIZE (750 - 2 * MARGIN) // (BOARD_SIZE - 1)这里要注意是BOARD_SIZE - 1因为15条线之间只有14个空隙我之前第一次写的时候用15除结果棋盘左右不对称。绘制线段的逻辑是循环遍历所有行列从边距开始每隔GRID_SIZE画一条横线或竖线for i in range(BOARD_SIZE): start MARGIN i * GRID_SIZE pygame.draw.line(screen, (0, 0, 0), (MARGIN, start), (750 - MARGIN, start)) pygame.draw.line(screen, (0, 0, 0), (start, MARGIN), (start, 750 - MARGIN))这段代码会在窗口上形成一个完整的棋格但光画线还不够还需要把鼠标点击位置换算到最近的交叉点坐标。换算逻辑是用鼠标坐标减去边距后除以格间距四舍五入取整再乘以格间距加回边距得到该点的精确像素位置。row round((mouse_y - MARGIN) / GRID_SIZE) col round((mouse_x - MARGIN) / GRID_SIZE) new_x MARGIN col * GRID_SIZE new_y MARGIN row * GRID_SIZE这个换算很容易出错。我第一次写出来的时候棋子总是落在线的中间而不是交叉点原因就是少做了四舍五入和回乘操作。鼠标拿到的像素坐标是连续的但棋盘只有离散的225个点必须做一次最近点匹配。还要加一个边界判断防止用户点在棋盘外面导致数组越界if 0 row BOARD_SIZE and 0 col BOARD_SIZE: # 可以落子3.2 棋子绘制与落子位置对齐棋子用圆形表示黑棋和白棋的区别就是填充颜色。Pygame里画圆需要传入圆心坐标、半径和颜色。我的棋子半径为18像素比半个格间距小一点这样黑白棋相贴时不会重叠得太生硬。radius 18 if current_player 1: # 黑棋 pygame.draw.circle(screen, (0, 0, 0), (new_x, new_y), radius) else: # 白棋 pygame.draw.circle(screen, (255, 255, 255), (new_x, new_y), radius)为了视觉效果更真实我通常会在圆形外面加一层浅灰色描边代码是pygame.draw.circle(screen, (200, 200, 200), (new_x, new_y), radius, 2)。这个细节虽然不影响玩法但能让棋子看起来更有立体感。落子位置对齐后还要把棋子的状态存到二维列表里。我用board [[0 for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]初始化0代表空1代表黑棋2代表白棋。每次落子后更新对应元素胜负判定就是基于这个二维数组来扫描的。存储数据结构的选择很关键。有人会用字典键是(row, col)元组值是对应棋子颜色但列表的索引访问速度更快而且在写判定算法时用双重循环遍历特别方便所以我推荐二维列表。这个思路在后来写AI预测时也复用得上。4. 对战逻辑落子、交替与胜负判定实现4.1 玩家交替落子的状态管理五子棋是回合制游戏黑棋先手白棋后手。要实现交替落子最直接的办法是用一个变量current_player来表示当前该谁下落子成功后切换玩家。核心代码是if event.type pygame.MOUSEBUTTONDOWN and game_over False: row, col get_board_pos(pygame.mouse.get_pos()) if board[row][col] 0: board[row][col] current_player draw_piece(row, col, current_player) if check_win(row, col, current_player): game_over True winner current_player else: current_player 3 - current_player # 1变22变1这个3 - current_player是个偷懒技巧黑棋是1白棋是23减1等于23减2等于1一次切换完成。很多人会写成条件判断但我觉得这个算术表达式更简洁。交替逻辑里有个容易踩的坑玩家在已经落过子的位置再次点击如果不加判断就会覆盖原有棋子。所以每次事件处理都要检查board[row][col] 0。另一个坑是游戏结束后还允许落子所以要加game_over标志。我在实际测试中还会处理一个细节如果鼠标按下去但按在窗口外pygame.mouse.get_pos()依然会返回某个坐标这时换算出来的行列可能超出范围必须在前面加一个范围守卫。我的做法是如果行列越界就直接返回不让后续代码执行。4.2 胜负判定五子连线检测算法详解胜负判定是五子棋的核心算法我也为此花了不少心思。最简单的思路是在每次落子后只检查这颗棋子周围的一圈看四个方向水平、垂直、两条对角线上有没有连续五颗同色棋子。我的实现方法是写一个函数传入落子位置和玩家颜色然后遍历四个方向向量directions [(1, 0), (0, 1), (1, 1), (1, -1)] def check_win(row, col, player): for dx, dy in directions: count 1 # 正方向延伸 for step in range(1, 5): r row dx * step c col dy * step if 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 else: break # 反方向延伸 for step in range(1, 5): r row - dx * step c col - dy * step if 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 else: break if count 5: return True return False这里有个关键细节判定时的边界检查我把0 r 15和0 c 15写在条件里一旦越界立刻停止延伸防止数组索引出错。正反两个方向的计数加起来就是当前方向上的连续同色棋子数只要≥5就赢。有人可能想优化成更复杂的算法但对五子棋来说每次落子后只检查附近35个点开销极小。加上一局游戏最多225步性能完全不是问题。我实测下来哪怕在普通笔记本上帧率也稳定在60FPS。4.3 游戏状态管理开始、进行中、结束任何游戏都需要状态管理五子棋也不例外。我用三个状态来标识游戏进程等待开始、对战中、游戏结束。为了简单我用布尔变量组合game_over控制游戏是否继续winner记录赢家0代表没有current_player决定当前是谁的回合。当检测到胜者后弹出提示信息。我在窗口标题栏显示黑棋胜或白棋胜同时在棋盘中央显示一个半透明的结果框。为了让玩家能方便开新局我设置按键R或空格键重置游戏if event.type pygame.KEYDOWN and event.key pygame.K_r: reset_game()reset_game里要做的事包括把board全部置零、game_over设为False、winner设为0、current_player设为1。这个重置逻辑我一开始忘了清空winner后来发现上一局赢家还会显示在下一局标题上排查了一会儿才明白是残留状态。更完整的项目还会加入开始菜单界面和音效但这两个属于锦上添花。我的原则是先把核心对战跑通再考虑外围功能这样即使出了问题也能快速定位是绘制问题还是逻辑问题。5. 完整代码示例与逐段解析可直接运行5.1 主循环与事件监听我把之前提到的所有模块整合成一个可直接运行的完整脚本。在写主循环时我的习惯是所有事件都集中处理绘制逻辑单独抽成函数这样代码结构更清晰。下面这段是主循环部分import pygame import sys pygame.init() BOARD_SIZE 15 MARGIN 50 WINDOW_SIZE 750 GRID_SIZE (WINDOW_SIZE - 2 * MARGIN) // (BOARD_SIZE - 1) RADIUS 18 screen pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE)) pygame.display.set_caption(五子棋对战 - 黑先白后) clock pygame.time.Clock() board [[0] * BOARD_SIZE for _ in range(BOARD_SIZE)] current_player 1 game_over False winner 0 def get_board_pos(pos): x, y pos if x MARGIN or y MARGIN or x WINDOW_SIZE - MARGIN or y WINDOW_SIZE - MARGIN: return None, None col round((x - MARGIN) / GRID_SIZE) row round((y - MARGIN) / GRID_SIZE) return row, col def draw_board(): screen.fill((210, 180, 140)) for i in range(BOARD_SIZE): start MARGIN i * GRID_SIZE pygame.draw.line(screen, (0, 0, 0), (MARGIN, start), (WINDOW_SIZE - MARGIN, start)) pygame.draw.line(screen, (0, 0, 0), (start, MARGIN), (start, WINDOW_SIZE - MARGIN)) # 画中心星位天元和四角星可选 for (r, c) in [(7, 7), (3, 3), (3, 11), (11, 3), (11, 11)]: star_x MARGIN c * GRID_SIZE star_y MARGIN r * GRID_SIZE pygame.draw.circle(screen, (0, 0, 0), (star_x, star_y), 4) def draw_board_pieces(): for row in range(BOARD_SIZE): for col in range(BOARD_SIZE): if board[row][col] 0: continue center_x MARGIN col * GRID_SIZE center_y MARGIN row * GRID_SIZE if board[row][col] 1: pygame.draw.circle(screen, (0, 0, 0), (center_x, center_y), RADIUS) pygame.draw.circle(screen, (100, 100, 100), (center_x, center_y), RADIUS, 2) else: pygame.draw.circle(screen, (255, 255, 255), (center_x, center_y), RADIUS) pygame.draw.circle(screen, (100, 100, 100), (center_x, center_y), RADIUS, 2) def check_win(row, col, player): directions [(1, 0), (0, 1), (1, 1), (1, -1)] for dx, dy in directions: count 1 for i in range(1, 5): r, c row dx * i, col dy * i if 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 else: break for i in range(1, 5): r, c row - dx * i, col - dy * i if 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 else: break if count 5: return True return False def reset_game(): global board, current_player, game_over, winner board [[0] * BOARD_SIZE for _ in range(BOARD_SIZE)] current_player 1 game_over False winner 0 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: mouse_pos pygame.mouse.get_pos() row, col get_board_pos(mouse_pos) if row is not None and col is not None and board[row][col] 0: board[row][col] current_player draw_board_pieces() if check_win(row, col, current_player): game_over True winner current_player else: current_player 3 - current_player if event.type pygame.KEYDOWN and event.key pygame.K_r: reset_game() draw_board() draw_board_pieces() if game_over: font pygame.font.Font(None, 74) text font.render(黑棋胜! if winner 1 else 白棋胜!, True, (255, 0, 0)) text_rect text.get_rect(center(WINDOW_SIZE // 2, WINDOW_SIZE // 2)) screen.blit(text, text_rect) pygame.display.flip() clock.tick(60)这里有一个组织技巧draw_board_pieces函数在每次落子后被手动调用一次作用是立即刷新那一帧然后再在主循环里统一绘制。虽然只使用主循环的draw_board()和draw_board_pieces()也能实现效果但手动调用会避免偶尔出现的落子后要等下一帧才显示的感觉实测体验更顺滑。5.2 关键函数与类设计的复盘如果代码只用函数全局变量会堆在一起维护起来有点乱。但为了减少学习负担我用函数加全局变量的方式完成整个项目。如果你想为后续扩展做铺垫可以引入一个Game类把状态和方法都封装进去。我用类重构过代码结构大致是这样class GomokuGame: def __init__(self): self.board [[0] * 15 for _ in range(15)] self.current_player 1 self.game_over False self.winner 0然后把check_win、reset_game、handle_click都变成类的方法。这么做的好处是如果以后要接AI或者多人联机只需在每个实例中保存独立状态不互相干扰。但说实话对于这个教程项目用简单函数完全够用。我更建议你把类重构作为练手环节先跑通函数版再尝试改成类版这一改能加深你对封装和状态管理的理解。实战里还要考虑一个细节当玩家按R键重置后窗口标题上的胜负信息要跟着变清晰。我在重置函数里没有改标题但游戏结束后标题文字个人喜欢保留因为很多玩家会希望看到上一局的结果。如果想要更干净的体验可以在重置时重新设置pygame.display.set_caption。6. 常见问题与避坑指南从实测中积累的经验6.1 安装与运行期间的高频报错排查先说Pygame的安装。网上搜pygame官方下载可能会跳到英文官网但官方推荐的方式就是pip install pygame。如果你用IDE比如VSCode、PyCharm可以打开终端在项目虚拟环境中安装。如果你下载的是某个博主发的绿色版Pygame或者离线安装包我建议谨慎使用——我遇到过好几个版本不兼容的问题最后还是乖乖用pip。运行时最常见的报错是pygame.error: video system not initialized原因是你调用了pygame.display.set_mode()之前没有执行pygame.init()。初始化在项目最开始写一行就够了。还有一个高频问题是字体对象。在一些Linux环境里pygame.font.Font(None, 74)的None会调用系统默认字体如果环境没有中文字体渲染黑棋胜会变成方块乱码。解决办法是指定字体路径比如simhei.ttf或者用pygame.font.get_default_font()查一下实际可用字体。我一般直接把汉字换成英文Black Wins省心。6.2 逻辑Bug与视觉Bug的排除思路我在这里帮大家把五子棋教程里最容易翻车的三个点列出来第一是坐标换算错误。因为棋盘有边距鼠标坐标和棋盘索引之间需要做减法再除法。如果忘了处理边距棋子会整体偏移点在交叉点上却落到了旁边。我建议在写get_board_pos后用几个边界值测试点(MARGIN, MARGIN)应该得到(0,0)点(WINDOW_SIZE - MARGIN, WINDOW_SIZE - MARGIN)应该得到(14,14)。第二是胜负判定误判。我在测试时发现如果棋子在边界附近反向延伸的循环很容易越界。一定要在循环里包含范围检查否则会读取到错误的数据导致明明只有四连却判赢。调试方法是故意布置一个在边缘的五连然后打印board里的实际值确认检测逻辑没偷懒。第三是落子交替混乱。如果你把current_player切换写在了胜负判定之前那么当赢家产生时棋子的颜色和显示可能不一致。我在代码里是先更新棋盘、再判定胜负、最后才换玩家这样赢家展示永远是正确的。针对视觉问题大家常遇到的是一个奇怪的现象棋子画出来了但画面闪烁或撕裂。这是双缓冲问题。Pygame默认使用双缓冲你要做的只是在每轮循环最后调用pygame.display.flip()而不是pygame.display.update()。flip会整体交换缓冲区视觉上更平滑。7. 项目扩展从五子棋到更多玩法的心得7.1 给游戏加入一个简单的AI玩家如果你觉得双人对战不够刺激下一个自然目标就是加入AI。手写一个简单的AI并不需要复杂神经网络当前比较实用的方案是minimax搜索配合启发式评分。简单来说AI会遍历所有空位计算每个位置对双方的价值分数比如活三加100分、冲四加1000分最后选择得分最高的点。这样一个初级AI已经能打败不少新手玩家代码量也只增加不到100行。如果你不想自己写网上也有开源的rapfi五子棋算法可供参考。rapfi是专业的五子棋搜索引擎但它的复杂度较高更适合作为学习材料读源码而不是直接塞进这个教程项目里。我的建议是先用简单的贪心评分搭一个能玩的AI再逐步优化。7.2 增加音效、菜单与对战记录除了AI你还可以给游戏添加音效。Pygame处理音频很简单pygame.mixer.Sound(move.wav)然后调用.play()即可。需要注意的是在Pygame中要使用pygame.mixer.init()初始化音频模块否则播放时会报错。我的习惯是将音效文件放在sounds目录确保运行时路径一致。菜单方面可以在游戏循环里根据不同的scene变量绘制不同画面。五子棋的游玩场景我认为只需要两个主菜单和游戏画面。主菜单上有开始游戏、退出两个按钮点击后切换状态。这一套状态切换逻辑在后续做其他游戏时也会反复用到。最后我还尝试过给游戏加一个简单的棋谱记录功能把每一步的(row,col)存成一个列表然后导出为PGN格式棋谱文件。这个功能对复盘很有帮助也能让程序显得更专业。如果你对这个功能感兴趣可以在键盘事件里加入CtrlS来触发保存代码循环里读取列表再写入文本文件即可。根据我个人经验从一个最简单可运行的游戏开始不断加小功能远比一开始就规划一个庞大复杂的项目更容易坚持下来。我现在写这个Pygame五子棋时也是从第一版只有静态棋盘开始一步步加上了落子、胜负判断和重置逻辑。每一步都验证通过再进入下一步这样每一个新功能带来的成就感都能支撑我继续写下去。相信你也能用同样的节奏做出属于自己的一款五子棋对战小游戏。