首页 / 资讯中心 / 文章详情

Win32扫雷工程:双缓冲绘图与位运算状态机实战

Win32扫雷工程:双缓冲绘图与位运算状态机实战 ★ FEATURED ARTICLE
简介本资源为基于Visual C开发的经典Windows扫雷游戏完整源码工程面向C初学者与Windows桌面应用入门开发者旨在通过可运行的实战项目理解MFC框架、事件驱动编程及基础算法实现。压缩包共54个文件含6个核心CPP源文件、8个H头文件构成程序逻辑主体7个BMP图像资源与1个ICO图标支撑界面显示3个WAV音效增强交互体验另有EXE可执行文件便于直接运行验证整体包体仅1.95MB轻量易解压。目前已有95人学习下载适合希望从零掌握VC图形界面开发、二维数组雷区建模、递归展开算法及MFC对话框控件响应机制的学习者。项目目录结构清晰包含MineWnd、DlgCustom、DlgHero等多个功能模块源码注释充分调试信息完整是深入理解Windows平台C游戏开发全流程的优质实践范例。1. 这不是教学Demo而是一个能直接编译、带完整资源管理的VC扫雷工程——它用纯Win32 API实现双缓冲绘图、自定义位图按钮、计时与雷数实时同步连右键菜单响应和失败爆炸动画都写死了逻辑适合想啃透Windows桌面游戏底层渲染与消息循环的新手也够得上某高校课程设计答辩的完整度你可能在CSDN或某论坛见过“VC扫雷源码”点开却发现只有main.cpp和几行CreateWindow调用资源全靠MessageBox硬凑点击无反馈、重绘闪屏、雷区大小写死成9×9——那种代码只能叫“扫雷概念验证”。而这个项目是实打实的工程级落地它包含完整的.rc资源文件含IDB_MINE、IDB_FLAG等8个位图ID、独立的GameBoard类封装格子状态与邻雷计算、CMainFrame中精细控制WM_PAINT双缓冲流程、甚至把“第一次点击必不踩雷”这个反直觉逻辑拆成了InitBoardWithSafeFirstClick()函数。我拿它给A同学做毕设辅导时他改了3小时就跑通了16×3099雷的高级模式某实验室把它当Win32图形编程的baseline直接替换了原来用MFC封装的旧例程。它不依赖ATL、不引入第三方库所有GDI对象创建/销毁、消息分发路径都暴露在源码里——你要的不是“会扫雷”而是“看懂扫雷怎么在Windows内核消息泵里一帧帧活过来”。2. 工程结构与编译环境从VC6.0到VS2022的兼容性适配策略2.1 目录结构解析为什么resource.h必须和.rc同目录且不能用相对路径引用位图项目解压后呈现标准VC6.0工程布局但已通过手动补丁适配现代工具链MineSweeper/ ├── MineSweeper.dsp # VC6.0工程文件保留用于参考配置 ├── MineSweeper.dsw # 工作区文件 ├── MineSweeper.h # 主窗口类声明含CMainFrame : public CFrameWnd ├── MineSweeper.cpp # WinMain入口、CWinApp派生类实现 ├── GameBoard.h / .cpp # 核心游戏逻辑雷区生成、邻格计数、递归展开 ├── Resource.h # 资源ID定义关键所有IDB_XXX在此声明 ├── MineSweeper.rc # 资源脚本含BITMAP、MENU、ACCELERATORS ├── res/ │ ├── mine.bmp # 雷图标16×1624位真彩色 │ ├── flag.bmp # 旗标16×16 │ ├── question.bmp # 问号16×16 │ └── numbers/*.bmp # 0-8数字图标各16×16 └── Debug/ # 编译输出目录VC6默认提示Resource.h必须与.rc文件位于同一目录且.rc中#include resource.h不能写成../resource.h。VC6.0资源编译器RC.exe在解析位图时会将IDB_MINE等标识符直接映射为资源段偏移若头文件路径错误链接阶段报LNK2001: unresolved external symbol IDB_MINE——这不是代码错是资源注册断裂。2.2 VC6.0原始配置迁移至VS2022的关键三步VS2022默认禁用老旧的Win32 API安全警告如strcpy但此项目大量使用lstrcpy、TextOut等GDI函数需主动关闭特定警告并补全宏定义预处理器定义追加在VS2022项目属性 → C/C → 预处理器 → 预处理器定义中添加WIN32;_WINDOWS;_CRT_SECURE_NO_WARNINGS;_WINSOCK_DEPRECATED_NO_WARNINGS_CRT_SECURE_NO_WARNINGS是必须项原代码用wsprintf拼接字符串显示雷数VS2022默认报C4996警告不加此宏会导致编译中断。GDI资源清理补丁原VC6代码在OnPaint()末尾仅调用DeleteObject(hBitmap)但未释放SelectObject(hdc, hOldBitmap)返回的旧位图句柄。VS2022下易触发GDI句柄泄漏任务管理器中“GDI对象”数持续上涨。修复代码如下// GameBoard.cpp 中 OnPaint 函数片段 void CGameBoard::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bitmap; bitmap.CreateCompatibleBitmap(dc, m_nWidth, m_nHeight); CBitmap* pOldBitmap memDC.SelectObject(bitmap); // 保存旧位图指针 // ... 双缓冲绘制逻辑DrawGrid, DrawCells... dc.BitBlt(0, 0, m_nWidth, m_nHeight, memDC, 0, 0, SRCCOPY); // 关键修复必须恢复并删除memDC选中的位图 memDC.SelectObject(pOldBitmap); // 恢复旧位图 bitmap.DeleteObject(); // 删除新创建的位图 memDC.DeleteDC(); // 删除内存DC }位图资源加载兼容性处理VC6的LoadImage默认用LR_LOADFROMFILE而VS2022需显式指定LR_CREATEDIBSECTION以支持24位BMP透明通道。修改CMainFrame::LoadBitmaps()中加载逻辑// 原VC6写法在VS2022中可能加载失败 // m_hbmMine (HBITMAP)LoadImage(NULL, res\\mine.bmp, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE); // VS2022兼容写法强制DIB位图 m_hbmMine (HBITMAP)LoadImage( AfxGetInstanceHandle(), MAKEINTRESOURCE(IDB_MINE), IMAGE_BITMAP, 0, 0, LR_CREATEDIBSECTION | LR_DEFAULTSIZE | LR_LOADMAP3DCOLORS );LR_LOADMAP3DCOLORS确保系统色表如3D阴影色正确映射避免数字图标边缘出现紫边——这是Win32 GDI渲染的老坑新手常以为是图片问题实则是资源加载标志缺失。2.3 编译与运行验证如何确认双缓冲真正生效而非伪优化仅靠肉眼观察“不闪屏”不足以验证双缓冲。真实验证需两步注入GDI句柄监控在OnPaint()开头添加日志OutputDebugString(L[DEBUG] CGameBoard::OnPaint start\n); // ... 绘制逻辑 ... OutputDebugString(L[DEBUG] CGameBoard::OnPaint end\n);启动程序后用Visual Studio的“调试 → 窗口 → 输出”查看日志频率。若每秒稳定输出2~3次非连续刷屏说明WM_PAINT被节流双缓冲起效。强制触发重绘对比在CMainFrame::OnSize()中注释掉Invalidate()调用改为// Invalidate(); // 原始代码 RedrawWindow(NULL, NULL, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);观察窗口缩放时是否仍有撕裂感。若无撕裂则证明RedrawWindow已绕过默认重绘队列双缓冲链路完整。3. 核心模块拆解GameBoard类如何用二维数组状态机实现扫雷逻辑闭环3.1 雷区数据结构设计为什么用BYTE而非bool且m_bIsMine与m_nNeighbor分开存储GameBoard.h中定义核心数据结构class CGameBoard { private: BYTE** m_ppbCells; // 二维动态数组每个BYTE存复合状态 int m_nWidth, m_nHeight; int m_nTotalMines; // 状态位定义节省空间避免结构体对齐填充 static const BYTE CELL_HIDDEN 0x01; // 未翻开 static const BYTE CELL_REVEALED 0x02; // 已翻开 static const BYTE CELL_FLAGGED 0x04; // 插旗 static const BYTE CELL_QUESTION 0x08; // 问号 static const BYTE CELL_MINE 0x10; // 是雷独立位不与隐藏/翻开互斥 public: void InitBoard(int width, int height, int mines); void RevealCell(int x, int y); // 主翻开逻辑 void ToggleFlag(int x, int y); // 右键切换旗/问号 bool IsMine(int x, int y) { return (m_ppbCells[y][x] CELL_MINE); } int GetNeighborCount(int x, int y); // 计算周围雷数 };为什么用BYTE若用bool m_bIsMine[100][100]int m_nNeighbor[100][100]内存占用10000×(14)50KB而BYTE**二维数组位运算仅需10000×110KB。更重要的是——状态耦合一个格子可同时是“隐藏插旗”0x01|0x040x05也可“已翻开是雷”0x02|0x100x12这种正交状态用位域天然表达比多字段结构体更易维护。3.2 第一次点击必安全InitBoardWithSafeFirstClick()的三次校验逻辑扫雷规则要求“首次点击绝不会踩雷”但原生随机布雷无法保证。该工程采用“延迟布雷”策略void CGameBoard::InitBoardWithSafeFirstClick(int firstX, int firstY) { // Step 1: 全区域初始化为安全非雷 for (int y 0; y m_nHeight; y) for (int x 0; x m_nWidth; x) m_ppbCells[y][x] 0; // Step 2: 在firstX/firstY周围3×3区域标记为禁止布雷区 for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { int nx firstX dx, ny firstY dy; if (nx 0 nx m_nWidth ny 0 ny m_nHeight) { m_ppbCells[ny][nx] | 0x80; // 临时标记禁止位 } } } // Step 3: 在剩余区域随机撒雷跳过被标记的格子 int placed 0; while (placed m_nTotalMines) { int rx rand() % m_nWidth; int ry rand() % m_nHeight; if (!(m_ppbCells[ry][rx] 0x80)) { // 未被禁止 m_ppbCells[ry][rx] | CELL_MINE; placed; } } // 清除禁止标记 for (int y 0; y m_nHeight; y) for (int x 0; x m_nWidth; x) m_ppbCells[y][x] ~0x80; }血泪经验此处0x80是临时标记位若忘记最后的~0x80清除会导致后续GetNeighborCount()计算时把禁止位误判为雷——A同学曾因此调试2小时最终在GetNeighborCount中加断点发现m_ppbCells[y][x] CELL_MINE恒为true。从此我养成了习惯所有临时标记操作必须用// BEGIN TEMP MARK 和// END TEMP MARK 包裹并在结束处强制ASSERT校验。3.3 递归展开算法RevealCell()如何防止栈溢出与重复访问标准DFS展开在16×30雷区可能触发深度100的递归VC默认栈仅1MB易崩溃。该工程改用显式栈访问标记void CGameBoard::RevealCell(int x, int y) { if (x 0 || x m_nWidth || y 0 || y m_nHeight) return; if (!(m_ppbCells[y][x] CELL_HIDDEN)) return; // 已翻开或标记不处理 std::stackstd::pairint, int stack; stack.push({x, y}); // 访问标记数组避免重复入栈 bool** visited new bool*[m_nHeight]; for (int i 0; i m_nHeight; i) { visited[i] new bool[m_nWidth]{false}; } while (!stack.empty()) { auto [cx, cy] stack.top(); stack.pop(); if (visited[cy][cx]) continue; visited[cy][cx] true; // 标记为已翻开 m_ppbCells[cy][cx] ~CELL_HIDDEN; m_ppbCells[cy][cx] | CELL_REVEALED; // 若邻雷数为0则展开周围8格 if (GetNeighborCount(cx, cy) 0) { for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { if (dx 0 dy 0) continue; int nx cx dx, ny cy dy; if (nx 0 nx m_nWidth ny 0 ny m_nHeight) { if (m_ppbCells[ny][nx] CELL_HIDDEN) { stack.push({nx, ny}); } } } } } } // 释放visited内存 for (int i 0; i m_nHeight; i) delete[] visited[i]; delete[] visited; }玄学细节stack.push({nx, ny})的入栈顺序决定了展开方向左上→右下。若想模拟原版扫雷的“从点击点向四周波纹展开”可将8个方向按曼哈顿距离排序后入栈当前代码按dx/dy循环顺序实际效果是“Z字形扫描”但功能完全等价。4. 图形渲染与交互双缓冲绘图链路与右键状态机的实现真相4.1 WM_PAINT全流程从BeginPaint到BitBlt的7个不可省略步骤CMainFrame::OnPaint()是整个视觉系统的中枢其执行链路必须严格遵循GDI规范void CMainFrame::OnPaint() { CPaintDC dc(this); // 1. 获取设备上下文自动处理无效区 // 2. 创建兼容内存DC关键避免屏幕闪烁 CDC memDC; memDC.CreateCompatibleDC(dc); // 3. 创建兼容位图尺寸客户区 CRect rect; GetClientRect(rect); CBitmap bitmap; bitmap.CreateCompatibleBitmap(dc, rect.Width(), rect.Height()); // 4. 将位图选入内存DC返回旧位图句柄 CBitmap* pOldBitmap memDC.SelectObject(bitmap); // 5. 填充背景防止残留图像 memDC.FillSolidRect(rect, ::GetSysColor(COLOR_BTNFACE)); // 6. 调用GameBoard绘制游戏区域核心业务逻辑 m_gameBoard.Draw(memDC, 10, 50); // (x,y)偏移避开菜单栏 // 7. 将内存DC内容拷贝到屏幕DC原子操作无闪烁 dc.BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 清理见2.2节修复 memDC.SelectObject(pOldBitmap); bitmap.DeleteObject(); memDC.DeleteDC(); }为什么必须用CPaintDC而非CDC*CPaintDC构造时自动调用BeginPaint获取系统计算出的无效矩形rcPaint确保只重绘脏区域若用GetDC()则重绘整个窗口性能暴跌。某次我误用GetDC16×30雷区拖动窗口时CPU飙到30%换成CPaintDC后降至3%。4.2 右键状态机FLAG → QUESTION → HIDDEN的三态循环如何绑定到WM_RBUTTONDOWN扫雷右键行为是经典状态机点击未翻开格子循环切换【插旗→问号→隐藏】。该工程将状态流转封装在CGameBoard::ToggleFlag()中并由CMainFrame::OnRButtonDown()驱动// CMainFrame.cpp void CMainFrame::OnRButtonDown(UINT nFlags, CPoint point) { // 将屏幕坐标转为客户区坐标 ScreenToClient(point); // 计算点击落在哪个格子格子大小24×24像素 int cellX (point.x - 10) / 24; // 减去左偏移 int cellY (point.y - 50) / 24; // 减去上偏移菜单栏高度 if (cellX 0 cellX m_gameBoard.GetWidth() cellY 0 cellY m_gameBoard.GetHeight()) { // 关键仅当游戏未结束时才响应右键 if (!m_bGameOver) { m_gameBoard.ToggleFlag(cellX, cellY); Invalidate(); // 触发重绘 } } CFrameWnd::OnRButtonDown(nFlags, point); } // GameBoard.cpp void CGameBoard::ToggleFlag(int x, int y) { BYTE cell m_ppbCells[y][x]; if (cell CELL_REVEALED) return; // 已翻开格子不响应 if (cell CELL_FLAGGED) { cell ~CELL_FLAGGED; // 移除旗 cell | CELL_QUESTION; // 切换为问号 } else if (cell CELL_QUESTION) { cell ~CELL_QUESTION; // 移除问号 // 保持CELL_HIDDEN即回归隐藏态 } else { cell | CELL_FLAGGED; // 插旗 } }注意Invalidate()必须在ToggleFlag()之后调用否则状态变更不会触发重绘。曾有开发者把Invalidate()放在if(!m_bGameOver)外层导致游戏结束后仍能插旗视觉上旗消失但内存状态已乱。4.3 爆炸动画实现为什么用SetTimer而非Sleep以及如何避免定时器嵌套当玩家踩雷时需播放“雷爆炸”动画连续切换3帧位图。若用Sleep(100)阻塞主线程UI将假死。该工程用SetTimer实现非阻塞动画// CMainFrame.h #define TIMER_EXPLODE 1001 // CMainFrame.cpp void CMainFrame::OnLButtonDown(UINT nFlags, CPoint point) { // ... 点击逻辑 ... if (m_gameBoard.IsMine(cellX, cellY)) { m_bGameOver true; m_nGameResult RESULT_LOSE; SetTimer(TIMER_EXPLODE, 150, NULL); // 150ms一帧 } } void CMainFrame::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_EXPLODE) { static int frame 0; static const UINT bmpIds[] {IDB_MINE1, IDB_MINE2, IDB_MINE3}; // 切换爆炸位图 if (frame 3) { m_hbmMine (HBITMAP)LoadImage( AfxGetInstanceHandle(), MAKEINTRESOURCE(bmpIds[frame]), IMAGE_BITMAP, 0, 0, LR_DEFAULTSIZE ); frame; Invalidate(); } else { KillTimer(TIMER_EXPLODE); frame 0; // 显示失败对话框 MessageBox(_T(Game Over!), _T(Mine Sweeper), MB_OK); } } CFrameWnd::OnTimer(nIDEvent); }避坑定时器回调中的资源泄漏每次LoadImage都会创建新位图句柄若不释放旧句柄3帧后内存泄漏。修复方式在OnTimer开头添加if(m_hbmMine) DeleteObject(m_hbmMine);。这是Win32 GDI开发的铁律——所有LoadImage/CreateBitmap必须配对DeleteObject。5. 避坑指南编译、运行与调试中高频翻车的5个真实场景5.1 现象编译通过但运行时报“找不到MSVCP60.dll”原因VC6.0生成的EXE依赖微软旧版C运行库而Win10/11默认不安装。VS2022编译时若未静态链接仍会引用msvcp140.dll等新库但项目中混用了VC6风格的#include afxwin.h导致链接器混淆。解决在VS2022项目属性 → 配置属性 → C/C → 代码生成 → 运行库改为/MT多线程静态链接彻底消除DLL依赖。5.2 现象点击格子无反应调试发现OnLButtonDown根本没被调用原因CMainFrame未重载PreTranslateMessage导致鼠标消息被子窗口如CGameBoard截获但CGameBoard未处理WM_LBUTTONDOWN。原VC6工程中CGameBoard是CWnd派生类但未实现消息映射。解决在CGameBoard.h中添加消息映射宏在.cpp中实现空处理函数// CGameBoard.h afx_msg void OnLButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() // CGameBoard.cpp BEGIN_MESSAGE_MAP(CGameBoard, CWnd) ON_WM_LBUTTONDOWN() END_MESSAGE_MAP() void CGameBoard::OnLButtonDown(UINT nFlags, CPoint point) { // 转发给父窗口CMainFrame GetParent()-PostMessage(WM_LBUTTONDOWN, nFlags, MAKELPARAM(point.x, point.y)); }5.3 现象高级模式16×30下右键切换旗子时UI卡顿明显原因ToggleFlag()中每次调用Invalidate()触发全窗口重绘而16×30雷区有480个格子OnPaint()需遍历全部绘制。解决改为局部重绘——计算被点击格子的客户区矩形仅使其失效// CMainFrame.cpp 中 OnRButtonDown 末尾 CRect cellRect; cellRect.left 10 cellX * 24; cellRect.top 50 cellY * 24; cellRect.right cellRect.left 24; cellRect.bottom cellRect.top 24; InvalidateRect(cellRect, TRUE); // 仅重绘该格子5.4 现象数字图标显示为黑块但位图文件确认无损原因TextOut绘制数字时未设置文本背景模式为透明导致数字被系统背景色通常是灰色覆盖。解决在GameBoard::DrawNumber()中添加memDC.SetBkMode(TRANSPARENT); // 关键 memDC.SetTextColor(RGB(0, 0, 0)); memDC.TextOut(x, y, szNum, len);5.5 现象修改雷区大小后窗口无法完整显示新布局右侧被裁切原因CMainFrame::OnSize()中未动态调整客户区尺寸仍按固定值MoveWindow(0,0,400,500)设置。解决在OnSize()中根据雷区尺寸计算窗口宽高void CMainFrame::OnSize(UINT nType, int cx, int cy) { CFrameWnd::OnSize(nType, cx, cy); if (nType ! SIZE_MINIMIZED) { int boardWidth 10 m_gameBoard.GetWidth() * 24 10; // 左右边距各10 int boardHeight 50 m_gameBoard.GetHeight() * 24 10; // 菜单栏50下边距10 SetWindowPos(NULL, 0, 0, boardWidth, boardHeight, SWP_NOMOVE | SWP_NOZORDER); } }6. 二次开发实战从添加计分系统到移植为控制台版本的3种改造路径6.1 路径一为游戏增加计分系统基于时间与步数的复合评分原项目仅有胜负判定无分数。添加计分需三处修改新增数据成员CGameBoard.hprivate: DWORD m_dwStartTime; // 游戏开始时间GetTickCount int m_nClickCount; // 总点击次数左右键均计 int m_nScore; // 实时分数 public: void StartTimer() { m_dwStartTime GetTickCount(); m_nClickCount 0; m_nScore 0; } void AddClick() { m_nClickCount; UpdateScore(); } int GetScore() { return m_nScore; }实现评分逻辑GameBoard.cppvoid CGameBoard::UpdateScore() { if (m_dwStartTime 0) return; DWORD elapsed (GetTickCount() - m_dwStartTime) / 1000; // 秒数 // 公式基础分1000 - 时间惩罚(每秒扣2分) - 步数惩罚(每步扣1分) m_nScore 1000 - elapsed * 2 - m_nClickCount * 1; if (m_nScore 0) m_nScore 0; }在UI层显示分数CMainFrame::OnPaint末尾CString scoreText; scoreText.Format(_T(Score: %d), m_gameBoard.GetScore()); dc.TextOut(10, 25, scoreText); // 在菜单栏下方显示验证技巧在CMainFrame::OnLButtonDown中调用m_gameBoard.AddClick()并用OutputDebugString打印m_nScore变化。启动后快速点击空白区观察分数是否按公式递减——这是最直接的逻辑验证。6.2 路径二将图形界面剥离导出纯逻辑库供其他UI调用若想用Qt或WebAssembly重写界面只需提取GameBoard为独立静态库新建Win32静态库工程将GameBoard.h/.cpp、Resource.h删去位图ID、MineSweeper.h仅保留CGameBoard声明加入。移除所有GDI相关代码Draw系列函数、CBitmap成员保留InitBoard、RevealCell、ToggleFlag等纯逻辑接口。导出C风格接口避免C name mangling// GameBoard_C.h #ifdef __cplusplus extern C { #endif typedef void* GAMEBOARD_HANDLE; GAMEBOARD_HANDLE CreateGameBoard(int width, int height, int mines); void DestroyGameBoard(GAMEBOARD_HANDLE h); void RevealCell(GAMEBOARD_HANDLE h, int x, int y); int GetCellState(GAMEBOARD_HANDLE h, int x, int y); // 返回枚举值 #ifdef __cplusplus } #endif关键教训导出函数必须用extern C否则C#或Rust调用时因name mangling找不到符号。我曾为某跨平台项目导出此库因漏写extern C在.NET Core中DllImport始终报EntryPointNotFoundException排查3小时才发现是链接约定问题。6.3 路径三逆向移植为控制台版本无GUI纯字符界面保留全部游戏逻辑仅替换渲染层为printf删除CMainFrame、OnPaint等所有UI类新建ConsoleRenderer.cppvoid RenderBoard(const CGameBoard board) { int w board.GetWidth(), h board.GetHeight(); for (int y 0; y h; y) { for (int x 0; x w; x) { BYTE state board.GetCellState(x, y); if (state CGameBoard::CELL_REVEALED) { if (state CGameBoard::CELL_MINE) { printf(X); // 踩雷 } else { int n board.GetNeighborCount(x, y); printf(%d, n ? n : .); // 0显示为. } } else if (state CGameBoard::CELL_FLAGGED) { printf(F); } else { printf(#); // 未翻开 } } printf(\n); } }主循环用_getch()替代消息循环int main() { CGameBoard board; board.InitBoard(9, 9, 10); board.StartTimer(); while (!board.IsGameOver()) { system(cls); // 清屏 RenderBoard(board); printf(输入坐标(x y)或q退出: ); char input[10]; fgets(input, sizeof(input), stdin); if (input[0] q) break; int x, y; if (sscanf(input, %d %d, x, y) 2) { board.RevealCell(x, y); } } return 0; }从那以后我每次做Win32 GUI项目都会在README.md里同步维护一份console_version/目录里面放着精简后的控制台版。不是为了炫技而是当客户突然说“先给我个命令行版跑通逻辑”时我能30秒内给出可执行文件——这比解释“为什么GUI要一周”有用得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站