一、简介
黑白棋 游戏规则:
黑白棋的棋盘是一个有8*8方格的棋盘。下棋时将棋下在空格中间,而不是像围棋一样下在交叉点上。开始时在棋盘正中有两白两黑四个棋子交叉放置,黑棋总是先下子 。
把自己颜色的棋子放在棋盘的空格上,而当自己放下的棋子在横、竖、斜八个方向内有一个自己的棋子,则被夹在中间的全部翻转会成为自己的棋子。并且,只有在可以翻转棋子的地方才可以下子。如果一方不能翻转另一方的棋子,就由另一方连续下子,直到这一方可以下。
如果双方都不能翻转对方的棋子或棋盘下满了,一盘棋就结束了。哪一方的棋子多哪一方就获胜。
我设计的双人黑白棋实现了如下功能:
1.使用MFC绘制棋盘、棋子并可以使用鼠标操作;
2.判断哪些地方可以落子;
3.终局时判断胜负。
完整代码点击此处下载。
此外,我还发表了一篇MFC双人五子棋的文章,黑白棋很多代码都是参考的五子棋,这篇博客也有很多地方是参考的五子棋那篇博客,有兴趣的人可以点击此处看看。
二、程序设计
1.创建资源文件
调整对话框尺寸为400*400(Windows坐标是800*900),在下方添加三个Button控件,分别是“开始游戏”(IDC_START)“结束本局”(IDC_ENDGAME)“退出”(IDC_QUIT),把“开始游戏”设为默认按钮,“结束本局”设为初始禁用,再添加一个Static控件(IDC_CHESSCOUNT),初始文本设为“双人黑白”,游戏进行的时候我们用它显示棋子个数。把对话框标题改为“双人黑白棋”。棋盘和棋子后面我们会用代码绘制。
然后,添加两个cursor光标资源,图案分别是一个黑棋和一个白棋。黑棋的ID是IDC_BLACK,白棋的ID是IDC_WHITE。
2.下子算法设计
黑白棋的判断胜负很简单,就是看看哪一方棋子个数多就行了,但判断哪里能下子比较复杂。要知道,黑白棋不是任意地方都能下子的,而是能翻转对方的棋子才可以下。
为了方便修改棋盘尺寸,我们定义了一个宏SIZE,它代表棋盘的行数和列数。
#define SIZE 8
下面我们开始创建变量。接下来的变量都放在对话框类中作为对话框类的成员。首先,我们创建一个SIZE*SIZE的int(char也行)二维数组(名为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 COthelloDlg::GetChessBoardColor(int nx, int ny)
{
return ChessBoard[ny][nx];
}
接下来,我们开始创建算法的核心部分。我创建了一个函数,名为GetNextSameColorChessPos,这个函数的功能是获取指定坐标上的棋子在指定方向上的最近的、中间没有空格的同色棋子的坐标,如果没有则返回(SIZE,SIZE),即(8,8),表示没有找到。举个例子,假如下面的棋盘右上角棋子坐标为(3,3),GetNextSameColorChessPos(3,3,7)的值就为(0,0)(即左下角棋子的坐标)。其中,参数表中第三个参数是方向,7代表左下。
该函数代码如下:
CPoint COthelloDlg::GetNextSameColorChessPos(int nx, int ny, int direction, int TestColor = 2)
{
int x = nx, y = ny;
int color = TestColor;
if (color == 2)
color = GetChessBoardColor(x, y);
switch (direction)//注意:Windows系统和数组坐标以左上角为原点,所以上下要相反
{
case 0://左
while (1)
{
x--;
if (x < 0)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
//函数肯定会返回,无需break
case 1://左上
while (1)
{
x--;
y--;
if (x < 0 || y < 0)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 2://上
while (1)
{
y--;
if (y < 0)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 3://右上
while (1)
{
x++;
y--;
if (x >= SIZE || y < 0)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 4://右
while (1)
{
x++;
if (x >= SIZE)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 5://右下
while (1)
{
x++;
y++;
if (x >= SIZE || y >=SIZE)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 6://下
while (1)
{
y++;
if (y >=SIZE)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
case 7://左下
while (1)
{
x--;
y++;
if (x < 0 || y >=SIZE)//未找到
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == -1)//中间有空格
return CPoint(SIZE, SIZE);
if (GetChessBoardColor(x, y) == color)
return CPoint(x, y);
}
default:
return CPoint(SIZE, SIZE);
}
}
这个函数的代码虽然长,但不复杂,写完第一种情况之后几乎全是复制,修改一下符号就行了。这里需要注意的就是符号,有++ – >= <,很容易重复了。还要注意,这里上并不y坐标是+,而是-,这是因为Windows系统和数组坐标以左上角为原点。注意这个函数的第四个参数TestColor,用于测试,也就是说那个坐标还没有棋子,给它假设一种颜色,看看下在这里行不行,用于鼠标点击后判断能不能落子。如果不填这个参数,默认是获取指定坐标的棋子颜色进行判断。
接下来,我们就可以创建一个判断某个坐标能不能落子的函数了。该函数代码如下:
bool COthelloDlg::CanItPlaceChessPieces(int x, int y, int color)
{
int sum = 0;
for (int direction = 0; direction < 8; direction++)
{
int count;
switch (direction)
{
case 2://上
case 6://下
{
int pos = GetNextSameColorChessPos(x, y, direction, color).y;
count = abs(pos - y) - 1;
//计算y轴坐标的差
if (pos == SIZE)//没找到
count = 0;
}
break;
default://其它方向,计算x轴坐标的差
{
int pos = GetNextSameColorChessPos(x, y, direction, color).x;
count = abs(pos - x) - 1;
//计算y轴坐标的差
if (pos == SIZE)//没找到
count = 0;
}
}
sum += count;
}
return sum;//return (sum > 0);
}
这个函数的实现原理是遍历8个方向,把每个方向中间间隔的棋子(可翻转的棋子)个数加起来,最后判断它们的和是否为0,就可以判断能否落子了。这个函数要注意的是switch语句,当direction等于2或6,也就是上方或下方的时候我们需要计算两个坐标y轴的差,因为每一行上肯定只有一个棋子。其它方向我们都可以计算x轴坐标的差,因为每一列上肯定只有一个棋子。
到现在,下子部分的算法设计完毕,我们开始设计响应控件事件和绘制棋盘的有关函数。
2.窗口消息、控件事件和绘图函数
让我们先从简单的入手吧。我们先处理窗口的WM_CLOSE消息,当窗口收到WM_CLOSE消息时,判断游戏是否在进行,如果在进行则弹出对话框询问是否要退出否则直接退出。代码如下:
void COthelloDlg::OnClose()
{
if (!IsPlaying || MessageBoxW(L"正在游戏中,确定要退出吗?", L"双人黑白棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
CDialogEx::OnClose();
}
这里用到了一个C++逻辑或运算符基本规则,那就是如果第一个表达式成立,就不会计算第二个表达式。所以如果!IsPlaying(游戏不在进行),就不会弹出对话框,而是直接关闭。
“退出”按钮的BN_CLICKED消息和WM_CLOSE消息的处理程序基本相同,这里就不再赘述了。代码如下:
void COthelloDlg::OnBnClickedQuit()
{
if (!IsPlaying || MessageBoxW(L"正在游戏中,确定要退出吗?", L"双人黑白棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
EndDialog(0);
}
接着,我们修改一下对话框的DoDataExchange函数,进行数据初始化。代码很简单,就不再解释。代码如下:
void COthelloDlg::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 COthelloDlg::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+1)/*黑白棋下在格子里,要多画一条线*/; i++)
{
dc.MoveTo(40, 50 + i * 90);
dc.LineTo(760, 50 + i * 90);
}//绘制棋盘横线
for (int i = 0; i < (SIZE + 1); i++)
{
dc.MoveTo(40 + i * 90, 50);
dc.LineTo(40 + i * 90, 770);
}//绘制棋盘竖线
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(90 * nx + 85, 90 * ny + 95);//圆心
dc.SelectObject(brush_w);
dc.Ellipse(o.x - 30, o.y - 30, o.x + 30, o.y + 30);
}
else if (color == 1)//黑棋
{
CBrush brush_b(RGB(0, 0, 0));
const CPoint o(90 * nx + 85, 90 * ny + 95);//圆心
dc.SelectObject(brush_b);
dc.Ellipse(o.x - 30, o.y - 30, o.x + 30, o.y + 30);
}
}
}
}
}
代码应该不难理解,用的是MFC的绘图工具,和GDI绘图类似,非常方便,不需要担心内存泄漏等问题,构造函数和析构函数会自动处理绘图对象的创建和删除。但有些MFC或Windows API开发经验的人可能会注意到一个问题:我用的是CPaintDC,相当于Windows API中的BeginPaint函数,这样只会绘制一次,绘制结束后不会再收到WM_PAINT消息,下棋后无法正常显示棋子。这个问题有道理。但是,因为黑白棋不需要一直重绘已经存在的棋子,如果一直重绘已经存在的棋子会大幅度降低程序性能,所以我在SetChessBoardColor函数中会绘制新下的棋子,已经绘制的棋子和棋盘不会改变,这个函数下面会讲到。这里之所以还添加绘制棋子的程序,是因为游戏过程中,窗口被移出屏幕边缘或被最小化,恢复正常时窗口会收到WM_PAINT消息,如果不绘制棋子,则无法正常显示棋盘上已经存在的棋子。
既然提到了SetChessBoardColor函数,那我们先来看看它的代码。这个函数不仅能修改ChessBoard,还能在屏幕上绘制出棋子,这样就不用重绘整个棋盘了。
void COthelloDlg::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(90 * nx + 85, 90 * ny + 95);//圆心
dc->SelectObject(brush_w);
dc->Ellipse(o.x - 30, o.y - 30, o.x + 30, o.y + 30);
}
else if (color == 1)//黑棋
{
CBrush brush_b(RGB(0, 0, 0));
const CPoint o(90 * nx + 85, 90 * ny + 95);//圆心
dc->SelectObject(brush_b);
dc->Ellipse(o.x - 30, o.y - 30, o.x + 30, o.y + 30);
}
}
然后是CleanChessBoard函数,该函数清空ChessBoard数组和屏幕上的棋子。代码如下:
void COthelloDlg::CleanChessBoard()
{
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
ChessBoard[i][j] = -1;
}
}
Invalidate();
}
然后我们来创建EndGame函数,它的功能是结束游戏。代码如下:
void COthelloDlg::EndGame()
{
CleanChessBoard();
IsPlaying = false;
GetDlgItem(IDC_START)->SetWindowTextW(L"开始游戏");
GetDlgItem(IDC_ENDGAME)->EnableWindow(FALSE);
GetDlgItem(IDC_CHESSCOUNT)->SetWindowTextW(L"双人黑白棋");
}
接下来,我们创建“开始游戏”按钮的BN_CLICKED消息。由于开始游戏与重玩的代码完全相同,所以我们就不再创建重玩按钮,而是通过修改“开始游戏”按钮的窗口标题实现。代码如下:
void COthelloDlg::OnBnClickedStart()
{
if (IsPlaying && MessageBoxW(L"确定要重玩吗?", L"双人黑白棋", MB_YESNO | MB_ICONQUESTION) == IDNO)
return;
GetDlgItem(IDC_START)->SetWindowTextW(L"重玩");
IsPlaying = true;
NowColor = 1;//黑先
GetDlgItem(IDC_ENDGAME)->EnableWindow(TRUE);
CleanChessBoard();
SetChessBoardColor(SIZE / 2 - 1, SIZE / 2 - 1, 1);
SetChessBoardColor(SIZE / 2, SIZE / 2, 1);
SetChessBoardColor(SIZE / 2, SIZE / 2 - 1, 0);
SetChessBoardColor(SIZE / 2 - 1, SIZE / 2, 0);
GetDlgItem(IDC_CHESSCOUNT)->SetWindowTextW(L"黑棋:2个\t白棋:2个");
//黑白棋初始有四个棋子
}
这里用到了一个C++逻辑与运算符基本规则,那就是如果第一个表达式不成立,就不会计算第二个表达式。所以如果IsPlaying(游戏在进行)不成立,就不会 弹出对话框,而是直接进行下面的代码。
接着,我们创建“结束本局”的BN_CLICKED消息处理程序。因为我们以前已经写了EndGame函数,这里直接调用就行了。代码如下:
void COthelloDlg::OnBnClickedEndgame()
{
if (MessageBoxW(L"确定要结束本局吗?", L"双人黑白棋", MB_YESNO | MB_ICONQUESTION) == IDYES)
EndGame();
}
然后是对话框的WM_SETCURSOR消息处理程序。这个函数设置鼠标光标的状态,决定鼠标是黑子、白子还是普通。代码如下:
BOOL COthelloDlg::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message)
{
POINT point;
GetCursorPos(&point);
ScreenToClient(&point);
if (!IsPlaying || point.x < 40 || point.x>760 || point.y < 50 || point.y>770)
return CDialogEx::OnSetCursor(pWnd, nHitTest, message);
if (NowColor == 1)//黑棋
SetCursor(LoadCursorW(AfxGetApp()->m_hInstance, MAKEINTRESOURCE(IDC_BLACK)));
else
SetCursor(LoadCursorW(AfxGetApp()->m_hInstance, MAKEINTRESOURCE(IDC_WHITE)));
return TRUE;
}
现在,我们要创建一个极其重要的消息处理函数——响应鼠标左键松开的消息处理函数,其中point是鼠标的坐标(以客户区为参照系)。这个函数的功能是在鼠标单击处放置棋子、翻转棋子、显示棋子个数并判断胜负。代码如下:
void COthelloDlg::OnLButtonUp(UINT nFlags, CPoint point)
{
if (!IsPlaying || point.x < 40 || point.x>760 || point.y < 50 || point.y>770)
return;
int x = int(round((point.x - 45 - 40) / 90.0));
int y = int(round((point.y - 45 - 50) / 90.0));
//将鼠标坐标转为数组下标
if (GetChessBoardColor(x, y) != -1)//如果已有棋子
return;
if (!CanItPlaceChessPieces(x, y, NowColor))
return;
SetChessBoardColor(x, y, NowColor);
for (int direction = 0; direction < 8; direction++)
{
CPoint pt = GetNextSameColorChessPos(x, y, direction);
if (pt.x == SIZE)
continue;
switch (direction)//注意:Windows系统和数组坐标以左上角为原点,所以上下要相反
{
case 0://左
for (int nx = pt.x + 1; nx < x; nx++)//注意符号
SetChessBoardColor(nx, y, NowColor);
break;
case 1://左上
for (int nx = pt.x + 1, ny = pt.y + 1; nx < x; nx++, ny++)//注意符号
SetChessBoardColor(nx, ny, NowColor);
break;
case 2://上
for (int ny = pt.y + 1; ny < y; ny++)//注意符号
SetChessBoardColor(x, ny, NowColor);
break;
case 3://右上
for (int nx = pt.x - 1, ny = pt.y + 1; nx > x; nx--, ny++)//注意符号
SetChessBoardColor(nx, ny, NowColor);
break;
case 4://右
for (int nx = pt.x - 1; nx > x; nx--)//注意符号
SetChessBoardColor(nx, y, NowColor);
break;
case 5://右下
for (int nx = pt.x - 1, ny = pt.y - 1; nx > x; nx--, ny--)//注意符号
SetChessBoardColor(nx, ny, NowColor);
break;
case 6://下
for (int ny = pt.y - 1; ny > y; ny--)//注意符号
SetChessBoardColor(x, ny, NowColor);
break;
case 7://左下
for (int nx = pt.x + 1, ny = pt.y - 1; nx < x; nx++, ny--)//注意符号
SetChessBoardColor(nx, ny, NowColor);
break;
}
}
bool b1 = false, b2 = false;
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
if ((GetChessBoardColor(i, j) == -1) && (CanItPlaceChessPieces(i, j, !NowColor)))
{
NowColor = (!NowColor);
b1 = true;
break;
}
}
if (b1)
break;
}
if (!b1)
{
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
if ((GetChessBoardColor(i, j) == -1) && (CanItPlaceChessPieces(i, j, NowColor)))
{
b2 = true;
break;
}
}
if (b2)
break;
}
}
//黑白棋不一定是轮流下,如果一方无棋可下则另一方一直下,直到那一方可以下
SendMessage(WM_SETCURSOR);
//以上为放置棋子
int white = 0, black = 0;
for (int i = 0; i < SIZE; i++)
{
for (int j = 0; j < SIZE; j++)
{
int color = GetChessBoardColor(i, j);
if (color == 0)
white++;
else if (color == 1)
black++;
}
}
CString str;
str.Format(L"黑棋:%d个\t白棋:%d个", black, white);
CRect rcStatic;
GetDlgItem(IDC_CHESSCOUNT)->GetWindowRect(&rcStatic);
ScreenToClient(&rcStatic);
InvalidateRect(&rcStatic);
GetDlgItem(IDC_CHESSCOUNT)->SetWindowTextW(str);
if ((!b1)&&(!b2))//双方都无棋可下
{
if (white > black)
MessageBoxW(L"白棋胜利!\n"+str, L"双人黑白棋", MB_OK | MB_ICONINFORMATION);
else if (black > white)
MessageBoxW(L"黑棋胜利!\n"+str, L"双人黑白棋", MB_OK | MB_ICONINFORMATION);
else
MessageBoxW(L"平局!\n"+str, L"双人黑白棋", MB_OK | MB_ICONINFORMATION);
EndGame();
}
//以上为判断胜负
}
代码最复杂的部分是翻转棋子。首先,翻转棋子不需要擦除,直接在原来的棋子上覆盖就行了。翻转棋子的代码一定要注意符号,一不小心就会重复。告诉你们一个小窍门:如果初始化nx或ny时是+1,后面就对应着<和++,否则对应>和–。它的原理倒是很简单,就是把两个相同颜色的棋子中间夹着的棋子全部变成一种颜色。还有一点,在显示棋子个数的时候没有简单地SetWindowText,这是因为如果不强制刷新,新的数据会在旧数据上面显示。毕竟Static控件一般是用来显示固定的文本的。当然,也可以用Edit控件代替,不过要设为只读的。
为了美观,我还为程序添加了一个背景图,添加背景图的过程很简单,先把图片添加到资源文件里,再在OnInitDialog函数中添加一句
SetBackgroundImage(IDB_BACKGROUNDIMAGE);
就可以了。
3.其它功能
由于时间问题,黑白棋还有很多功能没有实现,如悔棋、保存和打开棋局、显示哪些地方可以落子等。下面我给大家提供一些思路:
悔棋
因为黑白棋落子后还要翻转,所以不能逆推出上一步,只能创建一个3维数组,保存每一步棋盘的状态。这和双人五子棋的悔棋功能有些相似,有兴趣的话可以看看五子棋的悔棋功能。
保存、打开棋局
这个功能和五子棋的功能基本相同,只要按格式把内存中相关变量复制到磁盘里就行了。具体见五子棋的保存、打开棋局功能。
显示落子的地方
在一个函数中遍历棋盘的所有空交叉点,在可以落子的地方绘图。不过每走完一步不要忘记把上一步绘制的擦除哦!
三、程序截图
至此,双人黑白棋已经完成。这个程序我调试过很多次,目前没有发现bug,如果有人发现了漏洞或有更好的意见,欢迎大家在评论区提出。
下面我给出程序的一些截图。
初始状态:
游戏中:
判断胜负:
PS:如果觉得写得好,不要忘记点个赞哦!