三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

1. 项目概述:从棋盘到代码的实战之旅

最近在整理硬盘里的老项目,翻出来一个当年花了不少心思写的中国象棋游戏。这可不是网上那些只有简单走子规则的Demo,而是一个包含了完整棋盘绘制、规则校验、人机对战(虽然算法比较基础)和悔棋功能的C++实战项目。当时写这个,一方面是为了巩固自己的面向对象设计能力,另一方面也是想挑战一下如何用相对底层的C++去构建一个逻辑清晰、结构良好的图形界面程序。很多朋友学C++到指针、类之后就觉得迷茫,不知道能干嘛,其实做一个这样的项目就是最好的练手方式。它不像大型游戏引擎那么复杂,但又足够让你把封装、继承、多态、STL容器、文件I/O这些核心知识点全都用上一遍。今天我就把这个项目的源码拿出来,掰开揉碎了讲讲里面的设计思路和关键实现,无论你是想学习C++项目实战,还是单纯想找个有趣的代码来研究,相信都能有所收获。源码我也会放在文末,你可以直接下载、编译、运行,甚至在此基础上添加网络对战、更智能的AI等新功能。

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

2.1 为什么选择中国象棋作为C++实战项目?

在决定用C++写点什么的时候,我排除了控制台小游戏和过于复杂的3D引擎。中国象棋是个绝佳的选择。首先,它的规则明确且复杂,足以考验逻辑抽象能力。比如“马走日”要蹩马腿,“炮”吃子需要炮架,这些规则用代码实现起来,需要严谨的状态判断和数据结构设计。其次,它的状态空间适中,一个棋盘90个交叉点,双方各16个子,用二维数组或更高级的数据结构都能清晰表示,非常适合用来练习核心数据结构的应用。最后,它具备一个完整应用的所有要素:数据模型(棋盘棋子)、业务逻辑(走法规则)、用户界面(图形或字符界面)和控制流程(游戏循环)。通过这个项目,你能系统地实践从需求分析、类设计、编码实现到调试优化的完整软件开发流程,这是看书和做练习题无法替代的体验。

2.2 面向对象设计:如何抽象棋盘与棋子?

面向对象的核心是“谁做什么”。在中国象棋里,最自然的两个对象就是“棋盘”和“棋子”。但设计时要注意避免上帝类。我的设计是:Board类负责管理整个棋盘的状态,它是一个容器,持有所有Piece对象的指针或引用。Piece是一个基类,抽象出棋子的通用属性和行为,比如颜色、位置、是否被吃掉等。然后,针对“车”、“马”、“炮”、“兵”、“将”等不同棋子,派生出自定义的类,如RookKnightCannon等。每个派生类重写基类的“是否可移动至目标位置”的虚函数。这样,当需要判断走法是否合法时,只需调用piece->canMoveTo(targetPos, board),多态机制会自动调用对应棋子类的规则判断逻辑,代码非常清晰且易于扩展。例如,要新增一种“变体象棋”的古怪棋子,只需继承Piece并实现其规则即可,无需修改棋盘和其他棋子的代码。

// 基类 Piece 的简化示例 class Piece { public: enum Color { RED, BLACK }; Piece(Color color, Point position) : color_(color), position_(position), alive_(true) {} virtual ~Piece() = default; // 纯虚函数,用于判断走法合法性 virtual bool isValidMove(const Point& target, const Board& board) const = 0; virtual std::string getName() const = 0; // 获取棋子名称,如“车” Color getColor() const { return color_; } Point getPosition() const { return position_; } void setPosition(const Point& pos) { position_ = pos; } bool isAlive() const { return alive_; } void setAlive(bool alive) { alive_ = alive; } protected: Color color_; Point position_; bool alive_; };

2.3 模块划分与项目结构

