news 2026/10/24 4:54:38

从零实现扫雷游戏:二维数组与递归算法的入门实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现扫雷游戏:二维数组与递归算法的入门实战

1. 为什么我劝你从扫雷开始练手

扫雷这个游戏,很多人第一次接触是在老版本的Windows系统里,那个灰色的小窗口、像素风的方块、右键插旗的操作,几乎是整整一代人的共同记忆。但你可能不知道的是,这个看起来简单的游戏,其实是编程入门阶段最好的练手项目之一。它涉及的知识点覆盖面非常广:二维数组的操作、递归算法的实现、随机数生成与分布控制、边界条件处理、用户输入解析、游戏状态管理,甚至还能延伸到图形界面编程和性能优化。我见过太多新手一上来就想写聊天软件、做电商网站,结果被复杂的框架和配置劝退。扫雷不一样,它足够小,小到你可以在一个下午就写出一个能跑的版本;但它又足够深,深到你可以花好几周去打磨细节、优化体验。

这篇文章要聊的,就是怎么从零开始做一个初级扫雷游戏。所谓“初级”,不是指功能残缺或者代码粗糙,而是指核心玩法完整、逻辑正确、代码结构清晰,同时不引入过于复杂的依赖。适合的读者是刚学完一门编程语言基础语法、想找个项目练手的人,也适合那些想回顾经典算法、重新体会编程乐趣的老手。我会把整个开发过程拆开来讲,从设计思路到具体实现,从踩过的坑到调试技巧,尽量做到你跟着读完之后,能自己动手写出一个像样的扫雷出来。

2. 扫雷的核心机制与设计思路拆解

2.1 游戏的基本规则回顾

扫雷的规则说起来很简单:一个由方块组成的网格,其中随机分布着若干地雷。玩家点击一个方块,如果下面是地雷,游戏结束;如果不是地雷,方块会显示一个数字,表示它周围八个相邻方块中地雷的数量。如果数字是零,意味着周围没有地雷,游戏会自动展开相邻的空白区域。玩家可以通过右键标记疑似地雷的位置,当所有非地雷方块都被翻开时,游戏胜利。

这个规则描述里其实藏着好几个需要仔细处理的技术点。第一,地雷的随机分布不能是纯随机的,否则可能出现第一下就踩雷的情况,体验极差。第二,数字的计算依赖于对每个方块周围八个位置的遍历,这涉及到边界处理。第三,空白区域的自动展开本质上是一个递归或广度优先搜索的过程,需要防止重复访问和栈溢出。第四,游戏状态的判断需要同时跟踪已翻开方块数、已标记地雷数、剩余地雷数等多个变量。

2.2 数据结构的选择与理由

在初级扫雷里,最核心的数据结构就是一个二维数组。假设我们做一个9乘9的网格,里面一共有10个地雷,那么可以用一个9行9列的二维数组来存储每个方块的状态。每个方块需要记录的信息包括:是否是地雷、是否已翻开、是否被标记、周围地雷数量。一种做法是用一个结构体或类来封装这些属性,另一种做法是用多个二维数组分别存储不同的状态。

我个人的习惯是用一个二维数组存储地雷布局和数字,用另一个二维数组存储方块的可见状态。这样做的好处是逻辑清晰,调试的时候可以直接打印出地雷布局来对照检查。比如定义一个board数组,其中-1表示地雷,0到8表示周围地雷数量;再定义一个visible数组,0表示未翻开,1表示已翻开,2表示已标记。这种分离的设计在后续扩展功能时也会更方便,比如想加一个“提示”功能,只需要临时修改visible数组即可。

2.3 随机布雷的算法与优化

随机布雷听起来简单,但要做好并不容易。最朴素的做法是每次随机生成一个坐标,如果该位置还没有地雷就放一个,直到放满为止。这种做法的缺点是随着地雷数量接近总格子数,效率会急剧下降,因为大量随机坐标会落在已有地雷的位置上。对于9乘9放10个雷这种规模,朴素方法完全够用,但如果你想把网格扩大到16乘16甚至30乘16,就需要更高效的算法。

更好的做法是使用Fisher-Yates洗牌算法的变体:创建一个包含所有格子坐标的列表,随机打乱,然后取前N个作为地雷位置。这样时间复杂度是线性的,而且不会出现重复。在Python里可以用random.shuffle,在JavaScript里可以自己实现一个简单的洗牌函数。另外,为了保证第一次点击不会踩雷,可以在玩家第一次点击之后再布雷,并且排除掉点击位置及其周围八个格子。这个细节虽然小,但对游戏体验的提升非常明显。

2.4 递归展开的实现与风险控制

