C++实现中国象棋:面向对象设计、走步提示与悔棋机制详解

📅 2026/7/24 17:55:46
C++实现中国象棋:面向对象设计、走步提示与悔棋机制详解
1. 项目概述与核心价值最近在整理自己的代码仓库翻出来一个几年前用C写的中国象棋游戏项目。这个项目麻雀虽小五脏俱全除了基本的棋盘绘制和走棋逻辑还完整实现了走步提示、悔棋、计时和游戏结束判定这几个核心功能。当时写这个项目主要是为了深入理解C面向对象设计在游戏逻辑中的应用以及如何管理一个稍显复杂的游戏状态。现在回头看里面有不少设计思路和踩过的坑对于想用C入门游戏开发或者想做一个完整小项目的朋友来说应该有不少参考价值。这个项目本质上是一个控制台应用没有花哨的图形界面所有交互都通过命令行完成。这反而让我们能更聚焦于游戏规则、状态管理和算法逻辑本身。它解决了几个关键问题如何让电脑或另一个玩家知道当前有哪些合法走法如何让玩家在走错后能回退一步甚至多步如何给游戏增加时间限制模拟真实对弈的紧迫感以及如何精准地判断将死、困毙等结束条件如果你正在学习C并且已经厌倦了书本上的练习题想找一个能综合运用类、继承、多态、STL容器和算法的实战项目那么这个中国象棋游戏会是一个绝佳的练手选择。接下来我会拆解整个项目的设计思路、关键实现细节以及那些只有实际动手才能遇到的“坑”。2. 整体架构与核心类设计一个象棋游戏最核心的就是对棋盘和棋子的抽象。我的设计采用了经典的面向对象方法将游戏中的实体和逻辑清晰地划分到不同的类中。2.1 棋子与棋盘的抽象首先我定义了一个Piece棋子基类以及代表具体棋种的派生类如King,Rook,Knight等。基类中包含棋子的通用属性颜色红方或黑方、位置棋盘坐标、是否存活等。最关键的是一个虚函数getPossibleMoves每个派生类都需要重写这个函数根据自身走法规则比如车走直线、马走日计算出所有理论上合法的目标位置。class Piece { public: enum Color { RED, BLACK }; Piece(Color color, int x, int y) : color_(color), x_(x), y_(y), alive_(true) {} virtual ~Piece() default; // 获取所有可能的移动位置不考虑棋盘边界和其他棋子阻挡 virtual std::vectorstd::pairint, int getPossibleMoves() const 0; // 检查从当前位置(x_, y_)移动到(toX, toY)是否符合该棋子的基本走法规则 virtual bool isValidMove(int toX, int toY) const 0; Color getColor() const { return color_; } std::pairint, int getPosition() const { return {x_, y_}; } void setPosition(int x, int y) { x_ x; y_ y; } bool isAlive() const { return alive_; } void setAlive(bool alive) { alive_ alive; } protected: Color color_; int x_, y_; // 棋盘坐标例如(0,0)代表左上角 bool alive_; };以“车”为例它的getPossibleMoves实现会返回当前行和当前列上所有除了自身位置以外的点。而isValidMove则用于快速校验一次移动是否符合“直线”规则。棋盘则用一个Board类来表示它内部维护了一个二维数组或向量的Piece智能指针例如std::vectorstd::vectorstd::unique_ptrPiece board_。Board类的职责很重初始化在游戏开始时按照标准棋局摆放所有棋子。状态查询给定一个坐标返回上面的棋子如果有。移动执行执行一次移动操作包括吃子逻辑目标位置有敌方棋子则将其setAlive(false)。移动验证这是核心中的核心。它需要调用棋子的isValidMove并检查移动路径上是否有其他棋子阻挡比如车、炮、象同时还要结合象棋的特殊规则比如将帅不能照面、过河卒子才能横走等。胜负判定检查是否有一方的将/帅被将死或困毙。注意这里有一个关键设计决策为什么把getPossibleMoves和isValidMove分开getPossibleMoves主要用于生成“走步提示”它返回的是基于棋子自身规则的所有可能终点不考虑棋盘当前状态如阻挡。而isValidMove是最终执行移动前的综合校验需要结合棋盘实时状态进行。两者分工明确避免逻辑耦合。2.2 游戏状态与历史管理为了支持悔棋我们必须记录游戏的历史状态。简单记录每一步的起始和目标坐标是不够的因为吃子操作是不可逆的我们需要知道被吃掉的棋子是什么。因此我设计了一个GameState结构体和一个Game主控类。GameState是一个快照它包含了在某一时刻所有必要的信息struct GameState { std::vectorstd::vectorstd::unique_ptrPiece boardSnapshot; // 棋盘快照深拷贝成本高需优化 Piece::Color currentPlayer; // 当前行棋方 int redTimeRemaining; // 红方剩余时间秒 int blackTimeRemaining; // 黑方剩余时间秒 // 还可以记录上一步移动信息用于界面高亮等 };直接深拷贝整个棋盘尤其是包含智能指针的二维结构在每一步都进行对性能是灾难。一个更优的方案是只记录增量变化。我实现了一个MoveRecord移动记录类它记录一次移动的详细信息移动的棋子指针、起始位置、目标位置、以及被吃掉的棋子指针如果有。这样悔棋时只需要逆向应用这个记录即可。Game类是整个游戏的大脑它聚合了Board和两个玩家的时间信息并维护了一个std::stackMoveRecord作为历史记录栈。每次成功移动后就将本次移动的记录压栈。执行悔棋时从栈顶弹出记录并执行逆向操作将棋子移回原位如果该记录显示有吃子则将被吃棋子“复活”并放回棋盘。2.3 计时器模块的设计计时功能我选择独立成一个Timer类。这个类在单独的线程中运行每隔一秒或更短通过回调函数通知Game类更新当前行棋方的剩余时间。Game类中保存红黑双方的剩余时间秒并在每次切换行棋方时暂停上一方的计时器启动当前方的计时器。class Timer { public: using Callback std::functionvoid(); Timer(int intervalMs, Callback callback); void start(); void stop(); void pause(); void resume(); private: void run(); std::thread worker_; std::atomicbool running_{false}; std::atomicbool paused_{false}; int intervalMs_; Callback callback_; };这里涉及到多线程编程。计时器线程需要安全地与主游戏线程负责接收输入和更新显示通信。我使用std::atomic布尔标志来控制计时器的启停和暂停避免数据竞争。当任何一方时间耗尽计时器回调会触发Game类的结束逻辑判定该方超时负。实操心得在多线程环境下更新控制台显示要小心。最好将时间显示更新和游戏状态更新的逻辑放在主线程计时器线程只负责触发一个“时间到”的事件标志由主线程在每次循环中检查并处理。直接在线程中调用printf或cout可能导致输出错乱。3. 核心功能实现细节剖析有了清晰的架构接下来就是填充血肉实现标题中的几个核心功能。3.1 走步提示的生成算法走步提示功能就是在玩家选中一个己方棋子后高亮显示所有合法的目标位置。实现它分为两步生成候选位置调用选中棋子的getPossibleMoves()方法获得基于其走法规则的所有可能目标格。合法性过滤对每一个候选位置调用Board::validateMove方法进行综合校验。这个校验非常关键它需要检查路径阻挡对于车、炮、马、象、士、将移动路径上是否有其他棋子马和象有特殊的“绊马腿”和“塞象眼”规则。目标位置是否在棋盘内是否已有己方棋子特殊规则将帅照面移动后是否导致双方的将/帅处于同一纵列且中间无任何棋子阻挡这是不允许的。自投罗网移动后是否导致自己的将/帅暴露在对方棋子的直接攻击之下即被“将军”通常走步提示中不应包含会导致立即被将死的走法这属于更高级的“应将”逻辑初期可以先忽略但一个完善的提示应该过滤掉这类“自杀式”走法。过滤完成后剩下的位置就是真正的合法走步可以在界面上高亮显示。这里有一个性能考量对于“兵”或“帅”这类可能走法较少的棋子直接计算没问题但对于“车”在空旷棋盘上可能走法有十几个每次选中都进行全量计算和过滤是可以接受的。如果未来要扩展AI可能需要更高效的方法。3.2 悔棋机制的实现与数据管理悔棋的核心在于状态回溯。如前所述我采用MoveRecord增量记录方案。class MoveRecord { public: Piece* movedPiece; // 移动的棋子 int fromX, fromY; // 起始位置 int toX, toY; // 目标位置 Piece* capturedPiece; // 被吃掉的棋子nullptr表示无 std::pairint, int capturedPiecePos; // 被吃子的原位置 // 执行逆向操作用于悔棋 void undo(Board board) { // 1. 将棋子移回原位 board.movePiece(toX, toY, fromX, fromY); // 2. 如果之前有吃子则恢复该棋子到原位置 if (capturedPiece) { board.placePiece(capturedPiece, capturedPiecePos.first, capturedPiecePos.second); capturedPiece-setAlive(true); } // 注意还需要切换当前行棋方回上一方 } };Game类维护一个std::stackMoveRecord。每次执行移动Game::makeMove成功后就创建一个包含所有信息的MoveRecord并压栈。当用户触发悔棋时调用Game::undoMove()它执行以下操作检查历史栈是否为空。弹出栈顶的MoveRecord。调用record.undo(board)恢复棋盘状态。切换当前行棋方。更新时间如果需要悔棋通常不返还用时但也可以设计为回退时间。踩过的坑存储Piece*原始指针是危险的。如果棋盘重构导致棋子对象被销毁或移动这个指针就悬空了。解决方案是使用std::shared_ptrPiece并在MoveRecord中存储std::weak_ptrPiece或者为每个棋子赋予唯一ID通过ID在棋盘中查找。我最终选择了唯一ID的方案更稳定。3.3 计时功能的精准控制计时器看似简单但要精准且稳定并不容易。我的Timer类使用std::chrono库来保证计时精度。void Timer::run() { auto lastTime std::chrono::steady_clock::now(); while (running_) { if (!paused_) { auto now std::chrono::steady_clock::now(); auto elapsed std::chrono::duration_caststd::chrono::milliseconds(now - lastTime); if (elapsed.count() intervalMs_) { callback_(); // 通知主游戏逻辑一秒到了 lastTime now; } } std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 短暂休眠避免空转耗CPU } }在Game类中我为红黑双方各设置一个Timer实例或者更简单地只用一个Timer但记录两个独立的时间变量并根据当前行棋方决定扣除哪个。每次Timer回调触发就将当前行棋方的剩余时间减1并刷新界面显示。当时间减到0立即触发游戏结束逻辑判定当前方超时负。注意事项计时与界面刷新分离不要在计时器线程中直接更新控制台。应该通过线程安全的方式如原子变量、消息队列将“时间更新事件”传递给主线程。处理悔棋与计时关于悔棋时是否要回退时间规则没有定论。我的实现是悔棋不回溯时间以简化逻辑。如果你需要支持竞赛规则可以在MoveRecord中也记录当时双方剩余时间悔棋时一并恢复。游戏暂停当游戏弹出结束提示或进行其他菜单操作时需要暂停计时器。我的Timer类提供了pause()和resume()方法通过原子布尔标志控制。3.4 游戏结束的全面判定中国象棋的结束条件不止于“将死”。一个健壮的结束判定模块需要检查以下几种情况将死当前行棋方被将军且所有可能的走法包括移动将帅、垫子、吃子解将都无法解除将军状态。这是最复杂的判定。实现思路首先检测当前方是否被将军调用Board::isInCheck。如果是则遍历当前方所有存活棋子的所有合法走法模拟执行每一步然后检查模拟后的状态是否仍被将军。如果所有模拟走法都无法摆脱被将军则为将死。困毙当前行棋方未被将军但没有任何合法的走法可走任何移动都会导致被将军即“自杀”是不允许的。判定相对简单只需检查当前方是否存在任何一枚棋子有至少一个合法移动目标即可。超时如前所述任何一方计时归零。认输玩家主动认输。长将虽然规则上长将通常由裁判裁定但在程序中可以加入简单的检测如果同一连续将军状态重复出现多次例如3次可自动判负。这需要记录历史局面。我将这些判定逻辑集中在Game::checkGameOver()方法中它在每次移动后和计时器回调中都会被调用。一旦满足任何结束条件就设置游戏状态为结束并显示相应的提示信息例如“红方超时黑方获胜”、“黑方被将死红方获胜”。实现难点“将死”判定中的模拟走法是最耗性能的部分尤其是在残局阶段。优化方法包括缓存棋子的合法移动列表在模拟时只深度复制必要的棋盘局部状态使用“王车易位”类似的思路优先检查将帅的逃跑路线等。对于初级版本在9x10的棋盘上即使暴力模拟所有走法性能也足以接受。4. 控制台交互与用户体验优化在控制台下做一个好用的交互界面需要处理输入、输出和界面刷新。4.1 棋盘绘制与状态显示我用字符来绘制棋盘和棋子。例如用-和|画网格用R代表红车r代表黑车等。每次棋盘状态变化或需要高亮提示时都需要清屏并重绘整个界面。void ConsoleView::drawBoard(const Board board, const std::vectorstd::pairint, int highlights) { system(cls); // Windows清屏Linux/Mac用 clear std::cout 0 1 2 3 4 5 6 7 8\n; // 列坐标 for (int y 0; y 10; y) { std::cout y ; for (int x 0; x 9; x) { auto piece board.getPieceAt(x, y); if (std::find(highlights.begin(), highlights.end(), std::pairint,int(x,y)) ! highlights.end()) { std::cout \033[42m; // 设置绿色背景高亮 } if (piece) { std::cout getPieceChar(piece); // 根据棋子类型和颜色返回对应字符 } else { std::cout .; } std::cout \033[0m ; // 重置颜色 } std::cout std::endl; } // 显示当前行棋方、双方剩余时间等信息 std::cout 当前行棋方: (currentPlayer Piece::RED ? 红方 : 黑方) std::endl; std::cout 红方时间: redTime 秒 | 黑方时间: blackTime 秒 std::endl; std::cout 命令: (m)移动 (u)悔棋 (h)提示 (r)重新开始 (q)退出 std::endl; }使用 ANSI 转义序列\033[42m可以设置背景色用于高亮合法走步位置用户体验会好很多。4.2 输入处理与命令解析主游戏循环不断等待用户输入。我设计了一套简单的命令语法m 0 1 0 2表示移动棋子从 (0,1) 到 (0,2)。u表示悔棋。h在输入棋子坐标后显示该棋子的走步提示。s选中某个坐标的棋子后续再输入目标坐标完成移动两步操作更符合图形界面思维。输入解析器需要健壮能处理非法格式、超出边界的坐标、选择空位置或对方棋子等情况并给出明确的错误提示。4.3 走步提示的交互流程走步提示的交互流程整合在输入循环中玩家输入s 4 1假设想选中红方的中炮。程序检查 (4,1) 位置是否有当前行棋方的棋子。如果有则调用Board::getLegalMovesForPiece获取所有合法目标位置列表。程序调用drawBoard并传入这个高亮位置列表棋盘上这些格子会以绿色背景显示。玩家看到高亮提示后再输入m 4 1 4 3平中炮来完成移动。如果输入的目标不在高亮列表中则提示移动非法。这种“先选中再移动”的两步模式比直接输入起始和目标坐标更直观也更容易与图形界面移植。5. 项目构建、测试与扩展思考5.1 开发环境与构建这个项目是纯C标准库项目不依赖任何第三方图形库。我使用 CMake 来管理构建过程这样跨平台Windows/Linux/macOS会比较容易。核心的编译要求是支持 C11 或以上标准的编译器如 GCC, Clang, MSVC。CMakeLists.txt的基本内容如下cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 11) add_executable(chinese_chess src/main.cpp src/Game.cpp src/Board.cpp src/Piece.cpp src/Timer.cpp src/View.cpp # ... 其他源文件 )在 VS Code 或 CLion 中配置好对应的 C 编译套件如 MinGW-w64 或 MSVC就可以很方便地编译和调试。5.2 核心功能的单元测试对于这种逻辑复杂的项目写一些简单的单元测试能极大提升代码可靠性。我用 Catch2 框架单头文件易于集成为棋盘移动验证、将军检测等核心函数写了测试。例如测试“马”的走法和绊马腿TEST_CASE(Knight movement and block, [board]) { Board board; board.initializeStandard(); auto knight board.getPieceAt(1, 0); // 红方左马 REQUIRE(knight ! nullptr); SECTION(Valid move without block) { // 马走日到 (2, 2) bool valid board.validateMove(1, 0, 2, 2); CHECK(valid true); } SECTION(Invalid move due to block (绊马腿)) { // 在 (1,1) 放一个棋子绊住马腿 board.placePiece(std::make_uniquePawn(Piece::RED), 1, 1); bool valid board.validateMove(1, 0, 2, 2); CHECK(valid false); } }为Timer类写测试时需要模拟时间流逝可以使用std::chrono的模拟时钟或者注入一个时间源。5.3 常见问题排查与调试技巧在开发过程中我遇到了不少典型问题这里记录一下排查思路棋子移动规则异常比如车可以斜着走。首先检查该棋子类如Rook的isValidMove实现确保它正确地限制了只能走直线。然后检查Board::validateMove中路径阻挡的逻辑是否正确。调试技巧在validateMove函数中增加详细的日志输出打印出起始、目标坐标以及每一步的检查结果。悔棋后状态错乱比如被吃掉的棋子复活在了错误的位置。重点检查MoveRecord::undo函数。确保capturedPiece指针在移动执行时被正确赋值指向被吃棋子对象而不是其副本并且capturedPiecePos记录的是被吃棋子被吃前的位置。调试技巧在每次执行移动和悔棋操作前后打印整个棋盘的快照可以用简单的字符表示进行肉眼比对。计时器不同步或漂移计时器走时不准或者游戏暂停后计时器没停。检查Timer线程的循环逻辑。std::this_thread::sleep_for并不精确它受系统调度影响。更稳健的做法是计算下一次触发的时间点然后sleep_until。同时确保pause()和resume()正确设置了paused_原子标志。调试技巧在计时器回调函数里打印当前系统时间观察间隔是否稳定在1秒。“将死”判定逻辑死循环或误判这是最复杂的部分。如果判定逻辑陷入死循环可能是模拟走法时没有正确恢复棋盘状态导致后续模拟基于一个被破坏的棋盘。确保每次模拟都是在一个独立的、深拷贝的或正确还原的棋盘副本上进行。如果判定不准可能是“将军”检测函数isInCheck有漏洞没有考虑到所有棋子的攻击范围。调试技巧编写针对特定残局局面的测试用例例如“单车难破士象全”是否会被误判为将死一步步跟踪isInCheck和模拟走法的过程。5.4 项目扩展方向这个基础版本完成后有很多可以扩展和优化的方向图形界面用 SFML、SDL2 或 Qt 替换控制台界面实现真正的鼠标点击和更美观的棋盘、棋子绘制。人工智能对手实现一个简单的象棋AI。可以从随机走法开始逐步加入基于规则的走法如优先吃子、保护将帅最后实现 Minimax 搜索算法配合简单的局面评估函数根据棋子价值和位置打分。这是一个全新的、富有挑战性的领域。网络对战将Game类中的状态序列化通过网络套接字在两名玩家间同步。需要处理网络延迟、断线重连、观战模式等。棋谱记录与复盘将每一步的MoveRecord保存为文件如标准的 PGN 格式或自定义格式支持加载棋谱一步步复盘。规则完善实现更复杂的竞赛规则如“长捉”、“长拦”等禁止着法的自动判定以及“六十回合自然限着”的和棋规则。这个项目虽然不大但它几乎涵盖了小型游戏开发的所有核心要素对象建模、状态管理、用户交互、算法逻辑甚至简单的多线程。把它吃透你对C的理解和工程能力会上一个扎实的台阶。我个人最大的体会是前期花时间设计清晰的数据结构如MoveRecord和接口如Board::validateMove比后期在混乱的代码里修修补补要高效得多。在实现“将死”判定时我也深刻感受到单元测试的重要性没有那些测试用例我可能永远发现不了某些边界情况下的逻辑错误。