用MFC实现一个简单灵活的扫雷程序
主体界面布局
主体界面采用对话框模板,界面布局如下:
程序实际运行的界面示意如下:
对照上面两张图可知,主体对话框中只有三个静态控件(一个按钮,一个复选框,一个文本显示框)。这三个静态控件之下是m行n列的按钮矩阵。这个按钮矩阵是动态创建和加载的。
矩阵单元设计
一个矩阵单元可表示一个雷,也可表示一个非雷。当表示非雷时,该单元对应有一个数,以记录它周围的雷数,这个数为0到8之内的整数。为便于处理,让雷单元对应整数9。并使用-1表示未布局的单元。
一个单元存在三种状态:覆盖,打开,标记(为雷)。一个单元要关联一个按钮,以响应相应的鼠标左击、右击事件。于是有如下数据结构定义:
1 enum ENUM_BTN_STATUS 2 { 3 E_BtnSta_Covered, 4 E_BtnSta_Open, 5 E_BtnSta_Flagged 6 }; 7 8 struct SCell 9 { 10 CButton* pBtn; 11 int nVal; 12 ENUM_BTN_STATUS eStatus; 13 14 SCell() 15 { 16 pBtn = NULL; 17 reset(); 18 } 19 void reset() 20 { 21 nVal = -1; 22 eStatus = E_BtnSta_Covered; 23 } 24 };
动态按钮数组相关事件处理设计
按钮矩阵里的按钮,按按钮ID来看构成一个一维动态按钮数组。这些动态按钮的左击/右击事件要求在统一的事件处理方法里处理,其好处是不言而喻的。
如下代码段中5、6、7、8行为新增代码:
1 BEGIN_MESSAGE_MAP(CsimpleMinerDlg, CDialogEx) 2 ON_WM_SYSCOMMAND() 3 ON_WM_PAINT() 4 ON_WM_QUERYDRAGICON() 5 ON_CONTROL_RANGE(BN_CLICKED, IDC_BTN_HEAD, IDC_BTN_HEAD + 1000, OnButtonClick) 6 ON_NOTIFY_RANGE(NM_RCLICK, IDC_BTN_HEAD, IDC_BTN_HEAD + 1000, OnRClicked) 7 ON_BN_CLICKED(IDC_CHECK_SHOW, OnBnClickedCheckShow) 8 ON_BN_CLICKED(IDC_BTN_CUSTOM, OnBnClickedBtnCustom) 9 END_MESSAGE_MAP()
第5行指定ID从IDC_BTN_HEAD到IDC_BTN_HEAD + 1000的按钮的单击(即左击)事件统一由接口OnButtonClick处理。同样,第6行指定这些按钮的右击事件统一由接口OnRClicked处理。
7、8两行指定主体界面两个静态控件的单击事件分别由对应的接口处理。
对话框里用户增加的静态控件的ID值是由Visual Studio自行指定的,一般从1000开始往上逐一加1,统一在resource.h里定义,如:
#define IDC_BTN_CUSTOM 1000 #define IDC_CHECK_SHOW 1001
上面所述的一组动态按钮的ID值需要避免与静态控件的ID值重叠,因此另行在simpleMinerDlg.h里定义IDC_BTN_HEAD,如下:
#define IDC_BTN_HEAD 3000
就是说,3000到4000这些ID值预留给上述矩阵单元使用。矩阵单元的最大数量为18×32=576,预留的ID数量是足够的。
动态二维数组设计
按钮矩阵里的按钮,按行列排列构成一个二维动态按钮数组。为了对邻居单元计算方便,使用std::vector嵌套实现动态二维数组,具体如下:
#define VecCell std::vector<SCell> // CsimpleMinerDlg Dialog class CsimpleMinerDlg : public CDialogEx { ... protected: ... std::vector<VecCell> m_vecCells;
...
}
CsimpleMinerDlg类其它新增的成员变量
UINT m_uiRowSum; UINT m_uiColSum; UINT m_uiMineSum; UINT m_uiFlagSum; UINT m_uiOpenSum; int m_nCustomMineSum; BOOL m_bShow;
矩阵行数,矩阵列数,总雷数,已经标记为雷的单元数,已经点开的单元数,要定制的总雷数,是否显示当前剩余的雷数(对应主体界面上的复选框控件)。
主体界面的初始呈现
CsimpleMinerDlg的构造函数实现如下,粗体为新增行:
CsimpleMinerDlg::CsimpleMinerDlg(CWnd* pParent /*=NULL*/) : CDialogEx(CsimpleMinerDlg::IDD, pParent) , m_bShow(FALSE), m_nCustomMineSum(-1) { m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME); resetValues(); m_uiRowSum = 18; m_uiColSum = 18; }
初始呈现的是一个18×18的按钮矩阵。
resetValues接口做布局前的简单数据清零处理:
void resetValues() { m_uiMineSum = 0; m_uiOpenSum = 0; m_uiFlagSum = 0; }
在CsimpleMinerDlg::OnInitDialog的末尾仅增加如下一行代码:
initButtons();
在此处,initButtons接口负责主体界面的初始呈现。该接口也负责完成定制后的主体界面呈现,后面再说。
initButtons接口的具体实现如下:
1 void CsimpleMinerDlg::initButtons() 2 { 3 resetValues(); 4 5 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 6 { 7 VecCell vec; 8 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 9 { 10 SCell oCell; 11 oCell.pBtn = new CMyButton; 12 vec.push_back(oCell); 13 } 14 m_vecCells.push_back(vec); 15 } 16 17 srand((UINT)time(0)); 18 /// create button images and arrange the mines 19 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 20 { 21 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 22 { 23 if (m_nCustomMineSum == -1 && (UINT)rand() % 5 == 0) 24 { 25 ++m_uiMineSum; 26 m_vecCells[idxR][idxC].nVal = 9; 27 } 28 m_vecCells[idxR][idxC].pBtn->Create(_T(""), WS_VISIBLE | WS_CHILD | WS_BORDER, 29 CRect(10 + idxC * 40, 50 + idxR * 40, 10 + idxC * 40 + 38, 50 + idxR * 40 + 38), this, IDC_BTN_HEAD + idxR * m_uiColSum + idxC); 30 } 31 } 32 if (m_nCustomMineSum != -1) 33 { 34 layoutCustomMines(); 35 } 36 37 GetDlgItem(IDC_STATIC_LEFT_SUM)->ShowWindow(m_bShow ? SW_SHOW : SW_HIDE); 38 fillOut(); 39 40 int nEndX = 40 * m_uiColSum + 40; 41 if (nEndX < 305) 42 { 43 nEndX = 305; 44 } 45 SetWindowPos(NULL, -1, -1, nEndX, 40 * m_uiRowSum + 100, SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOZORDER); 46 }
initButtons接口实现主要分为三个部分:动态构建二维数组m_vecCells;布雷和为每个矩阵单元创建按钮视图(为了减少遍历次数,放在一次遍历里一起做);为非雷单元填数(在fillOut接口里实现)。
末尾的SetWindowPos调用是为了动态调整主体界面的大小,确保正好露出界面上静态和动态控件。
SimpleMiner提供两种布雷策略:按比例布雷;按定制雷数布雷(在layoutCustomMines接口实现)。
初始呈现时,m_nCustomMineSum取值为-1,因而采用按比例布雷的策略。布雷时遍历每个单元,做一次rand()得到一个随机数,该数整除5的话就把对应的单元设置为雷。总体而言,雷的总数约占总单元数的1/5。
oCell.pBtn = new CMyButton;
这行代码用的是CMyButton,而不是pBtn定义对应的CButton。这是因为MFC只为CButton开放了单击和双击事件处理接口,而没有开放右击事件处理接口。因而需要派生出一个可以处理右击事件的按钮类。具体参见:
为非雷单元填数
fillOut接口实现如下:
1 /// set value for each non-mine cell 2 void CsimpleMinerDlg::fillOut() 3 { 4 setLeftMineSum(); 5 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 6 { 7 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 8 { 9 SCell& oCell = m_vecCells[idxR][idxC]; 10 if (oCell.nVal == -1) 11 { 12 oCell.nVal = calcNeighborMineSum(idxR, idxC); 13 } 14 } 15 } 16 }
其中setLeftMineSum接口很简单,用于在文本显示框里显示剩余雷数:
1 void CsimpleMinerDlg::setLeftMineSum() 2 { 3 CString str; 4 str.Format(_T("%d"), (int)m_uiMineSum - (int)m_uiFlagSum); 5 GetDlgItem(IDC_STATIC_LEFT_SUM)->SetWindowText(str); 6 }
calcNeighborMineSum接口实现计算某个非雷单元的周围有几个雷单元:
1 UINT CsimpleMinerDlg::calcNeighborMineSum(UINT uiRow, UINT uiCol) 2 { 3 UINT uiRet = 0; 4 5 /// concerning last row 6 if (uiRow > 0) 7 { 8 if (uiCol > 0 && m_vecCells[uiRow - 1][uiCol - 1].nVal == 9) // left cell 9 { 10 ++uiRet; 11 } 12 if (m_vecCells[uiRow - 1][uiCol].nVal == 9) // middle cell 13 { 14 ++uiRet; 15 } 16 if (uiCol + 1 < m_uiColSum && m_vecCells[uiRow - 1][uiCol + 1].nVal == 9) // right cell 17 { 18 ++uiRet; 19 } 20 } 21 22 /// concerning current row 23 if (uiCol > 0 && m_vecCells[uiRow][uiCol - 1].nVal == 9) // left cell 24 { 25 ++uiRet; 26 } 27 if (uiCol + 1 < m_uiColSum && m_vecCells[uiRow][uiCol + 1].nVal == 9) // right cell 28 { 29 ++uiRet; 30 } 31 32 /// concerning next row 33 if (uiRow + 1 < m_uiRowSum) 34 { 35 if (uiCol > 0 && m_vecCells[uiRow + 1][uiCol - 1].nVal == 9) // left one 36 { 37 ++uiRet; 38 } 39 if (m_vecCells[uiRow + 1][uiCol].nVal == 9) // middle one 40 { 41 ++uiRet; 42 } 43 if (uiCol + 1 < m_uiColSum && m_vecCells[uiRow + 1][uiCol + 1].nVal == 9) // right one 44 { 45 ++uiRet; 46 } 47 } 48 49 return uiRet; 50 }
依次考察上一行(上左、上中、上右),当前行(左、右),下一行(下左、下中、下右),累计周围雷数。
矩阵单元的单击(左击)事件处理
1 void CsimpleMinerDlg::OnButtonClick(UINT id) 2 { 3 UINT uiRow = (id - IDC_BTN_HEAD) / m_uiColSum; 4 UINT uiCol = (id - IDC_BTN_HEAD) % m_uiColSum; 5 SCell& oCell = m_vecCells[uiRow][uiCol]; 6 if (oCell.eStatus == E_BtnSta_Open) 7 { 8 smartUncoverNeighbors(uiRow, uiCol); 9 checkIfDone(); 10 return; 11 } 12 if (oCell.eStatus == E_BtnSta_Flagged) 13 { 14 removeFlag(oCell); 15 return; 16 } 17 if (oCell.nVal == 9) 18 { 19 gameOver(oCell); 20 return; 21 } 22 uncover(uiRow, uiCol); 23 checkIfDone(); 24 }
首先根据按钮的ID值求出按钮在矩阵中所在行、列位置,进而对应到具体的矩阵单元。然后按被单击的单元所处的状态分情况进行处理:
1、如果该单元已经是点开状态,则调用smartUncoverNeighbors接口和checkIfDone接口
2、如果该单元是标雷状态,则调用removeFlag接口去除标雷状态,即回到覆盖状态
3、如果该单元是覆盖状态,再进一步查看该单元的取值:如果是9,说明点到雷了,调用gameOver接口;否则调用uncover接口显示该单元的取值,最后调用checkIfDone接口
uncover、uncoverNeighbors和smartUncoverNeighbors
uncover接口可作用于打开指定非雷单元,即在对应按钮上显示该单元的数值,如果该数值为0,则还要把该单元邻接的所有单元都打开。
uncover接口也可作用于打开指定雷单元,即打开取值为9的单元(在对应按钮上显示“M”,并弹出提示窗告知游戏失败结束)。
uncover接口的实现如下:
1 void CsimpleMinerDlg::uncover(UINT uiRow, UINT uiCol) 2 { 3 SCell& oCell = m_vecCells[uiRow][uiCol]; 4 if (oCell.eStatus != E_BtnSta_Covered) 5 { 6 return; 7 } 8 if (oCell.nVal == 9) 9 { 10 gameOver(oCell); 11 return; 12 } 13 CString str; 14 str.Format(_T("%d"), oCell.nVal); 15 oCell.pBtn->SetWindowTextW(str); 16 oCell.eStatus = E_BtnSta_Open; 17 ++m_uiOpenSum; 18 if (oCell.nVal != 0) 19 { 20 return; 21 } 22 uncoverNeighbors(uiRow, uiCol); 23 }
uncoverNeighbors接口实现把指定单元的邻接单元全部打开:
1 void CsimpleMinerDlg::uncoverNeighbors(UINT uiRow, UINT uiCol) 2 { 3 /// uncover neighboring cells 4 if (uiRow > 0) // having last row 5 { 6 if (uiCol > 0) 7 { 8 uncover(uiRow - 1, uiCol - 1); 9 } 10 uncover(uiRow - 1, uiCol); 11 if (uiCol + 1 < m_uiColSum) 12 { 13 uncover(uiRow - 1, uiCol + 1); 14 } 15 } 16 if (uiCol > 0) 17 { 18 uncover(uiRow, uiCol - 1); 19 } 20 if (uiCol + 1 < m_uiColSum) 21 { 22 uncover(uiRow, uiCol + 1); 23 } 24 if (uiRow + 1 < m_uiRowSum) // having next row 25 { 26 if (uiCol > 0) 27 { 28 uncover(uiRow + 1, uiCol - 1); 29 } 30 uncover(uiRow + 1, uiCol); 31 if (uiCol + 1 < m_uiColSum) 32 { 33 uncover(uiRow + 1, uiCol + 1); 34 } 35 } 36 }
可以看到,uncover和uncoverNeighbors之间存在递归调用。
smartUncoverNeighbors接口,如上所见,是在一个非雷单元被打开之后再次被单击后调用。该接口用于当一个单元邻接的雷单元全部被标注出来后,只需点击一次该单元就自动打开其邻接的非雷单元,而不必逐个去点一次。当然,如果标雷错误,这个操作会导致打开雷单元而提前宣告游戏失败结束。
1 void CsimpleMinerDlg::smartUncoverNeighbors(UINT uiRow, UINT uiCol) 2 { 3 SCell& oCell = m_vecCells[uiRow][uiCol]; 4 if (oCell.eStatus != E_BtnSta_Open || oCell.nVal == 0) 5 { 6 return; 7 } 8 int nNeiborFlags = 0; 9 int nNeiborCovers = 0; 10 calcNeiborFlagsAndCovers(uiRow, uiCol, nNeiborFlags, nNeiborCovers); 11 if (nNeiborFlags != oCell.nVal || nNeiborCovers == 0) 12 { 13 return; 14 } 15 uncoverNeighbors(uiRow, uiCol); 16 }
calcNeiborFlagsAndCovers接口用来计算指定单元的邻接单元中处在标雷和覆盖状态的各有多少个:
1 void CsimpleMinerDlg::calcNeiborFlagsAndCovers(UINT uiRow, UINT uiCol, int& nNeiborFlags, int& nNeiborCovers) 2 { 3 nNeiborFlags = 0; 4 nNeiborCovers = 0; 5 6 /// having last row 7 if (uiRow > 0) 8 { 9 if (uiCol > 0) // left 10 { 11 cumuFlagsAndCovers(m_vecCells[uiRow - 1][uiCol - 1].eStatus, nNeiborFlags, nNeiborCovers); 12 } 13 cumuFlagsAndCovers(m_vecCells[uiRow - 1][uiCol].eStatus, nNeiborFlags, nNeiborCovers); // mid 14 if (uiCol + 1 < m_uiColSum) // right 15 { 16 cumuFlagsAndCovers(m_vecCells[uiRow - 1][uiCol + 1].eStatus, nNeiborFlags, nNeiborCovers); 17 } 18 } 19 20 /// current row 21 if (uiCol > 0) // left 22 { 23 cumuFlagsAndCovers(m_vecCells[uiRow][uiCol - 1].eStatus, nNeiborFlags, nNeiborCovers); 24 } 25 if (uiCol + 1 < m_uiColSum) // right 26 { 27 cumuFlagsAndCovers(m_vecCells[uiRow][uiCol + 1].eStatus, nNeiborFlags, nNeiborCovers); 28 } 29 30 /// next row 31 if (uiRow + 1 < m_uiRowSum) 32 { 33 if (uiCol > 0) // left 34 { 35 cumuFlagsAndCovers(m_vecCells[uiRow + 1][uiCol - 1].eStatus, nNeiborFlags, nNeiborCovers); 36 } 37 cumuFlagsAndCovers(m_vecCells[uiRow + 1][uiCol].eStatus, nNeiborFlags, nNeiborCovers); // mid 38 if (uiCol + 1 < m_uiColSum) // right 39 { 40 cumuFlagsAndCovers(m_vecCells[uiRow + 1][uiCol + 1].eStatus, nNeiborFlags, nNeiborCovers); 41 } 42 } 43 }
辅助函数cumuFlagsAndCovers的实现如下:
1 void cumuFlagsAndCovers(ENUM_BTN_STATUS eState, int& nNeiborFlags, int& nNeiborCovers) 2 { 3 if (eState == E_BtnSta_Flagged) 4 { 5 nNeiborFlags++; 6 } 7 else if (eState == E_BtnSta_Covered) 8 { 9 nNeiborCovers++; 10 } 11 }
矩阵单元的右击事件处理
右击事件处理比较简单,就是标雷和去标雷:
1 void CsimpleMinerDlg::OnRClicked(UINT id, NMHDR* pNotify, LRESULT* pResult) 2 { 3 UINT uiRow = (id - IDC_BTN_HEAD) / m_uiColSum; 4 UINT uiCol = (id - IDC_BTN_HEAD) % m_uiColSum; 5 SCell& oCell = m_vecCells[uiRow][uiCol]; 6 if (oCell.eStatus == E_BtnSta_Covered) 7 { 8 addFlag(oCell); 9 m_uiFlagSum++; 10 setLeftMineSum(); 11 } 12 else if (oCell.eStatus == E_BtnSta_Flagged) 13 { 14 removeFlag(oCell); 15 m_uiFlagSum--; 16 setLeftMineSum(); 17 } 18 }
标雷和去标雷,两个辅助函数:
void removeFlag(SCell& oCell) { oCell.pBtn->SetWindowTextW(_T("")); oCell.eStatus = E_BtnSta_Covered; } void addFlag(SCell& oCell) { oCell.pBtn->SetWindowTextW(_T("√")); oCell.eStatus = E_BtnSta_Flagged; }
Well Done与Game Over后的重新布局
checkIfDone和gameOver接口实现中,在弹完相应提示后重新布局:
1 void CsimpleMinerDlg::checkIfDone() 2 { 3 if (m_uiOpenSum + m_uiMineSum != m_uiRowSum * m_uiColSum) 4 { 5 return; 6 } 7 AfxMessageBox(_T("Well Done!"), MB_ICONINFORMATION); 8 resetButtons(); 9 layoutMines(); 10 fillOut(); 11 } 12 13 void CsimpleMinerDlg::gameOver(SCell& oCell) 14 { 15 oCell.pBtn->SetWindowTextW(_T("M")); 16 AfxMessageBox(_T("Game Over!"), MB_ICONEXCLAMATION); 17 resetButtons(); 18 layoutMines(); 19 fillOut(); 20 }
重新布局分为三个部分:单元回置;布雷;填数。此时的重新布局和初始呈现时相比,少了矩阵单元构建、对应按钮视图创建等环节,因为这些都是重用的。
resetButtons接口实现:
1 void CsimpleMinerDlg::resetButtons() 2 { 3 resetValues(); 4 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 5 { 6 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 7 { 8 SCell& oCell = m_vecCells[idxR][idxC]; 9 oCell.reset(); 10 if (oCell.pBtn) 11 { 12 oCell.pBtn->SetWindowTextW(_T("")); 13 } 14 } 15 } 16 }
重新布雷的接口如下:
1 /// arrange mines 2 void CsimpleMinerDlg::layoutMines() 3 { 4 if (m_nCustomMineSum != -1) 5 { 6 layoutCustomMines(); 7 return; 8 } 9 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 10 { 11 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 12 { 13 UINT uiRand = (UINT)rand() % 5; 14 if (uiRand == 0) 15 { 16 ++m_uiMineSum; 17 m_vecCells[idxR][idxC].nVal = 9; 18 } 19 } 20 } 21 } 22 23 void CsimpleMinerDlg::layoutCustomMines() 24 { 25 /// layout by custom 26 while ((int)m_uiMineSum < m_nCustomMineSum) 27 { 28 int nRand = rand(); 29 UINT val = (UINT)nRand % (m_uiRowSum * m_uiColSum); 30 UINT row = val / m_uiColSum; 31 UINT col = val % m_uiColSum; 32 if (m_vecCells[row][col].nVal != 9) 33 { 34 ++m_uiMineSum; 35 m_vecCells[row][col].nVal = 9; 36 } 37 } 38 }
布局定制
在主体界面点击Custom按钮,会弹出如下所示窗口,以定制矩阵行数、列数、雷数:
Custom按钮单击事件处理接口实现如下:
1 void CsimpleMinerDlg::OnBnClickedBtnCustom() 2 { 3 CCustomDlg dlg; 4 dlg.setValues(m_uiRowSum, m_uiColSum, m_uiMineSum); 5 if (IDOK != dlg.DoModal()) 6 { 7 return; 8 } 9 m_nCustomMineSum = dlg.m_nMines; 10 if ((int)m_uiRowSum == dlg.m_nRows && (int)m_uiColSum == dlg.m_nCols) 11 { 12 resetButtons(); 13 layoutMines(); 14 fillOut(); 15 return; 16 } 17 deleteButtons(); 18 m_uiRowSum = (UINT)dlg.m_nRows; 19 m_uiColSum = (UINT)dlg.m_nCols; 20 initButtons(); 21 }
如果定制后与定制前的矩阵行数或列数有变化,则需要清除原有布局的单元及按钮实例和按钮视图实例,并调用initButtons重新布局。
deleteButtons接口负责清除原有布局的单元及按钮实例和按钮视图实例,具体实现如下:
1 void CsimpleMinerDlg::deleteButtons() 2 { 3 for (UINT idxR = 0; idxR < m_uiRowSum; ++idxR) 4 { 5 for (UINT idxC = 0; idxC < m_uiColSum; ++idxC) 6 { 7 m_vecCells[idxR][idxC].pBtn->DestroyWindow(); 8 delete m_vecCells[idxR][idxC].pBtn; 9 } 10 } 11 m_vecCells.clear(); 12 }
其它
完整代码文件和配套的资源和工程文件可以从如下位置提取:
https://github.com/readalps/SimpleMiner