用Python和pygame实现五子棋:二维列表、绘制循环与四方向胜负判定 📅 发布时间:2026/9/14 7:34:25 👁 浏览次数: 简介一份完整的Python课程设计五子棋游戏源码面向刚接触Python和pygame的初学者可在模仿与运行中掌握游戏开发的基础流程。项目使用二维列表存储棋盘状态通过鼠标点击交互落子棋局中实时显示下棋方、开局时间并能自动判定胜负、播放背景音乐代码按棋盘绘制、落子处理、胜负感知等模块组织便于理解事件循环和界面刷新逻辑。压缩包共7个文件涵盖Python主程序、sln/pyproj工程文件、两张图片素材、一段mp3音乐和README说明文档总大小3.16MB结构简洁适合课程设计提交或自学练习。目前已有2371人学习或下载对想用pygame完成小游戏项目、梳理五子棋算法思路的同学来说是一份可直接运行、便于二次修改的参考资源。1. 二维列表当棋盘五子棋最难的不是画线而是判赢用 pygame 写五子棋课程作业很多提交都卡在同一处界面画得像模像样鼠标也能落子一旦形成连续五子胜负判断就开始乱报。这个项目思路正好相反它用一份 15×15 的二维 list 当唯一数据源board[row][col] 只有 0、1、2 三个值分别表示空、白棋、黑棋窗口只负责读这份数组并画图鼠标点击、回合切换、胜负扫描、右侧信息栏都围绕这份数组做读写。于是棋盘逻辑和画面渲染完全解耦每次落子后只需要检查最后一颗棋子的四个延伸方向全盘扫描都省掉。这份 python 课程设计虽然规模不大但把事件循环、状态管理、二维数组操作和 pygame 绘制串成了一条完整链路新手能靠它理解游戏程序的基本骨架写过几年代码的人也可以直接把它当棋盘模型接到网络对弈或 AI 落子上。2. 棋盘数据结构与 pygame 绘制循环的拆分设计2.1 为什么棋盘用二维 list 而不是一维数组先看核心数据结构初始化代码ROWS COLS 15 EMPTY, WHITE, BLACK 0, 1, 2 board [[EMPTY for _ in range(COLS)] for _ in range(ROWS)]这样生成的是 15 行、每行 15 个元素的二维列表board[7][7] 直接对应棋盘正中间那格。选择二维 list 而不压缩成一维数组核心原因是它让“行列坐标”和“代码下标”一一对应。落子时需要按 row、col 定位棋盘位置绘制棋子时需要双层循环按行列遍历胜负判定时四个方向向量要同时操作行号和列号。如果压成一维列表 board[row * COLS col]以上每个操作都要多做一次乘除法调试时还得心算某个下标对应棋盘上哪个点属于纯粹增加心智负担的写法。15×15 一共才 225 个整数存储开销可以忽略没必要用可读性换性能。数字选用 0/1/2 而不是字符串好处在后续代码里会体现出来。board[r][c] 0 写起来短更重要的是这份二维数组可以直接作为 JSON 数据发送给服务端也可以原样输入给蒙特卡洛树搜索这类 AI 算法省掉了从枚举到数值的转换层。pygame 绘制和胜负判断都只是读取或修改这份整数矩阵没有第二个数据副本从根上避免了“界面显示的棋子和逻辑里的棋子不一致”这类经典 bug。2.2 窗口尺寸计算与绘制顺序画棋盘前先确定窗口布局。棋盘 15 条线之间有 14 个间距每个间距取 40 像素左侧留白 60右侧单独留出 120 像素宽的信息栏。参数计算值作用CELL_SIZE40相邻两条网格线的像素距离MARGIN60棋盘到窗口左侧和上侧的留白INFO_WIDTH120右侧信息栏宽度显示下棋方、时间、胜负WINDOW_WIDTH60 14×40 120 740棋盘区加信息栏的纵向总宽WINDOW_HEIGHT60 14×40 60 680上下留白加棋盘区高度这里最容易算错的是把 15 当成格子数去乘 CELL_SIZE结果窗口比实际棋盘大出一整条边棋盘整体偏到左上角。绘制函数如下它只读 board不修改任何落子状态import pygame def draw_board(screen): screen.fill((198, 150, 100)) # 棕色背景 color (80, 50, 30) # 深棕色网格线 end_x MARGIN (COLS - 1) * CELL_SIZE end_y MARGIN (ROWS - 1) * CELL_SIZE for i in range(ROWS): y MARGIN i * CELL_SIZE pygame.draw.line(screen, color, (MARGIN, y), (end_x, y), 2) for i in range(COLS): x MARGIN i * CELL_SIZE pygame.draw.line(screen, color, (x, MARGIN), (x, end_y), 2) def draw_pieces(screen, board): for row in range(ROWS): for col in range(COLS): state board[row][col] if state EMPTY: continue x MARGIN col * CELL_SIZE y MARGIN row * CELL_SIZE if state WHITE: pygame.draw.circle(screen, (245, 245, 220), (x, y), CELL_SIZE // 2 - 4) else: pygame.draw.circle(screen, (20, 20, 20), (x, y), CELL_SIZE // 2 - 4)逻辑说明draw_board 先铺背景再画网格线顺序不能反否则背景色会盖住所有线。draw_pieces 遍历整个 board遇到 EMPTY 直接 continue减少无意义的画圆调用虽然是 225 次循环、量级很小但保留这个习惯后换成 19×19 棋盘或需要热更新时能直接复用。棋子半径取 CELL_SIZE // 2 - 4等于 16 像素留出的 4 像素间隙让相邻棋子之间有明确的视觉边界不会挤成一团。画完两种棋子后黑白颜色分别为象牙白 (245,245,220) 和近黑 (20,20,20)对比度接近真实木质棋盘上的棋子观感。2.3 主循环中帧率、事件和绘制的顺序主循环是最容易写乱的地方。下面的最小骨架先保证窗口能稳定刷新def main(): pygame.init() screen pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption(python五子棋) clock pygame.time.Clock() board [[EMPTY for _ in range(COLS)] for _ in range(ROWS)] turn BLACK game_over False start_time time.time() while True: clock.tick(30) for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() return draw_board(screen) draw_pieces(screen, board) pygame.display.flip()clock.tick(30)让主循环每秒最多执行 30 次也就是帧间隔约 33 毫秒这里没有复杂的粒子效果30 FPS 足够让鼠标点击反馈看起来连续同时给 CPU 留出余量。事件处理放在循环开头保证鼠标点击在当前帧内就被消费掉。如果把事件循环放到绘制之后再处理鼠标输入会整体延迟一帧平时无感但一旦后面接 AI 计算或者网络同步事件堆积会直接表现为操作卡顿。pygame.display.flip() 把后台缓冲区的画面提交到屏幕。pygame 默认双缓冲所有 draw 操作画在不可见的后备缓冲里不调用 flip 就不会显示。刚接触 pygame 的人最容易漏掉这一行漏掉的直接结果是窗口全黑或者只有系统拖拽时才会出现残影。绘制顺序、事件顺序、双缓冲刷新这三件事理顺后整份 python 游戏代码的框架就立住了。3. 鼠标点击落子坐标换算与回合状态的维护3.1 像素坐标反算棋盘行列鼠标点击返回的是窗口像素坐标 (mx, my)而棋盘逻辑需要的是行列号。由于绘制时棋盘左上角固定在 (MARGIN, MARGIN)反算公式就是一次整数除法def pixel_to_grid(mx, my): if mx MARGIN or my MARGIN: return None row (my - MARGIN) // CELL_SIZE col (mx - MARGIN) // CELL_SIZE if row ROWS or col COLS: return None return row, col先排除棋盘左上区域外的点击再分别把纵坐标和横坐标偏移量除以 CELL_SIZE取整数部分。横向举例mx 落在第 0 条线到第 1 条线之间时mx - MARGIN 在 0 到 39 之间整除 40 得 0对应第 0 列落在第 1 条线到第 2 条线之间时结果为 1对应第 1 列。row 和 col 的越界判断必须保留否则点击右侧信息栏时 col 会算成 15直接访问 board[row][15] 就抛 IndexError。注意Python 的负数整除是向下取整-10 // 40 的结果是 -1。如果去掉 mx MARGIN 的拦截点击棋盘左侧留白区域时 mx - MARGIN 是负数会得到 col -1board[row][-1] 不报错但访问的是这一行倒数第一列表现就是“点在棋盘外棋子落到棋盘里”。鼠标横坐标 mx(mx - MARGIN) // CELL_SIZE命中列60 ≤ mx 1000第 0 列100 ≤ mx 1401第 1 列620 ≤ mx 66014第 14 列mx ≥ 66015越界返回 None3.2 交替落子与合法位置校验把落子逻辑挂到鼠标左键事件上完整片段如下elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if game_over: continue grid pixel_to_grid(*event.pos) if grid is None: continue row, col grid if board[row][col] ! EMPTY: continue board[row][col] turn if check_win(board, row, col, turn): game_over True winner turn elif is_board_full(board): game_over True turn WHITE if turn BLACK else BLACK四个连续判断分别对应四种输入对局已结束、点击位置落在棋盘外、点击位置已有棋子、正常落子。board[row][col] turn 是唯一的写操作写完后立即调用 check_win 判断当前这一步是否获胜。胜利判断用 elif 接 is_board_full顺序不能交换因为最后一子落下时可能既填满棋盘又形成五连这种情况必须按胜利处理。最后一行交换回合当前是黑棋就改为白棋当前是白棋就改回黑棋实现“黑先白后”的交替下棋。这段代码里 turn 的初值是 BLACK也就是项目里固定的黑棋先手。如果要做成白棋先手只需要把第 2.3 节 main 函数里的 turn BLACK 改成 turn WHITE其余逻辑完全不用动这也是把回合状态单独抽成变量的好处。3.3 右侧信息栏显示下棋方、开局时间和胜负信息栏画在 draw_info 函数里与棋盘绘制互不干扰info_font pygame.font.SysFont(microsoftyahei, 18) def draw_info(screen, turn, start_time, winner): x MARGIN (COLS - 1) * CELL_SIZE 24 lines [] lines.append(下棋方: (黑棋 if turn BLACK else 白棋)) lines.append(开局: time.strftime(%H:%M:%S, time.localtime(start_time))) if winner: lines.append(胜利: (黑棋 if winner BLACK else 白棋)) for i, text in enumerate(lines): line info_font.render(text, True, (255, 255, 255)) screen.blit(line, (x, 40 i * 36))这里字体名直接用 microsoftyaheiWindows 系统基本都有macOS 上对应 pingfangscLinux 常见 wqy-zenhei。如果找不到指定字体pygame 会退回默认字体中文渲染成方框这是课程演示时最常见的翻车点建议在 README 里标注运行环境。x 坐标取棋盘最后一列向右偏移 24 像素刚好落在 INFO_WIDTH 区域内每行文本纵向间隔 36 像素三行信息正好占满信息栏中上部。font.render 属于较重的文本绘制操作但这里只有三行文本、30 FPS 下完全可接受。如果以后要显示落子记录列表就不能每帧 render 所有行应该改成内容变化时才重新生成文字表面并缓存。4. 四方向扫描每次落子后的胜负判定实现4.1 局部判定代替全盘扫描初版代码常见的胜负判断是每次落子后遍历整个 board对每个格子往右、下、右下、左下数连续棋子。从功能上讲没有错问题在于做了大量重复劳动一局 30 到 50 步的对局每步全盘扫描约 225 个格子总循环次数也就上万性能完全够。真正的风险是边界处理工作量翻了四倍四个方向上很容易漏掉某一处的越界判断尤其棋盘边缘的误判难以通过简单测试发现。更合理的做法是只检查最后一步落下的位置。新落下的棋子只会让某个方向的连续序列变长或者新建序列不可能让已有序列变短因此胜负一定和这一步相关。check_win 接收当前落子的 row、col 和 player向四个方向的两个延长线延伸统计最多检查 40 个格子。这种局部判断带来的另一个好处是之后接入 AI 搜索时每次模拟落子只需要算这一步周围的形势不用对整个棋盘重算评估值。4.2 方向向量与连续计数四方向扫描用一张方向向量表驱动比把四个方向写成四份独立代码可维护得多方向向量 (dr, dc)扫描线覆盖方向(0, 1)水平从左到右(1, 0)垂直从上到下(1, 1)主对角线从左上到右下(1, -1)副对角线从右上到左下完整判定函数def check_win(board, row, col, player): directions [(0, 1), (1, 0), (1, 1), (1, -1)] for dr, dc in directions: count 1 for sign in (1, -1): r row dr * sign c col dc * sign while 0 r ROWS and 0 c COLS and board[r][c] player: count 1 r dr * sign c dc * sign if count 5: return True return False逻辑说明count 初始为 1把当前落下的这颗棋子本身算进去。sign 决定延伸方向sign 1 沿方向向量正向延伸sign -1 反向延伸每个方向先数完正向再数反向把两侧连续同色棋子累加到同一个 count 上。while 条件包含越界判断和同色判断两者同时满足才继续走下一格。只要任意方向 count 大于等于 5立刻返回 True。(1, -1) 这个方向的 c 在延伸过程中会变小可能减到负数所以 while 里必须有 0 c 的判断。很多漏判都是从这里来的board[r][-1] 在 Python 中不会抛异常它会访问该行最后一个元素导致副对角线方向在棋盘右边界附近数出一串虚假的“连续棋子”该赢的局面判不赢。4.3 长连、边界与平局的细节上面的判定用的是 count 5即六连、七连同样判胜。这符合普通大众玩法竞赛规则里黑棋长连属于禁手但课程设计引入禁手规则会把代码复杂度抬高一个级别没有必要。项目说明里写一句“按普通玩法五子及以上均判胜”就够了。棋盘四角是边界条件的极端例子角点落子后四个方向里有三个在第一步就被越界截断count 只往棋盘内侧延伸这种情况下如果出现五连说明整条线贴着边沿或墙生长逻辑依然正确。真正要留意的是平局处理要在落子后、胜负判断之后调用def is_board_full(board): for row in range(ROWS): for col in range(COLS): if board[row][col] EMPTY: return False return Trueis_board_full 和 check_win 的调用顺序不能反过来。最后一手棋落下时可能同时满足“棋盘满”和“五连”两个条件此时应该先报告胜利而不是平局。如果先判断棋盘满玩家在最后一步赢棋会被误报成平局这种边界情况在答辩演示时特别容易被评委点出来。5. 音频播放、窗口退出保护与项目文件整理5.1 BGM 播放的初始化顺序与音量控制项目素材里带了 BGM.mp3播放需要 pygame.mixer.music 模块。初始化代码建议这样写pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init() pygame.mixer.music.load(BGM.mp3) pygame.mixer.music.play(-1) # -1 表示循环播放 pygame.mixer.music.set_volume(0.4)pre_init 的四个参数是采样率 44100、位深 16、声道数 2、缓冲区大小 512。它必须写在 pygame.init() 之前因为 mixer 在 init 阶段就会按系统默认参数打开音频设备提前调用 pre_init 相当于告诉音频子系统“我后面要用的音乐是这个格式”避免播放某些采样率不常见的 mp3 时出现爆音或者音调走样。如果演示机器没有声卡pygame.mixer.init 会失败这时最好用 try 包住音频初始化try: pygame.mixer.music.load(BGM.mp3) pygame.mixer.music.play(-1) except pygame.error: passset_volume(0.4) 是防御性设置。课程演示时教室里通常有投影仪和麦克风背景音乐音量拉满会盖过讲解声音摘要里提到“如果引起不适可以事先关闭音量”实际实现时直接 set_volume(0) 就行不需要改动播放入口。对局结束后调用 pygame.mixer.music.stop() 停掉音乐不要用 pygame.mixer.quit()quit 会把整个音频设备关掉之后想播放音效就必须重新初始化。5.2 游戏结束后的输入屏蔽与 QUIT 保护游戏结束后还能继续落子是这类项目中出现频率最高的 bug。原因一般是 game_over 变量只参与显示没有在鼠标事件入口拦住后续逻辑。修正方式是在 MOUSEBUTTONDOWN 分支第一行检查elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if game_over: continue这样无论胜负还是平局之后的所有落子判断都不会执行棋盘保持终局画面。窗口退出保护则需要显式处理 QUIT 事件if event.type pygame.QUIT: pygame.quit() return这两行必须放在事件循环内部。如果把 pygame.quit() 写到 while 之后程序会一直卡在循环里无法到达如果直接调用 sys.exit() 而不经过 pygame.quit()某些音频驱动会残留后台进程在 Windows 上表现为关闭窗口后音乐还在响。先 quit 再 return 是最稳的退出路径。5.3 源码包结构与常见问题排查表这个项目压缩包里包含 README.md、GCH.png、WCH.png 两张运行截图、BGM.mp3以及主程序 task2_version2.0.py。另有两个 Visual Studio 相关文件 task2_version2.0.sln 和 task2_version2.0.pyproj说明作者是在 VS 的 Python 工作负载里开发的直接运行 task2_version2.0.py 即可不需要打开 .sln。真正在自己机器上跑的时候可能遇到下面这些问题现象可能原因处理方式点击窗口没有反应事件类型写成了 KEYDOWN改用 MOUSEBUTTONDOWN 且 event.button 1棋子闪烁或残留绘制顺序反了棋子被棋盘画覆盖先 draw_board 再 draw_pieces中文显示成方框SysFont 指定的字体不存在Windows 用 microsoftyaheiLinux 用 wqy-zenheiBGM 不出声没有 pre_init或 mp3 编码不兼容加 pre_init转成 44100Hz 16bit 双声道点棋盘外区域直接崩溃pixel_to_grid 缺少越界判断先判断 mx、my 下限再判断 row、col 上限这些坑大多不在核心算法而在工程边界坐标转换、资源加载、字体兼容、音频初始化。每一类都能在半小时内定位但演示现场碰上一个就足够让整个程序看起来不太可靠。6. 把单局逻辑扩展成带历史回放的对局状态类6.1 用 list 记录落子历史不引入额外对象前面的代码里 board、turn、game_over 都是 main 函数内的独立变量功能跑通了但扩展起来不舒服。比如要做悔棋就得再维护一份落子记录要做棋谱导出又得把状态打包。把这些散落的变量收敛成一个 GameState 类同时用 history 列表存每一步的位置和颜色悔棋和重放就都有了基础class GameState: def __init__(self): self.board [[EMPTY for _ in range(COLS)] for _ in range(ROWS)] self.turn BLACK self.game_over False self.winner None self.history [] def move(self, row, col): if self.game_over or self.board[row][col] ! EMPTY: return False self.board[row][col] self.turn self.history.append((row, col, self.turn)) if check_win(self.board, row, col, self.turn): self.game_over True self.winner self.turn elif is_board_full(self.board): self.game_over True self.turn WHITE if self.turn BLACK else BLACK return True def undo(self): if not self.history: return row, col, player self.history.pop() self.board[row][col] EMPTY self.game_over False self.winner None self.turn playermove 方法把合法性校验、写棋盘、胜负判断、回合切换收敛到一个入口之后不管来自鼠标事件、网络消息还是 AI 决策调用路径完全一致。history 里存的是 (row, col, player) 三元组一局棋通常只有几十条记录内存开销忽略不计。undo 做悔棋时除了清掉棋盘上的子还必须把 game_over 和 winner 一并恢复否则悔到中盘时界面还停在终局状态也无法继续落子。turn 恢复为被撤销这步的 player因为下一步正好轮到这颗棋子所属的颜色。进一步往前推一步move 方法已经具备完整的输入校验和返回值把它包上一层 socket 监听这个类就能直接作为服务端对弈对象客户端只负责把落子坐标发过来。再往上层看给 GameState 增加一个 replay 方法把自己归零后按 history 逐步重放就得到了棋谱复盘功能def replay(self): steps list(self.history) self.__init__() for row, col, player in steps: self.board[row][col] player self.history.append((row, col, player))重放时跳过 move 里的胜负判断因为原始胜负已经由对局时确认过复盘只需要还原局面。但如果是用来分析“第几步已经形成五连”的棋谱诊断工具就应该在每一步后调用 check_win把结果记录进另一个列表。到这里这个五子棋课程设计已经从单纯的 pygame 界面程序变成了一套可测试、可扩展、可迁移的对局状态核心公式、AI 评估、历史记录都围绕同一个数据模型展开。本文还有配套的精品资源点击获取