C++ 国际象棋控制台游戏





5.00/5 (22投票s)
一个用 C++ 编写的简单国际象棋游戏,在控制台中运行。出于教学目的和娱乐而制作 :)
如果您想在解压后验证可执行文件的哈希值,SHA-1 值为:a3deafa91b02e06781666f96b05a1a321f56a8a2。
(如果您没有 VS 2015 Redistributables,请在此处查找)。
源代码
欢迎在 GitHub 上分叉此项目。
屏幕截图
引言
互联网上有许多国际象棋游戏的实现,其中大多数功能比这个更丰富。然而,开发一个更简单、更轻量级的软件并没有什么缺点,特别是为了教学目的。
这个游戏是什么(或者说它试图成为什么)
- 轻量级。应用程序 1.2 版本的大小是 159 KB。
- 完全在控制台中实现
这个游戏不是/不具备什么
- 没有图形用户界面 (GUI)
- 没有人工智能 (AI)
背景
这个游戏在控制台中运行,这意味着用户没有可用的 GUI。所有输入都来自键盘,为此,它使用坐标记谱法。
白棋用大写字母表示,黑棋用小写字母表示。它们都用其名称的首字母表示,唯一的例外是骑士(Knight),它用 N 表示,而 K 留给王(King)。
Pawn(兵)
Rook(车)
Knight(骑士)
Bishop(主教)
Queen(后)
King(王)
我将尝试解释我在开发游戏时使用的一些概念,如果有任何不清楚的地方或者我遗漏了重要的地方,请在讨论中告诉我。
绘制棋盘
我们可以分别使用 ASCII 字符 0xDB
和 0xFF
来绘制白色和黑色单元格。
#define WHITE_SQUARE 0xDB
#define BLACK_SQUARE 0xFF
首先,我们必须决定棋盘上的方块应该有多大。说到高度,棋盘上的一个方块应该和一个字符一样大吗?或者两个或三个?
下图说明了我们拥有的选项
我最终选择了第三个选项,这意味着一个方块的高度等于三个字符。现在,我们面临另一个问题。上面提到的字符(0xDB 和 0xFF)不是方形的;它们实际上是矩形的,一边是另一边大小的两倍。这意味着,为了形成一个正方形,我们必须在一行中使用六个字符。
这些是绘制棋盘的函数
void printBoard(Game& game)
{
cout << " A B C D E F G H\n\n";
for (int iLine = 7; iLine >= 0; iLine--)
{
if ( iLine%2 == 0)
{
// Line starting with BLACK
printLine(iLine, BLACK_SQUARE, WHITE_SQUARE, game);
}
else
{
// Line starting with WHITE
printLine(iLine, WHITE_SQUARE, BLACK_SQUARE, game);
}
}
}
void printLine(int iLine, int iColor1, int iColor2, Game& game)
{
// Define the CELL variable here.
// It represents how many horizontal characters will form one squarite
// The number of vertical characters will be CELL/2
// You can change it to alter the size of the board
// (an odd number will make the squares look rectangular)
int CELL = 6;
// Since the width of the characters BLACK and WHITE is half of the height,
// we need to use two characters in a row.
// So if we have CELL characters, we must have CELL/2 sublines
for (int subLine = 0; subLine < CELL/2; subLine++)
{
// A sub-line is consisted of 8 cells, but we can group it
// in 4 iPairs of black&white
for (int iPair = 0; iPair < 4; iPair++)
{
// First cell of the pair
for (int subColumn = 0; subColumn < CELL; subColumn++)
{
// The piece should be in the "middle" of the cell
// For 3 sub-lines, in sub-line 1
// For 6 sub-columns, sub-column 3
if ( subLine == 1 && subColumn == 3)
{
cout << char(game.getPieceAtPosition(iLine, iPair*2) != 0x20 ?
game.getPieceAtPosition(iLine, iPair*2) : iColor1);
}
else
{
cout << char(iColor1);
}
}
// Second cell of the pair
for (int subColumn = 0; subColumn < CELL; subColumn++)
{
// The piece should be in the "middle" of the cell
// For 3 sub-lines, in sub-line 1
// For 6 sub-columns, sub-column 3
if ( subLine == 1 && subColumn == 3)
{
cout << char(game.getPieceAtPosition(iLine,iPair*2+1) != 0x20 ?
game.getPieceAtPosition(iLine,iPair*2+1) : iColor2);
}
else
{
cout << char(iColor2);
}
}
}
}
}
代码结构
代码由三个 .cpp 文件组成
- main.cpp:应用程序的入口点。提示用户执行一个动作(新游戏、移动、撤销、保存、加载、退出),并根据要执行的动作,提示更多信息并调用其他文件中的函数。
- chess.cpp:由两个类组成。第一个名为
Chess
,包含enum
s、struct
s 和简单函数,用于描述棋子、颜色和棋盘。第二个名为Game
,它继承自Chess
。它存储单个游戏的所有信息,例如棋盘上每个棋子的位置、已进行的移动列表、被捕获的棋子列表。它还包含用于确定王是否被将军、是否允许易位、某个方格是否被占据的所有必要函数,以验证移动是否有效。 - user_interface.cpp:主要由将信息打印到控制台的函数组成,例如打印棋盘、最后几步、菜单、给用户的消息等。
我以这样一种方式设计了应用程序:如果用户界面要改进(例如,如果有人决定分叉此代码并开发 GUI),则不应修改 chess.cpp 文件。所需的更改基本上是用新的界面替换 user_interface.cpp 文件,并替换 main.cpp 文件中对该界面的调用。
验证移动
bool isMoveValid(Chess::Position present, Chess::Position future,
Chess::EnPassant* S_enPassant, Chess::Castling* S_castling)
[请参阅 main.cpp,第 19 行]
在用户输入移动棋子的命令后,必须检查几项内容以验证其是否为有效移动。
- 期望的棋子是否允许朝该方向移动? 在这里,我们必须为所有类型的棋子创建案例的 switch 语句。骑士、车、象和后相对简单,因为它们总是以相同的方式移动。另一方面,兵垂直移动,但允许斜向移动以捕获棋子。此外,它们可以选择前进两格,但仅限于第一次移动。甚至还有“吃过路兵”移动,即兵向前移动但仍然捕获棋子。王可以向各个方向移动一格,但当易位发生时,它可以移动两格(但仅限于王和涉及移动的车都是第一次移动)。
- 目标方格上是否有相同颜色的另一颗棋子? 如果是,则移动无效。如果有棋子,但颜色不同,则该棋子将被捕获。
- 此举是否会将王置于将军状态? 无论王是否已经处于将军状态,我们都需要检查,在该移动之后,王是否会立即受到任何对手棋子的攻击。
存储移动和被捕获的棋子
我们正在利用 C++ 提供的某些容器来存储游戏信息。但首先,我创建了一个简单的结构,用于存储一白一黑的移动。每个移动都是一个字符串,包含要移动的棋子的位置,后跟一个破折号,以及目标方格,例如 E2-E4。
struct Round
{
string white_move;
string black_move;
};
双端队列是我选择用于存储回合的数据结构。它是一种多功能结构,允许从队列的开头和结尾插入和删除元素。声明和使用示例如下
std::deque<Round> rounds;
// How many rounds are stored?
rounds.size()
// Access a round
rounds[i].white_move.c_str()
// Clear the container
rounds.clear();
// Insert or remove elements
rounds.pop_back();
rounds.push_back(round);
对于被捕获的棋子,它们存储在向量中
// Save the captured pieces
std::vector<char> white_captured;
std::vector<char> black_captured;
它们不能像这样打印在屏幕上
cout << "WHITE captured: ";
for (unsigned i = 0; i < game.white_captured.size(); i++)
{
cout << char(game.white_captured[i]) << " ";
}
cout << "black captured: ";
for (unsigned i = 0; i < game.black_captured.size(); i++)
{
cout << char(game.black_captured[i]) << " ";
}
这是结果。
玩游戏
开始新游戏
启动应用程序,按 N,然后按 ENTER,即可开始新游戏。棋盘显示出来,轮到白方下。
进行移动
输入 M 进行移动。
系统将提示您选择要移动的棋子。通过输入两个字符(大写或小写将得到相同的结果)来完成此操作,首先描述列,然后描述您想要移动的棋子当前所在的行。例如,王前面的白兵位于 E2 方格。
接下来,系统将提示您输入目标方格。最常见的移动之一是将兵从 E2 移动到 E4。
如果移动无效,您将收到警告。
撤销移动
只需输入 U,然后按 ENTER,即可撤销上一步移动。只能撤销最后一步移动。
将死
bool Game::isCheckMate()
[请参阅 chess.cpp,第 1394 行。]
每一步之后,我们都必须检查是否发生了将死。以下是要遵循的步骤
- 王是否被将军? 如果没有,则无需进一步检查。
- 王是否可以移动到另一个方格? 如果王可以移动到另一个方格并且不再受到攻击,那么就不是将死。
- 攻击者是否可以被吃掉或有其他棋子挡住? 如果攻击者可以被吃掉,那么就不是将死。如果不能,仍然有可能有其他棋子挡在攻击者和王之间。
如果问题二和问题三的答案是否定的,那么就是将死,游戏结束!
保存/加载游戏
保存游戏很有用,如果您想稍后完成它,但它也是一个非常有用的调试工具。如果我正在测试将死、易位甚至“吃过路兵”的移动,每次都从所有棋子都在其原始位置开始是很乏味的。能够将游戏保存在特定位置,纠正代码并从同一点再次测试,这被证明是一个非凡的工具。
它是如何完成的?当用户在菜单上输入“S”保存游戏时,系统会提示他输入一个名称。应用程序将在可执行文件所在的目录中创建一个(或覆盖)名为“name_entered.dat”的文件。如果您好奇,可以用 notepad++ 打开该文件查看。文件的前几行可能如下所示
[Chess console] Saved at: Fri Feb 9 00:07:43 2018
E2-E4 | C7-C5
C2-C3 | D7-D5
时间和日期是为了调试目的而包含的。
在标题行上方,所有移动都打印出来,每行一回合,总是从白方开始。因此,在这种情况下,白方开始将兵从 E2 推到 E4,黑方将兵从 C7 推到 C5。(如果您想知道这是否是黑方的好棋,嗯,这是由加里·卡斯帕罗夫对阵深蓝所下的)。
由于所有移动都存储在双端队列中,因此很容易将该信息打印到文件中,如下所示
void saveGame(void)
{
string file_name;
cout << "Type file name to be saved (no extension): ";
getline(cin, file_name);
file_name += ".dat";
std::ofstream ofs(file_name);
if (ofs.is_open())
{
// Write the date and time of save operation
auto time_now = std::chrono::system_clock::now();
std::time_t end_time = std::chrono::system_clock::to_time_t(time_now);
ofs << "[Chess console] Saved at: " << std::ctime(&end_time);
// Write the moves
for (unsigned i = 0; i < current_game->moves.size(); i++)
{
ofs << current_game->rounds[i].white_move.c_str() <<
" | " << current_game->moves[i].black_move.c_str() << "\n";
}
ofs.close();
createNextMessage("Game saved as " + file_name + "\n");
}
else
{
cout << "Error creating file! Save failed\n";
}
return;
}
当用户想要加载已保存的游戏时,应用程序会提示用户输入文件名(同样,不带 .dat 扩展名)。之后,步骤是:首先,检查文件是否存在并打开。跳过第一行(标题)后,应读取每一行,将其分为白方和黑方的移动,并且必须验证每个移动的有效性。
这真的有必要吗?好吧,我们当然在保存之前验证了所有移动,但我们不能保证文件没有被篡改,所以最好谨慎行事并再次验证。
您可以在 github 项目页面上找到一堆已保存的游戏,这些游戏帮助我测试和调试了游戏。请特别注意 KasparovVSdeepblue_game_1.dat。对我来说,重现 深蓝对卡斯帕罗夫,1996 年第一场比赛的每一步都充满了乐趣。这是一场重要的比赛,因为这是国际象棋计算机在正常国际象棋锦标赛条件和经典时间控制下,首次战胜在位世界冠军的比赛。
Bug
此应用程序肯定不是没有 bug 的。如果您遇到错误、崩溃、游戏中出现无效情况等,请给我发电子邮件截图或(更好)保存游戏并给我发送 .dat 文件。非常感谢您的帮助!
改进/未来步骤
Unicode 中的国际象棋符号
并非所有人都知道 Unicode 中有国际象棋符号。然而,将它们输出到控制台并非那么简单。有两个注意事项
- 控制台必须以 Unicode 输出文本
- 控制台使用的字体必须实现国际象棋棋子的字形(并非所有字体都如此)
使用以下源代码和 ConEmu 终端模拟器,我成功地打印了 Unicode 中的棋子。
void printChessPiecesUnicode()
{
_setmode(_fileno(stdout), _O_WTEXT);
std::wcout << L'\u2654' << ' ' << L'\u2655' << ' ' << L'\u2656' << ' '
<< L'\u2657' << ' ' << L'\u2658' << ' ' << L'\u2659' << endl;
std::wcout << L'\u265A' << ' ' << L'\u265B' << ' ' << L'\u265C' << ' '
<< L'\u265D' << ' ' << L'\u265E' << ' ' << L'\u265F' << endl;
}
这是结果。
然而,在对此事深思熟虑了一段时间后,我决定只有少数用户能够正确显示棋子,所以不值得付出努力。尽管如此,我很好奇是否有任何读者会觉得有挑战性用国际象棋字形绘制棋盘和棋子。
图形用户界面
这个游戏可以受益的显着改进之一是一个漂亮的 GUI。这里有很多选择:wxWidgets、Windows Forms 和 Windows Presentation Foundation (WPF),仅举几例。
由于国际象棋游戏的所有逻辑都实现在 Chess.cpp 文件中的两个类中,因此它可以构建为 DLL,供其他编程语言访问。
如果您觉得有必要解决我建议的任何改进,欢迎您在 GitHub 上分叉该项目,让我们进一步讨论!
历史
- 2018 年 4 月 21 日:初始版本
- 2022 年 10 月 5 日:文章更新
- 2024 年 6 月 6 日:更新了包含应用程序 1.2 版本的下载链接