一个清晰的项目结构能让开发和维护事半功倍。我的项目主要分为以下几个模块(对应不同的头文件和源文件):

  1. 核心模型模块(piece.h/cpp,board.h/cpp):定义了所有棋子类和棋盘类,是项目的引擎。
  2. 游戏逻辑模块(game.h/cpp):Game类作为总控制器,它聚合了Board和两个玩家(可能是HumanPlayerAIPlayer),负责管理游戏状态(谁走棋、是否结束)、处理走棋请求、判断胜负(是否被将军、绝杀)。
  3. 玩家接口模块(player.h/cpp):定义玩家基类。HumanPlayer负责从图形界面或命令行接收用户输入;AIPlayer则封装了简单的搜索算法(比如极大极小算法)来生成走法。
  4. 视图模块:这部分取决于你用的图形库。我最初用Windows GDI写了一个简单版本,后来用Qt重写了一个更美观的。视图模块的职责是将BoardPiece的状态渲染到屏幕上,并捕获用户的鼠标点击事件,转换成逻辑坐标后传递给Game控制器。
  5. 工具模块(utils.h/cpp):包含一些通用工具,如坐标转换(屏幕坐标到棋盘逻辑坐标)、枚举定义、日志记录等。

这样的分层架构(模型-视图-控制器,MVC)使得各模块职责单一。模型模块完全不关心界面;视图模块只负责显示和输入;游戏逻辑模块协调一切。未来如果你想移植到其他平台(比如用SDL或控制台),只需要重写视图模块,核心的游戏逻辑代码几乎不用动。

3. 核心实现细节与关键技术点剖析

3.1 棋盘的数据结构选择与内存管理

棋盘本质上是一个10行9列的网格。最直观的方法是使用一个10x9的二维数组,数组元素是Piece*类型。空位用nullptr表示。这种方法的优点是访问速度快(O(1)),实现简单。但缺点也很明显:需要手动管理内存,当棋子被吃掉时,你需要决定是delete这个棋子对象还是将其标记为“死亡”但保留对象。我选择的是后者,因为悔棋功能需要恢复被吃掉的棋子。因此,我在Board类中维护了两个std::vector<std::unique_ptr<Piece>>,分别存储红黑双方的所有棋子对象。棋盘数组Piece* board_[10][9]只是这些棋子对象的指针映射。初始化时,创建32个棋子对象放入vector,并将它们的指针填入数组的相应位置。当棋子被吃,board_对应位置置为nullptr,但vector中的对象依然存在,只是状态变为alive_=false。悔棋时,只需从vector中找到该棋子,将其状态恢复并重新指回board_数组即可。使用std::unique_ptr自动管理生命周期,避免了内存泄漏。

class Board { public: Board(); // ... 其他方法 Piece* getPieceAt(const Point& pos) const { if (pos.x < 0 || pos.x >= 9 || pos.y < 0 || pos.y >= 10) return nullptr; return board_[pos.y][pos.x]; } bool movePiece(const Point& from, const Point& to); // 移动棋子,包含吃子逻辑 private: // 使用智能指针管理棋子对象生命周期 std::vector<std::unique_ptr<Piece>> redPieces_; std::vector<std::unique_ptr<Piece>> blackPieces_; // 棋盘指针数组,指向上述容器中的对象 Piece* board_[10][9] = { nullptr }; void initializeBoard(); // 初始化棋盘和棋子 };

3.2 棋子移动规则的精确实装

这是项目的核心算法部分。每种棋子的规则都是一个独立的函数。以“马”为例,其规则是“马走日”,且不能“蹩马腿”。实现时,先计算起点到终点的坐标差(dx, dy)。合法的“日”字走法满足(abs(dx)==1 && abs(dy)==2) || (abs(dx)==2 && abs(dy)==1)。然后检查“蹩马腿”:如果abs(dx)==2(横向走日),则检查马腿位置(from.x + dx/2, from.y)是否有棋子;如果abs(dy)==2(纵向走日),则检查(from.x, from.y + dy/2)。有棋子则不能走。

“炮”的规则更特殊:移动时路径上必须无子(像车一样),吃子时则必须且仅有一个“炮架”(路径上恰好有一个棋子)。实现时,需要编写一个通用的isPathClear函数,检查两点之间(水平或垂直)的直线路径上有多少个棋子。如果是移动,要求棋子数为0;如果是吃子,要求棋子数恰好为1,并且目标位置有敌方棋子。

“将/帅”和“士”的移动被限制在九宫格内。这里的一个关键技巧是,不要写死坐标,而是用九宫格的边界来定义。例如,红将的九宫格是(x in [3,5] && y in [0,2])。这样代码更清晰,也便于修改。

注意:规则校验函数isValidMove的输入是目标位置和当前的棋盘状态。函数内部不能直接修改棋盘状态,它必须是“只读”的。真正的移动和吃子逻辑在Board::movePiece中完成,那里会再次校验规则(或复用isValidMove),然后更新board_数组和棋子对象的状态。

3.3 游戏状态管理与胜负判定

游戏的主要状态有:PLAYING(对弈中)、RED_WINBLACK_WINDRAW(和棋)。Game类中有一个状态机来维护这些状态。每次走棋后,都需要进行胜负判定。

胜负判定的核心是“是否被将军”以及“是否被将死”。

