ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python+Pygame开发五子棋:从数据结构到AI算法实战

Python+Pygame开发五子棋:从数据结构到AI算法实战 1. 项目概述1.1 核心需求解析五子棋这个项目看起来不过是棋盘上黑白子的博弈但真正动手去写你会发现它几乎涵盖了游戏开发的全部基础知识点数据结构设计、图形渲染、交互事件、AI策略落子、胜负判定、状态管理。我从第一次在终端里用星号和井号打印棋盘到最终做出带悔棋、AI对战、计分板的完整客户端中间踩了不少坑也沉淀了不少值得写下来的心得。这篇文章写的是我用Python Pygame做一个可玩的五子棋游戏的完整过程适合刚入门游戏开发或者想用项目练手Python的朋友。看这篇文章你会收获三样东西一是整套可复制的代码结构与实现思路二是算法层面的优化方案尤其是AI落子和胜负判定三是我在实际运行中踩过的坑和解决办法这些在教程里通常不会写。做这个项目前我给自己定了一个目标不能只是“能落子”的demo必须做到有图形界面、能人机对战还能双人对战、有悔棋和重开功能。很多新手练五子棋都在控制台搞定但一旦牵扯到图形界面事件循环、帧率控制、界面刷新这些问题就会一个接一个冒出来这正是我想要的练习效果。1.2 方案选型与技术范围选择Python Pygame不是因为它最强大而是因为它让你在“看到成果”和“搞清楚原理”之间找到一个平衡点。Py game的事件驱动模型相当于你手机里的通知推送系统——鼠标点击是通知键盘按键是通知窗口关闭也是通知。游戏主循环不断询问“有没有新通知有就处理”这种模型理解透了以后学任何图形界面的游戏开发都顺畅。开发环境的准备很简单安装Python 3.8以上版本后执行pip install pygame我在Windows 11和macOS上都跑过没遇到什么环境兼容问题。建议用虚拟环境隔离依赖尤其是同时在做多个Python项目的时候。Pygame的版本我用的是2.5.x不同小版本的API变化不大基本不影响本文代码。技术方案最终确定如下语言Python 3.10图形库Pygame 2.5棋盘15x15标准棋盘格子边长40像素交互鼠标点击落子按键快捷键操作AI基础评分函数 极大极小搜索用α-β剪枝优化难度分三档架构棋盘逻辑与界面渲染分离为以后扩展网络对战留余地2. 五子棋实现的核心细节2.1 棋盘数据结构的建模棋盘是游戏的心脏。数据结构设计得好后面AI、胜负判断、悔棋功能的实现都会变得很顺畅。我见过不少人开局就写二维数组存棋子这没错但到了实现悔棋或者复盘时就痛苦了。我用了经典方案15x15的二维列表0表示空1表示黑棋2表示白棋。self.board [[0 for _ in range(15)] for _ in range(15)]这行代码只是起点。游戏真正需要的不只是“当前棋盘长什么样”还有“棋局是怎么一步步走到这里的”。悔棋的实现方式是每次落子后把坐标push进一个move_stack悔棋时直接从栈顶弹出最后一步回滚棋盘状态。这个思路和浏览器返回上一页一个道理把历史记录堆起来才能回退。如果没有这个历史栈悔棋就只能用深拷贝保存每个历史棋局内存开销大而且代码丑得多。如果你还想做“撤销对手的落子”或“全部重开”历史栈就更重要了。我最初的版本没有考虑这些后来加功能时不得不重构这是我希望你从一开始就避免的。教训是设计数据结构时多想想未来可能要加什么功能而不是只管当前需求。2.2 界面渲染的坐标换算Pygame里所有绘图都基于像素坐标而游戏逻辑关心的是棋盘行列号。鼠标点击位置到行列号的换算是每个图形界面棋盘游戏都会遇到的基础问题。棋盘左上角留了40像素的边距行列索引i对应像素坐标(30 i * 40, 30 j * 40)。反向换算鼠标点击位置def get_board_pos(mouse_pos): x, y mouse_pos row round((y - 30) / 40) col round((x - 30) / 40) if 0 row 15 and 0 col 15: return row, col return None这里有个关键细节用round而不是int转换。round是四舍五入点击位置靠近格子边缘时也能正确落到最近的格点上int直接截断会导致点击格子上半部分落到上一行手感会有明显偏差。我第一版就用的int结果测试时总觉得落子位置偏了半格排查了很久才发现是这个原因。棋盘绘制我用了Pygame的draw.line和draw.circle线条颜色选了深棕色接近木质棋盘质感。坐标计算要特别小心边界不要因为少画一条线导致棋盘不对称。这一步没有什么高技术含量纯粹是耐心活但棋盘画得整洁整个游戏的第一印象就好了很多。2.3 胜负判定算法的优化五子棋胜负判定的常规做法是每次落子后从落子位置向四个方向横、竖、两个对角线延伸数连续同色棋子任意方向连成5颗或以上即判胜。def check_winner(board, row, col): player board[row][col] directions [(0, 1), (1, 0), (1, 1), (1, -1)] for dr, dc in directions: count 1 for sign in (1, -1): r, c row sign * dr, col sign * dc while 0 r 15 and 0 c 15 and board[r][c] player: count 1 r sign * dr c sign * dc if count 5: return True return False只判当前落子位置而不是全盘扫描是性能的关键。15x15棋盘不算大全盘扫描也不是不行但全盘扫描意味着每步都要遍历225个位置而局部判定只需要检查落子点附近最多4条线时间差在可以忽略和明显卡顿之间。需要注意“连成5颗或以上”和“恰好5颗”的区别。标准五子棋规则中超过5颗的长连并不算赢部分规则甚至算输但很多休闲向五子棋只要≥5就判胜。我在代码里写成count 5因为本文讨论的是休闲玩法如果你写的是严格比赛规则需要把条件改成count 5并且确认两端不被阻断。2.4 AI落子算法的设计思路AI是五子棋项目的重头戏。最简单的是随机落子纯属凑数往上一步是打分制——也就是遍历棋盘的每个空位计算该位置的“价值分”选最高分落子。打分需要同时评估进攻自己连子和防守对手连子。我实现的评分函数如下五连100000分活四50000分冲四10000分活三5000分眠三1000分活二500分眠二100分对于每个空位分别在四个方向扫描计算如果放上自己的棋子能形成多少连子、是否被堵死。AI对每个位置给出的进攻分和防守分取最高的作为决策依据。防守分就是把该位置当成白棋来算AI虽是黑棋也要评估对手如果占据这里会有多危险。def evaluate_position(board, row, col, ai_color): attack_score evaluate_line_score(board, row, col, ai_color) human_color 3 - ai_color defend_score evaluate_line_score(board, row, col, human_color) return max(attack_score, defend_score)这个思路对应的是AI策略中“最小防守损失、最大进攻收益”的核心逻辑一方面尽量让自己形成有力棋形另一方面要堵住对手的活三和冲四。基础打分AI在休闲五子棋中已经能胜任大多数情况它不仅能赢新手也有能力暴打随手乱下的玩家。但它的弱点也明显缺乏“多步计算”能力看不到两步以后的棋不会设计陷阱。想进一步提升就用极大极小搜索加上α-β剪枝搜索深度设到4层配合评估函数。代价是计算耗时明显上升需要设定一个落子时间上限比如2秒超时就退回深度浅一层的决策。实际测试经验打分AI应对90%的休闲局没有任何问题除非你自己本身就是五子棋高手否则不要一上来就做深度搜索。先把基础打分打磨好效果远超预期。3. 完整实现与核心代码解析3.1 项目代码结构我建议按模块组织代码而不是把所有逻辑塞进一个main.py。下面是我用的一套简单结构清晰易扩展后续加AI难度或者网络对战时不用大改gobang/ ├── main.py # 程序入口Pygame初始化与主循环 ├── board.py # 棋盘逻辑类落子、悔棋、胜负判断 ├── ai.py # AI模块评分函数、决策 ├── ui.py # 界面绘制相关棋盘、棋子、按钮 ├── config.py # 配置颜色、大小、尺寸常量 └── requirements.txt这套结构好在分工明确。board.py只负责数据逻辑完全不涉及绘图也就是说就算有一天你想做Web版本的同一套逻辑把board.py和ai.py直接搬过去还能用。测试也更方便不必打开图形窗口就能单独验证胜负逻辑。3.2 初始化与主循环框架main.py的核心代码逻辑import pygame from board import Board from ai import AI from ui import draw_board, draw_buttons def main(): pygame.init() screen pygame.display.set_mode((760, 680)) pygame.display.set_caption(五子棋) clock pygame.time.Clock() board Board() ai AI(level2) game_mode PVE # PVE人机对战 / PVP双人对战 current_player 1 # 1黑棋先行玩家2白棋AI game_over False running True while running: clock.tick(60) # 控制帧率1秒最多循环60次 for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN: handle_click(event.pos) if game_mode PVE and current_player 2 and not game_over: row, col ai.get_move(board) board.place(row, col, 2) current_player 1 if board.check_winner(row, col, 2): game_over True print(AI获胜) draw_board(screen, board) draw_buttons(screen) pygame.display.flip() pygame.quit() if __name__ __main__: main()主循环本质是一个永不停止的while循环每轮做三件事处理事件、更新游戏状态、重新绘制画面。Pygame不像网页前端有浏览器帮你管理渲染周期开发者的工作就是让循环转起来并且保持稳定。clock.tick(60)的意义在于控制循环速率不写的话游戏会以每秒数千帧的速度狂转CPU占用直接拉满落子判定也会因事件处理过快而出现各种奇怪问题。3.3 棋盘核心逻辑的实现细节board.py的完整逻辑如下包含初始化、落子、悔棋、胜负判定class Board: def __init__(self, size15): self.size size self.board [[0 for _ in range(size)] for _ in range(size)] self.move_stack [] def place(self, row, col, player): if self.board[row][col] ! 0: return False self.board[row][col] player self.move_stack.append((row, col, player)) return True def undo(self): if not self.move_stack: return None row, col, _ self.move_stack.pop() self.board[row][col] 0 return row, col def reset(self): self.board [[0 for _ in range(self.size)] for _ in range(self.size)] self.move_stack.clear() def check_winner(self, row, col, player): if self.board[row][col] ! player: return False 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 self.size and 0 c self.size and self.board[r][c] player: count 1 r dr c dc # 反方向延伸 r, c row - dr, col - dc while 0 r self.size and 0 c self.size and self.board[r][c] player: count 1 r - dr c - dc if count 5: return True return Falseplace方法必须有“该位置是否被占用”的判断这是一个非常重要的输入校验。否则多个人点击同一个位置棋子会被重复覆盖棋局数据就全乱了。很多“bug”看起来神出鬼没追根溯源都在这种基础校验的缺失上。悔棋的undo处理直接返回了(row, col)被弹出的位置信息实际上就是棋盘上最后一步棋的位置绘制时不需要主动“擦除”因为每一帧都从零重画整个画面这得益于Pygame的绘制方式——直接把整个棋盘重绘一遍而不是在旧画面上涂改。这里跟你分享一个核心技巧游戏画面用全量重绘而不是局部更新。Painter模型是“每一帧都从白纸开始画”你只要保证状态数据正确渲染一定是对的。很多新手想用局部擦除来“优化性能”反而制造了大量难以排查的脏痕问题。3.4 AI评分机制的工程实现AI模块的实现是评分函数的策略体现落子优先选择能“活三变活四”或“双活三”的棋形。我在实际调试中总结了一套简单高效的评分方式。class AI: def __init__(self, level2): self.level level # 难度1简单 2中等 3困难 def get_move(self, board): best_score -1 best_moves [] ai_color 2 # AI执白 for row in range(board.size): for col in range(board.size): if board.board[row][col] ! 0: continue # 只评估与已有棋子距离不超过2的位置极大提升效率 if not self._near_existing(board, row, col): continue score self._evaluate(board, row, col, ai_color) if score best_score: best_score score best_moves [(row, col)] elif score best_score: best_moves.append((row, col)) if best_moves: import random return random.choice(best_moves) return board.size // 2, board.size // 2_near_existing这个逻辑很重要是我用来优化性能用的。15x15棋盘有225个位置如果AI每次都要遍历全部空位做评分速度会很慢而且大量远离棋局的空位评分毫无意义。我加了一个限制条件只评估与已有棋子距离2格以内的空位。这相当于围棋里的“只有战斗区域才需要计算”全局安全性几乎不受影响但耗时会缩减到原来的五分之一。早期版本没有这个优化AI每次走棋在棋盘空旷时要遍历近200个位置每个位置还要做复杂的方向扫描能明显感觉到一步要等几秒。加了这个限制后中前期的思考时间缩短到1秒以内体验完全不一样了。_evaluate方法实现为def _evaluate(self, board, row, col, player): attack self._evaluate_point(board, row, col, player) defend self._evaluate_point(board, row, col, 3 - player) return max(attack * 1.1, defend)把进攻分稍微乘了一个1.1的权重这是无数对局测试出来的结果。纯防守型AI很容易陷入被动对手下一步活三它就堵堵完对手继续展开自己始终没机会布局。给进攻一点优先权可以让AI在防守的同时主动找机会形成四连和五连。实际对局测试下来1.1这个比例棋风比较均衡。3.5 PVE与PVP模式切换及悔棋的实现游戏要可玩模式切换必须流畅。我设置了两个按钮点击“人机对战”进入PVE模式点击“双人对战”进入PVP模式。切换时要重置棋盘和所有历史状态不然棋局数据会串。悔棋逻辑要分模式处理。PVE模式里如果当前是玩家回合点悔棋应该回滚两步一步AI的上一步一步玩家的上一步。PVP模式则回滚一步。def handle_undo(): if game_over: return if game_mode PVE: board.undo() # 撤销AI的一步 board.undo() # 撤销玩家的一步 current_player 1 else: board.undo() current_player 3 - current_player game_over False这里要特别注意game_over状态的恢复。如果不重置game_over为False就会出现赢了之后点悔棋画面一直停在“获胜”提示的尴尬局面。这是我的真实踩坑经历调试时发现提示不消失想了整整十分钟才意识到还要把获胜标志位复位。对于多人对战的计分我在界面顶部放了黑方白方各自的胜场计数。计分是独立于棋盘的另一个状态不能在棋盘重置时被清零。用单独的变量保存每次判胜后更新只有在点“重新开始”时才重置分数。3.6 界面绘制与交互细节界面设计没有太多技术深度但直接影响体验。我用了几个细节提升视觉效果棋子画了阴影和渐变高光比纯色圆形更有立体感当前轮到哪一方时棋盘右侧用彩色圆点提示最后落子位置用红色小方块标记方便快速找到最近一步这在复盘时特别有用。棋子绘制的核心代码def draw_piece(screen, row, col, color): center_x MARGIN col * GRID_SIZE center_y MARGIN row * GRID_SIZE # 阴影 if color 1: pygame.draw.circle(screen, (30, 30, 30), (center_x 3, center_y 3), RADIUS) pygame.draw.circle(screen, (10, 10, 10), (center_x, center_y), RADIUS) pygame.draw.circle(screen, (80, 80, 80), (center_x - 2, center_y - 2), RADIUS - 4) else: pygame.draw.circle(screen, (200, 200, 200), (center_x 3, center_y 3), RADIUS) pygame.draw.circle(screen, (240, 240, 240), (center_x, center_y), RADIUS) pygame.draw.circle(screen, (255, 255, 255), (center_x - 2, center_y - 2), RADIUS - 4)阴影和三层圆的绘制让棋子有一种轻微浮雕感。这个效果其实就是用偏移 颜色深浅欺骗人眼成本几乎为零视觉效果提升很明显。黑色棋子先画一圈深灰作为阴影再画黑色主体最后在偏左上画小一圈的亮灰模拟光线——类似你给头像加了层高光立刻从平面图标变成了有质感的圆钮。3.7 交互响应与游戏状态管理主循环的事件处理里鼠标点击要区分落在哪个区域——棋盘点还是按钮区。每颗棋子只需要正确识别一次点击事件队列不能积压重复的点击。这里有一个细节值得注意Pygame有一个事件队列机制如果一帧里处理了多个MOUSEBUTTONDOWN可能会造成一次点击触发两次落子的问题。解决方案是给落子操作加一个冷却判断if pygame.mouse.get_pressed()[0]: if pygame.time.get_ticks() - last_click_time CLICK_COOLDOWN: handle_click() last_click_time pygame.time.get_ticks()300毫秒的冷却阈值是我试过比较舒服的参数。它不会影响连续快速点击的响应但又有效避免了同一时间点上重复事件造成的双落子问题。这也解释了为什么事件循环里处理简单判断可能会翻车——点击操作不是瞬时脉冲物理上的单击可能在事件队列中产生多个事件记录。状态管理方面用一个简单的枚举把游戏状态分清class GameState: PLAYING 1 # 对局中 WINNER 2 # 有人获胜 DRAW 3 # 和棋全盘满子PVE对战时AI的计算是在主循环中同步执行的。当AI计算需要1秒以上时画面会短暂卡住。工业级做法是开一个线程做AI计算主循环保持流畅渲染但这会引入线程间的状态同步问题复杂度提升不少。我选择了折中AI计算超过3秒时才考虑异步处理否则保持同步简单可靠。4. 运行调试中的常见问题与优化思路实践4.1 棋盘显示不完整或坐标偏移如果你发现鼠标点到某个位置棋子却出现在相邻格子上问题绝大多数出在坐标换算逻辑。我处理过从round误写成int导致偏移半格的情况还有一次把边距算错导致整个棋盘整体偏移了十几像素看起来棋子都悬空了。排查技巧是在画棋盘时给每个格子角落打上小点或者临时输出鼠标坐标转换后的row和col到控制台点击几个不同位置验证很快就能定位问题所在。建议把MARGIN和GRID_SIZE定义在config文件里只改一处就能控制整块棋盘而不是散落在代码各处手动填数字。4.2 AI思考时间长导致界面卡顿AI思考时间太长会让玩家产生“程序死了”的错觉。玩家体验其实有个隐性门槛3秒以上思考就会让人开始感到不安。我在实际优化过程中做了三件事第一只评估棋子2格范围内的空位这一步效果最明显。第二把中间空棋盘时的AI走法固定为第一次落子策略不必计算——这是基于开局执黑方优先下天元棋盘正中心的策略省掉不少无意义搜索。第三给难度档位设置最大计算深度中等难度上浅层搜索评分函数混合困难难度才用完整搜索每档控制在300毫秒到2秒的安全区间。4.3 胜负判定偶发失效多人反馈过一种情况明明棋盘上已经连成五颗了却没有触发胜利。排查发现问题在于我的胜利判定只检查了最后一步落子位置的方向值。如果连成五子里的最后一手不是关键胜负手时判定完全没有问题但如果构造特殊比如棋子是被对方在另一处落子后突然形成的就存在逻辑漏洞。正常对局里连五的发生一定是在最后落子那个位置延伸出来的理论上不存在五连却判定不出的情况除非棋形状态被破坏或读写不一致。把board状态全量打个日志回放一遍是最后的排查手段。每次落子后记录整个棋盘状态到文本文件然后从最后一次能正常判定的棋局逐步重放定位出现异常的落子那一手基本就能找到原因。这类不定时出现的bug靠肉眼盯代码很难发现日志回放才高效。4.4 悔棋后AI重复走同一位置一个有意思的问题玩家在AI走完后点悔棋把AI和玩家的两步都撤了然后玩家如果还走原位置AI可能就会重复上一轮的步骤。这不是bugAI本来就根据当前棋盘状态按最优解走重来一次自然容易选择同一个位置。如果希望AI每次有点变化可以在得分完全相同的位置中随机选择这就是我的get_move里用了random.choice的原因。从玩家角度这一步让对局过程有了变动不会每次复盘都走一模一样的变化线。4.5 棋盘状态重置不干净所有需要重置的功能——新开局、模式切换、返回主菜单——都要做“深度重置”而不只是改了某几个变量。我最初的reset方法只清空了二维数组忘了清move_stack导致新开局点悔棋把上一局的棋给撤销出来场面一度很滑稽。复用代码时总结出一个规矩凡是做重置逻辑必须把对局相关的所有状态变量列成清单逐个确认是否处理。状态PVE模式下是否重置PVP模式下是否重置模式切换时是否重置board数组是是是move_stack是是是current_player是是是game_over是是是双方比分否否是最后一步标记是是是这个表格帮我避免很多次脑内状态混乱。5. 体验优化与玩法扩展5.1 音效与动画反馈纯粹的功能能玩加反馈才好玩。我为落子加了一个轻脆的“嗒”声获胜时有一段短旋律。Pygame的mixer模块可以加载wav或ogg文件不要用mp3Pygame对mp3解码支持不稳定有时候加载失败有时候播放有杂音。pygame.mixer.init() place_sound pygame.mixer.Sound(sounds/place.wav) win_sound pygame.mixer.Sound(sounds/win.wav) place_sound.play()音效不需要复杂关键是动作发生后100毫秒内反馈到耳朵里延迟太久就会让人感觉声音和动作脱节。落子动画我用的简单缩放效果棋子从外圈以30毫秒间隔逐帧扩大绘制到标准大小实测手感比较跟手代码量也不大相当于为每次落子额外画了3帧中间状态。让我对这个效果产生执念的原因是玩过太多棋类游戏后发现没有动画反馈的落子经常让人怀疑自己到底点上没有。5.2 多档AI难度的灰度控制难度设计不是直白给AI调“更聪明”的参数而是为不同水平的玩家提供不同挑战。我定义了三档简单AI只在进攻分里做决策忽略防守偶尔故意随机扣掉20%概率不走最优给人留活路中等攻防兼顾、只在空位2格范围内做评估能挡住活三有时应对双活三不够从容困难攻防兼修引入双威胁感知能识别出能同时制造两个活三的位置并优先抢占对大多数休闲玩家来说需要认真应对难度的调节本质上是在“做出最优决策”和“刻意制造破绽”之间画一条线。简单AI如果真正做到每一步棋都最优新手玩家很难赢游戏就没有乐趣了。给简单AI加随机失误概率是迎合用户水平的做法。5.3 后续扩展方向的思考当核心玩法稳定后值得考虑的扩展方向有很多。网络对战是最自然的选择——这个我要提醒你涉及网络通信就要考虑连接管理和退出异常复杂度上一个台阶。我做过的简单实现是用Socket传坐标服务端做端口监听用的是基本库但掉线重连和状态同步问题花了我大量时间建议先确认自己是否真的需要网络功能再动手。另外一个成本较低但收益明显的扩展方向是棋谱记录与导出。每次对局结束后把move_stack保存为JSON文件相当于拿到了完整棋谱可以做复盘、做统计、甚至可以做一个“分析你最近开局偏好”的功能。这个功能做起来难度不高价值长期存在。6. 实操心得与经验总结做个能玩的五子棋项目不难几天就能让你从零到一把基础功能都跑通。但从“能玩”到“好玩、流畅、稳定”还要花不少精力打磨。这种打磨不是某个炫酷技术而是数据完整性、事件响应准确性、状态重置干净度这些听起来平凡无奇的细节。我在实际开发中总结了两条核心经验想分享给你们第一逻辑与渲染分离带来的收益远超预期。我写第一版时把棋盘状态检查和画图混在一起每次改功能都提心吊胆生怕动一处影响了另一处。后来花了半天时间把两者彻底分开后续加任何功能——AI难度、音效、动画、悔棋——都顺畅了很多。别急着说“这个小项目不需要设计模式”哪怕只是把代码按职责拆分成不同文件对于学习成长已经是个很好的开始。第二善用日志打印来排查交互类bug。图形界面的问题经常不是“逻辑错了”而是“状态在某个顺序下变得不对”。每次落子时打印当前行列、轮到谁、胜败状态看起来繁琐一旦bug浮现那些打印就是最好的破案线索。五子棋项目做一个版本后如果还有余力强烈建议尝试写一个AI难度更高的版本不是从网上下载现成引擎而是自己动手实现限制搜索深度、启发式搜索、膨胀剪枝这些经典棋类AI技术——这套知识对后续做任何类型的博弈类项目都是通用的比起不断开新项目练手把一个项目做深更划算。
返回列表