当玩家点击一个数字为零的方块时,游戏需要自动展开周围的所有方块。这个过程天然适合用递归来实现:翻开当前方块,如果数字为零,则对周围八个相邻方块递归执行同样的操作。但递归有两个风险:一是栈溢出,如果空白区域很大,递归深度可能超过语言默认的栈限制;二是重复访问,如果不加控制,同一个方块可能被多次翻开,导致死循环。

解决重复访问的办法很简单,在递归函数开头检查该方块是否已经翻开,如果是就直接返回。解决栈溢出的办法有两种:一是改用迭代加队列的方式实现广度优先搜索,二是限制递归深度或者增大栈空间。对于初级扫雷的规模,递归深度通常不会超过几十层,所以直接用递归问题不大。但如果你打算做更大的网格,建议从一开始就用队列来实现,代码也不会复杂多少。

3. 从零搭建扫雷的完整实操过程

3.1 开发环境与语言选择

做扫雷可以用任何你熟悉的编程语言。如果你刚开始学Python,那就用Python,标准库里的random和collections足够应付所有需求,不需要安装任何第三方包。如果你想做带图形界面的版本,Python的tkinter是内置的,虽然界面风格比较老旧,但胜在零依赖。如果你学的是JavaScript,那更简单,一个HTML文件加一个script标签就能跑起来,浏览器就是你的运行环境。C++的话可以用控制台版本练手,重点放在算法和内存管理上。

我下面以Python为例来讲解,因为它的语法最接近伪代码,读起来没有障碍。但思路是通用的,你换成任何语言都一样。先创建一个minesweeper.py文件,然后我们一步步往里加东西。

3.2 初始化游戏面板

第一步是定义游戏的基本参数和初始化面板。我们需要三个东西:地雷的位置、每个格子周围的地雷数、以及格子的可见状态。先定义常量:

ROWS = 9 COLS = 9 MINES = 10

然后写一个函数来创建初始面板。这里我采用“先全部初始化为零,再随机放置地雷,最后计算数字”的流程:

import random def create_board(rows, cols, mines): board = [[0 for _ in range(cols)] for _ in range(rows)] positions = [(r, c) for r in range(rows) for c in range(cols)] random.shuffle(positions) for i in range(mines): r, c = positions[i] board[r][c] = -1 return board

这段代码里,board数组用-1表示地雷,0表示空白。注意这里用了列表推导式来创建二维数组,而不是[[0]*cols]*rows,后者会导致所有行共享同一个列表对象,修改一行会影响所有行,这是一个非常经典的Python陷阱,我当年第一次写的时候就被坑过。

3.3 计算每个格子周围的地雷数

地雷放好之后,需要遍历每个非地雷格子,统计它周围八个方向上的地雷数量。这里的关键是边界处理:对于边缘和角落的格子,相邻位置会少于八个,不能直接访问数组范围之外的下标。

def count_adjacent_mines(board, rows, cols): for r in range(rows): for c in range(cols): if board[r][c] == -1: continue count = 0 for dr in [-1, 0, 1]: for dc in [-1, 0, 1]: if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols: if board[nr][nc] == -1: count += 1 board[r][c] = count return board

这段代码用双重循环遍历八个方向,然后用边界检查过滤掉越界的位置。虽然写起来有点啰嗦,但逻辑非常直观,不容易出错。如果你追求代码简洁,也可以把方向向量定义成一个常量列表,但初级版本没必要过度设计。

3.4 实现方块的翻开与递归展开

翻开方块是游戏的核心交互。玩家输入一个坐标,程序需要判断该位置是否合法、是否已经翻开、是否是地雷,然后执行相应的操作。如果是地雷,游戏结束;如果是数字,显示数字;如果是零,触发递归展开。

def reveal(board, visible, rows, cols, r, c): if not (0 <= r < rows and 0 <= c < cols): return if visible[r][c] != 0: return visible[r][c] = 1 if board[r][c] == 0: for dr in [-1, 0, 1]: for dc in [-1, 0, 1]: if dr == 0 and dc == 0: continue reveal(board, visible, rows, cols, r + dr, c + dc)

这个递归函数先检查边界和可见状态,然后把当前格子标记为已翻开。如果当前格子的数字是零,就对周围八个格子递归调用自身。由于函数开头有visible[r][c] != 0的检查,已经翻开的格子会直接返回,所以不会出现无限递归。这个写法简洁明了,是我个人最推荐的版本。

3.5 游戏主循环与胜负判断

有了上面的基础函数,主循环就很好写了。每一轮先打印当前可见的面板,然后等待玩家输入。玩家可以输入坐标来翻开方块,也可以输入特定命令来标记地雷。每次操作后检查是否踩雷或者是否已经翻开所有非地雷格子。

def print_visible(visible, board, rows, cols): for r in range(rows): line = "" for c in range(cols): if visible[r][c] == 0: line += "# " elif visible[r][c] == 2: line += "F " else: if board[r][c] == -1: line += "* " else: line += str(board[r][c]) + " " print(line)

