本文主要讲解如何基于C++和MFC类库实现计算机博弈比赛中常用的程序界面,为了方便讲解,如图所示,采用一个假想的棋种-肆棋作为例子,规则非常简单:4*4的棋盘上有黑白双方共8枚棋子,每方有4个棋子放置在底线,默认黑方先行,交替行棋,每次走一个棋子,每个棋子只可以选择向前、向左上、向右上前进,遇到对方棋子可以吃掉,不可以连吃。双方轮流行棋至无棋可走为终局,棋子多者为胜方,棋子相同为和局。
一、生成程序配置
启动Visual Studio 2019,如果之前已经建立了项目,那么选择“打开项目或解决方案”继续对程序进行编辑,如果是新建的项目,选择“创建新项目”,为了减少开发的工作量,我们使用Visual Studio自带的向导来生成所需要的程序框架。
语言选择C++,平台选择Windows,项目类型选择桌面,我们选择“MFC应用”,然后点击下一步。
如果MFC应用的选项没有出现,那么很有可能是MFC库没有安装,我们可以点击下方的“安装多个工具和功能”启动installer重新配置。具体选项参见https://blog.csdn.net/rzhengbj163/article/details/97979908。
选择“MFC应用”后,下一步需要输入项目名称和解决方案名称,点击“创建”,在本例中我们将程序取名为MyChess。
下一步进入的是MFC应用程序的向导,其中类型有单个文档、多个文档、基于对话框、多个顶层文档几个选择。如果我们不需要用到菜单栏和工具栏,只要用到按钮的话,可以选择基于对话框的类型,如果希望程序框架直接支持菜单栏和工具栏,选择单个文档类型。下面对这两种程序框架的生成分别进行介绍。
在“应用程序”选项卡中选择“单个文档”,其它选项可以按照如图所示进行设置,和视觉样式相关的设置我们选择最简单的。选择静态库是为了生成的程序在某些没有安装相应库的计算机上也能正常运行。文档/视图结构是MFC类库直接支持的一种结构,方便开发者生成简单如“记事本”,复杂如“Microsoft Word”这样的程序,这类程序的特点是程序都与特定类型的文档相关联。例如记事本关联txt类型文件,Word关联doc类型或docx类型文件。例如“双击直接打开文件”,“打开文件时基于类型对文件进行过滤”等功能,选择文档/视图结构后程序框架是直接支持的,开发者可以不必关心这些通用功能的实现,只需要对其它细节进行完善,能大大缩短开发时间。另外标准程序中默认带有菜单和工具栏,要对它们进行修改,只需要在资源编辑器里进行编辑,然后实现对应的函数即可。
在我们这个例子中,如果我们希望能够实现棋局内容的保存,希望界面直接支持工具栏、菜单栏,选择“单个文档”程序类型比较合适。选择“基于对话框”的程序类型也可以实现差不多的功能,但功能的实现主要依靠添加按钮。本文在后面介绍如何为界面添加按钮时对其进行介绍。
为了和基于对话框的方式保持一致性,我们不对文件扩展名进行设置。用户界面选项卡中选项也采用最简单的,如下图所示。
其它选项卡高级功能和生成的类采用默认值,不做修改。但是我们应该知道生成的类主要包括四个:由CWinApp派生的CMyChessApp类,由CFrameWnd派生的CMainFrame类,由CView派生的CMyChessView类和由CDocument派生的CMyChessDoc。
上述设置完成后,Visual Studio会为我们生成一个程序框架,点击“本地Windows调试器”启动程序。
启动程序后界面如图所示,点击工具栏中的各个按钮,或是菜单中的选项,可以看到这个完全靠集成开发环境生成的程序已经能完成一些简单功能了。
在添加具体功能之前,我们需要有个管理肆棋的类CChess,如下为其代码。
#include
#include
class CChess
{
public:
CChess() { }
~CChess() { }
BOOL ReadfromFile(char* path)
{
int data[16];
std::ifstream f(path, std::ios::binary);
if (!f)
return FALSE;
f.read((char*)& data[0], 16 * sizeof(data[0]));
f.read((char*)& m_cur, sizeof(m_cur));
f.close();
Load(&data[0]);
return TRUE;
}
BOOL WritetoFile(char* path)
{
int data[16];
Save(&data[0]);
std::ofstream f(path, std::ios::binary);
if (!f)
return FALSE;
f.write((char*)& data[0], 16 * sizeof(data[0]));
f.write((char*)& m_cur, sizeof(m_cur));
f.close();
return TRUE;
}
void Load(int* pdata)
{
int row, col;
for (row = 0; row < 4; row++)
{
for (col = 0; col < 4; col++)
{
m_Board[row][col] = pdata[row * 4 + col];
}
}
}
void Save(int* pdata)
{
int row, col;
for (row = 0; row < 4; row++)
{
for (col = 0; col < 4; col++)
{
pdata[row * 4 + col] = m_Board[row][col];
}
}
}
void Begin(int borw)
{
int col;
for (col = 0; col < 4; col++)
{
//第一行为黑棋
m_Board[0][col] = -1;
//第二行为空
m_Board[1][col] = 0;
//第三行为空
m_Board[2][col] = 0;
//第四行为白棋
m_Board[3][col] = 1;
}
m_cur = borw;
}
//棋子从(startrow, startcol)移动到(stoprow, stopcol)
//先验证移动的合法性,然后再移动改变棋局数据
void move(int startrow, int startcol, int stoprow, int stopcol)
{
if (startrow < 0 || startrow >= 4) { return; }
if (startcol < 0 || startcol >= 4) { return; }
if (stoprow < 0 || stoprow >= 4) { return; }
if (stopcol < 0 || stopcol >= 4) { return; }
//必须移动当前方的棋子
if (m_cur != m_Board[startrow][startcol])
return;
int drow, dcol;
drow = stoprow - startrow;
dcol = stopcol - startcol;
//左右移动只有三种方式,不满足为非法移动
if ((dcol != 1) && (dcol != 0) && (dcol != -1)) { return; }
//黑棋只能向下移动
if ((m_Board[startrow][startcol] == -1) && (drow != 1)) { return; }
//白棋只能向上移动
if ((m_Board[startrow][startcol] == 1) && (drow != -1)) { return; }
//如果是合法移动,执行该移动
m_Board[stoprow][stopcol] = m_Board[startrow][startcol];
//原位置置为空
m_Board[startrow][startcol] = 0;
//交换行棋方
m_cur = -1 * m_cur;
}
//如果黑白方有一方不能走棋,则棋局结束,win的值为1白方胜,win的值为-1
//黑方胜,win的值为0,双方和棋
BOOL IsEnd(int& win)
{
BOOL blackcango = FALSE;
BOOL whitecango = FALSE;
int blackcnt = 0;
int whitecnt = 0;
//检查是否能够走棋,同时统计黑白棋子数目
int row, col;
for (row = 0; row < 4; row++)
{
for (col = 0; col < 4; col++)
{
//黑棋
if (-1 == m_Board[row][col])
{
if (!blackcango)
{
if ((row + 1) < 4)
{
//不能吃己方棋子
if ((col - 1) > -1 && (m_Board[row + 1][col - 1] != -1))
blackcango = TRUE;
if ((col + 1) < 4 && (m_Board[row + 1][col + 1] != -1))
blackcango = TRUE;
if (m_Board[row + 1][col] != -1)
blackcango = TRUE;
}
}
blackcnt++;
}
if (1 == m_Board[row][col])
{
if (!whitecango)
{
if ((row - 1) > -1)
{
//不能吃己方棋子
if ((col - 1) > -1 && (m_Board[row - 1][col - 1] != 1))
whitecango = TRUE;
if ((col + 1) < 4 && (m_Board[row - 1][col + 1] != 1))
whitecango = TRUE;
if (m_Board[row - 1][col] != 1)
whitecango = TRUE;
}
}
whitecnt++;
}
}
}
if (blackcnt == whitecnt)
win = 0;
else if (blackcnt < whitecnt)
win = 1;
else
win = -1;
if (blackcango && whitecango)
return FALSE;
else
return TRUE;
}
int GetPawn(int row, int col) { return m_Board[row][col]; }
protected:
// 0为空,1为白棋,-1为黑棋
int m_Board[4][4];
int m_cur;
};
为了保证该类能被正常使用,我们将对该类的声明代码添加至MyChess.h中 CMyChessApp的声明之前,并加上全局变量进行声明extern CChess g_chess;,最后要在语句 CMyChessApp theApp;后补上 CChess g_chess;,这是全局变量的定义。
另外我们应该对视图进行一定的设置,保证类视图、解决方案资源管理器视图、资源视图出现在界面上,方便后续的操作。
二、添加菜单
点击资源视图,展开视图中Menu列表,能看到生成的菜单资源IDR_MAINFRAME,其中大部分选项是不需要的,我们可直接删除修改。
点击菜单项时右侧属性窗口中显示出相关参数,其中主要需要修改的是以下几项:Caption项,其内容为显示在界面上的文字,&符号用来使用键盘操作时可以用哪个字母来选择,后面的Ctrl+N为程序框架定义好的快捷键。Checked选项不需要修改,这个用于设置是否选中当前项,ID这一项非常重要,像ID_FILE_NEW这样的生成框架自带ID,直接绑定框架中的虚函数,如果想利用程序框架不要对其进行修改。Prompt是提示,即当鼠标放到菜单项时显示的文字。
由于肆棋的功能非常简单,我们修改后的菜单项如图所示,不需要的菜单项已经被删除,生成框架自带的新建、打开、保存、另存保留,“新建”更名为“开局”,如下图所示:
删除原有“编辑”菜单下所有菜单项,改名为先手,增加两个菜单项“黑方”和“白方”,用来设置谁为先手。其ID分别命名为ID_CHECK_BLACKFIRST和ID_CHECK_WHITEFIRST,之所以在名字内加上CHECK是因为该项用来标记哪个被选中,在后面的操作中对其理解会更加具体。
最后在帮助中我们添加一个菜单项“使用说明”,ID为ID_HELP_MANUAL,如下图所示,当我们为该菜单项添加代码后,所要执行的功能是打开说明文档。
上述就是如何修改菜单项的具体步骤,可以看到借助资源管理器编辑菜单是十分方便的。下面介绍如何为菜单项绑定具体的功能代码。
对于开局、打开、保存和另存为来说,由于框架已经为这些ID绑定了虚函数所以我们只需要激活这些虚函数,并在其中填入代码即可。如图所示首先在类视图“CMyChessDoc”上右键,选择类向导。
选择类向导后能够看到上方类名是CMyChessDoc,左侧虚函数列表中有OnNewDocument已经被添加至了右侧列表,我们只需要双击OnOpenDocument和OnSaveDocument就可以将其添加到右侧了。
添加完成后我们点击编辑代码。就可以进入源代码编辑状态。
上述操作实际上是以图形交互的方式将虚函数的重载实现加到了CCMyChessDoc这个类,因此我们通过类视图也能看到这些函数被添加了进去,直接在类视图中双击相应的函数名,也可以进入源代码编辑状态。
对于三个函数需要添加的代码如下,由于我们的CChess类已经对相关功能进行了实现,因此在这些虚函数中只需要调用全局对象g_chess的对应功能的函数即可,其中临时变量CStringA strA主要是为了对数据类型LPCTSTR进行转换,转换为char*类型。特别需要注意的是OnSaveDocument虚函数中,在加入了g_chess的保存函数之后,直接return TRUE,将原有的返回函数注释掉了,这是因为如果保留原有的CDocument::OnSaveDocument(lpszPathName),该函数将继续执行框架中的序列化函数Serialize,这样会导致g_chess的保存失效。由于CChess类的开局函数Begin需要输入先手参数,我们还需要为CMyChessDoc添加一个成员变量m_first。
BOOL CMyChessDoc::OnNewDocument()
{
if (!CDocument::OnNewDocument())
return FALSE;
// TODO: 在此添加重新初始化代码
// (SDI 文档将重用该文档)
g_chess.Begin(m_first);
return TRUE;
}
BOOL CMyChessDoc::OnOpenDocument(LPCTSTR lpszPathName)
{
if (!CDocument::OnOpenDocument(lpszPathName))
return FALSE;
// TODO: 在此添加您专用的创建代码
CStringA strA(lpszPathName);
g_chess.ReadfromFile(strA.GetBuffer(0));
strA.ReleaseBuffer();
return TRUE;
}
BOOL CMyChessDoc::OnSaveDocument(LPCTSTR lpszPathName)
{
// TODO: 在此添加专用代码和/或调用基类
CStringA strA(lpszPathName);
g_chess.WritetoFile(strA.GetBuffer(0));
strA.ReleaseBuffer();
return TRUE;
// return CDocument::OnSaveDocument(lpszPathName);
}
CMyChessDoc的成员变量m_first定义,获取和设置该值的函数以及在CMyChessDoc构造函数中对其进行初始化的代码如下:
public:
void SetWhoFirst(int borw) { m_first = borw; }
int GetWhoFirst() { return m_first; }
protected:
int m_first;
CMyChessDoc::CMyChessDoc() noexcept
{
// TODO: 在此添加一次性构造代码
m_first = -1;
}
上述函数和代码添加完毕后,程序就可以实现载入、保存,开局的功能了,我们可以观察到程序标题栏中前面的内容就是保存时自己定义的文件名。打开一个保存过的文件后,这个内容会变为打开文件的名字,这些就是在程序框架的支持下自动实现的。
尽管上述功能可以实现了,但是我们注意到还有些小缺陷,比如标题栏中的MyChess是项目的名字,我们可能希望修改一个名字,另外如果我们希望框架就像前面所说的那样能够绑定某个特定后缀的文件类型,还需要做一些属性上的修改。单文档框架在String Table资源中提供了可以配置这些内容的属性。如下图所示,我们需要对IDR_MAINFRAME这个ID对应的标题进行修改。
修改后的结果如下:
此时我们选择重新生成整个解决方案,可以看到标题栏发生了改变。
而且我们点击打开按钮时,右下角也出现了可以根据后缀过滤文件的选项,这些都是依赖程序框架实现的,不需要做太多编码就能实现一些常见功能。
三、添加工具栏按钮
工具栏的编辑和菜单栏是类似的,也是需要在资源视图中打开Toolbar下的工具栏资源IDR_MAINFRAME。
右键查看工具栏上按钮的属性,发现其ID和菜单栏中存在对应关系,如图所示第一个按钮就是开局,上面高度Height和宽度Width参数可以修改,为了使用方便一般可以将两者的值都设置为32。
选择单个按钮后Visual Studio的工具栏上会出现一组用于绘图的工具供用户对按钮图片进行美化,其包含的工具与windows自带的“画图”程序非常类似。由于如何美化图片不是本文的重点,这部分内容由读者自行探索。
工具栏按钮不是全部都需要,我们实际上可以发现工具栏按钮可以设置成和菜单栏一致,因此我们删去不需要的按钮,添加菜单项中之前设置过的“黑方”、“白方”以及“使用说明”按钮,注意一定要将按钮的ID设置成和菜单项完全一致。注意直接通过右键中的删除选项不能删除工具栏按钮,只能删除图片,要想去掉某个按钮可以用鼠标按住将其拖动到工具栏外。想要添加自定义按钮时,先选中最后的用于添加新按钮区域,在属性ID中选择之前菜单项中设置过的ID,并用绘图工具修改按钮图案,最后把按钮用拖拽的方式移动到合适的位置上。
修改后的工具栏如下图所示,其中黑色方块和白色方块对应的ID分别为ID_CHECK_BLACKFIRST和ID_CHECK_WHITEFIRST,书形的按钮ID为ID_HELP_MANUAL。
完成上述工作后,我们编译运行程序,可以看到界面上后来添加的三个按钮还是灰色的,其相应功能并未实现,仍需要对程序进行进一步修改。
在MFC框架中,对ID的响应通过消息机制来完成,消息在程序中是分层依次传输的,按照app->frame->view的次序依次传递。对于“使用说明”功能,在app类中响应该消息即可,而对于设置黑方和白方先手的功能,因为考虑到后面鼠标交互的大部分消息都要在view中响应,而这些消息的处理可能与先手的情况相关,因此在view中响应比较合适。
经过上述分析,我们做如下操作,在类视图中CMyChessApp上右键选择类向导,打开类向导后选择消息类型为COMMAND,双击要响应的对象ID为ID_HELP_MANUAL即可以将相应函数添加完成,然后点击“编辑代码”。
相应函数OnHelpManual中只需要添加一行代码即可,该行代码通过ShellExecute函数进行系统调用直接打开指定的文件——肆棋规则.rtf,该文件是我们之前用文本编辑器编辑的一个说明文档以rtf格式保存,注意函数中的文件名参数不含路径,因此该文件应该放置在和MyChess.exe同一文件夹下,在调试的时候由于工作目录为项目文件夹,因此应该把这个文件放到项目文件夹下,和其它源文件放在同一个文件夹里。
void CMyChessApp::OnHelpManual()
{
// TODO: 在此添加命令处理程序代码
ShellExecute(NULL, _T("open"), _T("肆棋规则.rtf"), NULL, NULL, SW_SHOWNORMAL);
}
添加完代码后,编译运行程序,测试该按钮的功能是否正常,如果正常的话应该出现类似于下图的界面,如图所示中系统是调用了“写字板”程序来打开文件,在其它电脑上,可能rtf文件关联的是word程序,那么点击按钮会启动word程序来打开这个文档。ShellExecute并不指定用哪个程序来打开文件,由系统已经配置好的文件关联决定使用哪个程序。
黑方和白方的工具栏设置与打开说明文档的功能有所差异,我们注意到如果是黑方先行就不可能是白方先行,两个状态是互斥的,虽然我们可以用类似上面的方法来为两个按钮添加函数,在函数中修改标识先手状态变量的值,但是这种状态变化不能直观反映出来,要解决这个问题需要用到MFC框架中内置的按钮状态,在MFC框架中按钮具有check状态,前述菜单项设置中也有checked属性的设置,这两者之间是对应的。下面我们具体介绍如何对某个按钮实现该状态的更改。
在类视图中CMyChessView上右键打开类向导,左侧选中两个设置先手的ID右侧消息分别选择COMMAND和UPDATE_COMMAND_UI,通过双击完成一共四个消息响应函数的添加。然后点击确定或选择编辑代码进入源代码编辑状态。
在CMyChessDoc已经有了标识先手的变量m_first,当其值为1时为白棋,当其值为-1时为黑棋。显然在上述四个函数中应该修改其值,但m_first的访问需要先调用GetDocument()获取文档指针,这就是文档/视图框架所提供的一个方案,可以在视图的函数中修改文档类中的值,具体如下所示:
void CMyChessView::OnCheckBlackfirst()
{
// TODO: 在此添加命令处理程序代码
GetDocument()->SetWhoFirst(-1);
}
void CMyChessView::OnUpdateCheckBlackfirst(CCmdUI* pCmdUI)
{
// TODO: 在此添加命令更新用户界面处理程序代码
pCmdUI->SetCheck(-1 == GetDocument()->GetWhoFirst());
}
void CMyChessView::OnCheckWhitefirst()
{
// TODO: 在此添加命令处理程序代码
GetDocument()->SetWhoFirst(1);
}
void CMyChessView::OnUpdateCheckWhitefirst(CCmdUI* pCmdUI)
{
// TODO: 在此添加命令更新用户界面处理程序代码
pCmdUI->SetCheck(1 == GetDocument()->GetWhoFirst());
}
完成上述修改之后,我们编译运行程序,并测试工具栏上的按钮,可以看到根据当前状态的不同,两个按钮具有了check的状态。
而且打开菜单栏查看的时候,发现菜单项前也有了标记。这也是框架支持下自动实现的。
要给工具栏添加其它功能也是一样的,包括添加菜单项没有的功能也是可以的,如果要添加的功能主要响应点击,类似于之前的“打开说明”功能,那么只需要为该ID添加COMMAND消息,如果希望按钮还能标识状态,那么还需要为该按钮添加UPDATE_COMMAND_UI消息。
四、添加按钮
前述是通过单文档框架来实现基本功能的,那么如果在开始采用对话框框架是不是也能实现相似的功能呢,答案是肯定的,具体介绍如下。重新新建一个项目MyChessDlg,Dlg为Dialog的缩写,意为基于对话框的程序。
在应用程序类型中选择“基于对话框”。
其它选项采用默认值即可,与前面“单个文档”一样,我们最好仍是查看一下生成的类中有几个类。基于对话框的框架比较简单,只有由CWinApp生成的CMyChessDlgApp类和由CDialogEx类生成的CMyChessDlgDlg类。
框架生成后,打开资源视图,选中对话框资源IDD_MYCHESSDLG_DIALOG,能够看到右侧有许多控件可以选择。
实际上要完成我们前面的功能只需要两类控件即可,回顾之前基于“单个文档”框架所实现的基本功能,将其列出如下:“开局”、“打开存档”、“保存存档”、“存档另存为”、“先手选择黑方”、“先手选择白方”、“使用说明”。
其中“存档另存为”功能其实和“保存文档”基本重复,因此取消该功能。而先手选择是互斥的,我们分别用两个按钮来实现不太合理,改用一个Combo控件来实现,其它功能都用标准的按钮控件来实现。具体如下:
选中对话框资源IDD_MYCHESSDLG_DIALOG,将上面的静态控件“TODO: 在此放置对话框控件。”删除,将“取消”按钮删除。选中“确定”按钮,将其Caption属性中“确定”两字改为“退出”。分别从工具箱中拖动四个按钮控件到界面上,将其Caption修改为如图所示的文字。最后拖动ComboBox组件到界面,为了美观,我们还另外拖动了一个GroupBox控件,将其Caption改名为先手。实际上对话框本身也是一个控件,我们选中对话框对其Caption也进行修改,改名为肆棋程序—计算机博弈界面设计演示。
另外如果我们希望按钮ID生成的响应函数名字能反映其功能,方便以后改写查看的话,对按钮ID也进行一定的编辑,使其名字和功能对应,如下表所示。
序号 |
ID |
Caption |
其它 |
1 |
IDC_BTN_OPENDOC |
打开存档 |
- |
2 |
IDC_BTN_SAVEDOC |
保存存档 |
- |
3 |
IDC_COMBO_FIRST |
- |
外观Type设置为下拉列表 |
4 |
IDC_BTN_MANUAL |
使用说明 |
- |
5 |
IDC_BTN_BEGIN |
开局 |
- |
6 |
IDOK |
退出 |
|
7 |
IDC_STATIC |
先手 |
Horizontal Alignment设置为Center |
事实上,所有静态控件包括GroupBox的默认ID都是IDC_STATIC,一般它们是不用于响应鼠标事件的。而IDOK默认关联退出函数,不需要添加任何代码就能实现终止对话框程序的功能。
组合对话框中下拉列表的内容是通过设置属性中的Data项来添加的,每行之间用分号分割。如果设置了Sort属性,实际运行将无视Data中的填写顺序,按照首字母顺序排列各项内容,如果将Sort属性设置为False,则呈现时还是按照Data中设置好的顺序显示在界面上。
完成上述基本设置后,为各个控件添加响应函数,对于按钮来说非常简单,在资源视图中双击按钮其响应函数就添加完成。对于Combo Box控件,我们需要响应的是选择改变的消息,因此还需要打开类向导对其进行配置。类向导界面中ID我们选择IDC_COMBO_FIRST,消息我们选择CBN_SELCHANGE,然后添加处理程序。
上述设置完成,我们为CMyChessDlgDlg类添加了5个函数OnBnClickedBtnOpendoc(), OnBnClickedBtnSavedoc(), OnBnClickedBtnManual(), OnBnClickedBtnBegin(), OnSelchangeComboFirst(),此时需要做的类似于前面基于单文档框架时所作的修改,首先添加类CChess的声明到CMyChessDlgApp,并加上全局对象的声明extern CChess g_chess;。然后在CMyChessDlgApp theApp;前加上定义CChess g_chess。
基于对话框的程序框架除CMyChessDlgApp外只有一个CMyChessDlgDlg类,我们前面添加的所有鼠标响应函数也都在这个类中,因此我们把标记先手的变量m_first也加到这个类中。
// 实现
protected:
int m_first;
HICON m_hIcon;
// 生成的消息映射函数
virtual BOOL OnInitDialog();
afx_msg void OnSysCommand(UINT nID, LPARAM lParam);
afx_msg void OnPaint();
在构造函数中对其初始化:
CMyChessDlgDlg::CMyChessDlgDlg(CWnd* pParent /*=nullptr*/)
: CDialogEx(IDD_MYCHESSDLG_DIALOG, pParent)
{
m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);
m_first = -1;
}
对于组合框IDC_COMBO_FIRST,我们初始时需要其选择对应m_first值的选项,在选择项改变时,在函数OnSelchangeComboFirst中根据当前选择项修改m_first的值。为实现这个目的,在OnInitDialog函数中添加如下代码,其中SetCurSel函数用于选择其中某一项,索引从0开始。
// 执行此操作
SetIcon(m_hIcon, TRUE); // 设置大图标
SetIcon(m_hIcon, FALSE); // 设置小图标
// TODO: 在此添加额外的初始化代码
((CComboBox*)GetDlgItem(IDC_COMBO_FIRST))->SetCurSel((-1 == m_first)?0:1);
return TRUE; // 除非将焦点设置到控件,否则返回 TRUE
对OnSelchangeComboFirst函数所做修改如下
void CMyChessDlgDlg::OnSelchangeComboFirst()
{
// TODO: 在此添加控件通知处理程序代码
int sel =((CComboBox*)GetDlgItem(IDC_COMBO_FIRST))->GetCurSel();
m_first = sel == 0 ? -1 : 1;
}
函数OnBnClickedBtnManual()和OnBnClickedBtnBegin()修改和之前类似。
void CMyChessDlgDlg::OnBnClickedBtnManual()
{
// TODO: 在此添加控件通知处理程序代码
ShellExecute(NULL, _T("open"), _T("肆棋规则.rtf"), NULL, NULL, SW_SHOWNORMAL);
}
void CMyChessDlgDlg::OnBnClickedBtnBegin()
{
// TODO: 在此添加控件通知处理程序代码
g_chess.Begin(m_first);
}
但是对于打开棋局和保存棋局,由于我们使用的不是文档/视图框架,需要编写额外的代码对文件进行处理,具体如下,我们主要利用了CFileDialog这个MFC中的类来完成读取棋局文件和保存棋局文件的功能。
void CMyChessDlgDlg::OnBnClickedBtnOpendoc()
{
// TODO: 在此添加控件通知处理程序代码
CFileDialog filedlg(TRUE, _T("*.rd"), NULL,
OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY,
_T("棋局文件(*.rd)|*.rd| All Files (*.*) |*.*||"), NULL);
filedlg.m_ofn.lpstrTitle = _T("打开棋局");
if (filedlg.DoModal() == IDOK)
{
CStringA strA(filedlg.GetPathName());
g_chess.ReadfromFile(strA.GetBuffer(0));
strA.ReleaseBuffer(0);
}
}
void CMyChessDlgDlg::OnBnClickedBtnSavedoc()
{
// TODO: 在此添加控件通知处理程序代码
CFileDialog filedlg(FALSE, _T("rd"), NULL, OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT,
_T("棋局文件(*.rd)|*.rd| All Files (*.*) |*.*||"), NULL);
// 打开文件对话框的标题名
filedlg.m_ofn.lpstrTitle = _T("保存棋局");
if (filedlg.DoModal() == IDOK)
{
CStringA strA(filedlg.GetPathName());
g_chess.WritetoFile(strA.GetBuffer(0));
strA.ReleaseBuffer(0);
}
}
综上我们可以看到,无论利用基于单文档的框架还是基于对话框的框架,也都是可以实现基本功能的,基于单文档的框架对文档的打开、保存有更好的支持,而且支持菜单以及工具栏,而基于对话框的程序主要是通过按钮等控件来实现功能。
五、图形绘制
前述功能完成后,我们仍然不能看到诸如棋盘棋子图形显示的效果,要将这些视觉元素按照要求显示出来,我们需要对CMyChessView的OnDraw函数进行修改,修改之前我们还需要先对WM_ERASEBKGND消息进行响应,这个消息是用来触发窗口自动擦除视图窗口内原有内容,修改为直接返回后,这个消息相当于被屏蔽,没有执行任何动作,事实上不响应这个消息也是为了防止闪烁。
相应代码如下:
BOOL CMyChessView::OnEraseBkgnd(CDC* pDC)
{
// TODO: 在此添加消息处理程序代码和/或调用默认值
return TRUE;
// return CView::OnEraseBkgnd(pDC);
}
然后我们在OnDraw中 if(!pDoc) return;这条语句之后添加如下代码,注意其中清空绘图区这一条语句,因为之前我们没有利用WM_ERASEBKGND清除背景,所以这里要自己写一条语句来完成这一步,由于我们使用了CMemDC建立缓存,不会引起闪烁。
在计算机图形处理中缓存机制是非常重要的,其原理的简化说明如下:假设屏幕是一块黑板,能够使用的颜色种类、大小等等都是黑板的属性,调用绘图函数例如Rectangle、Ellipse等就是在上面画图,因为作为屏幕的黑板是直接呈现在眼前的,所以如果内容是被一点点画上去,再加上可能会出现局部擦除,画的速度又非常快的话,用户看到的效果是不稳定的,在视觉上会观察到闪烁。但如果在用户看不到的地方,有另一块黑板属性和屏幕这块黑板完全一致,想要呈现的内容先在这个黑板上一次绘制完成,然后以非常快的速度直接对屏幕这块黑板整体调换(即缓存交换Swap),那么反而是不会有闪烁的感觉。在本例中用CMemDC建立的缓存就是那块用户看不到的黑板。通过对比不使用CMemDC,直接使用pDC和使用CMemCC进行后面绘图函数的操作,就可以直接观察到这种现象。相同的道理,因为WM_ERASEBKGND消息触发的清除内容是直接针对屏幕显示的,所以会引起闪烁,而对缓存写一条语句清除屏幕内容就不会闪烁了。
针对我们所使用的CChess类,我们要绘制的棋盘是个方形,而视图区域一般情况下是一个长宽不等的矩形,因此我们还需要计算偏移量,使得绘制图形时可以使其居中,为此我们加入相应的成员变量和计算函数,这些变量和函数在用用于交互的鼠标消息响应函数中也会用到。
protected:
void CalcLengthandDelta(const CRect& rc);
int m_len;
int m_deltax;
int m_deltay;
void CMyChessView::CalcLengthandDelta(const CRect& rc)
{
if (rc.Width() > rc.Height())
{
m_len = rc.Height() / 4;
m_deltax = (rc.Width() - rc.Height()) / 2;
m_deltay = 0;
}
else
{
m_len = rc.Width() / 4;
m_deltax = 0;
m_deltay = (rc.Height() - rc.Width()) / 2;
}
}
对OnDraw函数的修改如下:
void CMyChessView::OnDraw(CDC* pDC)
{
CMyChessDoc* pDoc = GetDocument();
ASSERT_VALID(pDoc);
if (!pDoc)
return;
// TODO: 在此处为本机数据添加绘制代码
//利用CMemDC创建缓冲区,将所有图形先绘制到dcMec
//中,然后再交换到pDC上,这样可以防止闪烁
CMemDC dcMem(*pDC, this);
CDC& dc = dcMem.GetDC();
//获取窗口绘图区大小
CRect rc;
GetClientRect(rc);
//计算棋盘宽度和偏移量
CalcLengthandDelta(rc);
//清空绘图区为白色
dc.FillSolidRect(&rc, RGB(255, 255, 255));
//在窗口绘图区中部绘制棋盘
int row, col;
for(row=0; row<4; row++)
{
for (col = 0; col < 4; col++)
{
int l = m_deltax + col * m_len;
int t = m_deltay + row * m_len;
//填充方格内部颜色
if ((row + col) % 2 == 0)
{
dc.FillSolidRect(CRect(l, t, l+m_len, t+m_len), RGB(252,213,181));
}
//选择画笔为黑笔,画刷为空,这样矩形内部无填充
CObject* poldbrush;
CObject* poldpen;
poldpen = dc.SelectStockObject(BLACK_PEN);
poldbrush = dc.SelectStockObject(NULL_BRUSH);
//绘制矩形
dc.Rectangle(l, t, l + m_len, t + m_len);
//绘制棋子
int pawntype = g_chess.GetPawn(row, col);
if (pawntype != 0)
{
if(pawntype==-1)
dc.SelectStockObject(BLACK_BRUSH);
else
dc.SelectStockObject(WHITE_BRUSH);
dc.Ellipse(l + 5, t + 5, l + m_len - 5, t + m_len - 5);
}
//将旧画笔画刷选择回设备环境
dc.SelectObject(poldbrush);
dc.SelectObject(poldpen);
}
}
}
添加完成之后,编译运行得到如下效果:
如果要对图形进行更加细节的修改,可以参考CPen和CBrush的相关函数说明,另外上面的方法是通过绘制图形来实现的,有时候我们看到一些界面中的棋盘或棋子比较漂亮,要想使用这些素材的话,需要采用贴图的方式。因为这种区别和其它实现细节是相对独立的,因此我们在后面的分析中使用的仍然是绘制图形的方式,对于想采用贴图的方式美化界面的读者,在通过下面将单个棋子绘制到界面中的例子理解之后可以自行研究使用。
一般情况下想使用的素材图片都是清晰的带透明通道的PNG图片,如下图所示的棋子素材图片大小为256*256,其周边在绘制到屏幕的时候应该是透明的,而且我们屏幕上要显示的区域大小也不一定是固定的,可能会随视图窗口大小而改变,要解决对含有透明通道的PNG图片进行大小可变的缩放贴图,比较好的办法是采用图形处理库GDIPlus,在Visual Studio 2019中默认支持该函数库,因此只要在项目中启用其即可。
在头文件MyChess.h中,加入如下代码,之后在项目中就可以调用GDIPlus中的函数了。
#pragma comment(lib,"gdiplus.lib")
#include "gdiplus.h"
using namespace Gdiplus;
GDIPlus的使用需要用GdiplusStartup函数初始化,在使用完毕程序退出之前需要使用GdiplusShutdown函数终止,释放占用的资源,显然我们将GdiplusStartup函数放在整个应用程序的初始化函数InitInstance中,将GdiplusShutdown函数放在程序退出时调用的ExitInstance函数中比较合适。此外我们还需要申请全局变量作为函数的参数。具体步如下:
在全局变量theApp和g_chess之后再定义两个变量g_gdiplusToken和g_gdiplusStartupInput。
CMyChessApp theApp;
CChess g_chess;
ULONG_PTR g_gdiplusToken;
GdiplusStartupInput g_gdiplusStartupInput;
在InitInstance中添加代码:
CWinApp::InitInstance();
//初始化Gdiplus
GdiplusStartup(&g_gdiplusToken, &g_gdiplusStartupInput, NULL);
在ExitInstance中添加代码:
AfxOleTerm(FALSE);
GdiplusShutdown(g_gdiplusToken);
return CWinApp::ExitInstance();
在GDIPlus中使用图片时,需要先对其进行载入,显然从硬盘载入图片文件需要的时间比较多,因此我们可以统一将载入图片文件的操作放到构造函数中,而释放资源的操作放到析构函数,载入图片后需要用指针变量指向申请到的变量,因此还需定义指针变量。具体如下:
首先为类CMyChessView添加成员变量m_pImg2Draw:
protected:
Image* m_pImg2Draw;
在构造函数中为变量申请空间,构造Image对象,其中"pieces.png"用于指明文件路径,如果只包含文件名,需要把该文件放到和exe文件同一目录,即应该与之前的“肆棋规则.rtf”放置到同一目录下:
CMyChessView::CMyChessView() noexcept
{
// TODO: 在此处添加构造代码
m_pImg2Draw = new Image(_T("pieces.png"));
}
在析构函数中添加代码释放空间:
CMyChessView::~CMyChessView()
{
delete m_pImg2Draw;
}
最后在OnDraw函数的结尾加入下面两行代码用以绘制棋子,其大小可以随窗口的高度而变化:
Graphics graph(dc.GetSafeHdc());
graph.DrawImage(m_pImg2Draw, 0, 0, rc.Height()/5, rc.Height()/5);
编译,运行该程序,我们可以看到具有高亮效果的棋子素材图片可以显示在程序视图窗口的左上角了。
最后需要说明的是DEBUG_NEW 和GDI+函数存在不匹配的问题,如果使用new操作符的时候,没有注释下面的代码(这段代码在MyChessView.cpp顶部)那么程序将崩溃无法运行。
//#ifdef _DEBUG
//#define new DEBUG_NEW
//#endif
在后面的介绍中我们不使用GDIPlus,因此相关代码为注释状态。
六、鼠标和图形之间的交互
在图形绘制功能实现之后,主要需要解决的问题就是鼠标和图形之间的交互了,正如通过前面介绍我们所看到的大部分功能借助MFC框架中自带的消息或是控件的事件绑定就可以实现,鼠标的交互功能主要也是通过鼠标消息处理函数实现的。在CMyChessView类上右键,打开类向导。
在类向导“消息”选项卡上选择添加WM_LBUTTONDOWN,WM_LBUTTONUP和WM_MOUSEMOVE三个消息,添加完成后点击“编辑代码”。
这三个消息分别在鼠标左键按下,鼠标左键弹起和鼠标移动时触发,通过它们的配合可以实现棋子的移动。
我们首先需要变量记录当前哪方在行棋,当鼠标左键按下时先要判断选择了哪个棋子,该棋子是否为可移动的合法棋子,然后鼠标移动时,棋子跟随鼠标移动,最后移动到某个位置当左键弹起时,调用CChess中的move函数移动棋子,如果位置不合法则棋子返回原位。分析这个过程我们发现,需要有多个状态需要记录,选中了那个棋盘格,要落下的是哪个棋盘格,左键按下时鼠标的坐标点和左键弹起时鼠标的坐标点需要用到,两者的差用来显示移动状态中的棋子,另外选中棋子的类型也需要记录,方便在其它函数中使用。而PointtoRowCol函数用来将鼠标点击点转化为棋盘格的索引坐标。
protected:
BOOL PointtoRowCol(const CPoint& pt, int& r, int& c);
int m_sttrow;
int m_sttcol;
int m_endrow;
int m_endcol;
CPoint m_ptClick;
CPoint m_ptMove;
int m_sel;
这些变量的初始化代码也放在CMyChessView类的构造函数中,m_ptClick和m_ptMove的值都只有在m_sel不为零时才有意义,因此不单独再初始化,而是在为m_sel赋值时初始化:
CMyChessView::CMyChessView() noexcept
{
// TODO: 在此处添加构造代码
// m_pImg2Draw = new Image(_T("pieces.png"));
m_sttrow = -1;
m_sttcol = -1;
m_endrow = -1;
m_endcol = -1;
m_sel = 0;
}
PointtoRowCol函数的实现代码如下,我们注意到其中使用了之前的m_deltax,m_deltay和m_len三个变量:
BOOL CMyChessView::PointtoRowCol(const CPoint& pt, int& r, int& c)
{
CRect rctile;
int rci;
for (rci = 0; rci < 16; rci++)
{
int left = m_deltax + rci % 4 * m_len;
int top = m_deltay + rci / 4 * m_len;
CRect rctile = CRect(left, top, left + m_len, top + m_len);
if (rctile.PtInRect(pt))
{
r = rci / 4;
c = rci % 4;
return TRUE;
}
}
return FALSE;
}
完成上述工作之后对鼠标响应的三个函数进行修改:
void CMyChessView::OnMouseMove(UINT nFlags, CPoint point)
{
// TODO: 在此添加消息处理程序代码和/或调用默认值
if (m_sel != 0)
{
CRect rc;
GetClientRect(rc);
if (PointtoRowCol(point, m_endrow, m_endcol))
{
m_ptMove = point;
Invalidate();
}
}
CView::OnMouseMove(nFlags, point);
}
void CMyChessView::OnLButtonDown(UINT nFlags, CPoint point)
{
// TODO: 在此添加消息处理程序代码和/或调用默认值
if (PointtoRowCol(point, m_sttrow, m_sttcol))
{
if (g_chess.GetPawn(m_sttrow, m_sttcol) != 0)
{
m_sel = g_chess.GetPawn(m_sttrow, m_sttcol);
m_ptClick = point;
m_ptMove = point;
Invalidate();
}
}
CView::OnLButtonDown(nFlags, point);
}
void CMyChessView::OnLButtonUp(UINT nFlags, CPoint point)
{
// TODO: 在此添加消息处理程序代码和/或调用默认值
if (m_sel != 0)
{
if (PointtoRowCol(point, m_endrow, m_endcol))
{
g_chess.move(m_sttrow, m_sttcol, m_endrow, m_endcol);
}
m_sel = 0;
m_sttrow = -1;
m_sttcol = -1;
Invalidate();
int borw;
if (g_chess.IsEnd(borw))
{
if (-1 == borw)
{
AfxMessageBox(_T("黑方胜!"));
}
else if (1 == borw)
{
AfxMessageBox(_T("白方胜!"));
}
else
{
AfxMessageBox(_T("和棋!"));
}
}
}
CView::OnLButtonUp(nFlags, point);
}
在OnLButtonUp函数中,需要判断是否当前步走完棋局结束,因此调用了CChess类的IsEnd函数,另外使用了系统提供的AfxMessageBox弹出消息框显示结果。
至此,所有工作全部完成后,可以体验一下这款肆棋程序了,如下图所示。