C++实现中国象棋:从面向对象设计到AI算法的完整项目实战

1. 项目概述:从棋盘到代码的完整映射

最近在整理硬盘里的老项目,翻出来一个几年前用C++写的中国象棋游戏。这个项目麻雀虽小,五脏俱全,从棋盘绘制、棋子移动规则、胜负判定到简单的人机对战逻辑都实现了。当时写它,一方面是出于对象棋这个国粹的兴趣,另一方面也是想用C++这个“老伙计”练练手,把面向对象、数据结构、算法这些基础概念串起来,做一个能实际跑起来的、带点复杂逻辑的桌面应用。如果你正在学习C++,想找一个比“学生管理系统”更有趣、比“贪吃蛇”更具挑战性的练手项目,或者你本身就是个象棋爱好者,想看看游戏背后的逻辑是如何运转的,那这个项目的源码或许能给你不少启发。

这个项目完全使用标准C++编写,不依赖任何大型游戏引擎(如Unity、Unreal),核心图形界面用的是Windows API(GDI)或者跨平台的SDL库(取决于版本),确保代码足够“干净”,能把焦点集中在游戏逻辑本身。整个工程结构清晰,一个ChessBoard类管理棋盘状态,一个Piece基类衍生出“车马炮”等各种棋子,规则引擎负责校验每一步的合法性,而AI部分则实现了一个简单的“极大极小值搜索”算法。接下来,我就把这个项目的设计思路、核心实现细节、踩过的坑以及如何扩展它,掰开揉碎了跟大家聊聊。

2. 项目整体架构与核心类设计

一个象棋程序,最核心的任务就是准确无误地模拟现实中的棋盘和规则。我的设计原则是“高内聚、低耦合”,让每个类只负责一件事,并且把易变的规则部分抽象出来。

2.1 核心数据模型:棋盘与棋子的表示

棋盘本质上是一个9x10的网格(中国象棋的规格)。最直观的方法是用一个二维数组来表示。但直接使用intchar数组虽然简单,却不利于扩展和封装。我选择定义一个ChessBoard类,它内部维护一个std::vector<std::vector<Piece*>>的棋盘矩阵(10行9列)。空位用nullptr表示。

棋子的设计采用了继承体系。定义一个抽象的Piece基类,包含所有棋子的共性:颜色(红方或黑方)、位置(行、列)、是否存活、以及一个纯虚函数bool isValidMove(const ChessBoard& board, int toRow, int toCol) const,用于判断从当前位置移动到目标位置是否合法。然后,为每种棋子创建派生类:Rook(车)、Knight(马)、Cannon(炮)、Elephant(象)、Mandarin(士)、King(将/帅)、Pawn(兵/卒)。

// Piece.h 片段 enum class PieceColor { RED, BLACK }; class Piece { protected: PieceColor color; int row, col; bool alive; public: Piece(PieceColor c, int r, int cl) : color(c), row(r), col(cl), alive(true) {} virtual ~Piece() = default; virtual PieceType getType() const = 0; virtual bool isValidMove(const ChessBoard& board, int toRow, int toCol) const = 0; // 获取棋子文字表示,如“车”、“马” virtual std::string getSymbol() const = 0; // Getter/Setter int getRow() const { return row; } int getCol() const { return col; } PieceColor getColor() const { return color; } bool isAlive() const { return alive; } void setPosition(int r, int c) { row = r; col = c; } void setAlive(bool a) { alive = a; } };

这种设计的优势在于,增加新的棋子类型(虽然象棋规则固定)或修改某类棋子的走法规则时,只需要修改或新增对应的派生类,棋盘和其他逻辑完全不用动,符合“开闭原则”。

2.2 游戏状态管理与规则引擎