胜负判断的逻辑是:如果玩家翻开的格子数等于总格子数减去地雷数,则胜利;如果翻开的格子是地雷,则失败。用一个计数器来跟踪已翻开的非地雷格子数是最简单的做法,每次成功翻开一个非地雷格子就加一,达到目标值就宣布胜利。

4. 那些年我踩过的坑与排查技巧

4.1 常见问题速查表

问题现象可能原因排查方法解决方案
第一下就踩雷布雷时没有排除首次点击位置打印地雷布局,观察首次点击坐标延迟布雷,首次点击后再生成地雷
递归展开时程序卡死递归没有终止条件或重复访问在递归函数入口打印坐标增加可见状态检查,已翻开则返回
数字显示不正确边界处理遗漏或方向遍历错误手动计算某个格子的周围地雷数对照检查边界条件,确保八个方向都遍历到
二维数组修改一行影响所有行使用了浅拷贝创建二维数组修改一个格子后打印整个数组用列表推导式逐行创建独立列表
标记地雷后无法取消标记逻辑没有实现切换检查标记分支的代码增加取消标记的分支,或使用循环切换状态

4.2 递归展开的性能陷阱

递归展开在9乘9的网格上跑起来毫无压力,但如果你把网格扩大到30乘16,空白区域可能非常大,递归深度可能达到几百层。Python默认的递归深度限制是1000,超过就会抛出RecursionError。我实测过,在30乘16、99个地雷的配置下,如果运气好碰到一大片空白区域,递归深度确实有可能接近这个限制。

解决办法有两个:一是用sys.setrecursionlimit把限制调高,但这只是治标不治本,而且设得太高可能导致真正的栈溢出崩溃;二是改用迭代加队列的方式实现广度优先搜索。后者是更稳妥的方案,代码改动也不大:

from collections import deque def reveal_iterative(board, visible, rows, cols, r, c): queue = deque([(r, c)]) while queue: cr, cc = queue.popleft() if not (0 <= cr < rows and 0 <= cc < cols): continue if visible[cr][cc] != 0: continue visible[cr][cc] = 1 if board[cr][cc] == 0: for dr in [-1, 0, 1]: for dc in [-1, 0, 1]: if dr == 0 and dc == 0: continue queue.append((cr + dr, cc + dc))

这个版本用队列代替了函数调用栈,完全避免了递归深度的问题,而且执行效率通常还更高一些。如果你打算做大型网格,强烈建议直接用这个版本。

4.3 随机布雷的分布均匀性

用洗牌算法布雷虽然效率高,但有一个潜在问题:地雷的分布是均匀随机的,这意味着有时候会出现地雷扎堆的情况,有时候又会出现大片空白。对于初级扫雷来说这完全没问题,但如果你想让游戏体验更平滑,可以加入一些约束条件,比如限制每个3乘3区域内最多有多少个地雷,或者保证地雷之间至少间隔一个格子。这些约束会让游戏更“友好”,但也会增加代码复杂度。我的建议是初级版本不要加这些,先让核心逻辑跑通,后续再考虑优化。

4.4 输入解析的健壮性

玩家输入是最容易出问题的地方。用户可能输入字母、负数、超出范围的数字、格式不对的字符串。如果不做任何检查,程序会直接崩溃。我见过很多新手写的扫雷,一输入非数字就报错退出,体验非常糟糕。正确的做法是用try-except包裹输入解析,对非法输入给出友好提示并让玩家重新输入。

def get_player_input(rows, cols): while True: try: raw = input("请输入坐标(行 列)或输入 m 行 列 标记:").strip() parts = raw.split() if parts[0] == 'm': r, c = int(parts[1]), int(parts[2]) if 0 <= r < rows and 0 <= c < cols: return ('mark', r, c) else: r, c = int(parts[0]), int(parts[1]) if 0 <= r < rows and 0 <= c < cols: return ('reveal', r, c) print("坐标超出范围,请重新输入。") except (ValueError, IndexError): print("输入格式不正确,请重新输入。")

这段代码虽然简单,但能挡住绝大多数误操作。注意parts[0] == 'm'的判断要放在前面,否则当玩家输入m的时候,int(parts[0])会抛出异常。

5. 从能跑到好用:体验优化的几个方向

5.1 首次点击保护与地雷重排

前面提到过,第一次点击不应该踩雷。实现方式有两种:一是延迟布雷,在玩家第一次点击之后再随机生成地雷,并排除点击位置及其周围八个格子;二是先布雷,如果第一次点击踩雷,就把这颗雷移到另一个非雷位置。第一种方式更简单,也更常用。具体做法是在游戏初始化时只创建空面板,等到第一次reveal调用时再执行布雷和数字计算。

