Python五子棋对战2.0实战:pygame图形界面与胜负判定算法

发布时间:2026/10/7 3:13:17
Python五子棋对战2.0实战:pygame图形界面与胜负判定算法
1. 从命令行到图形界面2.0版本的重写动机1.1 初代版本的问题先交代一下背景。我之前写过一个命令行版五子棋当时纯粹是为了练习Python语法功能就是两个人在终端里用坐标轮流落子比如输入“7,7”就在第7行第7列落子。代码不到200行跑起来能玩但说实话问题一大堆。第一是可视化太弱。命令行版每一手棋都要重新print整个棋盘用的还是字符拼的“●”“○”时间一长终端翻页玩家根本记不住棋盘全貌。下到中盘经常得靠纸笔记录体验非常割裂。第二是胜负判定写得极其暴力。初版我直接遍历整个棋盘对每个交叉点都检查四个方向是否有五连子。15×15的棋盘有225个交叉点每下一手棋就跑一次全盘扫描。虽然对人类玩家来说这点计算量可以忽略不计但代码风格很糟糕而且逻辑混在一起后面想加“禁手规则”和“AI提示”根本无从下手。第三个问题是没有状态管理。谁该落子、游戏是否结束、能不能悔棋——这些全靠一堆散落的全局变量硬撑。我自己写到后面都晕更别说让其他人看懂代码了。所以当我想把它做成一个能拿得出手的项目源码时心里很清楚必须重写。不是打补丁而是从架构上重新设计。这就是所谓“五子棋对战2.0”的由来。1.2 2.0版本的功能清单与技术选型2.0版本我定下的目标是这样的图形界面棋盘直接渲染在窗口里鼠标点击落子视觉效果接近真实棋盘。支持双人同机对战黑白双方轮流落子。落子后立刻判定胜负胜出时高亮五连子的位置。支持悔棋也就是撤销上一手操作。支持“下一步提示”在玩家犹豫时给一个参考落点。游戏结束后锁定棋盘不能继续落子并提供重新开局。代码结构清晰按模块拆分方便别人直接阅读、修改和扩展。技术选型方面我对比过tkinter和pygame。tkinter是Python自带的GUI库做这种网格类游戏其实也够用落子用Canvas画圆就行不需要额外装依赖。但问题在于tkinter的底层事件模型比较绕——鼠标事件、重绘、主循环这些逻辑要自己理得很清楚后期想加动画效果比如落子时的放大动画、获胜棋子的闪烁会比较费劲。而pygame本来就是做游戏用的有独立的主循环、方便的Surface绘制和事件队列机制而且网上资料多社区也活跃后续如果想把双人对战扩展成人机对战pygame的生态里还能找到AI相关的参考实现。所以我最终选了pygame。运行环境是Python 3.8以上版本依赖就只有pygame一个用pip安装即可。整个项目的源码结构也尽量保持简单核心逻辑放在一个gameplay模块里渲染交互独立出来两者通过接口对接这样即使在纯命令行下也能跑核心逻辑做自动化测试。2. 棋盘渲染与交互坐标换算是第一道坎2.1 窗口、棋盘与棋子的绘制层次界面这块的代码其实不难但有一个细节非常容易绕晕棋盘坐标和像素坐标之间的换算。pygame里鼠标事件给的是像素坐标也就是相对于窗口左上角的x、y值。但五子棋的逻辑坐标是行列号比如第7行第8列。你不换算就直接落子棋子位置会歪到谁都看不懂的地方。我的棋盘设计是这样的窗口尺寸640×640棋盘区域四周留30像素边距棋盘本身画15×15条交叉线每个格子的边长是40像素。这样棋盘区域从像素30延伸到30 40×14 590正好在窗口里居中。画棋盘的代码是这样的import pygame BOARD_SIZE 15 CELL_SIZE 40 MARGIN 30 WINDOW_SIZE MARGIN * 2 CELL_SIZE * (BOARD_SIZE - 1) def draw_board(screen, grid_color(0, 0, 0)): screen.fill((220, 180, 120)) # 木纹色背景 for i in range(BOARD_SIZE): # 横线 start_pos (MARGIN, MARGIN i * CELL_SIZE) end_pos (MARGIN CELL_SIZE * (BOARD_SIZE - 1), MARGIN i * CELL_SIZE) pygame.draw.line(screen, grid_color, start_pos, end_pos, 1) # 竖线 start_pos (MARGIN i * CELL_SIZE, MARGIN) end_pos (MARGIN i * CELL_SIZE, MARGIN CELL_SIZE * (BOARD_SIZE - 1)) pygame.draw.line(screen, grid_color, start_pos, end_pos, 1)有两点值得说。第一第i条线的位置要用MARGIN i * CELL_SIZE而不是MARGIN (i 1) * CELL_SIZE因为交叉点是从0开始编号的。第二横线和竖线的循环次数一样都是15次画出来的就是完整的网格。棋子的绘制就更简单了黑棋用黑色圆白棋用白色圆加灰色描边半径大约是格子边长的四成也就是16像素。落子时先画一个半径12的圆然后逐渐放大到16这样会有一点落子的动感但这个效果我在后面会专门说怎么做的。2.2 鼠标点击到交叉点的换算逻辑换算是重头戏。我的思路是先根据点击的像素坐标算出它落在哪个格子附近然后做“取整吸附”def screen_to_board(pos): x, y pos col round((x - MARGIN) / CELL_SIZE) row round((y - MARGIN) / CELL_SIZE) if not (0 row BOARD_SIZE and 0 col BOARD_SIZE): return None # 如果落点距离交叉点太远视为点空 px MARGIN col * CELL_SIZE py MARGIN row * CELL_SIZE if abs(px - x) CELL_SIZE * 0.4 or abs(py - y) CELL_SIZE * 0.4: return None return row, col这里我取的是四舍五入而不是向下取整原因是玩家点击时不太可能精确命中交叉点总会有几像素的偏移。如果直接整除那点击交叉点右侧的位置会落到下一列去非常反直觉。四舍五入以后点击位置往交叉点哪边偏就吸附到最近的交叉点上手感好很多。判断“太远”的那行代码很重要。玩家可能根本不想点棋盘交叉点而是在空白处随意点一下。如果不过滤系统会把很远的误触强行吸附到边缘交叉点上导致各种莫名其妙地落子。实测下来容差设成格子边长的四成比较合适既不会误伤操作又能容忍正常的手部偏移。2.3 落子位置在视觉上的偏差处理还有一个很隐蔽的细节。15条线围起来的格子数量是14×14但五子棋的落子交叉点是15×15。我第一版写的时候把格子数量当成交叉点数量结果棋盘最上面和最左边多画了一行一列边缘的棋子直接贴到窗口边界上去了。后来我意识到交叉点的索引范围是0到14对应像素位置是MARGIN i * CELL_SIZEi从0到14而不是MARGIN i * CELL_SIZE再往后挪一个CELL_SIZE。这个错误特别容易犯因为它只在边缘会有明显表现中间完全看不出来。绘制已落棋子时也要注意颜色的区分。黑棋和白棋的落子效果我觉得不必画阴影简单干净就好。真正要处理好的是选中和提示区域的对比度。比如“下一步提示”我用的是一种浅灰色半透明圆环和实心的黑白棋子都不一样一眼能区分。3. 胜负判定算法的演进从全盘扫描到增量判断3.1 初版全盘扫描为什么不够好初代的胜负判定是“每次落子后全盘扫”。写起来确实很简单def check_win_old(board): for row in range(BOARD_SIZE): for col in range(BOARD_SIZE): if board[row][col] 0: continue color board[row][col] for dx, dy in ((1,0),(0,1),(1,1),(1,-1)): count 1 for step in range(1, 5): nr, nc row dx*step, col dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] color: count 1 else: break for step in range(1, 5): nr, nc row - dx*step, col - dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] color: count 1 else: break if count 5: return (row, col, dx, dy, color) return None这个版本在功能上没错但问题很明显每一手棋都要遍历全部225个交叉点而且每个点最多要往四个方向延伸查询总的检查量是很大的。虽然现代电脑跑这个循环也就是几毫秒的事但作为一个项目源码这种写法会被很多人批评为“低效且思维混乱”。更关键的问题是这个函数无法判断赢的棋型。它只能告诉你“有人赢了”但不能告诉你“哪五个子连成了一条线”。所以我在2.0里做“获胜棋子高亮”的时候旧的检查函数根本不给力。高亮需要知道起点的行列和方向向量然后沿着方向往前推五个坐标画上红色圆环标记。旧的全盘扫描函数没有返回方向信息要额外再写一段逻辑去定位代码就重复了。3.2 增量判定只检查当前落子点五子棋有一个天然的规律胜负只可能和最后一手落下的棋子有关。前面如果已经赢了游戏早就结束了所以新棋子的加入只会影响以它为中心的连线不需要看别的位置。基于这个观察我把判定逻辑改了只对最后落下的棋子位置沿四个方向找连续同色子数。每个方向分正反两个方向延伸把两边的连续数量加在一起如果总数大于等于5就判胜。def check_win_at(board, row, col): color board[row][col] if color 0: return None for dx, dy in ((1,0),(0,1),(1,1),(1,-1)): count 1 # 正向延伸 for step in range(1, 5): nr, nc row dx*step, col dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] color: count 1 else: break # 反向延伸 for step in range(1, 5): nr, nc row - dx*step, col - dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] color: count 1 else: break if count 5: # 计算五连子的起点坐标用于高亮 start_row row - dx * (count - 1) start_col col - dy * (count - 1) return (start_row, start_col, dx, dy, color) return None这个函数的复杂度从O(棋盘面积)降到了O(1)因为四个方向最多每个延伸4步一共只查16个格子。对15×15的棋盘来说代码量差不多但逻辑清晰度提升非常明显。落子之后调用一次如果返回非空结果就意味着这一手棋直接决定了胜负。有人会问如果胜利的五连子不是最后一手棋所在的那条线怎么办答案是不可能。因为在这手棋落下之前棋盘上是没有五连的所以任何一条新形成的五连必然会包含这颗新棋子。只要从这颗新棋子出发检查就一定能捕捉到所有胜局。3.3 判定函数如何配合界面做高亮返回五连子起点和方向向量之后界面层要做的事情就很机械了。从起点开始沿着方向循环5次把每个点的像素坐标算出来画一个红色的圆环def draw_win_highlight(screen, start_row, start_col, dx, dy, color): for i in range(5): row start_row dx * i col start_col dy * i cx MARGIN col * CELL_SIZE cy MARGIN row * CELL_SIZE pygame.draw.circle(screen, (200, 30, 30), (cx, cy), 18, 3)高亮的颜色我用的是深红色和黑白棋子对比很强烈一眼就能看到是哪一条线决出了胜负。配合判定结果在窗口标题上显示“黑棋胜”或“白棋胜”整个游戏的对局闭环就通了。4. 悔棋、提示与状态管理2.0新功能的实现细节4.1 悔棋的栈与状态回滚设计悔棋这个功能看起来简单实际上牵扯到一个问题你不仅要把棋盘上的棋子撤掉还要把“当前轮到谁”的状态也回滚到上一手之前。如果只撤销棋子而忘记翻转回合玩家会发现白棋连续下了两手直接乱套。我的做法是维护一个历史栈栈里的每个元素记录一步棋的行、列和落子颜色class GameState: def __init__(self): self.board [[0] * BOARD_SIZE for _ in range(BOARD_SIZE)] self.current_player 1 # 1代表黑棋2代表白棋 self.history [] self.finished False def place(self, row, col): if self.finished or self.board[row][col] ! 0: return False self.board[row][col] self.current_player self.history.append((row, col, self.current_player)) winner_info check_win_at(self.board, row, col) if winner_info: self.finished True self.winner_info winner_info else: self.current_player 3 - self.current_player # 1 - 2 return True def undo(self): if not self.history: return False row, col, color self.history.pop() self.board[row][col] 0 self.finished False self.winner_info None self.current_player color # 回合还给上一手玩家 return True悔棋的关键在最后一行。color就是被撤销这步棋的颜色把它直接赋给current_player就等价于回到这步棋尚未落下的局面。这个写法比“每次悔棋都翻转回合”再额外判断边界要安全得多因为你不会遇到“白棋悔棋后还是白棋落子”这种反直觉的状态。另外一个要注意的点是只要一悔棋就必须把finished标志位重置掉。有些玩家看到胜负提示之后想悔棋看看别的走法如果你不重置状态棋盘就会一直锁定悔棋形同虚设。4.2 下一步提示启发式评分策略“下一步提示”算是2.0里比较加分的功能。我用的方法不是穷举搜索而是一种简单的启发式评分遍历棋盘上所有空交叉点对每个空位模拟放上当前玩家的棋子计算它在四条线上分别能形成多少个连续子然后加权求和。权重设计是核心。我的评分逻辑大概是这样的def evaluate_point(board, row, col, player): score 0 for dx, dy in ((1,0),(0,1),(1,1),(1,-1)): count 1 # 假设在该位置落子 # 正向数 for step in range(1, 5): nr, nc row dx*step, col dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] player: count 1 elif 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] 0: break # 遇到空位就停 else: break # 出界或遇到对方棋子 # 反向数 for step in range(1, 5): nr, nc row - dx*step, col - dy*step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] player: count 1 elif 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] 0: break else: break if count 5: score 100000 elif count 4: score 10000 elif count 3: score 1000 elif count 2: score 100 return score这只是非常初级的思路权重是拍脑袋定的没经过严格调参。实际跑起来的效果是提示位置至少是“有连贯潜力”的地方不会给你指一个孤立无援的角落。对普通玩家来说已经足够用了。更进阶的版本会同时考虑攻防也就是不仅评估自己落子的收益也评估对手在这个位置落子对自己构成的威胁把两者加权求和。我代码里留了这个扩展接口但2.0版本先不做因为篇幅已经不少了。4.3 对局状态机与重新开局状态管理是整个项目的骨架。我用一个GameState类来保存所有的对局信息而不是散落一地的全局变量。这样做的好处是多人联机的时候可以直接把这个类序列化传输写单元测试的时候也可以直接构造一个指定状态的GameState来测试特定局面。玩家交互主循环也比较简单核心流程是捕捉pygame事件。如果是鼠标左键按下把像素坐标换算成行列调用state.place(row, col)。如果落子成功检查返回值判胜情况。如果按了R键重置GameState。如果按了U键调用state.undo()悔棋。如果按了H键调用提示函数返回推荐落点并临时渲染成浅色环。更新画面。有一点之前吃过亏的地方pygame的pygame.time.delay()不要在主循环里随便用特别是不要为了做落子动画而阻塞循环。正确的做法是用一个变量记录动画开始的时间戳在每一帧绘制时根据时间差计算当前应该画多大半径的棋子然后让主循环继续跑。这样动画才不会卡住事件响应。我2.0版本里落子动画用的就是这种方案fall_animations [] # 每个元素是 (row, col, color, start_time) def draw_anims(screen, now): for item in fall_animations: row, col, color, start_time item progress min((now - start_time) / 120, 1.0) radius int(10 progress * 6) # 从10逐渐放大到16 cx MARGIN col * CELL_SIZE cy MARGIN row * CELL_SIZE pygame.draw.circle(screen, color, (cx, cy), radius)这个技巧我特别安利很多新手做动画时都用sleep结果游戏闪烁得一塌糊涂。用时间戳驱动渲染进程永远流畅动画效果也不会卡住。5. 开发中踩过的坑、打包发布与后续扩展5.1 几个印象深刻的坑第一个坑是高分屏缩放。Windows系统如果开了125%或150%的显示缩放pygame窗口会被系统拉伸鼠标坐标和渲染坐标之间会有偏差。解决方法是强制设置环境变量来禁用自动缩放或者在初始化窗口前检查import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass这段放在pygame初始化代码之前。不处理的话玩家在高分屏上点击落子棋子总会偏移几像素到十几像素非常劝退。第二个坑是pygame事件类型判断。很多人写的时候会用if event.type pygame.MOUSEBUTTONDOWN这是对的。但我第一次写的时候顺手用了if event.button pygame.KEYDOWN这种错乱的逻辑导致按键盘完全没反应。这类低级错误排查起来很烦人但只要把事件类型和事件属性分开检查就不会再犯。第三个坑是重绘问题。pygame不像tkinter那样自动重绘每一帧必须把整个窗口重新画一遍。如果只更新了棋盘而忘记填充背景色上一帧残留的棋子痕迹就会留在屏幕上出现“拖影”。所有绘制函数都应该统一在screen.fill()之后执行顺序不能乱。第四个坑是棋盘上棋子颜色的常量约定。我用的是1表示黑棋、2表示白棋、0表示空。但pygame里的颜色值是RGB元组如果直接写成(1,1,1)就会画出一个黑到看不见的圆。所以我单独建了一个映射表PLAYER_COLORS { 1: (0, 0, 0), # 黑棋 2: (255, 255, 255), # 白棋 }5.2 运行环境与打包发布项目的运行方式很简单。在requirements.txt里只写一行pygame2.5.2然后pip install -r requirements.txt python main.py如果是给不会Python的朋友体验我建议直接用PyInstaller打包成独立exe。打包命令是pyinstaller -F -w main.py -n gomoku-F是打包成单文件-w是隐藏控制台窗口。如果游戏里用到了素材图片或者字体文件记得用--add-data参数带上。我这次为了减小体积没有用外部图片棋子全是用pygame的绘图函数画的所以打包出来文件不到10MB。打包完成之后在dist目录下双击gomoku.exe就能直接玩。需要注意PyInstaller跨版本会有兼容性问题建议在和自己项目相同的Python版本下打包。5.3 后续怎么扩展成更强版本2.0版本目前只有双人同机对战想继续演进的话我认为最顺的路径有几个。第一个是接入AI。五子棋AI最经典的是极大极小搜索加alpha-beta剪枝评估函数可以用我上面写的启发式评分再细化。因为棋盘只有15×15搜索深度到4层配合剪枝普通人电脑上响应不到一秒已经能打出不错的防守反击。第二个是网络对战。用socket就算法上已经能实现把GameState类序列化以后通过网络传输核心逻辑几乎不用改。如果想要更省事可以直接套WebSocket做成网页版五子棋前端用JavaScript渲染棋盘后端Python跑核心逻辑。第三个是规则增强。五子棋正式比赛规则有禁手黑棋三三禁手、四四禁手、长连禁手2.0的判定算法只要在胜利判断之外再单独写一个check_forbidden函数扫一遍整盘检查禁手棋型即可。我目前的代码里专门预留了这个接口位置。第四个是模式扩展比如残局挑战。做一个棋谱文件格式把开局前N步固定写入然后让玩家接着下。这个对入门学习特别有用也是我做这个项目时比较想做的一个功能。实际上2.0版本截止到我提交源码这天是在一台配置很普通的Windows笔记本上跑的。帧率稳定在60FPS以上CPU占用也几乎可以忽略。整个项目大概800行左右我尽量把每个类的职责切分得清晰以后不管是谁拿来做毕业设计、课程作业还是单纯想学pygame应该都能比较顺畅地读懂。如果你也想自己动手实现一个我建议可以先把命令行版做出来跑通胜负判断再做图形界面最后一点点加功能。这样每一步都有可验证的成果不容易写到一半失去耐心。