ChessBoard类是游戏状态的中心枢纽。它不仅要存储棋子指针的矩阵,还要负责:

  1. 初始化棋盘:在构造函数中,按照初始布局创建所有棋子对象,并放入矩阵的对应位置。
  2. 移动棋子:提供bool movePiece(int fromRow, int fromCol, int toRow, int toCol)方法。这是最核心的函数之一。它的内部逻辑是:
    • 检查from位置是否有己方棋子。
    • 调用该棋子的isValidMove方法,传入当前棋盘状态和目标位置,进行规则校验。
    • 如果目标位置有对方棋子,则“吃子”(将对方棋子标记为alive=false,并从棋盘矩阵中移除其指针,注意内存管理)。
    • 更新本方棋子的位置。
    • 检查移动后是否导致己方“将帅”被将军,甚至被将死。
  3. 胜负判定:每次移动后,都需要检查是否构成“将死”或“困毙”。这通过GameState checkGameState(PieceColor currentSide)函数实现。它会模拟当前行棋方所有可能的走法,如果没有任何一步能解除“将军”状态,则判定为输棋。
  4. 棋盘状态查询:提供接口查询某个位置是否有子、是什么子、是否在某个颜色的攻击范围内等。这些查询是规则判断的基础。

规则引擎的逻辑分散在各个Piece派生类的isValidMove中,以及ChessBoard的全局规则检查(如“将帅不能照面”)里。这是整个项目算法最密集的部分。

注意:内存管理需要小心。棋盘矩阵存储的是原始指针。我选择在ChessBoard的析构函数中遍历矩阵,删除所有非空的Piece*。也可以在ChessBoard内部使用std::unique_ptr,但当时为了更清晰地展示指针操作和继承关系,选择了手动管理。在实际项目中,更推荐使用智能指针以避免内存泄漏。

2.3 人机对战AI的简易实现

为了让游戏能单人玩,我实现了一个非常基础的AI。它采用“极大极小值搜索”算法,并辅以“Alpha-Beta剪枝”来提升效率。

  1. 局面评估函数:这是AI的“眼睛”。我给每种棋子设定了一个基础价值分(例如:车=500,马=250,炮=240,兵=50,将=无穷大)。评估函数evaluateBoard(const ChessBoard& board)会计算红黑双方棋子总价值的差值,作为当前局面的分数(红方视角正分占优)。
  2. 搜索算法:AI会在给定的搜索深度内(比如3层),枚举己方所有合法走法,然后模拟对手会如何应对(对手会选择对AI最不利的走法,即“极小”),再思考自己后续的应对。这样递归下去,形成一个博弈树。最终,AI会选择那条即使对手最优应对后,自己仍能获得最好评估分数的走法路径。
  3. Alpha-Beta剪枝:在搜索过程中,如果发现某条分支已经明显差于当前已知的最好选择,就直接“剪掉”不再深入搜索,极大减少了需要评估的节点数,让搜索更深。
// 一个极度简化的极大极小搜索框架 int minimax(ChessBoard& board, int depth, int alpha, int beta, bool maximizingPlayer) { if (depth == 0 || board.isGameOver()) { return evaluateBoard(board); // 到达叶子节点,返回评估值 } if (maximizingPlayer) { int maxEval = INT_MIN; auto moves = generateAllMoves(board, RED); // 生成红方所有走法 for (auto& move : moves) { board.makeMove(move); // 执行走子 int eval = minimax(board, depth - 1, alpha, beta, false); board.undoMove(move); // 撤销走子(需要实现悔棋功能) maxEval = std::max(maxEval, eval); alpha = std::max(alpha, eval); if (beta <= alpha) break; // Alpha-Beta 剪枝 } return maxEval; } else { // 类似,为 minimizingPlayer(黑方)寻找最小评估值 int minEval = INT_MAX; // ... 生成黑方走法并递归 return minEval; } }

这个AI虽然谈不上强大(深度有限,评估函数简单),但足以提供一个有基本战术思维的对手,用于学习和测试绰绰有余。

3. 图形界面与用户交互实现

