ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ MFC扫雷游戏实训:递归展开与INI配置实战

C++ MFC扫雷游戏实训:递归展开与INI配置实战 简介这份资源是一份面向高校计算机相关专业学生的C软件项目实训报告以经典扫雷游戏为课题适合正在完成课程设计、实训作业或需要参考完整项目文档的初学者与指导教师。压缩包内仅含1个doc文档体积约366KB内容为电气信息学院实训报告涵盖实训目的、内容、分工安排、要求与成果等完整章节。报告以Visual C 6.0为开发环境详细阐述了扫雷游戏的整体设计思路与流程规划包括雷区布置与地雷随机生成方法并重点分析鼠标事件处理与递归清除未靠近地雷区方块两项核心实现同时涉及主菜单界面、胜负判断、英雄榜记录更新、背景音乐与帮助说明等功能模块。目前已有601人学习下载可为读者提供一份结构规范的实训报告范本帮助理解面向对象可视化编程的基本方法、事件驱动机制与递归算法在游戏中的实际应用也可作为撰写同类课程报告的参考素材。1. 从一份实训报告说起C 扫雷项目到底交付了什么很多人看到“C设计扫雷游戏报告软件项目实训”这个标题第一反应是“又一个课程作业”。但我把这份材料从头翻到尾之后发现它其实是一份完整的、可复现的 MFC 工程实录——不是那种只贴几段伪代码的糊弄报告而是从需求分析、模块划分、类结构设计到消息映射、递归展开、INI 配置读写全链路都落到了具体代码和资源 ID 上。它解决的核心问题是在 Visual C 6.0 环境下用 MFC 对话框框架从零搭一个带菜单、帮助、英雄榜、背景音乐和完整鼠标交互的扫雷游戏。适合两类人正在做 C 课程设计、需要一份能跑通的参考实现的学生以及想通过一个具体项目理解 Windows 消息机制、GDI 绘图和递归算法的初学者。下面我按“这份资源怎么用起来”的顺序拆。2. 工程骨架怎么搭MFC 对话框类结构与消息映射2.1 四个类加一个模块的职责划分这份实训报告最值得先看的是它的模块结构。整个游戏由四个类和一个独立模块组成职责边界划得很清楚类/模块职责关键接口CMineDlg主界面对话框类主界面、菜单创建与调用OnInitDialog、InitMenu、各菜单响应函数扫雷窗口类接收鼠标输入、格子翻转、计时、雷区显示StartGame、鼠标消息处理CHeroDlg英雄榜对话框类等级记录读写与更新SetWriteFlg、DoModal、OnBtnCHelpDlg帮助对话框类帮助提示显示OnOK背景音乐播放模块游戏中背景音乐控制PlayBackMusic这个划分的好处是主对话框只管调度不碰具体游戏逻辑扫雷窗口类独立处理鼠标和格子状态英雄榜和帮助各自封装成对话框通过 DoModal 弹出。常见做法是新手把所有逻辑塞进一个 Dlg 类里写到后面消息映射表几百行改一个功能牵一发动全身。这份报告的分法值得直接抄。2.2 消息映射表的写法与菜单响应函数MFC 的核心机制之一就是消息映射。报告里给出了完整的菜单响应实现我把它整理成可直接对照的代码结构// MineDlg.cpp 消息映射表 BEGIN_MESSAGE_MAP(CMineDlg, CDialog) ON_WM_SYSCOMMAND() ON_WM_PAINT() ON_WM_QUERYDRAGICON() // 菜单资源 ID 与响应函数映射 ON_COMMAND(IDR_ABOUT, OnAbout) ON_COMMAND(IDR_EXIT_GAME, OnExitGame) ON_COMMAND(IDR_HELP, OnHelp) ON_COMMAND(IDR_PLAY_MUSIC, OnPlayMusic) ON_COMMAND(IDR_START_GAME, OnStartGame) END_MESSAGE_MAP() BOOL CMineDlg::OnInitDialog() { CDialog::OnInitDialog(); m_bStart 1; // 游戏状态标志1 表示可操作 InitMenu(); // 初始化菜单勾选状态 return TRUE; } void CMineDlg::InitMenu() { CWnd* pMain AfxGetMainWnd(); CMenu* pMenu pMain-GetMenu(); // 初始状态下背景音乐菜单不勾选 pMenu-CheckMenuItem(IDR_PLAY_MUSIC, MF_BYCOMMAND | MF_UNCHECKED); }这段代码的逻辑链条是ON_COMMAND把菜单项 ID 和成员函数绑定点击菜单时 MFC 框架自动调用对应函数。InitMenu在对话框初始化时把背景音乐菜单设为未勾选状态保证界面状态和实际播放状态一致。参数说明MF_BYCOMMAND表示按命令 ID 操作MF_UNCHECKED表示取消勾选。如果你用的是 VS2010 及以上版本消息映射写法基本一致但资源编辑器界面不同CheckMenuItem的用法不变。2.3 背景音乐菜单的勾选切换逻辑背景音乐这块报告里用了一个比较巧妙的勾选状态判断void CMineDlg::OnPlayMusic() { CWnd* pMain AfxGetMainWnd(); CMenu* pMenu pMain-GetMenu(); // 查询当前菜单勾选状态 BOOL bCheck (BOOL)pMenu-GetMenuState(IDR_PLAY_MUSIC, MF_CHECKED); if (m_bStart) { if (bCheck) { pMenu-CheckMenuItem(IDR_PLAY_MUSIC, MF_BYCOMMAND | MF_UNCHECKED); } else { pMenu-CheckMenuItem(IDR_PLAY_MUSIC, MF_BYCOMMAND | MF_CHECKED); } PlayBackMusic(!bCheck); // 传入取反后的状态 } }逻辑说明先通过GetMenuState拿到当前勾选状态如果已勾选就取消并停止播放如果未勾选就勾选并开始播放。PlayBackMusic(!bCheck)的参数是布尔值!bCheck保证传入的“是否播放”与新的勾选状态一致。这里有个容易翻车的点GetMenuState的返回值在 MFC 不同版本里类型可能不同VC6 下返回 UINT直接强转 BOOL 没问题但在高版本编译器上建议用MF_CHECKED做位与判断避免符号扩展问题。3. 核心玩法怎么实现递归展开与鼠标事件处理3.1 递归展开空白区域的算法逻辑扫雷最核心的算法就是“翻开一个空白格自动展开周围所有空白格”。报告里明确用了递归来实现这也是整个项目里最值得吃透的部分。我按它的思路整理成可读的伪代码加注释// 递归展开函数row, col 为当前格子坐标 void CMineWnd::ExpandBlank(int row, int col) { // 边界检查超出雷区范围直接返回 if (row 0 || row m_nRows || col 0 || col m_nCols) return; // 已翻开的格子不重复处理 if (m_cellState[row][col] ! COVERED) return; // 标记为已翻开 m_cellState[row][col] OPENED; // 计算周围八格的地雷数 int nCount CountMinesAround(row, col); if (nCount 0) { // 有雷相邻显示数字递归终止 m_cellNum[row][col] nCount; return; } // 无雷相邻继续向八个方向递归 for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; ExpandBlank(row dr, col dc); } } }逻辑说明递归的终止条件有两个——越界或已翻开。当周围八格地雷数为 0 时向八个方向继续递归。参数m_cellState是格子状态数组COVERED表示未翻开OPENED表示已翻开。CountMinesAround遍历周围八格统计地雷数。这里的关键是递归前必须标记当前格为已翻开否则会无限递归导致栈溢出。血泪经验是如果雷区设得很大比如 30×16 以上递归深度可能触及默认栈大小常见做法是改成用队列做广度优先展开或者把递归改成显式栈。3.2 鼠标左键翻开与右键标记的事件分流鼠标事件是玩家输入的唯一入口。报告里把左键和右键的处理分开右键在“未标记→标记→疑似”三态之间循环void CMineWnd::OnLButtonDown(UINT nFlags, CPoint point) { // 将像素坐标转换为格子坐标 int row point.y / CELL_SIZE; int col point.x / CELL_SIZE; if (m_cellState[row][col] COVERED) { if (m_bMine[row][col]) { // 踩雷游戏结束 GameOver(false); } else { ExpandBlank(row, col); } } Invalidate(); // 触发重绘 CWnd::OnLButtonDown(nFlags, point); } void CMineWnd::OnRButtonDown(UINT nFlags, CPoint point) { int row point.y / CELL_SIZE; int col point.x / CELL_SIZE; if (m_cellState[row][col] COVERED) { m_cellState[row][col] MARKED; // 未标记 → 标记 } else if (m_cellState[row][col] MARKED) { m_cellState[row][col] QUESTION; // 标记 → 疑似 } else if (m_cellState[row][col] QUESTION) { m_cellState[row][col] COVERED; // 疑似 → 未标记 } Invalidate(); CWnd::OnRButtonDown(nFlags, point); }逻辑说明point是鼠标点击的像素坐标除以CELL_SIZE每格像素宽度得到格子行列号。左键只处理未翻开的格子踩雷调GameOver(false)否则调ExpandBlank。右键用状态机在三态间循环。Invalidate()触发窗口重绘让格子状态变化立刻反映到界面上。参数nFlags包含 Shift、Ctrl 等修饰键状态这里没用到但保留在函数签名里。注意VC6 的OnLButtonDown消息映射是ON_WM_LBUTTONDOWN()别写成ON_LBUTTONDOWN少个 W 编译不过。3.3 地雷随机布置与首次点击保护地雷布置的常见做法是在StartGame里用随机数生成。报告里没有贴出完整代码但按这个项目的结构我一般会这样写void CMineWnd::StartGame() { // 初始化所有格子为未翻开、无雷 memset(m_bMine, 0, sizeof(m_bMine)); memset(m_cellState, COVERED, sizeof(m_cellState)); srand((unsigned)time(NULL)); // 以时间为种子 int nPlaced 0; while (nPlaced m_nMineCount) { int r rand() % m_nRows; int c rand() % m_nCols; if (!m_bMine[r][c]) { m_bMine[r][c] true; nPlaced; } } m_nTimeUsed 0; // 计时归零 m_bStart 1; Invalidate(); }逻辑说明srand((unsigned)time(NULL))保证每次开局雷区不同。while循环用nPlaced计数避免重复位置导致死循环。m_nMineCount是当前难度的地雷总数。这里有个经典坑如果雷数接近格子总数随机碰撞概率急剧上升循环次数暴涨。常见做法是先把所有格子编号用洗牌算法取前 N 个作为雷位复杂度从 O(n²) 降到 O(n)。4. 英雄榜与配置文件INI 读写和对话框数据交换4.1 CHeroDlg 类的读写标志设计英雄榜的实现思路是用一个对话框类同时承担“读记录”和“写记录”两个职责通过一个m_bWriteflg标志区分。这个设计在课程项目里很实用省了一个类。class CHeroDlg : public CDialog { public: void SetWriteFlg(BOOL bflg); // 外部设置读写标志 CHeroDlg(CWnd* pParent NULL); enum { IDD IDD_HERO_LIST }; CString m_name; // 玩家姓名 int m_time; // 用时 virtual int DoModal(); protected: virtual void DoDataExchange(CDataExchange* pDX); virtual void OnOK(); DECLARE_MESSAGE_MAP() private: BOOL m_bWriteflg; // TRUE 为写模式FALSE 为读模式 };SetWriteFlg是外部调用的接口。游戏胜利时传TRUE弹出对话框让玩家输入姓名并写入记录查看英雄榜时传FALSE只读不写。DoDataExchange里用DDX_Text把控件和成员变量绑定UpdateData(TRUE)从控件刷到变量UpdateData(FALSE)从变量刷到控件。4.2 INI 文件读写与 DoModal 重载int CHeroDlg::DoModal() { char pszTmp[128] {0}; // 从 hero.ini 读取姓名 GetPrivateProfileString(HERO, name, , pszTmp, 127, .\\hero.ini); m_name CString(pszTmp); if (!m_bWriteflg) { // 读模式才读时间 GetPrivateProfileString(HERO, time, 0, pszTmp, 127, .\\hero.ini); m_time atoi(pszTmp); } return CDialog::DoModal(); } void CHeroDlg::OnBtn() { UpdateData(TRUE); // 控件 → 变量 if (m_bWriteflg) { CString tmp; tmp.Format(%d, m_time); WritePrivateProfileString(HERO, name, m_name, .\\hero.ini); WritePrivateProfileString(HERO, time, tmp, .\\hero.ini); } m_bWriteflg FALSE; CDialog::OnOK(); }逻辑说明GetPrivateProfileString从 INI 文件指定节HERO读取键值第三个参数是默认值读不到时返回。WritePrivateProfileString写入键值。DoModal被重载在弹出对话框之前先读文件保证界面显示的是最新记录。OnBtn里先UpdateData(TRUE)把用户输入同步到变量再判断是否写模式。参数.\\hero.ini是相对路径文件放在工程可执行文件同目录下。注意VC6 下GetPrivateProfileString的第二个参数是LPCTSTR如果工程用了 Unicode 字符集需要改成_T(HERO)和宽字符版本否则编译报错。5. 避坑与排查VC6 扫雷项目里最容易翻车的五个地方5.1 踩坑一递归展开导致栈溢出现象点击一个空白格后程序直接崩溃调试器停在ExpandBlank函数里调用栈显示递归了几千层。原因雷区较大且空白区域连成一片时递归深度等于空白格数量。VC6 默认栈大小 1MB每层递归压栈约几十字节超过约两万层就溢出。解决把递归改成用std::queue或std::stack做迭代展开。或者把雷区控制在 16×16 以内空白格数量不会超过 256递归深度安全。5.2 踩坑二鼠标坐标转换偏移现象点击格子和实际翻开的格子差一格或者点击边缘格子没反应。原因point是相对于窗口客户区的坐标如果雷区绘制时留了边距比如顶部有菜单栏、左侧有边框直接除以CELL_SIZE会偏移。解决在坐标转换前先减去雷区起始偏移量。常见做法是定义MINE_OFFSET_X和MINE_OFFSET_Yrow (point.y - MINE_OFFSET_Y) / CELL_SIZE。另外要加边界判断防止负数索引越界。5.3 踩坑三INI 文件路径不对导致读写失败现象英雄榜记录写入后重新打开游戏读不到或者GetPrivateProfileString总是返回默认值。原因.\\hero.ini是相对路径相对于当前工作目录。在 VC6 里直接 F5 调试时工作目录是工程目录但双击 exe 运行时工作目录是 exe 所在目录。两个位置不一致。解决统一用绝对路径或者用GetModuleFileName获取 exe 路径再拼接。调试时在 Project Settings → Debug → Working Directory 里设成 exe 输出目录。5.4 踩坑四菜单勾选状态与实际播放不同步现象背景音乐菜单显示已勾选但实际没声音或者点了停止之后菜单还勾着。原因GetMenuState的返回值判断方式在不同 MFC 版本下行为不一致或者PlayBackMusic的参数传反了。解决不要依赖GetMenuState的返回值做布尔判断改用自己维护一个m_bMusicPlaying成员变量菜单勾选和播放控制都读这个变量保证单一数据源。5.5 踩坑五VC6 在高版本 Windows 上运行闪退现象在 Win10/Win11 上编译出来的 exe 双击没反应或者弹出“应用程序正常初始化失败”。原因VC6 生成的程序依赖旧版 MFC 运行库高版本系统默认没有。另外 VC6 本身在高版本系统上兼容性也有问题。解决安装 Visual C 运行库合集或者把工程迁移到 VS2019/2022用 v143 工具集重新编译。迁移时注意GetPrivateProfileString在 Unicode 下要改宽字符版本消息映射基本不用动。6. 从能跑到好用难度分级与计时器的两个进阶技巧把基本功能跑通之后这份实训报告里其实还埋了两个可以深挖的点难度分级和计时器。报告里提到了“英雄榜记录更新”和“花费时间显示”但没有展开难度切换的实现。我按这个项目的结构补两个实用技巧。难度分级用菜单单选实现。在菜单资源里加“初级/中级/高级”三个菜单项用CCmdUI的SetRadio做单选效果void CMineDlg::OnUpdateDifficulty(CCmdUI* pCmdUI) { // 根据当前难度勾选对应菜单项 pCmdUI-SetRadio(pCmdUI-m_nID m_nCurrentLevel); } void CMineDlg::OnDifficultyEasy() { m_nCurrentLevel IDR_LEVEL_EASY; m_nRows 9; m_nCols 9; m_nMineCount 10; mine.StartGame(); // 用新参数重开 }SetRadio保证同一组菜单项只有一个被选中。m_nCurrentLevel记录当前难度 IDOnUpdateDifficulty在菜单弹出前被 MFC 自动调用根据当前难度刷新勾选状态。三个难度对应三组行列和雷数参数切换后调StartGame重新初始化。计时器用SetTimer而不是线程。MFC 对话框自带WM_TIMER消息在OnInitDialog里调SetTimer(1, 1000, NULL)启动一个 1 秒间隔的定时器在OnTimer里累加m_nTimeUsed并刷新显示void CMineWnd::OnTimer(UINT nIDEvent) { if (nIDEvent 1 m_bStart) { m_nTimeUsed; // 更新界面上的时间显示控件 CString strTime; strTime.Format(%d, m_nTimeUsed); GetDlgItem(IDC_TIME_STATIC)-SetWindowText(strTime); } CWnd::OnTimer(nIDEvent); }游戏结束时调KillTimer(1)停掉。用SetTimer的好处是不用管线程同步MFC 消息循环自动处理。注意nIDEvent要判断避免多个定时器冲突。验证方法很简单开一局初级点几个格子看计时器是否每秒递增踩雷后看计时器是否停止切换难度后看雷数和格子数是否变化。我一般还会在StartGame里加一句ASSERT(m_nMineCount m_nRows * m_nCols)防止参数配错导致死循环。从那以后我每次拿到这种课程项目代码都先把递归函数和文件路径这两处强制走一遍边界测试——递归改迭代、路径改绝对这两步做完基本就不会在演示现场翻车了。希望帮到你。本文还有配套的精品资源点击获取
返回列表