  1. 检测将军:遍历当前棋盘上所有敌方棋子,针对己方“将/帅”的位置,调用该棋子的isValidMove函数。如果任何一个敌方棋子可以合法地走到己方将帅的位置,则说明己方被“将军”。
  2. 检测将死(绝杀):当一方被将军时,需要检测他是否还有任何一步合法的走法可以解除将军状态。这需要“生成所有合法走法”:
    • 遍历己方所有存活棋子。
    • 对于每个棋子,遍历棋盘上所有可能的目标位置(90个点)。
    • 对于每个(棋子, 目标位置)组合,模拟走一步棋(创建一个棋盘的临时副本,在其上执行移动),然后检查模拟后的新棋盘状态,己方将帅是否仍然被将军。
    • 如果存在至少一种走法,使得模拟后己方将帅不被将军,则未被将死,游戏继续。
    • 如果所有可能的走法都无法解除将军,则被“将死”,游戏结束,对方获胜。

这个“生成所有走法并模拟”的过程是性能关键点,也是后续实现AI的基础。优化方法包括:缓存每个棋子的可能移动范围、使用位运算加速、采用更高效的棋盘表示法等。

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

4.1 选择图形库:从控制台到跨平台

最初为了快速验证逻辑,我写了一个控制台版本,用字符R表示红车,b表示黑卒。但这体验太差。后来我选择了Qt框架来构建图形界面。选择Qt的原因有几个:一是跨平台(Windows、macOS、Linux都能运行),二是信号与槽机制非常适合处理用户交互事件(如鼠标点击),三是它自带丰富的绘图功能,画棋盘、棋子很方便。当然,你也可以用SDL、SFML甚至Windows原生API,原理是相通的。在项目中,我将界面相关代码与核心逻辑代码严格分离。GameBoard类完全不包含任何Qt的代码。界面类(比如MainWindow)持有Game对象的一个实例,并通过调用Game的公共接口(如submitMove)来驱动游戏。

4.2 棋盘与棋子的绘制

绘制部分主要重写Qt窗口的paintEvent函数。首先绘制棋盘背景和网格线。棋盘是9x10的网格,每个格子是一个正方形。计算好每个格子的像素坐标后,用QPainter画线。

棋子的绘制稍微复杂。我准备了两种方案:一是用QPainter直接绘制文字(如“車”、“馬”),并填充红黑两色;二是使用图片资源。为了美观,我最终使用了图片。为红黑双方的7种棋子(将、士、象、马、车、炮、兵)各准备一张透明的PNG图片,共14张。在绘制时,根据棋子的类型和颜色,加载对应的图片,缩放后绘制到棋盘格子的中心位置。

void BoardWidget::paintEvent(QPaintEvent* event) { QPainter painter(this); // 1. 绘制棋盘背景和网格 drawBoardGrid(painter); // 2. 遍历棋盘逻辑数组,绘制棋子 for (int row = 0; row < 10; ++row) { for (int col = 0; col < 9; ++col) { Piece* piece = game_.getBoard().getPieceAt(Point(col, row)); if (piece != nullptr && piece->isAlive()) { QPixmap pixmap = getPieceImage(piece->getName(), piece->getColor()); QRect targetRect = calculateRectFromBoardPosition(col, row); painter.drawPixmap(targetRect, pixmap); } } } // 3. 高亮显示被选中的棋子和合法走法位置(如果有的话) if (selectedPiecePos_.isValid()) { highlightSelection(painter, selectedPiecePos_); } }

4.3 鼠标事件处理与走棋流程

用户走棋的流程是:点击一个己方棋子(选中)-> 点击一个目标位置(走棋)。这通过处理鼠标点击事件mousePressEvent来实现。

  1. 首先,将鼠标的像素坐标转换为棋盘逻辑坐标(col, row)
  2. 如果当前没有选中的棋子:
    • 检查该位置是否有棋子,且棋子颜色是当前行棋方。
    • 如果是,则将该位置记录为selectedPiecePos_,并调用game_.getBoard().getValidMoves(selectedPiecePos_)获取该棋子的所有合法目标位置(用于高亮提示),然后触发重绘。
  3. 如果已有选中的棋子:
    • 检查点击的目标位置。如果目标位置等于选中位置,则取消选中。
    • 否则,将(selectedPiecePos_, targetPos)作为一个走法,调用game_.submitMove(move)
    • Game::submitMove内部会进行规则校验、执行移动、更新游戏状态、切换行棋方,并返回成功或失败。
    • 如果成功,清空selectedPiecePos_,重绘棋盘。如果失败(如走法非法),可以给用户一个提示(比如状态栏显示“走法不符合规则”)。

实操心得:在事件处理函数中,不要进行复杂的游戏逻辑计算或耗时操作(比如AI思考),否则会阻塞界面线程,导致界面卡顿。对于AI走棋,应该启动一个单独的线程或使用定时器,在后台计算,计算完成后再通过信号通知主界面更新。

5. 基础AI实现:极大极小搜索算法

5.1 博弈树与评估函数

让电脑自己下棋,最经典的方法就是极大极小算法。其核心思想是模拟未来几步所有可能的走法,并选择对自己最有利的那一步。这需要两个关键组件:

  1. 走法生成器:给定一个棋盘状态,生成当前行棋方所有合法的走法。这在上文“检测将死”部分已经实现了。
  2. 局面评估函数:给一个棋盘状态打一个分数,分数越高对红方越有利,越低对黑方越有利。这是AI“智慧”的核心。一个简单的评估函数可以基于棋子价值:
    • 将/帅 = 10000分(无限大,实际上被将死就直接判负)
    • 车 = 500分
    • 马 = 300分
    • 炮 = 300分(炮的价值在不同阶段变化大,这里简化)
    • 士 = 200分
    • 象 = 200分
    • 兵/卒 = 100分(过河后价值可增加)
    • 总分数 = 红方所有存活棋子价值之和 - 黑方所有存活棋子价值之和。 还可以加上一些位置分,比如车占肋道、马卧槽等位置给予加分。

5.2 极大极小算法与Alpha-Beta剪枝

算法假设双方都绝对理性,红方(MAX方)希望最大化评估分数,黑方(MIN方)希望最小化评估分数。递归地模拟双方交替走棋,形成一个博弈树。

  • 在MAX层,选择子节点中评估值最大的。
  • 在MIN层,选择子节点中评估值最小的。 递归到一定深度(比如3层)后,不再继续模拟,而是调用评估函数计算当前局面的分数。

朴素的最大极小搜索需要遍历整个树,节点数随深度指数增长,计算量巨大。Alpha-Beta剪枝是一种优化,它可以“剪掉”那些明显不会影响最终结果的子树分支,从而大幅减少搜索量。其原理是维护两个值:alpha(当前MAX方至少能保证的分数下界)和beta(当前MIN方至少能保证的分数上界)。在搜索过程中,如果发现某个节点的值已经超出了[alpha, beta]这个窗口,那么剩下的兄弟节点就不用搜了。

// 极大极小搜索的简化伪代码 int minimax(Board& board, int depth, int alpha, int beta, bool isMaxPlayer) { if (depth == 0 || gameIsOver(board)) { return evaluateBoard(board); // 评估当前局面 } vector<Move> legalMoves = generateAllMoves(board, isMaxPlayer); if (isMaxPlayer) { int maxEval = INT_MIN; for (Move move : legalMoves) { board.makeMove(move); // 模拟走棋 int eval = minimax(board, depth - 1, alpha, beta, false); board.undoMove(move); // 撤销走棋(悔棋) maxEval = max(maxEval, eval); alpha = max(alpha, eval); if (beta <= alpha) { break; // Beta剪枝 } } return maxEval; } else { int minEval = INT_MAX; for (Move move : legalMoves) { board.makeMove(move); int eval = minimax(board, depth - 1, alpha, beta, true); board.undoMove(move); minEval = min(minEval, eval); beta = min(beta, eval); if (beta <= alpha) { break; // Alpha剪枝 } } return minEval; } }

5.3 性能优化与搜索策略

即使有Alpha-Beta剪枝,搜索深度也受限于时间。为了在有限时间内得到更好的着法,还需要一些策略:

  • 走法排序:在递归搜索前,对生成的走法进行排序。把“吃子”、“将军”等可能好的走法排在前面。这样Alpha-Beta剪枝能更早地发生,剪掉更多分支。
  • 迭代加深:不固定搜索深度,而是从1层开始搜,然后2层、3层...直到时间用完。这样可以在任何时候都有一个“当前最佳走法”,避免超时无结果。
  • 置换表:将搜索过的棋盘局面及其评估结果、最佳走法缓存起来。当再次遇到相同局面时,直接查表,避免重复计算。这需要为棋盘状态生成一个高效的哈希值(如Zobrist哈希)。
  • 开局库与残局库:对于固定的开局和必胜/必和的残局,直接查表,无需搜索。

在我的项目中,实现了一个搜索深度为3、带基础走法排序(优先吃价值高的子)的AI,其思考时间在可接受范围内,棋力足以给新手造成一定麻烦。

6. 项目编译、运行与扩展指南

6.1 环境配置与编译

项目源码使用CMake作为构建系统,这保证了跨平台的编译能力。你需要准备:

  1. C++编译器:Windows上推荐MinGW-w64或Visual Studio;Linux/macOS用GCC或Clang。
  2. Qt库(如果你编译图形界面版本):去Qt官网下载开源版本,并安装对应你编译器的Qt模块。
  3. CMake:版本3.10以上。

编译步骤:

# 在项目根目录下 mkdir build cd build cmake .. -DCMAKE_PREFIX_PATH="你的Qt安装路径/lib/cmake" # 如果需要Qt cmake --build . --config Release

编译成功后,在build目录下会生成可执行文件。控制台版本无需Qt,编译更简单。

6.2 代码结构导航与关键文件

下载源码后,你可以按以下顺序阅读核心代码:

  1. src/model/piece.h/cpp:所有棋子类的定义和规则实现。从这里入手理解游戏的核心规则。
  2. src/model/board.h/cpp:棋盘类的实现,重点关注movePieceisChecked(是否被将军)函数。
  3. src/logic/game.h/cpp:游戏流程控制、胜负判定。
  4. src/ai/minimax.h/cpp:AI算法的实现。
  5. src/ui/(如果存在):图形界面相关代码。MainWindow是入口,BoardWidget负责绘制和交互。

6.3 功能扩展与二次开发建议

这个项目是一个很好的起点,你可以在此基础上添加更多功能,深化对C++和游戏开发的理解:

  • 网络对战:使用网络库(如Boost.Asio或简单的socket)实现双人联机。需要设计一个简单的应用层协议来传输走法坐标和聊天信息。这会涉及到序列化、网络通信模型(客户端-服务器或P2P)和多线程。
  • 增强AI:实现更先进的搜索算法,如蒙特卡洛树搜索(MCTS),或者结合机器学习(虽然用C++实现较复杂)。也可以优化评估函数,加入更多局面特征(如棋子灵活性、控制区域、兵种协调性)。
  • 游戏功能:添加棋谱记录与回放(PGN格式)、多种AI难度等级、开局选择、声音特效、更精美的皮肤和动画。
  • 代码重构:尝试用设计模式优化代码结构,比如用“工厂模式”创建棋子,用“观察者模式”通知界面更新,用“命令模式”实现更强大的悔棋/重做功能。

7. 常见问题与调试技巧实录

在开发这个项目的过程中,我踩过不少坑,这里总结几个典型问题和解决方法,希望能帮你节省时间。

7.1 编译与链接问题

  • 问题:编译时提示“undefined reference tovtable for Piece”之类的链接错误。

  • 原因:这是C++虚函数表的经典问题。通常是因为在派生类中声明了虚函数(如isValidMove),但没有提供实现(哪怕是一个空的{}),或者忘记在基类的析构函数前加virtual关键字。

  • 解决:检查所有纯虚函数(=0)是否都在派生类中得到了实现。确保基类的析构函数是虚函数:virtual ~Piece() = default;

  • 问题:使用Qt时,编译通过但运行崩溃,提示“QWidget: Must construct a QApplication before a QWidget”。

  • 原因:程序的入口点不对。Qt图形程序需要先创建QApplication对象,然后创建窗口。

  • 解决:确保main函数是这样的结构:

    #include <QApplication> int main(int argc, char *argv[]) { QApplication app(argc, argv); // 先创建QApplication MainWindow window; window.show(); return app.exec(); // 进入事件循环 }

7.2 运行时逻辑错误

  • 问题:棋子可以走到棋盘外面,或者走到已经有己方棋子的位置。

  • 排查:首先在Piece派生类的isValidMove函数开头,添加通用的边界检查和目标位置友军检查。确保所有派生类都首先调用一个公共的isInsideBoardAndNotFriendly函数。这是防御性编程,避免每个棋子类重复写这段代码。

  • 问题:AI走棋时,有时会走出明显送子的“昏招”。

  • 排查

    1. 检查评估函数:打印出AI搜索后选择的走法及其评估分数。看它是不是真的认为那步棋最好。可能是评估函数的权重设置不合理,比如忽略了“被将军”的危险。
    2. 检查走法生成:确保AI生成的走法列表是完整的,没有漏掉某些合法走法,特别是“应将”的走法(解除将军的走法)。在将军状态下,只能走应将的步子。
    3. 检查搜索深度:深度太浅(比如1层)的AI就是“近视眼”,只能看到眼前吃子,看不到后续几步会被反将。尝试增加搜索深度,观察行为是否改善。
  • 问题:悔棋功能有时会导致棋盘状态错乱。

  • 排查:悔棋需要完美地恢复之前的状态。确保你的Board类实现了makeMoveundoMove这对函数,并且它们是可逆的。一个可靠的实现是,在makeMove时,不仅执行移动,还将移动的详细信息(起点、终点、被吃掉的棋子等)压入一个历史堆栈。undoMove时,从堆栈弹出信息并反向执行。务必处理好被吃掉的棋子的恢复。

7.3 内存与性能问题

  • 问题:随着游戏进行,程序越来越卡,特别是AI思考时。
  • 排查与优化
    1. 性能分析:使用性能分析工具(如gprofValgrindcallgrind、VS的性能探测器)找到热点函数。通常是generateAllMovesevaluateBoard
    2. 优化走法生成:避免每次都为所有棋子遍历90个位置。为每种棋子预计算相对移动方向(如马的8个方向),只检查这些方向上的目标位置。
    3. 优化评估函数:评估函数会被调用数百万次,必须非常高效。避免在评估函数中做复杂的计算或动态内存分配。考虑使用增量评估,即只计算移动棋子前后局面的分数差值,而不是每次都全盘计算。
    4. 引入置换表:如前所述,这是提升搜索效率最有效的手段之一。

7.4 图形界面相关

  • 问题:界面闪烁,特别是移动棋子或AI思考后重绘时。
  • 解决:这是双缓冲问题。在Qt中,最简单的解决方法是使用QWidgetsetAttribute(Qt::WA_OpaquePaintEvent);,并在paintEvent中确保绘制覆盖整个区域。更高级的做法是使用QGraphicsView场景,它自带高效的重绘管理。
  • 问题:鼠标点击位置不准确,有时点A格子却选中了B格子。
  • 排查:仔细检查mousePressEvent中的坐标转换逻辑。确保你正确计算了棋盘左上角在窗口中的偏移量、每个格子的像素尺寸。添加调试输出,打印出鼠标点击的像素坐标和转换后的逻辑坐标,进行比对。

这个项目从最初的字符界面到最终带简单AI的图形界面,前前后后调试了不下百次。最大的体会是,写游戏逻辑时,一定要先写单元测试。为isValidMoveisChecked这些核心函数编写测试用例,用各种边界情况(如马在棋盘边角、炮在不同吃子情况)去验证,能及早发现逻辑漏洞,比在完整的图形界面里调试要高效得多。

← 返回列表