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

📅 2026/8/9 3:58:53
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是一个基类抽象出棋子的通用属性和行为比如颜色、位置、是否被吃掉等。然后针对“车”、“马”、“炮”、“兵”、“将”等不同棋子派生出自定义的类如Rook、Knight、Cannon等。每个派生类重写基类的“是否可移动至目标位置”的虚函数。这样当需要判断走法是否合法时只需调用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 模块划分与项目结构一个清晰的项目结构能让开发和维护事半功倍。我的项目主要分为以下几个模块对应不同的头文件和源文件核心模型模块(piece.h/cpp,board.h/cpp)定义了所有棋子类和棋盘类是项目的引擎。游戏逻辑模块(game.h/cpp)Game类作为总控制器它聚合了Board和两个玩家可能是HumanPlayer或AIPlayer负责管理游戏状态谁走棋、是否结束、处理走棋请求、判断胜负是否被将军、绝杀。玩家接口模块(player.h/cpp)定义玩家基类。HumanPlayer负责从图形界面或命令行接收用户输入AIPlayer则封装了简单的搜索算法比如极大极小算法来生成走法。视图模块这部分取决于你用的图形库。我最初用Windows GDI写了一个简单版本后来用Qt重写了一个更美观的。视图模块的职责是将Board和Piece的状态渲染到屏幕上并捕获用户的鼠标点击事件转换成逻辑坐标后传递给Game控制器。工具模块(utils.h/cpp)包含一些通用工具如坐标转换屏幕坐标到棋盘逻辑坐标、枚举定义、日志记录等。这样的分层架构模型-视图-控制器MVC使得各模块职责单一。模型模块完全不关心界面视图模块只负责显示和输入游戏逻辑模块协调一切。未来如果你想移植到其他平台比如用SDL或控制台只需要重写视图模块核心的游戏逻辑代码几乎不用动。3. 核心实现细节与关键技术点剖析3.1 棋盘的数据结构选择与内存管理棋盘本质上是一个10行9列的网格。最直观的方法是使用一个10x9的二维数组数组元素是Piece*类型。空位用nullptr表示。这种方法的优点是访问速度快O(1)实现简单。但缺点也很明显需要手动管理内存当棋子被吃掉时你需要决定是delete这个棋子对象还是将其标记为“死亡”但保留对象。我选择的是后者因为悔棋功能需要恢复被吃掉的棋子。因此我在Board类中维护了两个std::vectorstd::unique_ptrPiece分别存储红黑双方的所有棋子对象。棋盘数组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::vectorstd::unique_ptrPiece redPieces_; std::vectorstd::unique_ptrPiece 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_WIN、BLACK_WIN、DRAW和棋。Game类中有一个状态机来维护这些状态。每次走棋后都需要进行胜负判定。胜负判定的核心是“是否被将军”以及“是否被将死”。检测将军遍历当前棋盘上所有敌方棋子针对己方“将/帅”的位置调用该棋子的isValidMove函数。如果任何一个敌方棋子可以合法地走到己方将帅的位置则说明己方被“将军”。检测将死绝杀当一方被将军时需要检测他是否还有任何一步合法的走法可以解除将军状态。这需要“生成所有合法走法”遍历己方所有存活棋子。对于每个棋子遍历棋盘上所有可能的目标位置90个点。对于每个(棋子, 目标位置)组合模拟走一步棋创建一个棋盘的临时副本在其上执行移动然后检查模拟后的新棋盘状态己方将帅是否仍然被将军。如果存在至少一种走法使得模拟后己方将帅不被将军则未被将死游戏继续。如果所有可能的走法都无法解除将军则被“将死”游戏结束对方获胜。这个“生成所有走法并模拟”的过程是性能关键点也是后续实现AI的基础。优化方法包括缓存每个棋子的可能移动范围、使用位运算加速、采用更高效的棋盘表示法等。4. 图形界面与用户交互实现4.1 选择图形库从控制台到跨平台最初为了快速验证逻辑我写了一个控制台版本用字符R表示红车b表示黑卒。但这体验太差。后来我选择了Qt框架来构建图形界面。选择Qt的原因有几个一是跨平台Windows、macOS、Linux都能运行二是信号与槽机制非常适合处理用户交互事件如鼠标点击三是它自带丰富的绘图功能画棋盘、棋子很方便。当然你也可以用SDL、SFML甚至Windows原生API原理是相通的。在项目中我将界面相关代码与核心逻辑代码严格分离。Game和Board类完全不包含任何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来实现。首先将鼠标的像素坐标转换为棋盘逻辑坐标(col, row)。如果当前没有选中的棋子检查该位置是否有棋子且棋子颜色是当前行棋方。如果是则将该位置记录为selectedPiecePos_并调用game_.getBoard().getValidMoves(selectedPiecePos_)获取该棋子的所有合法目标位置用于高亮提示然后触发重绘。如果已有选中的棋子检查点击的目标位置。如果目标位置等于选中位置则取消选中。否则将(selectedPiecePos_, targetPos)作为一个走法调用game_.submitMove(move)。Game::submitMove内部会进行规则校验、执行移动、更新游戏状态、切换行棋方并返回成功或失败。如果成功清空selectedPiecePos_重绘棋盘。如果失败如走法非法可以给用户一个提示比如状态栏显示“走法不符合规则”。实操心得在事件处理函数中不要进行复杂的游戏逻辑计算或耗时操作比如AI思考否则会阻塞界面线程导致界面卡顿。对于AI走棋应该启动一个单独的线程或使用定时器在后台计算计算完成后再通过信号通知主界面更新。5. 基础AI实现极大极小搜索算法5.1 博弈树与评估函数让电脑自己下棋最经典的方法就是极大极小算法。其核心思想是模拟未来几步所有可能的走法并选择对自己最有利的那一步。这需要两个关键组件走法生成器给定一个棋盘状态生成当前行棋方所有合法的走法。这在上文“检测将死”部分已经实现了。局面评估函数给一个棋盘状态打一个分数分数越高对红方越有利越低对黑方越有利。这是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); // 评估当前局面 } vectorMove 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作为构建系统这保证了跨平台的编译能力。你需要准备C编译器Windows上推荐MinGW-w64或Visual StudioLinux/macOS用GCC或Clang。Qt库如果你编译图形界面版本去Qt官网下载开源版本并安装对应你编译器的Qt模块。CMake版本3.10以上。编译步骤# 在项目根目录下 mkdir build cd build cmake .. -DCMAKE_PREFIX_PATH你的Qt安装路径/lib/cmake # 如果需要Qt cmake --build . --config Release编译成功后在build目录下会生成可执行文件。控制台版本无需Qt编译更简单。6.2 代码结构导航与关键文件下载源码后你可以按以下顺序阅读核心代码src/model/piece.h/cpp所有棋子类的定义和规则实现。从这里入手理解游戏的核心规则。src/model/board.h/cpp棋盘类的实现重点关注movePiece和isChecked是否被将军函数。src/logic/game.h/cpp游戏流程控制、胜负判定。src/ai/minimax.h/cppAI算法的实现。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走棋时有时会走出明显送子的“昏招”。排查检查评估函数打印出AI搜索后选择的走法及其评估分数。看它是不是真的认为那步棋最好。可能是评估函数的权重设置不合理比如忽略了“被将军”的危险。检查走法生成确保AI生成的走法列表是完整的没有漏掉某些合法走法特别是“应将”的走法解除将军的走法。在将军状态下只能走应将的步子。检查搜索深度深度太浅比如1层的AI就是“近视眼”只能看到眼前吃子看不到后续几步会被反将。尝试增加搜索深度观察行为是否改善。问题悔棋功能有时会导致棋盘状态错乱。排查悔棋需要完美地恢复之前的状态。确保你的Board类实现了makeMove和undoMove这对函数并且它们是可逆的。一个可靠的实现是在makeMove时不仅执行移动还将移动的详细信息起点、终点、被吃掉的棋子等压入一个历史堆栈。undoMove时从堆栈弹出信息并反向执行。务必处理好被吃掉的棋子的恢复。7.3 内存与性能问题问题随着游戏进行程序越来越卡特别是AI思考时。排查与优化性能分析使用性能分析工具如gprof、Valgrind的callgrind、VS的性能探测器找到热点函数。通常是generateAllMoves或evaluateBoard。优化走法生成避免每次都为所有棋子遍历90个位置。为每种棋子预计算相对移动方向如马的8个方向只检查这些方向上的目标位置。优化评估函数评估函数会被调用数百万次必须非常高效。避免在评估函数中做复杂的计算或动态内存分配。考虑使用增量评估即只计算移动棋子前后局面的分数差值而不是每次都全盘计算。引入置换表如前所述这是提升搜索效率最有效的手段之一。7.4 图形界面相关问题界面闪烁特别是移动棋子或AI思考后重绘时。解决这是双缓冲问题。在Qt中最简单的解决方法是使用QWidget的setAttribute(Qt::WA_OpaquePaintEvent);并在paintEvent中确保绘制覆盖整个区域。更高级的做法是使用QGraphicsView场景它自带高效的重绘管理。问题鼠标点击位置不准确有时点A格子却选中了B格子。排查仔细检查mousePressEvent中的坐标转换逻辑。确保你正确计算了棋盘左上角在窗口中的偏移量、每个格子的像素尺寸。添加调试输出打印出鼠标点击的像素坐标和转换后的逻辑坐标进行比对。这个项目从最初的字符界面到最终带简单AI的图形界面前前后后调试了不下百次。最大的体会是写游戏逻辑时一定要先写单元测试。为isValidMove、isChecked这些核心函数编写测试用例用各种边界情况如马在棋盘边角、炮在不同吃子情况去验证能及早发现逻辑漏洞比在完整的图形界面里调试要高效得多。