这个改动虽然小,但对体验的提升是巨大的。想象一下,你刚打开游戏,随手一点就炸了,那种挫败感会让人直接关掉窗口。而有了首次点击保护,玩家至少能先看到一片区域,产生继续探索的欲望。

5.2 标记功能的完整实现

标记功能是扫雷的重要组成部分,它让玩家可以记录自己的推理结果。在控制台版本里,标记通常用F字符表示。实现标记功能需要注意几点:标记只能作用于未翻开的格子;再次标记同一个格子应该取消标记;标记的数量不应该超过地雷总数(可选,有些版本不限制)。标记和翻开是两个独立的操作,需要在主循环里分别处理。

def toggle_mark(visible, rows, cols, r, c): if not (0 <= r < rows and 0 <= c < cols): return False if visible[r][c] == 0: visible[r][c] = 2 return True elif visible[r][c] == 2: visible[r][c] = 0 return True return False

这个函数在未标记和已标记之间切换,如果格子已经翻开则不做任何操作。返回布尔值是为了让主循环知道操作是否成功,以便决定是否重新打印面板。

5.3 计时器与剩余地雷计数

一个完整的扫雷游戏通常还会显示已经过去的时间和剩余的地雷数量。在控制台版本里,时间可以用time.time()来记录,每次操作后计算差值并显示。剩余地雷数等于总地雷数减去已标记的格子数。这两个信息虽然不影响核心玩法,但能显著提升游戏的完整感。

import time start_time = time.time() elapsed = int(time.time() - start_time) marked_count = sum(row.count(2) for row in visible) remaining = MINES - marked_count print(f"时间:{elapsed}秒 剩余地雷:{remaining}")

注意marked_count的计算方式,这里用了生成器表达式来统计所有标记格子的数量。如果你用的是Python 3.8以上版本,也可以用sum(map(lambda row: row.count(2), visible)),效果一样。

5.4 从控制台到图形界面的过渡思路

控制台版本跑通之后,如果你想进一步挑战自己,可以尝试做一个图形界面版本。Python的tkinter是最容易上手的,每个格子可以用一个Button组件表示,点击事件绑定到翻开函数,右键事件绑定到标记函数。难点在于递归展开时需要动态更新按钮的状态,以及如何用不同的颜色和图标来区分数字和地雷。

我的建议是先把控制台版本的所有逻辑封装成独立的函数或类,然后图形界面只负责显示和接收输入,所有游戏逻辑复用之前的代码。这样你可以做到逻辑和界面分离,调试起来也方便。如果一开始就把逻辑和界面混在一起写,后面改起来会非常痛苦。

6. 代码之外:一些个人体会

做扫雷这个项目,最大的收获其实不是学会了某个具体的算法,而是理解了“把复杂问题拆解成简单步骤”的思维方式。一开始你可能觉得无从下手,但把它拆成“创建面板、布雷、计算数字、翻开、判断胜负”这几个小问题之后,每一个都不难解决。这种拆解能力在后续做任何项目时都用得上。

另外,调试扫雷的过程也让我养成了一个习惯:在关键位置打印中间状态。比如布雷之后打印一下地雷布局,计算数字之后打印一下数字面板,递归展开时打印一下当前坐标。这些输出在最终版本里当然要去掉,但在开发阶段能帮你快速定位问题。我见过很多新手遇到bug就盯着代码发呆,其实加几行打印语句,问题往往一目了然。

最后说一个容易被忽略的细节:代码的可读性。扫雷的代码量不大,但如果你把所有的逻辑都塞在一个函数里,后面想改一个功能就会非常困难。我建议至少把面板创建、数字计算、翻开操作、输入解析这几个功能拆成独立的函数,每个函数只做一件事。变量命名也要有意义,不要用a、b、c这种名字,用board、visible、row、col这样的名字,过一周再回来看也能看懂。

如果你已经跟着上面的步骤写出了自己的扫雷,不妨再试试加一些扩展功能,比如自定义网格大小和地雷数量、增加难度选择菜单、记录最佳成绩、甚至做一个简单的AI自动求解器。每一个扩展都是一次新的学习机会,而且看着自己的游戏越来越完善,那种成就感是单纯刷题给不了的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:54:16

蓝桥杯Web应用开发赛题全解析:从基础语法到Vue实战备赛指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:53:02

用时间序列分析管理日更挑战:从移动平均到燃尽图的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:52:57

高并发下优惠券系统架构设计:从防超发到削峰填谷的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:52:33

明日方舟MAA自动化辅助:从安装配置到全自动日常的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:52:17

期货AI交易实战:特征工程、模型选型与风控架构全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:52:11

基于PCA9422与PIC18F97J94的锂电池电源管理设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华