资讯动态

Python+pygame打造4x4数字拼图:核心算法与完整源码解析

发布时间:2026/10/14 9:45:48 来源:尧图企业网站定制
拼图游戏是初学阶段特别适合拿来练手的一类小项目原因很直接它不依赖复杂的业务逻辑却把界面绘制、事件处理、状态管理这些游戏开发的常见环节都串起来了。这次我用 Python 实现的是一套经典 4x4 数字拼图也就是 15-puzzle棋盘上有 16 个格子其中 15 个格子里有数字方块空出最后一个位置点击与空格相邻的数字块就能让它滑动过去最终让数字 1 到 15 按顺序排好、空格回到右下角判定为通关。项目最外层只用到了 pygame 一个第三方库源码我会完整贴在正文里。如果你刚学完 Python 基础想找一个带图形界面、带交互逻辑的练手项目或者正在做课程设计参考这个项目的体量和难度都很合适哪怕是对 pygame 完全没有接触过的读者跟着源码跑起来也不会超过半小时。好接下来我把这个项目从设计思路、核心算法、完整源码到排坑记录都拆开来讲最后再加几个可以直接上手的进阶方向。1. 项目拆解这个拼图游戏到底在做什么1.1 需求与玩法定义做任何项目之前第一步都是把需求定义清楚不要上来就写代码。这个拼图游戏的需求非常明确简单来说就三条棋盘是 4x4 结构也就是 16 个格子初始状态为数字 1~15 打乱分布其中一个格子留空。玩家点击某个数字方块时如果该方块与空格相邻上、下、左、右四个方向则该方块移动到空格位置。当数字按 1 到 15 的顺序排列并且空格在右下角时游戏判定为胜利。最后一条有一个值得注意的细节空格必须固定在右下角。也就是说胜利状态不是单纯“数字顺序正确”而是要连空格位置一起校验。很多人第一次写这类游戏时只检查数字顺序结果数字排列对了但空格在左上角也被判赢了这其实不符合经典 15-puzzle 的规则。尺寸选择上我用了 4x4。3x3 太简单两三步就能完成观察5x5 又会让方块太小点击体验打折扣。4x4 是难度、尺寸和展示效果的折中方案也是这类拼图游戏最经典的规格。1.2 技术选型为什么是 pygame很多初学者在选图形库时会在 tkinter、pygame 和 PyQt 之间犹豫。我直接选了 pygame原因很简单tkinter 虽然内置、无需安装但它本质是界面控件库不是游戏框架。绘制方块、刷新画面、监听鼠标事件都能做但代码会很别扭尤其是涉及到游戏循环和实时刷新时。PyQt 功能更强大但安装包体积大、学习曲线陡对于这样一个 200 行级别的小项目属于杀鸡用牛刀。pygame 主打 2D 游戏开发安装轻量提供窗口创建、图形绘制、事件队列、时钟控制这些游戏开发最需要的能力与项目目标完全匹配。另外一个实用原因是 pygame 的事件模型很直观。它能自动把鼠标点击、键盘按下等操作包装成事件对象开发者只需要在循环里逐个处理即可这对理解游戏主循环的工作方式非常有帮助。后续如果想把项目扩展到俄罗斯方块、飞机大战这类经典游戏pygame 的经验也完全用得上。1.3 代码结构规划我习惯在写代码前先规划模块这个项目整体可以划分为四个部分常量配置棋盘大小、格子像素尺寸、颜色、窗口宽高等。棋盘工具函数创建复原态、打乱、寻找空格、判断相邻、交换方块。绘图函数把一维列表映射为二维棋盘并绘制到窗口。主循环事件监听、点击处理、胜利判定和画面刷新。其中棋盘统一用一维列表表示这是一个非常重要的小技巧。board 列表长度为 16board[i] 表示第 i 个格子里的数字0 代表空格。绘制的时候通过 i 对 4 取整除和取余就能得到行号和列号。这样设计的好处是数据逻辑和视觉表现分离后续做打乱、判断、交换时只需要操作一维列表不用直接处理二维行列关系。2. 核心逻辑拆解洗牌、移动与胜负判断这部分是整个项目的灵魂也是我认为最值得细讲的地方。很多拼图游戏跑起来没问题但打乱之后可能永远无法复原这就是算法设计出了问题。2.1 棋盘的一维列表表示先看数据模型。我用 board 列表存储整个棋盘状态board list(range(1, SIZE * SIZE)) [0]这行代码生成的结果是[1, 2, 3, ..., 15, 0]0 就代表空格。绘制棋盘时对列表下标 i 做以下换算row i // SIZE col i % SIZE然后根据 row 和 col 计算出方块在窗口中的像素坐标x MARGIN col * TILE y MARGIN row * TILE这种“一维存储、二维映射”的方式在棋盘类游戏里非常常见。它比二维列表更轻松因为交换两个元素时只需要处理下标不需要处理元组或嵌套列表寻找空格位置时也只需要一行代码def find_empty(board): return board.index(0)index 方法是列表自带的线性查找16 个元素的列表用它完全没问题。2.2 打乱算法为什么不能直接 random.shuffle我刚开始写这类项目时犯过一个大错误直接对复原态做random.shuffle(board)然后让玩家去拼。表面上看这是最自然的打乱方式但实际上大约有一半的随机排列是无解的。也就是说玩家无论怎么努力都还原不了。这和 15-puzzle 的排列奇偶性有关。简单解释就是把空格看作一个特殊的方块整个棋盘状态本质上是一个排列。在不掀桌子的前提下每步操作都是在做一次相邻交换而相邻交换的次数决定了排列的奇偶性。复原态是偶排列所以任何一个可解状态也必须是偶排列。随机打乱会均匀地产生奇排列和偶排列因此会产生大量无解局面。这个问题在 3x3 拼图上也存在。比如把三个方块调换位置这种“三轮换”会改变排列奇偶性可能让游戏变成死局。正确做法是从复原态出发随机模拟玩家的合法移动让空格在棋盘上随机游走几百步。因为每一步都是真实可逆的操作所以最终状态必然可以通过逆操作回到复原态。这样打乱出来的局面一定是有解的。def shuffle_board(board, steps500): cur board[:] for _ in range(steps): epos find_empty(cur) er, ec divmod(epos, SIZE) choices [] for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)): nr, nc er dr, ec dc if 0 nr SIZE and 0 nc SIZE: choices.append(nr * SIZE nc) target random.choice(choices) cur[epos], cur[target] cur[target], cur[epos] return cursteps 我取了 500。理论上一个足够长的随机游走可以洗出任意可解状态500 步对 4x4 来说已经能保证足够的混乱度。如果你把步数改成 50会发现棋盘虽然也乱了但整体格局和复原态差别有限玩起来不够过瘾。2.3 点击判定与交换逻辑玩家点击方块的目的是把方块移动到空格位置但前提是两者必须相邻。这里我通过曼哈顿距离来判断相邻关系def is_adjacent(board, pos): empty_pos find_empty(board) er, ec divmod(empty_pos, SIZE) r, c divmod(pos, SIZE) return abs(r - er) abs(c - ec) 1两个格子如果在同一行且列差为 1或者同一列且行差为 1曼哈顿距离就是 1符合相邻条件。这个判断方式比枚举四个方向更简洁而且可读性也不差。交换操作更简单把空格位置和目标位置的值对调即可def swap(board, pos): epos find_empty(board) board[epos], board[pos] board[pos], board[epos]这里有个细节交换之后移动步数要加一但不需要额外判断因为 is_adjacent 已经过滤掉了非相邻点击。2.4 胜利判定胜利条件我之前强调过必须是数字顺序正确且空格在右下角def is_solved(board): return board list(range(1, SIZE * SIZE)) [0]这个判断写起来很短但它隐含了对空格位置的校验。list(range(1, 16)) 生成[1, 2, ..., 15]再加[0]正好是复原态。如果玩家在某个状态碰巧让数字顺序正确但空格不在右下角这个判断会返回 False。3. 完整源码与运行效果3.1 环境准备与依赖安装运行这个项目只需要两个条件Python 3 环境和一个 pygame 库。安装 pygame 一行命令即可pip install pygame如果你电脑上同时安装了 Python 2 和 Python 3可能需要用 pip3 替代 pippip3 install pygame安装完成后把源码保存为 puzzle.py在终端执行python puzzle.py就可以看到游戏窗口。pygame 2.x 版本对 Python 3.8 以上支持都很好目前不需要特殊处理兼容性问题。3.2 完整代码下面就是完整的可运行源码。我加了比较详细的注释建议你照着敲一遍比直接复制对理解更有帮助。 Python 拼图游戏4x4 数字拼图15-puzzle 运行环境Python 3.8 / pygame 2.x 运行方式python puzzle.py import pygame import random import sys # 基础配置 SIZE 4 # 棋盘格数4x4 TILE 120 # 每个格子边长 MARGIN 20 # 棋盘四周留白 WIDTH TILE * SIZE MARGIN * 2 HEIGHT WIDTH 90 # 底部预留提示文字区域 FPS 60 # 刷新率 # 颜色 BG_COLOR (35, 37, 45) BOARD_COLOR (55, 60, 70) TILE_COLOR (72, 133, 244) EMPTY_COLOR (40, 44, 50) TEXT_COLOR (255, 255, 255) HINT_COLOR (180, 190, 205) pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(Python 拼图游戏 - 4x4 数字拼图) clock pygame.time.Clock() # ---------- 字体工具 ---------- def get_font(size): 优先匹配中文字体避免中文乱码找不到就回退到默认字体。 for name in (microsoftyahei, simhei, pingfangsc, wenquanyizenhei, notosanscjksc, arial): path pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size) font get_font(44) small_font get_font(22) # ---------- 棋盘逻辑 ---------- def find_empty(board): return board.index(0) def is_adjacent(board, pos): 判断 pos 位置是否在空格的相邻方向。 empty_pos find_empty(board) er, ec divmod(empty_pos, SIZE) r, c divmod(pos, SIZE) return abs(r - er) abs(c - ec) 1 def swap(board, pos): 交换 pos 位置与空格位置。 epos find_empty(board) board[epos], board[pos] board[pos], board[epos] def shuffle_board(board, steps500): 从复原态出发随机游走保证打乱后一定有解。 cur board[:] for _ in range(steps): epos find_empty(cur) er, ec divmod(epos, SIZE) choices [] for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)): nr, nc er dr, ec dc if 0 nr SIZE and 0 nc SIZE: choices.append(nr * SIZE nc) target random.choice(choices) cur[epos], cur[target] cur[target], cur[epos] return cur def is_solved(board): return board list(range(1, SIZE * SIZE)) [0] # ---------- 绘图函数 ---------- def draw_board(board): 根据一维列表绘制棋盘。 for idx, value in enumerate(board): row, col divmod(idx, SIZE) x MARGIN col * TILE y MARGIN row * TILE if value 0: rect pygame.Rect(x 2, y 2, TILE - 4, TILE - 4) pygame.draw.rect(screen, EMPTY_COLOR, rect, border_radius8) else: rect pygame.Rect(x 2, y 2, TILE - 4, TILE - 4) pygame.draw.rect(screen, TILE_COLOR, rect, border_radius8) text font.render(str(value), True, TEXT_COLOR) text_rect text.get_rect(centerrect.center) screen.blit(text, text_rect) # ---------- 主循环 ---------- def main(): solved list(range(1, SIZE * SIZE)) [0] board shuffle_board(solved) moves 0 win False running True while running: # 绘制背景和棋盘边框 screen.fill(BG_COLOR) board_bg pygame.Rect(MARGIN - 8, MARGIN - 8, TILE * SIZE 16, TILE * SIZE 16) pygame.draw.rect(screen, BOARD_COLOR, board_bg, border_radius12) draw_board(board) # 底部提示 if win: hint small_font.render( 恭喜拼图完成按 R 重新开始 / Esc 退出, True, (120, 255, 120)) else: hint small_font.render( f已移动 {moves} 步 按 R 重新开始 按 Esc 退出, True, HINT_COLOR) screen.blit(hint, (MARGIN, HEIGHT - 62)) # 事件处理 for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: running False elif event.key pygame.K_r: board shuffle_board(solved) moves 0 win False elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if win: continue mx, my event.pos col (mx - MARGIN) // TILE row (my - MARGIN) // TILE if 0 row SIZE and 0 col SIZE: pos row * SIZE col if board[pos] ! 0 and is_adjacent(board, pos): swap(board, pos) moves 1 if is_solved(board): win True pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit() if __name__ __main__: main()3.3 运行效果与按键说明游戏窗口打开后你会看到 15 个蓝色数字方块和一个深色空格。鼠标点击与空格相邻的方块方块就会“滑”过去每成功移动一步底部提示栏中的步数加一。游戏中可以按 R 随时打乱并重新开始按 Esc 退出。当你把数字排成下图所示的顺序1 在左上角依次向右最后一行 13、14、15右下角为空格画面下方的提示会变成绿色的“恭喜拼图完成”同时点击方块不再响应避免通关后误操作破坏局面。3.4 快速换成图片拼图的改法如果你不想玩数字拼图想换成自己的一张图片代码改造也不复杂。核心是用 pygame 加载并缩放图片然后用 subsurface 切割成小块def load_photo_tiles(path, rows4, cols4): img pygame.image.load(path) img pygame.transform.smoothscale(img, (cols * TILE, rows * TILE)) tiles [] for r in range(rows): for c in range(cols): rect pygame.Rect(c * TILE, r * TILE, TILE, TILE) tiles.append(img.subsurface(rect)) return tiles在使用时把原来的数字方块绘制逻辑替换成图片块绘制逻辑空格位置继续用背景色填充即可。需要注意的是图片路径不要写中文名或带空格pygame 对某些路径的兼容性处理得比较粗糙容易踩坑。另外建议图片像素比例尽量接近 1:1否则会出现明显的拉伸变形。4. 踩坑复盘与常见问题解决这部分是实操中容易遇到的问题我按照自己的踩坑经历整理了一份速查表很多问题看起来低级但新手几乎都会遇到。4.1 pygame 安装失败或 import 报错最常见的两个原因一是当前环境里有多个 Python 版本pip 装到了别的解释器里二是缺少底层依赖。Windows 环境下一般顺利Linux 环境有时候需要先安装 SDL 相关依赖。解决顺序建议是检查 Python 版本python --version确保是 3.x。使用pip --version确认当前 pip 归属的 Python 环境。在虚拟环境里安装 pygame防止污染全局环境。如果安装过程中出现编译错误优先换用pip install pygame --pre或者从预编译 wheel 安装。4.2 窗口打开后 CPU 占用非常高很多初学者写 pygame 程序时没有加clock.tick(FPS)导致主循环以无限高的频率不断刷新画面CPU 占用直接跑满。这个项目源码里已经加了clock.tick(FPS)它的作用是把每帧时长限制在 1/FPS 秒左右也就是每秒刷新 60 次既省资源又避免画面闪动。如果你在扩展时发现 CPU 异常高先检查是不是循环里少了这一句。4.3 点击方块没有反应点击失效通常不是逻辑错误而是坐标换算出了问题。比如没有把鼠标坐标减去 MARGIN 就直接除以 TILE会导致所有点击位置整体偏移一个边距值。源码中的换算col (mx - MARGIN) // TILE row (my - MARGIN) // TILE这两个减号非常关键。如果你在扩展时修改了棋盘边距不要忘记同步调整这里。另一种情况是方格被绘制在另一个 Surface 上点击处理器没有映射到该 Surface 的局部坐标也会出现看似“点不到”的问题。4.4 打乱后怎么都拼不回去这个我之前已经详细解释过直接 random.shuffle 生成的棋盘有大量无解局面。如果玩家反馈“这局有问题怎么都拼不出来”大概率就是打乱逻辑写错了。排查方法很简单把打乱函数换成源码中的 shuffle_board从复原态开始随机移动而不是直接对列表做随机排列。如果已经用 random.shuffle 生成了棋盘想以数学方式判断是否可解也有公式把空格方块忽略掉数逆序数的奇偶性再结合空格所在行位置判断。但这个公式实现起来比“随机游走洗牌”复杂没有必要在项目里自找麻烦。4.5 中文提示显示乱码pygame 的默认字体往往不支持中文直接绘制中文会出现方框或乱码。源码里我写了 get_font 函数会按系统常见中文字体名依次匹配。如果在你机器上仍然乱码第一件事是查看当前系统装了哪些中文字体然后在 get_font 的元组里增加对应字体名。macOS 上常见的是 PingFang SCWindows 上是微软雅黑或者黑体Linux 发行版则常见文泉驿、思源黑体。另一个省事方案是把界面文字全部换成英文比如Press R to restart这样就不会触发字体问题。但这多少影响中文读者的观感所以我还是保留了中文方案。4.6 移动速度太快导致误操作有些用户反馈“我只是点了一下好像方块连跳了两格”其实是因为连续点击被拆成了多次事件每次事件移动一格看起来像连跳。解决办法有两个方向一是每次点击后加 50~100 毫秒的冷却时间二是做滑动动画让方块平滑移动固定时间。后者体验更好我放在扩展部分来讲。5. 扩展玩法与进阶思路这个拼图游戏虽然已经能跑但它只是一个 MVP。如果你想让项目更有完成度下面这几个方向可以按顺序加上去难度从易到难。5.1 增加计时器和最优步数统计计时器是拼图游戏的标准配置。在主循环里用pygame.time.get_ticks()获取当前时间在游戏开始时记录起始时间通关时计算差值。注意不要在打乱前计时应该在玩家第一次点击方块时才启动计时器。实现思路是先定义一个 start_time 变量初始为 None第一次点击时赋值。这样即使用户先观察很长时间计时也不会计入。步数统计源码里已经有了同时显示计时器后体验会更接近正式游戏产品。5.2 支持 3x3、4x4、5x5 多难度把 SIZE 改成 3 或 5就能立即变成不同难度。但格子尺寸需要动态计算不然 5x5 会溢出窗口。推荐做法是先定义窗口宽度然后TILE min(120, (WIDTH - 2 * MARGIN) // SIZE)窗口高度也可以根据棋盘大小动态调整。另外难度切换最好做成按钮而不是每次手动改代码这样项目看起来就更像完整作品了。5.3 加入滑动动画现在方块是瞬间“瞬移”到空格位置的视觉上略显生硬。加入滑动动画的方法并不难不直接定位到目标格子而是维护每个方块的当前像素坐标和目标像素坐标在每帧中把当前位置向目标位置插值移动。到达目标位置后再结束本次移动。这个过程中需要注意的关键点是动画期间禁止响应新的点击事件否则方块位置和棋盘数组状态会不同步出现飘移、闪跳问题。5.4 自动求解器如果想让项目在算法层面更有亮色可以写一个自动求解模块。棋盘状态空间是 16 个格子的排列直接全排列搜索不现实但对 4x4 使用广度优先搜索加上逆序数剪枝仍然可以用于教学演示。更实用的做法是用 A* 搜索启发式函数使用曼哈顿距离总和def manhattan_heuristic(board): total 0 for idx, value in enumerate(board): if value 0: continue target_idx value - 1 r1, c1 divmod(idx, SIZE) r2, c2 divmod(target_idx, SIZE) total abs(r1 - r2) abs(c1 - c2) return total把这个启发式套进 A* 搜索框架就能让程序自己求解。对于 4x4 状态空间来说A* 搜索会在较短时间内给出步数较少的最优解或多步骤解。这个扩展写完后你会发现自己对这个游戏的理解完全上一个层次。5.5 音效和通关特效pygame 支持读取 WAV 或 OGG 格式音效在方块移动、通关时分别播放不同音效。通关特效可以用粒子效果模拟数字碎片散开或者用颜色渐变动画填充整个棋盘。这些属于锦上添花的功能不影响核心逻辑但却是从“功能型项目”走向“作品型项目”的重要一步。最后再分享一点个人体会拼图这一类小游戏技术上没有特别高深的地方但它能把很多基础知识点揉在一起列表与下标换算、图形绘制、事件循环、状态判断甚至算法里的奇偶性思考。我在带着别人复盘这类项目时发现最容易卡住的位置反而不是 pygame API而是“为什么随机打乱可能无解”这个逻辑认知。一旦把这个概念想清楚了后面写什么棋盘类游戏都不会再犯同类错误。另外一个建议是不要急于加花哨功能先把基础版跑通再用增量方式逐步往上面加计时器、动画、多难度。我在实际修改过程中也验证过每加一个小功能就要重新审视主循环里的事件处理和绘制顺序这部分代码最容易因为扩展而变得混乱。先保持主循环干净再谈加功能是我在这个项目里感受最深的一条经验。如果你照着源码做完了可以试着把图片模式的代码也补上换成一张自己喜欢的风景图游戏体验会完全不同。拼图游戏最迷人的地方就是规则简单但背后可挖掘的算法和交互细节远比表面看起来多。希望这份源码和思路能帮到你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