游戏逻辑是大脑,图形界面则是脸面。我做了两个版本:一个基于Windows原生GDI,用于快速验证;另一个基于SDL2,追求跨平台和更流畅的显示。

3.1 基于Windows GDI的图形绘制

GDI版本的核心是一个窗口过程函数,处理WM_PAINT消息。在OnPaint函数里:

  1. 绘制9x10的棋盘网格线。
  2. 遍历ChessBoard中的棋子,根据其位置、颜色和类型,在对应的网格交叉点绘制一个圆(代表棋子底座)和文字(如“車”、“馬”)。红子用红色文字,黑子用黑色或深灰色文字。
  3. 处理WM_LBUTTONDOWNWM_LBUTTONUP消息来实现点击选子和走子。需要将鼠标坐标转换为棋盘的行列索引。
// 简化的绘制函数思路 void drawBoard(HDC hdc, const ChessBoard& board) { // 1. 绘制棋盘背景和网格 Rectangle(hdc, boardLeft, boardTop, boardRight, boardBottom); for(int i=0; i<=9; ++i) { // 画横线 MoveToEx(hdc, boardLeft, boardTop + i * gridSize, NULL); LineTo(hdc, boardRight, boardTop + i * gridSize); } // ... 画竖线、楚河汉界等 // 2. 绘制棋子 for (int r = 0; r < 10; ++r) { for (int c = 0; c < 9; ++c) { Piece* p = board.getPieceAt(r, c); if (p && p->isAlive()) { int centerX = boardLeft + c * gridSize + gridSize/2; int centerY = boardTop + r * gridSize + gridSize/2; // 绘制圆形棋子底 HBRUSH brush = CreateSolidBrush(p->getColor()==RED? RGB(255,200,200): RGB(200,200,255)); SelectObject(hdc, brush); Ellipse(hdc, centerX - pieceRadius, centerY - pieceRadius, centerX + pieceRadius, centerY + pieceRadius); DeleteObject(brush); // 绘制棋子文字 SetTextColor(hdc, p->getColor()==RED? RGB(220,0,0): RGB(0,0,0)); TextOut(hdc, centerX-8, centerY-8, p->getSymbol().c_str(), 1); } } } }

实操心得:GDI编程中,设备上下文(DC)的状态管理很重要。比如画完棋子后,如果修改了画笔、字体,记得在函数结束前恢复原状,或者使用SaveDCRestoreDC。否则会影响后续的绘制。

3.2 基于SDL2的跨平台界面

