news 2026/7/24 17:55:41

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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::vector<std::pair<int, int>> getPossibleMoves() const = 0; // 检查从当前位置(x_, y_)移动到(toX, toY)是否符合该棋子的基本走法规则 virtual bool isValidMove(int toX, int toY) const = 0; Color getColor() const { return color_; } std::pair<int, 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::vector<std::vector<std::unique_ptr<Piece>>> board_Board类的职责很重:

  1. 初始化:在游戏开始时,按照标准棋局摆放所有棋子。
  2. 状态查询:给定一个坐标,返回上面的棋子(如果有)。
  3. 移动执行:执行一次移动操作,包括吃子逻辑(目标位置有敌方棋子则将其setAlive(false))。
  4. 移动验证:这是核心中的核心。它需要调用棋子的isValidMove,并检查移动路径上是否有其他棋子阻挡(比如车、炮、象),同时还要结合象棋的特殊规则,比如将帅不能照面、过河卒子才能横走等。
  5. 胜负判定:检查是否有一方的将/帅被将死或困毙。

注意:这里有一个关键设计决策:为什么把getPossibleMovesisValidMove分开?getPossibleMoves主要用于生成“走步提示”,它返回的是基于棋子自身规则的所有可能终点,不考虑棋盘当前状态(如阻挡)。而isValidMove是最终执行移动前的综合校验,需要结合棋盘实时状态进行。两者分工明确,避免逻辑耦合。

2.2 游戏状态与历史管理

为了支持悔棋,我们必须记录游戏的历史状态。简单记录每一步的起始和目标坐标是不够的,因为吃子操作是不可逆的,我们需要知道被吃掉的棋子是什么。因此,我设计了一个GameState结构体和一个Game主控类。

GameState是一个快照,它包含了在某一时刻所有必要的信息:

struct GameState { std::vector<std::vector<std::unique_ptr<Piece>>> boardSnapshot; // 棋盘快照(深拷贝成本高,需优化) Piece::Color currentPlayer; // 当前行棋方 int redTimeRemaining; // 红方剩余时间(秒) int blackTimeRemaining; // 黑方剩余时间(秒) // 还可以记录上一步移动信息,用于界面高亮等 };

直接深拷贝整个棋盘(尤其是包含智能指针的二维结构)在每一步都进行,对性能是灾难。一个更优的方案是只记录增量变化。我实现了一个MoveRecord(移动记录)类,它记录一次移动的详细信息:移动的棋子指针、起始位置、目标位置、以及被吃掉的棋子指针(如果有)。这样,悔棋时只需要逆向应用这个记录即可。

Game类是整个游戏的大脑,它聚合了Board和两个玩家的时间信息,并维护了一个std::stack<MoveRecord>作为历史记录栈。每次成功移动后,就将本次移动的记录压栈。执行悔棋时,从栈顶弹出记录,并执行逆向操作:将棋子移回原位,如果该记录显示有吃子,则将被吃棋子“复活”并放回棋盘。

2.3 计时器模块的设计

计时功能我选择独立成一个Timer类。这个类在单独的线程中运行,每隔一秒(或更短)通过回调函数通知Game类更新当前行棋方的剩余时间。Game类中保存红黑双方的剩余时间(秒),并在每次切换行棋方时,暂停上一方的计时器,启动当前方的计时器。

class Timer { public: using Callback = std::function<void()>; Timer(int intervalMs, Callback callback); void start(); void stop(); void pause(); void resume(); private: void run(); std::thread worker_; std::atomic<bool> running_{false}; std::atomic<bool> paused_{false}; int intervalMs_; Callback callback_; };

这里涉及到多线程编程。计时器线程需要安全地与主游戏线程(负责接收输入和更新显示)通信。我使用std::atomic布尔标志来控制计时器的启停和暂停,避免数据竞争。当任何一方时间耗尽,计时器回调会触发Game类的结束逻辑,判定该方超时负。

实操心得:在多线程环境下更新控制台显示要小心。最好将时间显示更新和游戏状态更新的逻辑放在主线程,计时器线程只负责触发一个“时间到”的事件标志,由主线程在每次循环中检查并处理。直接在线程中调用printfcout可能导致输出错乱。

3. 核心功能实现细节剖析

有了清晰的架构,接下来就是填充血肉,实现标题中的几个核心功能。

3.1 走步提示的生成算法

走步提示功能,就是在玩家选中一个己方棋子后,高亮显示所有合法的目标位置。实现它分为两步:

  1. 生成候选位置:调用选中棋子的getPossibleMoves()方法,获得基于其走法规则的所有可能目标格。
  2. 合法性过滤:对每一个候选位置,调用Board::validateMove方法进行综合校验。这个校验非常关键,它需要检查:
    • 路径阻挡:对于车、炮、马、象、士、将,移动路径上是否有其他棋子(马和象有特殊的“绊马腿”和“塞象眼”规则)。
    • 目标位置:是否在棋盘内?是否已有己方棋子?
    • 特殊规则
      • 将帅照面:移动后,是否导致双方的将/帅处于同一纵列且中间无任何棋子阻挡?这是不允许的。
      • 自投罗网:移动后,是否导致自己的将/帅暴露在对方棋子的直接攻击之下(即被“将军”)?通常走步提示中不应包含会导致立即被将死的走法,这属于更高级的“应将”逻辑,初期可以先忽略,但一个完善的提示应该过滤掉这类“自杀式”走法。

过滤完成后,剩下的位置就是真正的合法走步,可以在界面上高亮显示。这里有一个性能考量:对于“兵”或“帅”这类可能走法较少的棋子,直接计算没问题;但对于“车”在空旷棋盘上,可能走法有十几个,每次选中都进行全量计算和过滤是可以接受的。如果未来要扩展AI,可能需要更高效的方法。

3.2 悔棋机制的实现与数据管理

悔棋的核心在于状态回溯。如前所述,我采用MoveRecord增量记录方案。

class MoveRecord { public: Piece* movedPiece; // 移动的棋子 int fromX, fromY; // 起始位置 int toX, toY; // 目标位置 Piece* capturedPiece; // 被吃掉的棋子(nullptr表示无) std::pair<int, 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::stack<MoveRecord>。每次执行移动(Game::makeMove)成功后,就创建一个包含所有信息的MoveRecord并压栈。当用户触发悔棋时,调用Game::undoMove(),它执行以下操作:

  1. 检查历史栈是否为空。
  2. 弹出栈顶的MoveRecord
  3. 调用record.undo(board)恢复棋盘状态。
  4. 切换当前行棋方。
  5. 更新时间(如果需要,悔棋通常不返还用时,但也可以设计为回退时间)。

踩过的坑:存储Piece*原始指针是危险的。如果棋盘重构导致棋子对象被销毁或移动,这个指针就悬空了。解决方案是使用std::shared_ptr<Piece>并在MoveRecord中存储std::weak_ptr<Piece>,或者为每个棋子赋予唯一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_cast<std::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,立即触发游戏结束逻辑,判定当前方超时负。

注意事项

  1. 计时与界面刷新分离:不要在计时器线程中直接更新控制台。应该通过线程安全的方式(如原子变量、消息队列)将“时间更新事件”传递给主线程。
  2. 处理悔棋与计时:关于悔棋时是否要回退时间,规则没有定论。我的实现是悔棋不回溯时间,以简化逻辑。如果你需要支持竞赛规则,可以在MoveRecord中也记录当时双方剩余时间,悔棋时一并恢复。
  3. 游戏暂停:当游戏弹出结束提示或进行其他菜单操作时,需要暂停计时器。我的Timer类提供了pause()resume()方法,通过原子布尔标志控制。

3.4 游戏结束的全面判定

中国象棋的结束条件不止于“将死”。一个健壮的结束判定模块需要检查以下几种情况:

  1. 将死:当前行棋方被将军,且所有可能的走法(包括移动将帅、垫子、吃子解将)都无法解除将军状态。这是最复杂的判定。
    • 实现思路:首先检测当前方是否被将军(调用Board::isInCheck)。如果是,则遍历当前方所有存活棋子的所有合法走法,模拟执行每一步,然后检查模拟后的状态是否仍被将军。如果所有模拟走法都无法摆脱被将军,则为将死。
  2. 困毙:当前行棋方未被将军,但没有任何合法的走法可走(任何移动都会导致被将军,即“自杀”是不允许的)。判定相对简单,只需检查当前方是否存在任何一枚棋子有至少一个合法移动目标即可。
  3. 超时:如前所述,任何一方计时归零。
  4. 认输:玩家主动认输。
  5. 长将:虽然规则上长将通常由裁判裁定,但在程序中可以加入简单的检测,如果同一连续将军状态重复出现多次(例如3次),可自动判负。这需要记录历史局面。

我将这些判定逻辑集中在Game::checkGameOver()方法中,它在每次移动后和计时器回调中都会被调用。一旦满足任何结束条件,就设置游戏状态为结束,并显示相应的提示信息(例如:“红方超时,黑方获胜!”、“黑方被将死,红方获胜!”)。

实现难点:“将死”判定中的模拟走法是最耗性能的部分,尤其是在残局阶段。优化方法包括:缓存棋子的合法移动列表;在模拟时只深度复制必要的棋盘局部状态;使用“王车易位”类似的思路,优先检查将帅的逃跑路线等。对于初级版本,在9x10的棋盘上,即使暴力模拟所有走法,性能也足以接受。

4. 控制台交互与用户体验优化

在控制台下做一个好用的交互界面,需要处理输入、输出和界面刷新。

4.1 棋盘绘制与状态显示

我用字符来绘制棋盘和棋子。例如,用-|画网格,用R代表红车,r代表黑车等。每次棋盘状态变化或需要高亮提示时,都需要清屏并重绘整个界面。

void ConsoleView::drawBoard(const Board& board, const std::vector<std::pair<int, 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::pair<int,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 走步提示的交互流程

走步提示的交互流程整合在输入循环中:

  1. 玩家输入s 4 1(假设想选中红方的中炮)。
  2. 程序检查 (4,1) 位置是否有当前行棋方的棋子。如果有,则调用Board::getLegalMovesForPiece获取所有合法目标位置列表。
  3. 程序调用drawBoard,并传入这个高亮位置列表,棋盘上这些格子会以绿色背景显示。
  4. 玩家看到高亮提示后,再输入m 4 1 4 3(平中炮)来完成移动。如果输入的目标不在高亮列表中,则提示移动非法。

这种“先选中,再移动”的两步模式,比直接输入起始和目标坐标更直观,也更容易与图形界面移植。

5. 项目构建、测试与扩展思考

5.1 开发环境与构建

这个项目是纯C++标准库项目,不依赖任何第三方图形库。我使用 CMake 来管理构建过程,这样跨平台(Windows/Linux/macOS)会比较容易。核心的编译要求是支持 C++11 或以上标准的编译器(如 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_unique<Pawn>(Piece::RED), 1, 1); bool valid = board.validateMove(1, 0, 2, 2); CHECK(valid == false); } }

Timer类写测试时,需要模拟时间流逝,可以使用std::chrono的模拟时钟或者注入一个时间源。

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

在开发过程中,我遇到了不少典型问题,这里记录一下排查思路:

  1. 棋子移动规则异常:比如车可以斜着走。首先检查该棋子类(如Rook)的isValidMove实现,确保它正确地限制了只能走直线。然后检查Board::validateMove中路径阻挡的逻辑是否正确。调试技巧:在validateMove函数中增加详细的日志输出,打印出起始、目标坐标以及每一步的检查结果。

  2. 悔棋后状态错乱:比如被吃掉的棋子复活在了错误的位置。重点检查MoveRecord::undo函数。确保capturedPiece指针在移动执行时被正确赋值(指向被吃棋子对象,而不是其副本),并且capturedPiecePos记录的是被吃棋子被吃前的位置。调试技巧:在每次执行移动和悔棋操作前后,打印整个棋盘的快照(可以用简单的字符表示),进行肉眼比对。

  3. 计时器不同步或漂移:计时器走时不准,或者游戏暂停后计时器没停。检查Timer线程的循环逻辑。std::this_thread::sleep_for并不精确,它受系统调度影响。更稳健的做法是计算下一次触发的时间点,然后sleep_until。同时,确保pause()resume()正确设置了paused_原子标志。调试技巧:在计时器回调函数里打印当前系统时间,观察间隔是否稳定在1秒。

  4. “将死”判定逻辑死循环或误判:这是最复杂的部分。如果判定逻辑陷入死循环,可能是模拟走法时没有正确恢复棋盘状态,导致后续模拟基于一个被破坏的棋盘。确保每次模拟都是在一个独立的、深拷贝的或正确还原的棋盘副本上进行。如果判定不准,可能是“将军”检测函数isInCheck有漏洞,没有考虑到所有棋子的攻击范围。调试技巧:编写针对特定残局局面的测试用例,例如“单车难破士象全”是否会被误判为将死?一步步跟踪isInCheck和模拟走法的过程。

5.4 项目扩展方向

这个基础版本完成后,有很多可以扩展和优化的方向:

  • 图形界面:用 SFML、SDL2 或 Qt 替换控制台界面,实现真正的鼠标点击和更美观的棋盘、棋子绘制。
  • 人工智能对手:实现一个简单的象棋AI。可以从随机走法开始,逐步加入基于规则的走法(如优先吃子、保护将帅),最后实现 Minimax 搜索算法配合简单的局面评估函数(根据棋子价值和位置打分)。这是一个全新的、富有挑战性的领域。
  • 网络对战:将Game类中的状态序列化,通过网络套接字在两名玩家间同步。需要处理网络延迟、断线重连、观战模式等。
  • 棋谱记录与复盘:将每一步的MoveRecord保存为文件(如标准的 PGN 格式或自定义格式),支持加载棋谱一步步复盘。
  • 规则完善:实现更复杂的竞赛规则,如“长捉”、“长拦”等禁止着法的自动判定,以及“六十回合自然限着”的和棋规则。

这个项目虽然不大,但它几乎涵盖了小型游戏开发的所有核心要素:对象建模、状态管理、用户交互、算法逻辑,甚至简单的多线程。把它吃透,你对C++的理解和工程能力会上一个扎实的台阶。我个人最大的体会是,前期花时间设计清晰的数据结构(如MoveRecord)和接口(如Board::validateMove),比后期在混乱的代码里修修补补要高效得多。在实现“将死”判定时,我也深刻感受到单元测试的重要性,没有那些测试用例,我可能永远发现不了某些边界情况下的逻辑错误。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/24 17:52:59

数学推理大模型技术解析:从Transformer优化到实战应用

国产模型IMO 2026满分&#xff0c;GPT-SOL-5.6最快14分58秒&#xff1a;数学推理大模型的突破与实战应用在人工智能快速发展的今天&#xff0c;数学推理能力一直是衡量AI模型智能水平的重要标尺。近期&#xff0c;国产模型在国际数学奥林匹克竞赛&#xff08;IMO&#xff09;中…

作者头像 李华
网站建设 2026/7/24 17:51:49

华为MetaERP Oracle Fusion Financials 会计核算架构深度分析报告基于 Oracle Fusion Cloud Financials 26C 官方文档体系整理,涵盖设计哲

Oracle Fusion Financials 会计核算架构深度分析报告基于 Oracle Fusion Cloud Financials 26C 官方文档体系整理&#xff0c;涵盖设计哲学、业务流程、实现步骤、组织架构成熟度对比&#xff0c;以及 PTP 端到端业务场景的核算与预算控制逻辑。一、Fusion Accounting 的设计哲…

作者头像 李华
网站建设 2026/7/24 17:46:31

华为Atlas平台YOLOv5模型部署与优化实战

1. 项目背景与核心价值在边缘计算和端侧AI加速领域&#xff0c;华为Atlas系列硬件凭借其Ascend芯片的出色算力表现&#xff0c;正在成为工业级视觉检测部署的重要选择。最近我在一个车牌识别项目中&#xff0c;成功将YOLOv5模型部署到Atlas 200 DK开发者套件上&#xff0c;实测…

作者头像 李华
网站建设 2026/7/24 17:46:18

MSP430 SFR与SYS寄存器深度解析:中断、复位与系统配置实战指南

1. 项目概述与核心价值在嵌入式开发领域&#xff0c;尤其是与德州仪器&#xff08;TI&#xff09;的MSP430系列低功耗微控制器打交道时&#xff0c;我们经常会遇到一个核心概念&#xff1a;特殊功能寄存器。对于刚接触这类MCU的工程师来说&#xff0c;数据手册里那些密密麻麻的…

作者头像 李华
网站建设 2026/7/24 17:45:40

AI 体育赛事智能直播分析——实时多模态数据处理的技术方案与工程实现

AI 体育赛事智能直播分析——实时多模态数据处理的技术方案与工程实现 一、信息密度的落差&#xff1a;顶级赛事有数据增强&#xff0c;而业余赛场只有比分 一场羽毛球比赛持续 40~90 分钟&#xff0c;电视转播中的数据分析图层——杀球时速、跑动热力图、体能衰减曲线、战术模…

作者头像 李华