2026/11/5 6:56:37

C++控制台实现扫雷游戏

C++控制台实现扫雷游戏 前言扫雷的控制台版本不需要图形库核心只有四件事布雷、计算每格周围的地雷数、翻开格子含空白区域连锁展开、判定胜负。界面用字符网格输出输入用行列坐标整套代码只用标准库可以跨平台。常见问题集中在三处。第一随机数用法不对rand() % n不仅有模偏差而且未播种时序列固定每次开局地雷位置相同。第二翻开空白格时用递归展开遇到大连通区域可能栈溢出。第三边界判断漏掉角落与边缘数组越界访问。本文实现一个 9×9、10 颗雷的版本支持翻开与插旗用迭代式广度优先展开输入解析做了完整校验。代码以 C17 编写未在本机编译执行输出效果按逻辑推演。一、数据结构与布雷每个格子需要记录是否是雷、是否已翻开、是否插旗、周围雷数。用一维std::vectorCell加行列换算比vectorvectorCell少一层间接缓存也更友好。// 适用于 C17#include cstddef#include random#include vectorclass Minesweeper {public:static constexpr int kRows 9;static constexpr int kCols 9;static constexpr int kMines 10;Minesweeper() : cells_(static_caststd::size_t(kRows * kCols)) {place_mines();compute_adjacent();}private:struct Cell {bool mine false;bool revealed false;bool flagged false;int adjacent 0;};static std::size_t index(int r, int c) {return static_caststd::size_t(r * kCols c);}static bool in_range(int r, int c) {return r 0 r kRows c 0 c kCols;}void place_mines() {std::random_device rd;std::mt19937 gen(rd());std::uniform_int_distributionint dist(0, kRows * kCols - 1);int placed 0;while (placed kMines) {int pos dist(gen);Cell cell cells_[static_caststd::size_t(pos)];if (!cell.mine) {cell.mine true;placed;}}}void compute_adjacent() {for (int r 0; r kRows; r) {for (int c 0; c kCols; c) {if (cells_[index(r, c)].mine) continue;int count 0;for (int dr -1; dr 1; dr) {for (int dc -1; dc 1; dc) {if (dr 0 dc 0) continue;int nr r dr;int nc c dc;if (in_range(nr, nc) cells_[index(nr, nc)].mine) {count;}}}cells_[index(r, c)].adjacent count;}}}std::vectorCell cells_;int revealed_count_ 0;bool game_over_ false;// 公开接口见下文这里先给出数据与初始化逻辑std::mt19937是确定性伪随机数引擎用std::random_device播种后每次运行不同。std::uniform_int_distribution保证区间内均匀分布避免% n的模偏差。布雷用「重复抽取直到放满」的拒绝采样当雷数远小于格子数时效率很高若雷密度极大例如 80 格放 79 雷应改为洗牌算法否则期望抽取次数会急剧上升。compute_adjacent对每个非雷格检查八邻域边界用in_range过滤不会越界。这段是 O(R·C) 的固定开销开局算一次即可。二、翻开与连锁展开翻开一格分三种情况已翻开或已插旗忽略。是雷游戏结束。不是雷标记翻开若周围雷数为 0继续翻开八邻域。第三种的「继续翻开」如果用递归深度等于连通区域大小最坏可达 81 层——对这个规模没问题但换成 100×100 的棋盘就有风险。用显式栈或队列更稳。// 适用于 C17#include queue#include utility// 接上文类定义public:enum class Result { Ok, HitMine, Invalid };Result reveal(int r, int c) {if (!in_range(r, c)) return Result::Invalid;Cell start cells_[index(r, c)];if (start.revealed || start.flagged) return Result::Invalid;if (start.mine) {start.revealed true;game_over_ true;return Result::HitMine;}std::queuestd::pairint, int q;q.emplace(r, c);while (!q.empty()) {auto [cr, cc] q.front();q.pop();Cell cell cells_[index(cr, cc)];if (cell.revealed || cell.flagged || cell.mine) continue;cell.revealed true;revealed_count_;if (cell.adjacent ! 0) continue; // 有数字不再扩散for (int dr -1; dr 1; dr) {for (int dc -1; dc 1; dc) {if (dr 0 dc 0) continue;int nr cr dr;int nc cc dc;if (in_range(nr, nc) !cells_[index(nr, nc)].revealed !cells_[index(nr, nc)].flagged) {q.emplace(nr, nc);}}}}return Result::Ok;}bool toggle_flag(int r, int c) {if (!in_range(r, c)) return false;Cell cell cells_[index(r, c)];if (cell.revealed) return false;cell.flagged !cell.flagged;return true;}bool won() const {return revealed_count_ kRows * kCols - kMines;}bool lost() const { return game_over_; }入队时先做一次过滤出队时再检查一次是为了避免同一格被多个邻居重复入队。重复入队不会导致错误结果出队时会continue但会让队列膨胀对 81 格来说无所谓对大规模棋盘值得用一个visited标记。auto [cr, cc] q.front();是 C17 的结构化绑定。注意q.front()返回引用绑定到auto会拷贝一份std::pair这里正好需要副本因为随后就pop了。三、渲染与输入控制台渲染用字符未翻开显示#插旗显示F翻开且无雷显示周围雷数0 显示空格踩雷后显示*。输入用整行读取再解析避免cin 与getline混用。// 适用于 C17#include iostream#include sstream#include string// 接上文类定义public:void print(bool reveal_all) const {std::cout ;for (int c 0; c kCols; c) std::cout c ;std::cout \n;for (int r 0; r kRows; r) {std::cout r ;for (int c 0; c kCols; c) {const Cell cell cells_[index(r, c)];if (cell.flagged !reveal_all) {std::cout F ;} else if (!cell.revealed !reveal_all) {std::cout # ;} else if (cell.mine) {std::cout * ;} else if (cell.adjacent 0) {std::cout ;} else {std::cout cell.adjacent ;}}std::cout \n;}}};// 解析 r c 或 f r c成功返回 truebool parse_command(const std::string line, bool flag, int r, int c) {std::istringstream iss(line);std::string first;if (!(iss first)) return false;if (first f || first F) {flag true;return static_castbool(iss r c);}flag false;try {std::size_t used 0;r std::stoi(first, used);if (used ! first.size()) return false;} catch (const std::exception) {return false;}return static_castbool(iss c);}int main() {Minesweeper game;while (!game.won() !game.lost()) {game.print(false);std::cout 输入 r c 翻开f r c 插旗q 退出: ;std::string line;if (!std::getline(std::cin, line)) break;if (line q || line Q) break;bool flag false;int r 0, c 0;if (!parse_command(line, flag, r, c)) {std::cout 格式错误\n;continue;}if (flag) {game.toggle_flag(r, c);} else {auto result game.reveal(r, c);if (result Minesweeper::Result::HitMine) {game.print(true);std::cout 踩到地雷游戏结束\n;} else if (result Minesweeper::Result::Invalid) {std::cout 该格不能翻开\n;}}}if (game.won()) {game.print(true);std::cout 全部安全格已翻开你赢了\n;}return 0;}parse_command先读第一个 token如果是f按插旗解析否则按坐标解析并用std::stoi的used参数确认整个 token 都是数字。这样1a这类输入会被拒绝而不是被stoi截断成1。四、胜负判定与边界胜利条件是「所有非雷格都被翻开」而不是「翻开格子数 总格数」。用revealed_count_与kRows * kCols - kMines比较即可。插旗不影响胜负只影响显示也可以在胜利时额外检查旗子是否都插在雷上但那是另一种判定规则。失败条件是踩到雷。踩雷后应把全部棋盘揭示print(true)让玩家看到雷的位置而不是只显示踩中的那一格。几个边界翻已翻开的格子返回Invalid不改变状态。翻插旗的格子返回Invalid必须先取消旗子。也可以设计成「翻旗即取消旗」取决于交互习惯。坐标为负或越界返回Invalid不访问数组。棋盘全部是雷极端配置kRows * kCols - kMines 0开局即胜循环条件会立即退出。常见坑点❌std::rand() % 81布雷不播种。✅ 未播种时序列固定用std::mt19937std::random_devicestd::uniform_int_distribution。❌ 用递归展开空白区域。✅ 大连通区域会加深递归用std::queue或显式栈做迭代展开。❌ 检查八邻域时不做边界判断角落越界。✅ 每个邻居都过一遍in_range或在外围留一圈哨兵。❌ 踩雷后继续接受输入。✅ 主循环条件包含!game.lost()踩雷即退出循环。❌ 胜利条件写成「翻开格子数 总格数」。✅ 雷格永远不会被正常翻开应为总格数 - 雷数。❌std::cin r c后直接getline读到空行。✅ 统一用getline读整行再用istringstream解析。❌ 用system(cls)清屏跨平台失败。✅ 清屏是平台相关的要么不清屏要么按平台条件编译并注明依赖。❌ 插旗数量不限制玩家可以插满整个棋盘。✅ 可限制旗子数不超过雷数或把插旗仅当作标记、不参与胜负。总结模块要点布雷随机引擎 均匀分布 拒绝采样雷数计算八邻域边界用范围判断翻开迭代式 BFS遇数字停止扩散插旗只改标记不改翻开状态胜负翻开数 总格数 - 雷数踩雷即负输入getlineistringstream严格校验渲染字符网格踩雷后全揭示控制台扫雷的代码量不大但它把「随机数、二维索引、队列、状态机、输入解析」串在了一起。把这五件事分别写对再拼起来比一上来就写一个大main更容易调试。真要提升体验可以加首次点击免雷、计时、难度选择这些都属于在清晰的数据结构上做增量而不是推翻重写。