本人最近用MFC制作了一个双人五子棋,实现了以下功能:
1.完全使用鼠标操作;
2.自动判断胜负;
3.悔棋;
4.保存和打开棋局。
下面,我与大家分享一下我的制作流程。
开发环境: Visual Studio 2019
首先,创建双人五子棋解决方案,我的名称是GoBang。
创建对话框并调整对话框尺寸,我的尺寸是400*400。将对话框的标题改为“双人五子棋”,“边框”属性改为“对话框外框”,防止运行期间改变大小。
在对话框上放置6个Button控件,标题分别改为“开始游戏”(ID:IDC_START)“结束本局”(ID:IDC_ENDGAME)“退出”(ID:IDC_QUIT)“悔棋”(ID:IDC_REPENTANCE)“保存棋局”(ID:IDC_SAVE)“打开棋局”(ID:IDC_OPEN),将“结束本局”、“悔棋”和“保存”按钮设为最初禁用状态。
对话框到此设计完毕,棋盘我们会在后面使用代码绘制。最终的对话框效果如图所示:
然后,添加两个cursor光标资源,图案分别是一个黑棋和一个白棋。黑棋的ID是IDC_CURSOR1,白棋的ID是IDC_CURSOR2。
双人五子棋由于是用户之间的对战,无需设计AI算法,所以算法很简单。它用到的主要算法只有一个:判断胜负。下面,我们就来设计判断胜负的算法。当然,下面是我自己的算法,肯定还有更好的算法,欢迎大家在评论区中为我指出缺点。
为了方便修改棋盘尺寸,我们定义了一个宏SIZE,它代表棋盘的行数和列数。
#define SIZE 15
下面我们开始创建变量。接下来的变量都放在GoBangDlg.h中作为对话框类的成员。首先,我们创建一个SIZE*SIZE的int二维数组(名为ChessBoard),这个数组就代表棋盘,每个元素的不同值代表棋盘交叉点的不同状态:-1为空,0为白,1为黑。再创建一个bool变量NowColor用来记录下一步棋的颜色,false(0)为白,true(1)为黑。接着创建一个bool变量IsPlaying记录是否正在游戏。代码如下:
bool IsPlaying;
bool NowColor;
int ChessBoard[SIZE][SIZE];//棋盘,-1为空,0为白,1为黑
由于计算机中数组下标是先行后列(先y轴后x轴),与我们平常的习惯(先列后行)不符,所以我们先创建一个函数转换这个差异。当然,这个函数也可以不写,但后面绘制棋子和判断鼠标位置的程序需要修改一下。
int CGoBangDlg::GetChessBoardColor(int nx, int ny)
{
return ChessBoard[ny][nx];
}
接下来,我们开始创建算法的核心部分。我创建了一个函数,名为GetChessCount,这个名字很容易引起误解,这个函数的功能并不是获取棋盘上棋子的个数,而是获取指定坐标上的棋子在任意方向上相连的最大个数。举个例子,假如下面的棋盘中间的那一列最上方的棋子坐标为(5,5),那么GetChessCount(5,5)的值就为4(那个棋子横着连成了4个)。
该函数的实现代码如下:
int CGoBangDlg::GetChessCount(int nx, int ny)//获取指定棋子各个方向的同色棋子个数最大值
{
int color = GetChessBoardColor(nx, ny);//获取指定点的棋子颜色
if (color == -1)//空位
return -1;
int x = nx, y = ny;
int m_max, count;
while (--y >= 0 && GetChessBoardColor(x, y) == color);//获取这个棋子所在的y轴棋链中最下端的棋子坐标,注意行尾有分号,循环体什么也不做,y++并不是循环体
y++;//由于上面的循环是先把y减1再判断,所以需要加1得到真正的坐标
for (count = 1; (++y < SIZE) && (GetChessBoardColor(x, y) == color); count++);//获取y轴棋链的棋子个数,注意行尾有分号
m_max = count;
//y轴
x = nx, y = ny;
while (--x >= 0 && GetChessBoardColor(x, y) == color);
x++;
for (count = 1; ++x < SIZE && GetChessBoardColor(x, y) == color; count++);
if (m_max < count)
m_max = count;
//x轴,代码意义同上
x = nx, y = ny;
while (x - 1 >= 0 && y - 1 >= 0 && GetChessBoardColor(x - 1, y - 1) == color)//这里由于是x-1而不是--x,所以行尾没有分号,x--,y--才是循环体,后面无需把x和y的值增加1,下同
x--, y--;
for (count = 1; x + 1 < SIZE && y + 1 < SIZE && GetChessBoardColor(x + 1, y + 1) == color; count++)
x++, y++;
if (m_max < count)
m_max = count;
//左下到右上,代码意义同上
x = nx, y = ny;
while (x - 1 >= 0 && y + 1 < SIZE && GetChessBoardColor(x - 1, y + 1) == color)
x--, y++;
for (count = 1; x + 1 < SIZE && y - 1 >= 0 && GetChessBoardColor(x + 1, y - 1) == color; count++)
x++, y--;
if (m_max < count)
m_max = count;
//左上到右下,代码意义同上
return m_max;
}
接下来就简单了,只要用一个函数遍历棋盘,判断是否有五子连线就行了。不废话了,直接上代码:
int CGoBangDlg::GetWinner()//获取赢家,-1无,0白,1黑
{
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
int color = GetChessBoardColor(i, j);
if (color != -1)
{
if (GetChessCount(i, j) >= 5)
return color;
}
}
}
return -1;
}
至此,双人五子棋判断胜负算法部分设计完毕,之后我们只需要每走一步棋判断一次胜负就可以了。
除了基本的判断胜负,我还增加了一个悔棋功能。我们创建一个整形变量index,用于记录上一步是第几步棋,再创建一个类型为CPoint或POINT的数组,用来记录每一步棋的坐标。
int index;
CPoint order[SIZE*SIZE];
悔棋的具体算法在后面会讲解。
让我们先从简单的入手吧。我们先处理窗口的WM_CLOSE消息,当窗口收到WM_CLOSE消息时,判断游戏是否在进行,如果在进行则弹出对话框询问是否要退出否则直接退出。代码如下:
void CGoBangDlg::OnClose()
{
if (!IsPlaying || MessageBoxW(L"正在游戏中,确定要退出吗?", L"双人五子棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
CDialogEx::OnClose();
}
这里用到了一个C++逻辑或运算符基本规则,那就是如果第一个表达式成立,就不会计算第二个表达式。所以如果!IsPlaying(游戏不在进行),就不会 弹出对话框,而是直接关闭。
“退出”按钮的BN_CLICKED消息和WM_CLOSE消息的处理程序基本相同,这里就不再赘述了。代码如下:
void CGoBangDlg::OnBnClickedQuit()
{
if (!IsPlaying || MessageBoxW(L"正在游戏中,确定要退出吗?", L"双人五子棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
EndDialog(0);
}
接着,我们修改一下GoBangDlg的DoDataExchange函数,进行数据初始化。代码很简单,就不再解释。代码如下:
void CGoBangDlg::DoDataExchange(CDataExchange* pDX)
{
IsPlaying = false;
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
ChessBoard[i][j] = -1;
}
}//初始化棋盘
CDialogEx::DoDataExchange(pDX);
}
然后,我们要修改WM_PAINT消息绘制棋盘和棋子。代码如下:
void CGoBangDlg::OnPaint()
{
if (IsIconic())
{
CPaintDC dc(this); // 用于绘制的设备上下文
SendMessage(WM_ICONERASEBKGND, reinterpret_cast<WPARAM>(dc.GetSafeHdc()), 0);
// 使图标在工作区矩形中居中
int cxIcon = GetSystemMetrics(SM_CXICON);
int cyIcon = GetSystemMetrics(SM_CYICON);
CRect rect;
GetClientRect(&rect);
int x = (rect.Width() - cxIcon + 1) / 2;
int y = (rect.Height() - cyIcon + 1) / 2;
// 绘制图标
dc.DrawIcon(x, y, m_hIcon);
}
else
{
CPaintDC dc(this);
CPen pen(PS_SOLID, 2, RGB(0, 0, 0));
dc.SelectObject(pen);
for (int i = 0; i < SIZE; i++)
{
dc.MoveTo(50, 50 + i * 50);
dc.LineTo(750, 50 + i * 50);
}//绘制棋盘横线
for (int i = 0; i < SIZE; i++)
{
dc.MoveTo(50 + i * 50, 50);
dc.LineTo(50 + i * 50, 750);
}//绘制棋盘竖线
for (int nx = 0; nx < SIZE; nx++)//遍历棋盘,绘制每一个棋子
{
for (int ny = 0; ny < SIZE; ny++)
{
int color = GetChessBoardColor(nx, ny);
if (color == 0)//白棋
{
CBrush brush_w(RGB(255, 255, 255));
const CPoint o(50 * nx + 50, 50 * ny + 50);//圆心
dc.SelectObject(brush_w);
dc.Ellipse(o.x - 15, o.y - 15, o.x + 15, o.y + 15);
}
else if (color == 1)//黑棋
{
CBrush brush_b(RGB(0, 0, 0));
const CPoint o(50 * nx + 50, 50 * ny + 50);//圆心
dc.SelectObject(brush_b);
dc.Ellipse(o.x - 15, o.y - 15, o.x + 15, o.y + 15);
}
}
}
}
}
代码应该不难理解,用的是MFC的绘图工具,和GDI绘图类似,非常方便,不需要担心内存泄漏等问题,构造函数和析构函数会自动处理绘图对象的创建和删除。但有些MFC或Windows API开发经验的人可能会注意到一个问题:我用的是CPaintDC,相当于Windows API中的BeginPaint函数,这样只会绘制一次,绘制结束后不会再收到WM_PAINT消息,下棋后无法正常显示棋子。这个问题有道理。因为五子棋不需要一直重绘已经存在的棋子,如果一直重绘已经存在的棋子会大幅度降低程序性能,所以我在SetChessBoardColor函数中会绘制新下的棋子,已经绘制的棋子和棋盘不会改变,这个函数下面会讲到。这里之所以还添加绘制棋子的程序,是因为两种情况:
(1)游戏过程中,窗口被移出屏幕边缘或被最小化,恢复正常时窗口会收到WM_PAINT消息,如果不绘制棋子,则无法正常显示棋盘上已经存在的棋子。
(2)悔棋时由于无法直接擦除已经下了的棋子,需要调用Invalidate函数重新绘制,如果不绘制棋子,则无法正常显示棋盘上已经存在的棋子。
既然提到了SetChessBoardColor函数,那我们先来看看它的代码。这个函数不仅能修改ChessBoard,还能在屏幕上绘制出棋子,这样就不用重绘整个棋盘了。
void CGoBangDlg::SetChessBoardColor(int nx, int ny, int color)
{
ChessBoard[ny][nx] = color;
CDC* dc = this->GetDC();
CPen pen(PS_SOLID, 2, RGB(0, 0, 0));
dc->SelectObject(pen);
if (color == 0)//白棋
{
CBrush brush_w(RGB(255, 255, 255));
const CPoint o(50 * nx + 50, 50 * ny + 50);//圆心
dc->SelectObject(brush_w);
dc->Ellipse(o.x - 15, o.y - 15, o.x + 15, o.y + 15);
}
else if (color == 1)//黑棋
{
CBrush brush_b(RGB(0, 0, 0));
const CPoint o(50 * nx + 50, 50 * ny + 50);//圆心
dc->SelectObject(brush_b);
dc->Ellipse(o.x - 15, o.y - 15, o.x + 15, o.y + 15);
}
else//清除该坐标棋子,需要重绘,用于悔棋
{
RECT rect;
GetClientRect(&rect);
InvalidateRect(&rect);
}
}
然后是CleanChessBoard函数,该函数清空ChessBoard数组和屏幕上的棋子。代码如下:
void CGoBangDlg::CleanChessBoard()
{
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
ChessBoard[i][j] = -1;
}
}
Invalidate();//刷新
}
然后我们来创建EndGame函数,它的功能是结束游戏。代码如下:
void CGoBangDlg::EndGame()
{
CleanChessBoard();
IsPlaying = false;
index = -1;
GetDlgItem(IDC_START)->SetWindowTextW(L"开始游戏");
GetDlgItem(IDC_ENDGAME)->EnableWindow(FALSE);
GetDlgItem(IDC_REPENTANCE)->EnableWindow(FALSE);
GetDlgItem(IDC_SAVE)->EnableWindow(FALSE);
}
接下来,我们创建“开始游戏”按钮的BN_CLICKED消息。由于开始游戏与重玩的代码完全相同,所以我们就不再创建重玩按钮,而是通过修改“开始游戏”按钮的窗口标题实现。代码如下:
void CGoBangDlg::OnBnClickedStart()
{
if (IsPlaying && MessageBoxW(L"确定要重玩吗?", L"双人五子棋", MB_YESNO | MB_ICONQUESTION) == IDNO)
return;
GetDlgItem(IDC_START)->SetWindowTextW(L"重玩");
IsPlaying = true;
NowColor = 1;//黑先
index = -1;
GetDlgItem(IDC_ENDGAME)->EnableWindow(TRUE);
GetDlgItem(IDC_REPENTANCE)->EnableWindow(FALSE);
GetDlgItem(IDC_SAVE)->EnableWindow(TRUE);
CleanChessBoard();
}
这里用到了一个C++逻辑与运算符基本规则,那就是如果第一个表达式不成立,就不会计算第二个表达式。所以如果IsPlaying(游戏在进行)不成立,就不会 弹出对话框,而是直接进行下面的代码。
接着,我们创建“结束本局”的BN_CLICKED消息处理程序。因为我们以前已经写了EndGame函数,这里直接调用就行了。代码如下:
void CGoBangDlg::OnBnClickedEndgame()
{
if (MessageBoxW(L"确定要结束本局吗?", L"双人五子棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
EndGame();
}
然后是对话框的WM_SETCURSOR消息处理程序。这个函数设置鼠标光标的状态,决定鼠标是黑子、白子还是普通。代码如下:
BOOL CGoBangDlg::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message)
{
POINT point;
GetCursorPos(&point);
ScreenToClient(&point);
if (!IsPlaying || point.x < 40 || point.x>760 || point.y < 40 || point.y>760)//判断鼠标当前位置是否在棋盘里
return CDialogEx::OnSetCursor(pWnd, nHitTest, message);
if (NowColor == 1)//黑棋
SetCursor(LoadCursorW(AfxGetApp()->m_hInstance, MAKEINTRESOURCE(IDC_CURSOR1)));
else
SetCursor(LoadCursorW(AfxGetApp()->m_hInstance, MAKEINTRESOURCE(IDC_CURSOR2)));
return TRUE;
}
现在,我们要创建一个极其重要的消息处理函数——响应鼠标左键松开的消息处理函数,其中point是鼠标的坐标(以客户区为参照系)。这个函数的功能是在鼠标单击处放置棋子并判断胜负。代码如下:
void CGoBangDlg::OnLButtonUp(UINT nFlags, CPoint point)
{
if (!IsPlaying || point.x < 40 || point.x>760 || point.y < 40 || point.y>760)//判断鼠标是否在棋盘里
return;
int x = int(round(point.x / 50.0) - 1);//round是四舍五入函数
int y = int(round(point.y / 50.0) - 1);
//将鼠标坐标转为数组下标
if (GetChessBoardColor(x, y) != -1)//如果已有棋子
return;
SetChessBoardColor(x, y, NowColor);
NowColor = (!NowColor);
index++;
order[index].x = x;
order[index].y = y;
//记录上一步的坐标,用于悔棋
GetDlgItem(IDC_REPENTANCE)->EnableWindow(index > -1);
//如果可以悔棋,取消禁用“悔棋”按钮,否则禁用“悔棋”按钮
SendMessage(WM_SETCURSOR);//刷新鼠标
//以上为放置棋子
int winner = GetWinner();
int count = 0;
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
if (ChessBoard[i][j] != -1)
count++;
}
}
if (winner != -1||count == SIZE * SIZE)
{
if (winner == 0)
MessageBoxW(L"白棋胜利!", L"双人五子棋", MB_OK | MB_ICONINFORMATION);
else if (winner == 1)
MessageBoxW(L"黑棋胜利!", L"双人五子棋", MB_OK | MB_ICONINFORMATION);
else
MessageBoxW(L"平局!", L"双人五子棋", MB_OK | MB_ICONINFORMATION);
EndGame();
return;
}
//判断胜负
}
最后的判断胜负要注意,五子棋除了一方打败另一方,还有一种情况:平局。平局也就是棋盘所有交叉点都下满了,但还没有连成五个子。这时候,我们必须结束游戏,否则棋局无法进行。
最后,我们创建悔棋按钮被按下的消息处理函数。代码如下:
void CGoBangDlg::OnBnClickedRepentance()
{
SetChessBoardColor(order[index].x, order[index].y, -1);//清除上一步棋
index--;
GetDlgItem(IDC_REPENTANCE)->EnableWindow(index > -1);
NowColor = (!NowColor);//改变棋子颜色
}
至此,双人五子棋的消息处理部分已经完成,保存和打开的函数我会单独介绍。
任何一个可以存储信息的软件都有一种文件格式,如word的格式是doc或docx,powerpoint的格式是ppt或pptx等。如果你用记事本打开docx/pptx文档,会发现很简短的一篇文档用记事本打开都会变得很长,这是因为word在保存文字的同时还保存了很多其它属性,如字体、字号、行间距等等。当保存文件时,word会自动将用户输入的文件转化为docx格式,打开文件时又会按自己的文件格式读取文件,并转化为各种属性,这样就可以还原保存时的样子了。五子棋程序也不例外,为了保存完整的棋局状态,我们需要把内存中所有与棋局有关的变量都复制到磁盘中,这就需要我们专门设计一种文件格式。我设计的五子棋文件格式如下:
1.扩展名:gob。
2.文件由多个数字组成,保存了程序运行时的所有变量。
3.第1到第SIZE*SIZE(225)个数字:记录棋盘上每个交叉点的状态。(ChessBoard)
4.第SIZE*SIZE+1(226)个数字:记录下一步是哪一方走。(NowColor)
5.第SIZE*SIZE+2(227)个数字:记录已经走了多少步棋,供悔棋功能用。(index)
6.第SIZE*SIZE+3(228)到第(SIZE*SIZE+3+第226个数字*2)个数字:
每两个数为一组,记录每一步棋的x坐标和y坐标。(order)
例如:
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 0 -1 -1 -1
-1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 1 0 1 1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1
-1 -1 -1 0 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
1
13
7 7
6 8
5 7
5 6
3 5
3 4
6 2
11 3
10 9
3 10
3 9
8 10
8 7
6 7
这就是一个完整的gob文件的内容。
下面,我们开始创建保存算法,这个算法用到了ofstream类。代码如下:
void CGoBangDlg::OnBnClickedSave()
{
CFileDialog filedlg(FALSE);
filedlg.m_ofn.lpstrFilter = L"五子棋文件(*.gob)\0*.gob\0\0";
if (filedlg.DoModal() != IDOK)
return;
CString filename = filedlg.GetPathName();
if (filedlg.GetFileExt() == L"")//如果用户没有输入扩展名
filename += ".gob";
std::ofstream outfile;
outfile.open(CStringA(filename));
if(!outfile)
{
MessageBoxW(L"保存失败!", L"双人五子棋", MB_OK | MB_ICONERROR);
return;
}
for (int y = 0; y < 15; y++)
{
for (int x = 0; x < 15; x++)
{
outfile << GetChessBoardColor(x, y) << '\0';
}
outfile << '\r';
}
//输出ChessBoard数组
outfile <<NowColor<<'\r'<< index << '\r';
for (int i = 0; i <= index; i++)
outfile << order[i].x << '\0' << order[i].y << '\r';
outfile.close();
//输出order数组
MessageBoxW(L"保存成功!", L"双人五子棋", MB_OK | MB_ICONINFORMATION);
}
保存文件的算法比较简单,但读取文件就要麻烦一些了。我们来看看它的代码:
void CGoBangDlg::OnBnClickedOpen()
{
CFileDialog filedlg(TRUE);
filedlg.m_ofn.lpstrFilter = L"五子棋文件(*.gob)\0*.gob\0\0";
if (filedlg.DoModal() != IDOK)
return;
CString filename = filedlg.GetPathName();
if (filedlg.GetFileExt() == L"")
filename += ".gob";
std::ifstream infile;
infile.open(CStringA(filename));
if (!infile)
{
MessageBoxW(L"打开失败!", L"双人五子棋", MB_OK | MB_ICONERROR);
return;
}
for (int y = 0; y < 15; y++)
{
for (int x = 0; x < 15; x++)
{
int t;
infile >> t;
infile.seekg(infile.tellg().operator+(1));
ChessBoard[y][x] = t;
//因为保存文件已经用了GetChessBoardColor函数,文件中的数字是正常顺序,不能使用SetChessBoardColor函数。而且,SetChessBoardColor遇到-1就会刷新棋盘,性能不好。
}
}
Invalidate();//绘制棋盘和棋子
infile >> NowColor;
infile.seekg(infile.tellg().operator+(1));
infile >> index;
for (int i = 0; i <= index; i++)
{
infile.seekg(infile.tellg().operator+(1));
infile >> order[i].x;
infile.seekg(infile.tellg().operator+(1));
infile>> order[i].y;
}
infile.close();
GetDlgItem(IDC_START)->SetWindowTextW(L"重玩");
GetDlgItem(IDC_ENDGAME)->EnableWindow(TRUE);
GetDlgItem(IDC_REPENTANCE)->EnableWindow(index > 0);
GetDlgItem(IDC_SAVE)->EnableWindow(TRUE);
IsPlaying = true;
}
这里有一点要注意,那就是每读完一个数据就会有一行
infile.seekg(infile.tellg().operator+(1));
把指针向后移动一个字节。关于这个问题,我也很纳闷,正常的话ifstream类的函数会自动跳过空格,就像我们的cin一样,可如果没有这一行代码怎么也不行。也有可能是我的编译器问题,如果读者们在自己的电脑上运行有bug,那就把这一行代码删掉试试。
到现在,双人五子棋已经完成,我们可以邀请家人和朋友一起来玩了!这个程序我调试过很多次,目前没有发现bug,如果有人发现了漏洞或有更好的意见,欢迎大家在评论区提出。