Python五子棋小游戏开发实战:tkinter界面与AI算法

发布时间:2026/10/3 21:31:00
Python五子棋小游戏开发实战:tkinter界面与AI算法 简介一套基于Python实现的五子棋小游戏完整源码面向Python初学者、游戏开发入门者及对棋类算法感兴趣的学习者。代码覆盖棋盘绘制、黑白棋落子、胜负判定、人机对战等核心模块既可用于学习pygame图形界面开发也能帮助理解状态机与基础AI搜索思路。压缩包共含6个文件其中3个.py源码是主要学习对象2个.pyc为编译缓存文件另附1个.exe可直接双击运行体验游戏效果整体大小7.75MB。目前已有375人学习浏览资源目录结构简洁主程序与棋盘逻辑分离便于逐模块阅读和二次修改。通过阅读源码读者可快速掌握pygame事件处理、坐标换算、赢棋判断及悔棋回退等典型写法也可以在此基础上扩展禁手规则、网络对战或难度分级。1. 五子棋小游戏的开端为什么我劝 Python 新手不要跳过它在 Python 的众多练手项目里五子棋是能把“正反馈”拉得最快的那一类不需要装 pygame不需要搞资产管道直接拿标准库里的 tkinter 就能把棋盘画出来。要写一个能玩的五子棋小游戏 Python 源码核心工作无非三件把棋盘画出来、把胜负判出来、让电脑能陪你下棋。别看这三句话简单它们背后是事件驱动、状态管理、坐标换算和估值算法四个编程基本功一套全跑完比背一百页语法都顶用。这个项目适合刚入门想写第一个能交互界面的新手也适合想快速找回 GUI 手感的老开发——十几行代码就能看到窗口弹出不要总觉得游戏开发门槛有多高五子棋就是那个最合适的切入口。2. 把棋盘呈现在屏幕上tkinter 渲染与落子交互2.1 先跑通一个最小窗口尺寸和常量怎么定先说选型。市面上常见的 Python 游戏源码大体分两条路线一条用 pygame 做 2D 游戏另一条用 tkinter 做小工具型应用。对五子棋来说tkinter 的 Canvas 组件完全够用它既能画线、画圆也能绑定鼠标事件还不必额外安装依赖。这非常关键如果源码要让别人拿去直接跑依赖越少对方的环境问题越少这对新手尤其友好。你不需要 configure pygame也不用处理 win 平台上的 dll 缺失只需要确认 Python 安装时勾选了 tcl/tk就足够开工。import tkinter as tk from tkinter import messagebox # 基础常量棋盘规格、格子大小、边缘留白 BOARD_SIZE 15 # 标准五子棋棋盘是 15x15 交叉点 CELL_SIZE 30 # 两个交叉点之间的距离单位像素 MARGIN 30 # 棋盘四周留白给星位和棋子余量 root tk.Tk() root.title(五子棋小游戏) # 画布长宽 左留白 14 个格子间隔 右留白 canvas_width MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1) canvas_height MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1) canvas tk.Canvas(root, widthcanvas_width, heightcanvas_height, bg#E8C07A) # 木纹黄底色接近实体棋盘观感 canvas.pack() root.mainloop()跑起来以后你应该看到一块棕色底的画布。这里第一个新手常见误解是“15 个交叉点就要画 15 个格子”其实 15 个点之间只有 14 个间隔所以画布宽度的公式是MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1)。如果误乘 15画布会多出一段空白后续画线、落子的坐标也会跟着错位。常量一旦定好后面所有坐标换算都围绕这套值来别的地方再单独写一套数字迟早会翻车。2.2 画棋盘线与星位坐标公式一个都不能错窗口能显示了接着把所有网格线画到 Canvas 上。tkinter 画线用create_line参数是四个像素坐标起点 x、起点 y、终点 x、终点 y。水平线要从最左侧交叉点画到最右侧交叉点垂直线则从上到下循环 BOARD_SIZE 次即可。def draw_board(): # 画 15 条水平线行不变x 从 MARGIN 到远端 for i in range(BOARD_SIZE): y MARGIN i * CELL_SIZE canvas.create_line( MARGIN, y, MARGIN CELL_SIZE * (BOARD_SIZE - 1), y, fill#000000, width1 ) # 画 15 条垂直线列不变y 从 MARGIN 到远端 for i in range(BOARD_SIZE): x MARGIN i * CELL_SIZE canvas.create_line( x, MARGIN, x, MARGIN CELL_SIZE * (BOARD_SIZE - 1), fill#000000, width1 ) # 画星位天元 四角星传统棋盘定位标记 star_points [(3, 3), (3, 11), (7, 7), (11, 3), (11, 11)] for row, col in star_points: cx MARGIN col * CELL_SIZE cy MARGIN row * CELL_SIZE canvas.create_oval(cx - 4, cy - 4, cx 4, cy 4, fill#000000)这段代码的逻辑并不复杂但有一个实践要点别把MARGIN i * CELL_SIZE到处复制。更好的做法是抽两个坐标转换函数方便画线、画棋子和鼠标事件三处复用。我一般这么写def pos(row, col): 棋盘坐标转像素坐标返回 (x, y) x MARGIN col * CELL_SIZE y MARGIN row * CELL_SIZE return x, y这样后续画星位和棋子时输入row, col直接调用可读性更好也不会出现“明明想画第 3 行却算到了第 4 行”这种低级偏差。2.3 鼠标点击落子像素坐标换算与两层校验棋盘有了接下来让它能响应点击。tkinter 的事件绑定写法是canvas.bind(Button-1, handler)回调函数里的event.x和event.y是鼠标点击的像素坐标。问题在于用户不会精确点到交叉点中心游戏要把像素坐标换算回最近的交叉点。def click_handler(event): # 像素坐标 - 棋盘坐标减边缘后除以格子大小再四舍五入 col round((event.x - MARGIN) / CELL_SIZE) row round((event.y - MARGIN) / CELL_SIZE) # 边界检查点出棋盘范围直接忽略 if row 0 or row BOARD_SIZE or col 0 or col BOARD_SIZE: return # 重复落子检查棋盘对应位置已经有棋子就不能再下 if board[row][col] ! 0: return # 当前玩家为 1黑或 2白写入棋盘状态 global current_player board[row][col] current_player draw_piece(row, col, current_player) # 切换玩家1 变 22 变 1 current_player 3 - current_player board [[0] * BOARD_SIZE for _ in range(BOARD_SIZE)] current_player 1 canvas.bind(Button-1, click_handler)这里最值得解释的是round()的作用。假设格子大小为 30用户点在 x44 处(44 - 30) / 30 0.466四舍五入后落第 0 列点在 x46 处算出来 0.533落第 1 列。也就是说每个交叉点周围 15 像素内的点击都会归到该点这个手感跟真实棋盘一致。千万别用int()截断int(0.533)直接变成 0会让所有落点整体偏向棋盘左上角下几手之后你就发现棋子全挤到一边去了。draw_piece的函数逻辑很简单在 Canvas 上画一个填充圆def draw_piece(row, col, player): x, y pos(row, col) r CELL_SIZE // 2 - 2 # 棋子半径略小于格子一半 color #000000 if player 1 else #FFFFFF canvas.create_oval(x - r, y - r, x r, y r, fillcolor, outline#000000)注意棋子颜色黑棋用纯黑白棋用纯白再加黑色描边这样白棋在木纹底色上才看得清楚。如果你用白棋不打描边靠近棋盘边缘时会“消失”在背景里。3. 核心判胜逻辑胜负检查、平局兜底与状态锁3.1 四方向扫描用方向向量一次性数完横竖斜五子棋的核心规则只有一个任意方向连续同色棋子达到 5 颗即获胜。方向一共有四种——横、竖、主对角线、副对角线。最直接的实现是“八方向延伸”从刚落的棋子出发沿某条线的正反两个方向数同色棋子的数量加起来就算这条线上的连子数。def check_win(row, col, player): # 四个方向向量水平、垂直、主对角线、副对角线 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 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 # 某条线上连续数量达到 5 就判胜 if count 5: return True return False方向向量是这段代码的精髓。(0, 1)表示行不变、列递增即水平向右查(1, 0)表示行递增、列不变即垂直向下查(1, 1)是行、列同时递增对应主对角线(1, -1)是行递增、列递减对应副对角线。两个 while 循环分别处理正方向和反方向合起来正好把一条直线上的同色棋子数完整。边界检查0 r BOARD_SIZE and 0 c BOARD_SIZE绝不能省。如果棋子落在边缘r 或 c 会一路减到 -1在 Python 里board[-1]不会报错反而会取到数组末尾的一行——这是整个五子棋源码里最隐蔽的 bug 之一能让胜利判断变得完全随机。另外判胜用count 5而不是count 5因为盘面可能出现六连甚至更长同样应判胜。3.2 判胜时机为什么查早了输了不认查晚了赢了不响判胜函数放在哪一行调用直接决定游戏逻辑是否正确。正确顺序是写入棋盘数组 → 画棋子 → 判胜 → 弹出提示 → 状态锁住 → 切换玩家。把判胜放在current_player 3 - current_player之后是一个极常见的错误此时 current_player 已经变成了对方check_win 查询的是对方颜色当然永远查不出刚落的这步棋赢没赢。新手跑一局就会发现“黑棋明明五连了程序就是没反应”。def click_handler(event): global current_player, game_over if game_over: return col round((event.x - MARGIN) / CELL_SIZE) row round((event.y - MARGIN) / CELL_SIZE) if not (0 row BOARD_SIZE and 0 col BOARD_SIZE): return if board[row][col] ! 0: return # 1. 写入棋盘 board[row][col] current_player # 2. 画棋子 draw_piece(row, col, current_player) # 3. 判胜 if check_win(row, col, current_player): winner_name 黑棋 if current_player 1 else 白棋 messagebox.showinfo(游戏结束, f{winner_name}获胜) game_over True return # 4. 切换玩家 current_player 3 - current_player这里game_over变量扮演了状态锁的角色。messagebox 是模态弹窗它会阻塞主线程直到用户点确定但弹窗关闭后如果游戏结束状态没记录点击事件仍然会触发落子用户就能在“已经分出胜负”的棋盘上继续下棋画面和逻辑都会错乱。所以game_over True必须在弹窗前置位并在 click_handler 最前面做拦截。3.3 平局检测棋盘下满之后的兜底十五路棋盘共 225 个交叉点全部填满仍无五连就应当平局。不能等到最后一手才判断否则黑棋下完之后黑白会继续轮流落子而实际上位置已经满了棋盘数组会一直被覆盖。正确做法是维护一个落子计数每步加一并在判胜之后、换手之前检查是否达到上限。move_count 0 def click_handler(event): global current_player, game_over, move_count if game_over: return # ... 坐标换算、落子、画棋 ... if check_win(row, col, current_player): messagebox.showinfo(游戏结束, f玩家 {current_player} 获胜) game_over True return move_count 1 if move_count BOARD_SIZE * BOARD_SIZE: messagebox.showinfo(游戏结束, 平局棋盘已下满。) game_over True return current_player 3 - current_player平局虽然很少出现但实现它能让源码的完整性上一个档次。一段只写了胜负、不写平局的五子棋代码本质上是不完整的游戏源码——棋盘下满以后程序会进入“假死”状态点哪儿都没反应用户还以为自己把程序跑崩了。4. 给游戏加上人机对手估值函数决定电脑下哪4.1 评分规则连子数量与权重的搭配现在进入五子棋小游戏的进阶部分——人机对战。不需要上深度学习一个“估值打分”的方案就能让电脑看起来有点脑子。思路很简单遍历所有空位把电脑的棋子“假装”放到某个空位然后计算这个位置在四个方向上的连子潜力连子数越多分越高。最后取最高分的空位作为电脑的落点。def evaluate_point(row, col, player): 评估某个空位对指定玩家的价值返回分数 score 0 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 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: score 10000 elif count 4: score 1000 elif count 3: score 100 elif count 2: score 10 return score权重表是这套 AI 的灵魂。10000 代表“这手下去直接赢”1000 代表“活四级别”下完就赢或必杀100 代表“活三”10 代表“连二”。分值差距呈十倍递增是为了让 AI 在做选择时毫不犹豫地优先保大局。你可以调整这些权重来改变 AI 性格把 10000 和 1000 之间的差距改大AI 会更注重一击致命把差距改小AI 会更保守优先堵住对方的潜在四连。4.2 进攻与防守把对手的威胁折算进分数有了估值函数AI 还需要同时考虑进攻和防守。设想有两种位置位置 A 能让 AI 形成活三位置 B 能让对手形成活四。如果只看进攻分电脑会选 A然后对手在 B 下一手直接获胜。所以要给每个空位算两个分己方分数和假设对方落子的分数再取其中较大者作为最终优先级。def ai_move(): best_score -1 best_pos (BOARD_SIZE // 2, BOARD_SIZE // 2) # 空盘默认落天元 for row in range(BOARD_SIZE): for col in range(BOARD_SIZE): if board[row][col] ! 0: continue # 己方进攻价值 my_score evaluate_point(row, col, AI_PLAYER) # 对手下在这里的威胁价值 opp_score evaluate_point(row, col, HUMAN_PLAYER) # 进攻和防守折算对手威胁乘以 0.9 再比较 score max(my_score, opp_score * 0.9) if score best_score: best_score score best_pos (row, col) return best_pos0.9这个防守权重是经验值也是整个 AI 最值得调参的地方。取 1.0AI 会把“我活三”和“对手活四”等同对待结果过度防守错过自己的进攻机会取 0.5AI 只顾进攻对手出现必杀也不去堵。0.9 意味着只有当对手威胁分比己方进攻分高出一成以上时AI 才会选择防守。这个阈值不需要多精确能让电脑在大部分对局中不落下风即可。另一个细节是空棋盘时所有空位的分数都是 0best_score初始化为 -1 才能让第一次比较生效此时best_pos的默认值就派上用场——直接下天元。用BOARD_SIZE // 2而非写死 7是为了以后如果想改成 19 路棋盘AI 也能自动找到中心点。4.3 别让 AI 卡界面主线程与子线程的边界当前的ai_move()遍历 225 个空位每个位置做四个方向的扩展判断计算量非常小毫秒级完成直接在点击事件里同步调用没问题。但如果你以后想升级 AI——例如搜索两步、三步甚至加入剪枝计算量会指数增长几秒钟的卡顿肉眼可见。到那时候就要学会把 AI 计算放到子线程并理解 tkinter 的线程边界。import threading def on_player_move(row, col): # 玩家落子后启动子线程计算 AI 的下一步 threading.Thread(targetai_think, daemonTrue).start() def ai_think(): # 子线程里只做计算不做任何界面操作 row, col ai_move() # 用 after 调度回主线程再落子 canvas.after(0, lambda: ai_apply_move(row, col))tkinter 不是线程安全的所有 Canvas 操作、messagebox、窗口属性修改都必须在主线程运行。子线程算完结果后通过canvas.after(0, callback)把真正的落子动作排回主线程这是唯一不会引起界面崩溃的做法。如果你直接在子线程里调用create_oval程序不会立刻报错但可能随机出现棋子消失、界面错乱或直接闪退这类问题极难排查因为它是时序相关的黑匣子。5. 五子棋小游戏避坑指南我跑过的 5 个真实翻车现场5.1 运行环境没配好python 安装教程没说的事现象代码复制到自己电脑上运行时报ModuleNotFoundError: No module named tkinter或者 import tkinter 直接失败。 原因有些人按 python 安装教程装的是精简版或者安装时没勾选 tcl/tk 组件。Windows 上常见的“从微软商店安装 Python”默认可能不带完整的 tkinter 绑定Linux 上更常见系统自带的 Python 只装了核心模块tkinter 属于独立包。 解决Windows 用户去 python 官网重新下载安装包安装向导里确保勾选 tcl/tkLinux 用户执行sudo apt-get install python3-tkDebian/Ubuntu或对应发行版的 tkinter 包。装完之后在命令行敲python -c import tkinter不报错就说明环境就绪。5.2 点击没反应事件绑定写错了对象现象窗口正常显示棋盘也画完了但鼠标怎么点都不出棋子。 原因bind方法绑到了 root 窗口而不是 canvas 画布。root 和 canvas 都有自己的事件绑定接口但 canvas 内部的图形并不响应 root 上的调用。新手很容易把canvas.bind(Button-1, handler)写成root.bind(...)程序不会报错事件却被吞掉了。 解决确认绑定语句写在canvas实例上并且放在canvas.pack()之后。布局管理器尚未初始化时画布的坐标系统可能出错事件响应也不稳定。写完以后加一行print(event.x, event.y)验证回调是否真正触发。5.3 赢了不提示、输了不承认判胜顺序的锅现象黑棋连成五个程序毫无反应或者白棋接着下一步之后才突然弹出“白棋获胜”。 原因把check_win的调用放在current_player 3 - current_player切换之后。判胜函数查的是切换后的玩家颜色和实际落子颜色不一致导致永远查不到五连。另一个变体是把判胜放在写入棋盘数组之前那更离谱——棋盘上还没这个子当然数不出五连。 解决严格按“写入棋盘 → 画棋子 → 判胜 → 置 game_over → 切换玩家”的顺序执行。也可以把判胜封装成函数后在 switch 之前单独调一行if check_win(row, col, current_player):并用日志输出当前玩家和落子位置。5.4 窗口一缩放棋子就跑偏固定尺寸还是动态重绘现象窗口被拖动放大或缩小后棋子位置和棋盘网格错开有时棋子飞到格子外面。 原因Canvas 默认在窗口尺寸变化时会拉伸内容但棋盘线和棋子是按固定像素坐标绘制的拉伸后格子间距变了而落子换算仍用原始CELL_SIZE两者必然对不上。 解决最省心的做法是禁止缩放root.resizable(False, False)。这个游戏没有任何理由需要响应式窗口。如果你确实想要可缩放就必须监听Configure事件重算所有坐标并重绘棋盘和棋子代价不小收益很低实战中别自找麻烦。5.5 弹窗把程序卡死模态对话框和线程的关系现象点下制胜一手后弹窗出现“黑棋获胜”点确定后整个窗口无响应最小化再还原也救不回来。 原因messagebox.showinfo是模态对话框它在等待用户点击时阻塞当前线程。如果你的 AI 计算在主线程且放在弹窗之后执行那么弹窗会一直卡到 AI 算完。更糟的是如果在子线程里直接呼出 messagebox可能引发 tkinter 致命异常。 解决保证 AI 计算在调用弹窗之前完成或者把 AI 放在子线程并用after调度回主线程后弹窗。永远不要在子线程里触碰 messagebox 和 Canvas。6. 给五子棋加一个“后悔药”悔棋与棋谱复盘当基本对战逻辑稳定之后我最推荐先加的功能是悔棋。它不复杂却能让整个游戏源码的实用性上一个台阶。实现悔棋的核心是维护一个落子历史栈每次落子时把(row, col, player)压入列表悔棋时从栈顶弹出并撤销这一步在棋盘数组和 Canvas 上的所有痕迹。move_history [] # 元素格式 (row, col, player) def apply_move(row, col, player): board[row][col] player draw_piece(row, col, player) move_history.append((row, col, player)) move_count 1 def undo_move(): if not move_history: return row, col, player move_history.pop() board[row][col] 0 canvas.delete(fpiece_{row}_{col}) # 恢复轮到谁下并把终局状态复位 global current_player, game_over current_player player game_over False注意move_count也要跟着减一否则平局判定会提前触发。悔棋之后如果之前game_over是 True必须顺手复位到 False否则棋盘会继续锁死用户的“后悔药”吃了个寂寞。第二个值得加的功能是棋谱复盘。每次落子后用canvas.create_text在交叉点旁边标一个步数数字对局结束后在终端打印整个move_history。这样你可以逐手回看自己到底哪一步走歪了胜过在脑子里复盘一百遍。把历史栈用 json 存成文件还能跨程序复现棋局对调试 AI 的估值参数非常有帮助。我个人的习惯是AI 权重每次调整后手动和它对战五局再把五局棋谱打印出来对照看它是在哪一手开始犯傻然后调对应权重的量级。这个习惯帮我避开了很多“看似智能实则弱智”的调参陷阱。做五子棋源码这件事写代码只占一半时间另一半时间都在跟棋盘上的意外状况较劲。处理好边界、顺序和状态比多写一百行花哨功能管用得多。希望这套思路能帮到你让你少走一点我当年走过的弯路。本文还有配套的精品资源点击获取