☰
Python五子棋项目实战:从tkinter界面到AI人机对战算法解析
2026/10/14 7:58:21 网站建设 项目流程

五子棋这项目,我前前后后写过好几个版本,从最早控制台打印棋盘,到后面用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来标识空位、黑子、白子。这个选择看起来朴素,但实际用起来特别顺手,遍历、判断、重置都很方便。你不需要引入什么重型数据结构,五子棋的棋盘规模就这么大,15x15=225个格子,性能完全不是问题。下面这一段是我项目里的棋盘初始化代码:

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", width=1) canvas.create_line(offset + i * cell_size, offset, offset + i * cell_size, offset + (BOARD_SIZE - 1) * cell_size, fill="#654321", width=1) 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, width=480, height=480, bg="#E8D5A3") self.canvas.pack() self.btn_frame = tk.Frame(root) self.btn_frame.pack() tk.Button(self.btn_frame, text="双人对战", command=lambda: self.start_new_game(False)).pack(side=tk.LEFT, padx=5) tk.Button(self.btn_frame, text="人机对战", command=lambda: self.start_new_game(True)).pack(side=tk.LEFT, padx=5) 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路,看看哪些逻辑需要跟着改。这种动手实验比多写十个教程项目的收获都大。我在这个过程中体会最深的一点是,写游戏逻辑本身不难,难的是把边界条件和状态转换想清楚。边界处理好了,代码自然就稳健了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询