SDL版本在结构上更清晰。主循环遵循“事件处理 -> 逻辑更新 -> 渲染绘制”的标准游戏循环。

  1. 初始化SDL_Init,创建窗口和渲染器。
  2. 资源加载:可以加载棋子、棋盘背景的图片纹理,比GDI纯绘制更美观。我用位图工具预先做好了一套红黑棋子的图片。
  3. 事件循环
    SDL_Event event; bool quit = false; while (!quit) { while (SDL_PollEvent(&event)) { if (event.type == SDL_QUIT) quit = true; if (event.type == SDL_MOUSEBUTTONDOWN) { int mouseX, mouseY; SDL_GetMouseState(&mouseX, &mouseY); // 转换坐标,处理选子逻辑 handleMouseClick(mouseX, mouseY); } } // 游戏逻辑更新(如AI思考) updateGame(); // 渲染 renderer.clear(); drawChessBoard(renderer, boardTexture); drawPieces(renderer, board); renderer.present(); SDL_Delay(16); // 约60FPS }
  4. 渲染:在drawPieces函数中,根据棋子位置和类型,将对应的纹理渲染到窗口上。

SDL版本的优势是性能更好,动画(如棋子移动平滑过渡)更容易实现,而且代码可以不经修改或少量修改就在Windows、Linux、macOS上编译运行。

踩坑记录:在实现棋子移动动画时,最初我是在逻辑更新后立即改变棋子在棋盘数据结构中的位置,然后重绘。这会导致动画不连贯。正确的做法是:逻辑上记录移动的“起始位置”和“目标位置”,在渲染循环中,根据当前时间插值计算棋子的“绘制位置”,直到动画完成,才真正更新棋盘数据模型。这分离了逻辑状态和显示状态。

4. 核心规则算法的深度解析与实现

象棋规则的代码实现是项目的重中之重,也是最容易出错的地方。下面以“马走日”和“炮打隔山”为例,详细拆解。

4.1 “马走日”与“蹩马腿”的精确校验

马的走法是先直走一格,再斜走一格,形成“日”字形。关键在于“蹩马腿”:如果马要前往的某个方向,其前进路径的起始点紧邻位置有任意棋子(无论敌我),则不能向那个方向走。

bool Knight::isValidMove(const ChessBoard& board, int toRow, int toCol) const { int rowDiff = toRow - row; int colDiff = toCol - col; // 马走日:行列差的绝对值组合为(2,1)或(1,2) if (!((abs(rowDiff)==2 && abs(colDiff)==1) || (abs(rowDiff)==1 && abs(colDiff)==2))) { return false; } // 判断蹩马腿 int blockRow, blockCol; if (abs(rowDiff) == 2) { // 竖向走日字 blockRow = row + (rowDiff / 2); // 竖向的中间点 blockCol = col; } else { // abs(colDiff) == 2,横向走日字 blockRow = row; blockCol = col + (colDiff / 2); // 横向的中间点 } // 如果“马腿”位置有子,则走法非法 if (board.getPieceAt(blockRow, blockCol) != nullptr) { return false; } // 目标位置无子或有敌方棋子,则合法 Piece* target = board.getPieceAt(toRow, toCol); return (target == nullptr) || (target->getColor() != color); }

这里的关键是准确计算“马腿”的位置。算法是:先判断是“竖日”还是“横日”,然后取直走方向上的相邻格点。

4.2 “炮”的移动与吃子逻辑分离

炮的规则最特殊:移动时如车,必须直线行走,且路径上不能有任何棋子;但吃子时,必须且只能跨越一个“炮架”(任意一方的棋子),然后吃掉炮架后的第一个敌方棋子。

bool Cannon::isValidMove(const ChessBoard& board, int toRow, int toCol) const { // 1. 必须直线移动 if (row != toRow && col != toCol) return false; // 2. 计算路径和方向 int rowStep = (toRow == row) ? 0 : ((toRow > row) ? 1 : -1); int colStep = (toCol == col) ? 0 : ((toCol > col) ? 1 : -1); int currentR = row + rowStep; int currentC = col + colStep; int obstacleCount = 0; // 路径上的棋子计数 // 3. 遍历路径上的每一个格子(不包括起点和终点) while (currentR != toRow || currentC != toCol) { if (board.getPieceAt(currentR, currentC) != nullptr) { obstacleCount++; } currentR += rowStep; currentC += colStep; } // 4. 判断目标位置情况 Piece* target = board.getPieceAt(toRow, toCol); if (target == nullptr) { // 移动:路径上必须无任何棋子 return obstacleCount == 0; } else if (target->getColor() != color) { // 吃子:路径上必须有且仅有一个棋子(作为炮架) return obstacleCount == 1; } else { // 目标位置是己方棋子,非法 return false; } }

这个实现清晰地分离了移动和吃子两种情形,通过obstacleCount这个变量巧妙地统一了判断逻辑。

4.3 “将帅照面”与“长将”的全局规则

有些规则超出了单个棋子的移动范畴,需要在ChessBoardmovePieceisValidMove的更高层级进行校验。

  1. 将帅照面:将和帅不能处于同一条直线上,且中间没有任何棋子。这需要在移动任何棋子后进行检查。我的做法是在ChessBoard中提供一个bool isKingFacingKing() const方法,遍历找到双方将帅的位置,如果同列且中间无子,则返回true。在movePiece中,如果移动后导致“照面”,且移动的不是将帅本身,则这步棋是允许的(因为是你主动“亮将”);但如果移动后导致己方将帅与对方照面,则这步棋是非法的(不能主动送将)。

  2. 长将:一方连续不断地“将军”,而另一方无法避免,规则上判主动长将的一方犯规。这是一个状态检测问题。我采用的方法是:在ChessBoard中维护一个历史走子列表(std::vector<Move>)。每次走子后,检查当前局面(棋盘状态+轮到谁走)在最近N步内是否重复出现。如果重复出现,且总是同一方在“将军”,则可以判定为“长将”。实现起来相对复杂,需要定义局面的“哈希值”以便快速比较。

5. 项目构建、调试与性能优化实战

5.1 使用CMake管理跨平台构建

为了让项目在Windows(Visual Studio)、Linux(g++)和macOS(Clang)上都能方便地编译,我使用了CMake来管理构建过程。

cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 17) # 根据平台选择图形库 if(WIN32 AND NOT USE_SDL) # 默认在Windows上用GDI add_executable(ChineseChess WIN32 src/main_win.cpp src/ChessBoard.cpp src/Piece.cpp # ... 其他源文件 ) target_link_libraries(ChineseChess) else() # 其他平台或指定USE_SDL时用SDL2 find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) # 用于加载图片 add_executable(ChineseChess src/main_sdl.cpp src/ChessBoard.cpp src/Piece.cpp # ... 其他源文件 ) target_include_directories(ChineseChess PRIVATE ${SDL2_INCLUDE_DIRS} ${SDL2_IMAGE_INCLUDE_DIRS}) target_link_libraries(ChineseChess ${SDL2_LIBRARIES} ${SDL2_IMAGE_LIBRARIES}) endif()

这样,在Linux下直接cmake . && make即可生成使用SDL2的可执行文件。在Windows下,可以通过cmake -G "Visual Studio 16 2019" ..生成VS工程文件。

5.2 调试技巧与常见问题排查

开发过程中,棋盘状态错乱、规则判断异常是最常见的问题。

  1. 打印调试法:为ChessBoard实现一个printBoard()函数,以文本形式在控制台打印棋盘(用字母代表棋子),这是最直观的调试手段。在关键操作前后打印棋盘状态。
  2. 断言(Assert):在movePiece等函数开头加入断言,检查传入的行列索引是否在[0,8]和[0,9]范围内。这能快速捕捉到因坐标计算错误导致的越界访问。
  3. 单元测试:为Piece的各个派生类的isValidMove编写测试用例。例如,针对“马”,创建多个测试棋盘,测试正常走日、蹩马腿、吃子、走非法位置等情况。虽然最初没引入Google Test等框架,但一个简单的测试函数集合极大地提升了规则代码的可靠性。
  4. 内存泄漏检测:在Visual Studio中可以使用_CrtDumpMemoryLeaks(),在Linux下可以用Valgrind。确保ChessBoard析构时,所有new出来的Piece对象都被delete

5.3 AI搜索的性能优化点

最初的AI搜索深度只能到2层,思考时间就很长了。通过以下优化,我将搜索深度提升到了4-5层,响应速度在可接受范围内。

  1. 走法生成优化:不要每层搜索都重新生成全部走法。可以缓存每个位置、每种棋子的可能走法(预生成表)。但更有效的是实现一个“增量式”走法生成器,或者至少对生成的走法进行排序——把“吃子”、“将军”等可能好的走法放在前面,这样Alpha-Beta剪枝能更早发生,效率更高。
  2. 置换表:这是一个更高级的优化。将搜索过的局面及其评估结果、最佳走法、搜索深度等信息存储在一个哈希表中。当再次遇到相同局面时,可以直接查表,避免重复搜索。这需要为棋盘局面计算一个高效的哈希值(Zobrist Hashing是常用技术)。
  3. 评估函数缓存:局面评估函数可能被频繁调用。可以对评估结果进行缓存。当棋盘状态未发生变化时,直接返回缓存值。
  4. 开局库与残局库:对于前几步固定的开局和子力极少的残局,直接查预定义的最佳走法表,无需搜索。这能大幅提升开局和结束阶段的AI水平。

我的实测数据:在Release模式下,使用基础优化(走法排序、Alpha-Beta),在普通家用电脑上,搜索深度4层,AI思考时间大约在1-3秒,对于休闲对弈来说已经足够。深度5层则需要5-10秒,开始有可感知的延迟。

6. 功能扩展与项目进阶方向

这个基础框架有很多可以扩展和深化的地方,能让项目从一个“作业”升级为一个“作品”。

6.1 网络对战功能的实现

实现双人网络对战,需要引入网络通信模块。一个简单的设计是采用客户端-服务器(C/S)架构。

  1. 服务器:负责维护游戏房间、转发棋步、校验规则(防止客户端作弊)、判定胜负。可以用C++配合Boost.Asio或简单的socket编程实现。
  2. 客户端:除了原有的图形界面和本地规则校验,增加网络模块。走子后,将(fromRow, fromCol, toRow, toCol)序列化发送给服务器。同时,客户端需要从服务器接收对手的棋步并更新本地棋盘。
  3. 协议设计:定义简单的应用层协议。例如,用字符串"MOVE 0 1 2 3"表示从(0,1)移动到(2,3)。用"CHAT hello"表示聊天。用"RESIGN"表示认输。

注意事项:网络版必须考虑“状态同步”问题。所有关键的游戏状态改变(如走子、吃子、胜负)都应以服务器确认为准。客户端在发送走子请求后,应进入“等待响应”状态,禁止用户操作,直到收到服务器的“走子成功”广播。

6.2 集成更强大的AI引擎

可以抛弃自写的简单AI,集成开源的、强大的象棋引擎,如“象眼”(ElephantEye)或国际象棋引擎Stockfish(需要适配中国象棋规则变种)。通常这些引擎提供UCI(Universal Chess Interface)协议。你的C++程序可以作为一个“GUI”前端,通过标准输入输出与引擎进程通信,发送position startpos moves ...go depth 5这样的命令,并解析引擎返回的最佳走法bestmove e2e4。这能瞬间将你的游戏AI提升到职业水准。

6.3 复盘与棋谱文件支持

中国象棋有标准的棋谱记录格式(PGN的一种变体或XQF格式)。可以为项目增加保存和加载棋谱的功能。

  1. 保存:在游戏过程中,记录每一步的起始位置和目标位置。游戏结束后,将这些信息连同对局者、日期等元数据,按照特定格式(如简单的自定义文本格式或标准的XFQC格式)写入文件。
  2. 加载与复盘:从文件读取棋谱,然后控制棋盘一步步重新演示对局过程。这需要实现一个独立的“复盘模式”,在此模式下,用户只能控制“前进”、“后退”到某一步,而不能随意走子。
  3. 实现要点:需要在ChessBoard类中实现一个std::vector<Move> moveHistory来记录历史。同时,要实现bool makeMove(const Move& m)bool undoMove()函数。undoMove需要能够恢复被吃掉的棋子,这就要求Move结构体需要包含足够的信息(如移动的棋子、起始位置、目标位置、被吃的棋子指针等)。

这个项目虽然代码量不大,但涉及了C++面向对象设计、算法、图形界面、甚至初步的AI和网络编程,是一个综合性极强的练习。最重要的是,它有趣、有挑战,并且最终能产生一个你可以实际把玩、并向朋友展示的作品。在调试那些诡异的走法规则时,你对象棋规则本身的理解也会加深。如果你对某个扩展方向特别感兴趣,比如死磕AI算法让它更强,或者研究跨平台图形库的渲染细节,那这个项目就是一个绝佳的起点和试验场。