恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Python五子棋项目实战:从tkinter界面到AI人机对战算法解析
首页
资讯中心
/
Python五子棋项目实战:从tkinter界面到AI人机对战算法解析
Python五子棋项目实战:从tkinter界面到AI人机对战算法解析
发布时间:2026/10/14 7:58:23
五子棋这项目我前前后后写过好几个版本从最早控制台打印棋盘到后面用tkinter做图形界面再到给电脑加了个能打得过普通人的AI踩了不少坑。很多人学Python写第一个小游戏都会选五子棋但网上讲的要么只给代码不给思路要么把AI吹得神乎其神实际上手才发现一堆问题。这篇就基于我最近整理的一个“基础五子棋项目”把从界面搭建到规则判断、再到人机对战的完整思路捋一遍代码不是重点重点是每一步为什么这么设计你照着写完能真正理解背后的逻辑而不是单纯复制粘贴跑起来就完事。1. 项目整体设计与思路拆解动手写代码之前得先把项目拆清楚。这不是浪费时间而是让后面每一步都走得顺。我见过太多人上来就写界面写到一半发现逻辑理不顺推倒重来。1.1 核心需求解析基础五子棋项目需求其实非常明确一个可交互的图形化棋盘标准15路黑白双方轮流落子黑先白后落子后自动判胜负五子连珠即赢支持双人对战和人机对战两种模式听起来简单但这里面藏着一个关键问题程序和人的思维方式不一样。人看到的是棋盘上的线条和棋子程序看到的是数据。所以我一开始就在设计里明确了两层结构——表现层和数据层。表现层负责画棋盘、画棋子数据层负责记录每一步的状态、判断胜负。二者通过坐标换算进行连接这样程序才不会乱成一团。1.2 技术选型的理由图形界面这块我选了tkinter没选pygame。原因是tkinter是Python自带的库不需要额外安装环境兼容性好而且对于五子棋这种网格化、事件驱动的小程序来说tkinter的Canvas组件完全够用。选择pygame当然也能做但对初学者来说接触到的概念更杂反而不利于聚焦在棋类逻辑本身。数据层我直接用Python的二维列表list of lists构建一个15x15的数组元素值用0、1、2来标识空位、黑子、白子。这个选择看起来朴素但实际用起来特别顺手遍历、判断、重置都很方便。你不需要引入什么重型数据结构五子棋的棋盘规模就这么大15x15225个格子性能完全不是问题。下面这一段是我项目里的棋盘初始化代码BOARD_SIZE 15 EMPTY 0 BLACK 1 WHITE 2 def init_board(): return [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]1.3 关键模块划分与数据流我把项目拆成了四个模块棋盘管理、界面渲染、交互控制、AI策略。棋盘管理管的是数据层负责记录落子、重置棋盘、判断落子合法性。界面渲染管的是UI层把棋盘线和棋子画到Canvas上。交互控制响应鼠标事件把点击位置换算成棋盘坐标再调用棋盘管理更新数据。AI策略是人机模式下替电脑玩家挑位置下棋。这四个模块之间的数据流顺序是鼠标点击 - 坐标换算 - 数据更新 - 重绘画布 - 判断胜负 - 切换玩家。只要沿着这条线走代码结构就会非常清晰。我最开始写的时候没想清楚把画布操作和逻辑判断搅在一起后面加AI时改得头皮发麻。后来重写了一遍才彻底理顺这也是为什么这个项目值得认真梳理——它麻雀虽小五脏俱全。2. 核心细节解析与实操要点确定整体思路后就要解决具体细节问题了。这一章节是项目的重中之重里面包含了很多我实测下来的经验常规教程里很少讲到。细节决定成败五子棋项目尤其如此。2.1 棋盘坐标换算的精确定位坐标换算是整个项目里最容易出bug的地方。屏幕上Canvas的坐标系以像素为单位棋盘的逻辑坐标由行列索引表示两部分相差一个“棋盘起始偏移量”与“格子间距”。棋盘起始偏移量是指棋盘第一根线距离Canvas边缘的距离常设为20至30像素。格子间距则是相邻两线之间的距离用15路棋盘标准配置每格大小取30像素棋盘整体边长为14 * 30 420像素。因此Canvas的尺寸为420 2 * 起始偏移量例如偏移量30时是480。点击画布时得到的是像素坐标(x, y)换算回逻辑坐标的公式是col round((x - offset) / cell_size) row round((y - offset) / cell_size)注意这里必须是round四舍五入而不是int强制取整。因为玩家点击时很难正好点在交点正中心如果用int点击靠近相邻两个交点的交界区域时棋子可能落在错误的位置上体验会很差。我在这个细节上吃过亏,第一版用int写每次下快棋都感觉棋子偶尔歪一格换成round之后问题彻底消失。2.2 棋盘绘制与视觉优化细节画棋盘看着简单但有几个细节值得打磨。除了外边框的15条横线和15条竖线标准棋盘上还有5个星位点分别位于棋盘的四角和正中央。五子棋的星位虽然不是规则强制要求的但画上之后专业感会强很多。棋盘底色用接近木色的浅黄比较耐看线条用深褐色。棋子的绘制用create_oval实现需要计算每个棋子左上角和右下角的像素坐标。注意棋子直径不能刚好等于格子间距否则相邻棋子会碰到一般取格子间距的0.8倍左右视觉上刚刚好。黑子和白子不只是颜色不同最好给白子加一圈灰色描边不然白棋在浅黄底色上辨识度不够。下面是我项目中绘制棋盘的函数经过多次调整的参数版本def draw_board(canvas, board): canvas.delete(all) for i in range(BOARD_SIZE): canvas.create_line(offset, offset i * cell_size, offset (BOARD_SIZE - 1) * cell_size, offset i * cell_size, fill#654321, width1) canvas.create_line(offset i * cell_size, offset, offset i * cell_size, offset (BOARD_SIZE - 1) * cell_size, fill#654321, width1) for (x, y) in [(3,3), (11,3), (7,7), (3,11), (11,11)]: canvas.create_oval(offset x * cell_size - 3, offset y * cell_size - 3, offset x * cell_size 3, offset y * cell_size 3, fill#654321) for row in range(BOARD_SIZE): for col in range(BOARD_SIZE): if board[row][col] ! EMPTY: draw_piece(canvas, row, col, board[row][col])这里有个重要心得不要每次落子时只画新棋子而是直接重绘整个棋盘。原因很简单界面刷新逻辑统一避免增量绘制导致的历史状态不一致。虽然效率略低但225个格子重绘一次连1毫秒都不到完全不影响体验。2.3 胜负判定算法的四种方向扫描胜负判断是五子棋的核心逻辑思路并不复杂在每一步落子之后从该位置出发朝四个方向分别统计同色连续棋子的数量——水平、垂直、左上到右下、右上到左下连续数量累计达到5即判定获胜。实现时我选择从当前位置向两个相反方向分别扫描。比如水平方向先向左数有多少连续同色棋子再向右数两段数量加1便得到该方向的连子总数。这个“加1”是指自己所在的位置。四个方向只要有一个方向的连子数大于等于5游戏就结束。这里有一个小性能优化每步只需要检查当前位置的四个方向没必要扫描整个棋盘。扫描全局的写法虽然逻辑简单但会有大量无效计算。五子棋的胜负只和新落下的这枚棋子有关这是一个典型的局部判定问题。我在写AI的时候也用了同一个思路大幅节省了计算时间。下面是我采用的胜负判断核心代码def check_win(board, row, col, player): directions [(1, 0), (0, 1), (1, 1), (1, -1)] for dr, dc in directions: count 1 r, c row dr, col dc while 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 r dr c dc r, c row - dr, col - dc while 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 r - dr c - dc if count 5: return True return False这四个方向可以拆开理解水平方向从(0,1)开始横向移动垂直方向从(1,0)开始纵向移动斜向两个方向分别是(1,1)和(1,-1)。有些实现还会在判断连子过程中考虑六连或七连算不算赢这里我采用标准规则连续5个及以上即判胜。3. AI人机对战的实现思路双人模式写完后人机对战模式才是很多人真正感兴趣的。但要把AI写好需要先想清楚一个问题我们想要的AI到底是什么水平在这个基础项目里我实现的是一个基于评分函数的中等难度AI速度非常快普通人下不过它但不至于离谱到每一步都像大师。这条路线很适合作为进阶学习素材。3.1 入门版AI基于打分策略AI的核心逻辑是评估每一个空位的价值选分数最高的落子。评估的依据是这个位置在各个方向上对当前玩家的贡献度。我采用了一种简化的打分策略遍历棋盘上所有空位对每个空位分别计算“让黑棋在此落子的得分”和“让白棋在此落子的得分”两者相加得到这个位置的总分然后选分最高的位置下子。为什么要同时算两边的分因为进攻和防守是一体两面。如果这个位置能让白棋形成活三那它很危险黑棋如果不占白棋下一步就要活了。把两边的分加起来相当于同时考虑了进攻价值和防守价值这样AI就不会只顾自己冲而忽略了拦截对手。打分函数的核心是给不同的棋型分配不同的权重。这部分的权重设置我是在反复实战中调整的。权重如果相差太小AI会分不清先挡冲三还是先做活三权重如果相差太大AI又会变得只顾防守。下面这组权重是我觉得比较平衡的SCORES { live_four: 10000, rush_four: 1000, live_three: 1000, sleep_three: 200, live_two: 100, sleep_two: 20, }活四两端都开放的四连下在任一端直接获胜所以分数最高冲四只有一端开放的四连对手必须马上应对活三两端都开放的三连下一步可以形成活四眠三只有一端开放的三连威胁相对小一些活二和眠二属于早期布局的棋子分数最低在实际评分时我并不是直接检测这些棋型字符串而是扫描每个方向上的连续同色棋子数再检查两端是否有空位从而判断当前棋型属于哪种类型。这种实现方法虽然没办法覆盖所有复杂棋型比如双三、跳活三但已经足够让AI应付绝大多数对局了。3.2 攻防双视角评分与位置优化在实际实现AI评分时我喜欢用进攻分和防守分做加权组合。对于一个空位计算如果黑棋AI落在上面会产生多好的棋型——这是进攻分再计算如果白棋玩家落在上面会产生多好的棋型——这是防守分。最终得分是进攻分加上防守分的加权。我实测下来防守权重稍微高于进攻权重会更稳定因为我这个AI是后手防守居多优先破坏对手棋型能有效阻止玩家快速成型。评分函数需要基于一个基础工具对于某个坐标、某个方向、某个玩家计算从这个点开始沿该方向的连子情况和两端开口情况。我封装了一个evaluate_line辅助函数避免在AI主逻辑里重复写方向扫描代码也让阅读者更容易理解评分逻辑。这一块最值得注意的坑是AI不能只看紧邻的5个格子否则容易漏掉跳活三这种棋型。比如黑棋在(3,3)和(3,5)落子(3,4)是空位这种情况下如果(3,3)外侧还有棋子这其实形成了一个很有威胁的格局。我的评分函数里加了“跳跃检测”在计算棋型时允许一个空位作为跳跃点但限制为一个否则会把不相干的棋型也算在一起导致AI做出奇怪的选择。3.3 减少搜索空间的实用技巧如果AI遍历整个15x15棋盘的所有空位每次计算得分时还要扫描四方向虽然总共225个位置不算多但在对局尾声加上胜负判断和界面刷新还是会有轻微卡顿感。更重要的是这种写法会让AI评估那些离现有棋子很远、完全没有价值的角落纯属浪费计算资源。我的优化方案是只评估有邻居的空位。也就是空位周围八格内至少存在一个棋子的位置才进入评分环节。这个逻辑很直观五子棋的每一步都影响着已有棋子之间的关系离所有棋子都远的空位这一步落了也产生不了威胁。同时为了不让AI在最开始棋盘一片空白时无从下子我加了一个特例——如果棋盘上还没有任何棋子AI直接落中心点。经过这个优化AI的计算量从225个位置降到20到30个位置左右速度提升了七八倍如果不是刻意加延时你甚至看不到AI思考的过程。4. 实操过程与核心环节实现现在把前面讲的思路串起来带你走一遍完整的实现过程。从最初的界面搭建到最终的人机对战我按照实际操作的顺序来写这样你跟着做就能跑起来。4.1 初始化窗口与棋盘组件先创建主窗口设置标题、尺寸和背景色。Offset、格子大小这些参数我在前面已经讲过了这里直接上代码。import tkinter as tk from tkinter import messagebox class GomokuGame: def __init__(self, root): self.root root self.root.title(五子棋) self.board init_board() self.current_player BLACK self.game_over False self.ai_mode False self.cell_size 30 self.offset 30 self.canvas tk.Canvas(root, width480, height480, bg#E8D5A3) self.canvas.pack() self.btn_frame tk.Frame(root) self.btn_frame.pack() tk.Button(self.btn_frame, text双人对战, commandlambda: self.start_new_game(False)).pack(sidetk.LEFT, padx5) tk.Button(self.btn_frame, text人机对战, commandlambda: self.start_new_game(True)).pack(sidetk.LEFT, padx5) self.canvas.bind(Button-1, self.click_handler) self.draw_board_from_data()这里我改用了类来组织代码比零散函数堆在一起更好维护。在当前模式下点击棋盘区域会产生鼠标事件click_handler会被调用。后续所有的交互逻辑都从这一个入口进去流程比较清晰。4.2 事件循环与落子流程控制落子的完整流程是点击 - 换算坐标 - 检查合法性 - 更新数据 - 重绘 - 判胜 - 切换玩家。如果处于人机模式且当前轮次是AI则会多一步调用AI逻辑。def click_handler(self, event): if self.game_over: return col round((event.x - self.offset) / self.cell_size) row round((event.y - self.offset) / self.cell_size) if not (0 row BOARD_SIZE and 0 col BOARD_SIZE): return if self.board[row][col] ! EMPTY: return if self.ai_mode and self.current_player WHITE: return self.place_piece(row, col) def place_piece(self, row, col): self.board[row][col] self.current_player self.draw_board_from_data() if check_win(self.board, row, col, self.current_player): messagebox.showinfo(游戏结束, 黑棋获胜 if self.current_player BLACK else 白棋获胜) self.game_over True return self.current_player WHITE if self.current_player BLACK else BLACK if self.ai_mode and self.current_player WHITE and not self.game_over: self.root.after(200, self.ai_move) def ai_move(self): if self.game_over: return row, col self.evaluate_best_move() self.place_piece(row, col)注意self.root.after(200, self.ai_move)这一句。直接调用self.ai_move()会导致界面还没刷新完AI就立刻下子了观感上好像棋子在跳着走。加一个200毫秒的延迟模拟AI的思考时间视觉上更自然。4.3 完整对局测试与参数调整我自己写完第一版后进行了上百局测试测试重点包括边界处的落子是否正常、负方向扫描是否会数组越界、AI会不会把棋子下到已有棋子的位置、快速连续点击是否会出bug。边界问题是初学者最常忽略的。比如落子在(0,0)时胜负判断向左扫描会直接数组越界。我在check_win里加了边界条件判断扫描循环只有在坐标合法且棋子匹配时才继续否则立即停止。这个写法虽然多写了几个判断条件但安全性和正确性都提高了。测试过程中我发现一个影响体验的问题重开一局时如果AI模式没有正确重置界面会显示“人机对战”但实际下棋的却是两个人类。原因是start_new_game函数里忘了把current_player重置为黑棋。这种小问题调试起来不麻烦但给人的感觉很糟。我在所有模式切换按钮的回调里统一调用了重置函数从根上杜绝了这个问题。5. 常见问题与排查技巧实录写这个项目时我积累了一些非常有代表性的问题几乎每个项目都会遇到但网上很少有人系统整理。这里把我的排查过程完整记录下来希望能帮你少走弯路。5.1 棋子位置偏斜或错位最常见的问题就是棋子落不到交点正中央或者明明点的是这个交叉点棋子却跑到旁边去了。排查思路非常简单先在click_handler里把换算前的event.x和event.y打印出来再把换算后的row和col打印出来对比一下哪一步出了问题。如果row和col没问题说明是绘制函数的坐标计算错了检查draw_piece里的圆心坐标。如果row和col就有偏差就检查换算公式里的round和int。绝大部分人遇到的问题都是用了int取整或者没有减去偏移量就除以格子间距。记住这个公式就好(像素坐标 - 偏移量) / 格子间距偏移量决定了棋盘左边距和上边距。5.2 胜负判定失效五子连珠没反应这个问题有两种典型情况。第一种是边界处的五连珠不判胜这是扫描越界导致提前退出循环需要在数组下标越界时跳过而不是跳出。第二种是每次落子都能直接判胜甚至两个棋子的连线都能判胜这是方向增量没写对。我排查时写了一张对照表把四个方向增量分别列出来然后手动模拟一个简单棋型去验证。这里要特别注意坐标的对应关系如果board[row][col]中row表示行索引那行索引对应的是纵向移动即dx方向col表示列索引对应的是横向移动即dy方向。不少初学者在判断垂直方向时会把行列搞反导致垂直方向永远判定不了。5.3 AI模式不落子或者直接报错出现这个情况时先看控制台有没有报错信息。如果没有报错但AI就是不动大概率是事件循环被阻塞但又没走人机逻辑。我这里最容易犯的错误是双人对战模式结束后切换到人机模式game_over标志没有重置导致AI的移动请求被拦截。还有一个隐蔽的问题AI评分函数对空棋盘的边界处理。当棋盘上只有第一枚棋子时evaluate_best_move里的“周围有邻居”判断会漏掉一些边角位置导致AI没有可用的候选位置。我给这个情况加了一个兜底当候选列表为空时直接用棋盘中心点。这个兜底逻辑虽然简单但保证AI永远不会因为找不到位置而报错。5.4 画布卡顿与重绘性能虽然前面说了15x15的规模不大但如果你每次落子时都重新创建一个Canvas对象程序运行几十步后会越来越慢。正确做法是在同一个Canvas实例上操作落子时先清空再重绘而不是销毁重建。当你需要执行大量绘制操作时有一个tkinter的优化技巧使用canvas.create_oval返回的对象ID来更新棋子而不是删除全部重新创建。不过对这个项目来说全量重绘已经足够流畅我觉得没必要引入ID管理的复杂度。先把核心逻辑跑通再谈优化。6. 项目的扩展方向与我的实战体会写完这个基础版本之后我做了几个很有价值的扩展尝试分享出来供你参考。一个是悔棋功能。实现思路是为每一步落子保存棋盘快照悔棋时回滚到上一步的状态。最简单的方式是维护一个历史栈每当落子成功就将其坐标推入栈中悔棋时弹出栈顶点位把棋盘上那个位置恢复为空同时切换回上一个玩家。注意如果是在人机模式下悔棋一步应该同时撤销AI和玩家的落子否则系统会错乱。这个设计细节非常容易忽略我在这儿卡了挺久。另一个是禁手规则的实现。如果你要追求专业竞技规则就必须禁止黑棋下出“双三”“双四”和“长连”。实现思路是在每次黑棋落子后检查该位置是否同时形成了两个以上的活三或冲四以及是否有超过5个的连续棋型。这个检查要比普通胜负判断复杂因为它要统计整个棋盘上的棋型数量而不是只看单条连线。最后分享一下我的整体感受。这个项目看起来简单实际写下来它能把事件驱动编程、二维数组操作、图形界面布局、启发式搜索这几块知识点全部串联起来。我建议你不要只满足于把代码跑起来要试着改动几个关键参数比如把AI的防守权重提高看看对局风格有什么变化或者把棋盘改成19路看看哪些逻辑需要跟着改。这种动手实验比多写十个教程项目的收获都大。我在这个过程中体会最深的一点是写游戏逻辑本身不难难的是把边界条件和状态转换想清楚。边界处理好了代码自然就稳健了。