C++面向对象实战:从零构建中国象棋游戏引擎与图形界面
1. 项目概述:从棋盘到代码的完整旅程
最近在整理硬盘,翻出了一个大学时期写的C++中国象棋项目,重新编译运行了一下,感慨良多。这个项目麻雀虽小,五脏俱全,它不仅仅是一个简单的“小游戏”,而是一个涵盖了面向对象设计、图形界面、游戏逻辑、算法乃至文件操作的综合练习。对于正在学习C++,尤其是想从控制台“黑框框”迈向图形化、结构化项目实战的朋友来说,实现一个中国象棋游戏是一个非常经典的练手选择。它能让你深刻理解如何将现实世界的复杂规则,用清晰的代码结构来建模和实现。今天,我就把这个项目的完整思路、核心实现以及我踩过的那些坑,系统地梳理一遍,希望能给想动手实践的你一份可以直接“抄作业”的参考。
这个项目适合有一定C++基础(了解类、继承、多态、STL容器)的开发者。最终目标是实现一个具备完整行棋规则、胜负判定、图形化界面(哪怕是最简单的控制台图形或基础GUI库),并且代码结构清晰、易于扩展的程序。我们将从最核心的棋盘与棋子数据模型开始,逐步构建规则引擎,最后接入交互界面。整个过程,你会看到如何用C++的抽象思维来解决一个具体的、有明确规则的问题。
2. 核心架构与面向对象设计
在动手写第一行代码之前,花时间进行良好的设计至关重要。一个混乱的象棋程序很快就会变成难以维护的“屎山”。我们的核心设计思想是:高内聚、低耦合。将不同的职责划分到不同的类中。
2.1 核心类设计蓝图
整个项目可以围绕以下几个核心类展开:
- Piece(棋子基类):这是一个抽象基类,代表棋盘上的一个棋子。它应该包含棋子的通用属性,如颜色(红方或黑方)、位置(棋盘坐标),以及一个纯虚函数
bool isValidMove(int toX, int toY, const Board& board),用于判断从当前位置移动到目标位置是否合法。这个函数是规则的核心。 - 派生棋子类:从
Piece派生出具体的棋子类,如King(将/帅)、Guard(士)、Elephant(象)、Horse(马)、Rook(车)、Cannon(炮)、Pawn(兵/卒)。每个派生类重写isValidMove函数,实现各自独特的行走规则。例如,Horse类需要实现“马走日”和“蹩马腿”的逻辑。 - Board(棋盘类):这是游戏的状态核心。它内部维护一个二维数组(或向量)的
Piece*(智能指针更佳),表示棋盘上每个格子的棋子状态。它负责棋子的放置、移动、吃子,以及提供查询棋盘状态(如某个位置是否有子、是什么子)的接口。同时,它也应该能判断是否处于“将军”状态。 - Game(游戏控制类):这是整个游戏的控制器和规则仲裁者。它拥有一个
Board实例,管理当前行棋方(红先黑后),处理玩家的走棋输入,调用Board和Piece的方法验证走法是否合法,执行走棋,并判断游戏是否结束(将死、困毙、长将等)。它相当于连接数据(Board)与交互(UI)的桥梁。 - UI/Display(界面类):这是一个相对独立的模块,负责将
Board的状态以某种形式展示出来,并接收用户的输入。它可以是控制台字符图形,也可以是使用如 SFML、SDL2、Qt 等图形库绘制的图形界面。界面类应当只关心“显示”和“输入”,不包含游戏逻辑。
这种设计的优势在于,当你需要更换界面(比如从控制台换到Qt),你几乎不需要修改Piece、Board、Game类的代码,只需要重写UI类即可,充分体现了模块化思想。
2.2 数据结构与内存管理考量
棋盘表示:最简单的是使用一个9x10的二维数组(中国象棋棋盘是9列10行)。但更推荐使用std::array<std::array<std::unique_ptr<Piece>, 9>, 10>或者std::vector<std::vector<std::unique_ptr<Piece>>>。使用std::unique_ptr可以自动管理棋子对象的内存,避免内存泄漏。空位置用nullptr表示。
坐标系统:强烈建议在内部统一使用(x, y)坐标,其中x代表列(0-8),y代表行(0-9)。原点(0,0)可以设定为棋盘左下角(黑方底线左角)或左上角(红方底线左角),但必须在整个系统中保持一致。界面显示时,再将此内部坐标转换为屏幕坐标或用户熟悉的“九路”坐标(如“炮二平五”)。
走法表示:如何记录一步棋?可以定义一个简单的Move结构体:
struct Move { int fromX, fromY; // 起点 int toX, toY; // 终点 Piece* capturedPiece = nullptr; // 被吃掉的棋子(如果有) // 还可以加入棋步说明字符串,如“炮二平五” };Game类可以维护一个std::vector<Move>作为棋谱,用于实现悔棋、复盘功能。
注意事项:在
Board中移动棋子时,不仅仅是交换指针。如果目标位置有对方棋子(capturedPiece),需要先销毁或记录它,再将移动棋子的指针置入新位置,并将原位置置为nullptr。这个过程是游戏状态变更的核心,务必小心处理。
3. 棋子行走规则的精确实装
这是整个项目的算法核心,也是最容易出错的部分。每个棋子的isValidMove函数都需要访问棋盘对象,以判断路径上是否有其他棋子阻挡、是否满足特殊规则。
3.1 通用验证流程与基础规则
在具体棋子规则之前,Game或Piece的验证应包含一些通用检查:
- 目标位置是否在棋盘内?
(0 <= x < 9 && 0 <= y < 10)。 - 是否吃自己的子?检查目标位置棋子的颜色是否与移动方相同。
- 将帅是否照面?这是一个特殊规则,需要在
Board或Game层面单独检查,不属于单个棋子的移动规则。
每个棋子的isValidMove函数大体遵循以下模式:
bool Rook::isValidMove(int toX, int toY, const Board& board) const { // 1. 检查是否为直线运动 (dx==0 || dy==0) // 2. 检查路径上(起点和终点之间,不含终点)是否有任何棋子阻挡 // 3. 如果无阻挡,则移动合法 }3.2 复杂规则实现详解:马、炮、象
马(Horse):“马走日”和“蹩马腿”是重点。首先计算位移dx = abs(toX - x),dy = abs(toY - y)。合法的“日”字步是(dx==1 && dy==2) || (dx==2 && dy==1)。然后检查“马腿”:对于(dx=2, dy=1)的情况,马腿在(x + (toX-x)/2, y);对于(dx=1, dy=2)的情况,马腿在(x, y + (toY-y)/2)。检查马腿位置是否为nullptr。
炮(Cannon):炮的规则最特殊。它走直线,但吃子方式不同。
- 移动(不吃子):路径上必须没有任何棋子阻挡。
- 吃子:路径上必须恰好有一个棋子(充当“炮架”),并且目标位置有一个对方棋子。 实现时,可以先计算路径上的棋子数量
numPiecesBetween。如果目标位置无子 (board[toY][toX] == nullptr),则要求numPiecesBetween == 0;如果目标位置有对方子,则要求numPiecesBetween == 1。
象(Elephant):“象飞田”且不能过河。首先检查目标位置是否在本方河界内(红方 y >= 5, 黑方 y <= 4)。然后计算位移,应为dx == 2 && dy == 2。最后检查“象眼”((x + dx/2, y + dy/2))位置是否无子阻挡。
将/帅(King):除了九宫格内直行一步的规则,还需要在Game层面额外实现“将帅不能照面”的规则。在每次尝试移动或将移动后,检查双方将帅是否在同一列且中间无任何棋子阻挡。这是一个全局规则,建议放在Game::isMoveLegal()或Board::isChecked()函数中实现。
实操心得:实现这些规则时,务必为每个棋子编写详尽的单元测试。你可以创建一个简单的测试程序,初始化棋盘到特定局面,然后断言某个走法是否合法。例如,测试马在多种“蹩马腿”情况下是否真的不能移动。这是保证核心逻辑正确的唯一高效方法,远比在图形界面里手动点击测试要可靠得多。
4. 游戏流程控制与高级功能实现
有了棋子规则和棋盘,Game类就需要将它们串联起来,形成一个完整的游戏循环。
4.1 主循环与状态迁移
一个典型的游戏主循环(以控制台为例)如下:
void Game::run() { initBoard(); // 初始化棋盘,摆好棋子 currentPlayer = Player::RED; while (!isGameOver()) { renderBoard(); // 显示棋盘 Move move = getPlayerMove(); // 获取玩家输入 if (validateMove(move)) { makeMove(move); // 执行移动 switchPlayer(); // 切换行棋方 checkGameStatus(); // 检查是否将军、将死等 } else { displayMessage("非法走法,请重新输入!"); } } announceWinner(); }validateMove(move)是核心,它需要:
- 检查
move.from位置是否是当前行棋方的棋子。 - 调用该棋子的
isValidMove(toX, toY, board)。 - 执行“预移动”,检查移动后是否造成本方老将被将军(即“送将”)。这是中国象棋最重要的规则之一!实现方式是:临时执行这步走法,然后检查本方将是否被对方任何棋子攻击,检查完再撤销临时移动。
- 检查是否违反“将帅照面”等全局规则。
4.2 胜负判定逻辑拆解
游戏结束的条件不止“将死”。
- 将死:一方被将军,且所有可能的走法都无法解除将军状态。这需要
Game类能够为当前行棋方生成所有合法走法(generateAllLegalMoves),并逐一尝试,看是否存在一步棋能让自己解除被将军状态。如果所有走法尝试后仍被将军,则判负。 - 困毙:一方未被将军,但没有任何合法的走法可走(所有棋子都被堵死)。同样需要用到
generateAllLegalMoves,如果生成为空列表,则判负。 - 长将:这是比较复杂的棋规。需要在
Game中记录棋谱,并检测是否出现连续多次(如三次)重复的将军局面。实现起来需要记录局面的哈希值或直接存储棋盘状态快照进行比对。
generateAllLegalMoves函数的实现是胜负判定的基础。它的伪代码如下:
std::vector<Move> Game::generateAllLegalMoves(Player player) { std::vector<Move> moves; for (每个属于 player 的棋子) { for (棋盘上的每个目标位置) { Move move = {棋子位置, 目标位置}; if (validateMove(move)) { // 这里包含了“送将”检查 moves.push_back(move); } } } return moves; }这个函数会比较耗时,但它是实现智能AI(如极小化极大算法)和自动判定的关键。
4.3 功能扩展:悔棋、存盘与复盘
悔棋:只需要在Game类中维护一个std::stack<Move>作为历史走法栈。执行makeMove时,将完整的Move记录(包括被吃掉的棋子信息)压栈。悔棋时,从栈顶弹出记录,并执行反向操作:将棋子移回原位,如果该步吃了子,则恢复被吃的棋子。注意,被吃掉的棋子对象需要被保存(深拷贝或使用共享指针),而不能只保存指针(因为原对象可能已被销毁)。
存盘/读盘:将游戏状态序列化到文件。需要保存的信息包括:棋盘布局、当前行棋方、历史棋谱。一个简单的方法是为每个棋子类型定义一个字符编码(如K代表红帅,k代表黑将),将整个棋盘保存为一个10行9列的文本文件。第一行可以保存当前行棋方(RED/BLACK)。历史棋谱可以另存。读盘时,根据字符反向构建棋子对象,恢复棋盘。
踩坑记录:在实现悔棋时,我最初只保存了移动的起点和终点。当遇到“吃子”时,悔棋无法恢复被吃的棋子。必须保存被吃棋子的完整信息(类型、颜色),或者更简单点,在
Board中执行移动时,不立即销毁被吃棋子,而是将其移到一个“墓地”列表,悔棋时再取回来。这涉及到对象生命周期的管理,是C++项目设计的经典问题。
5. 图形界面集成与项目构建
游戏逻辑完备后,最后一步是为其加上“眼睛”和“耳朵”——图形界面。
5.1 控制台界面:快速验证逻辑
在开发初期,强烈建议先实现一个控制台界面。这能让你快速测试核心逻辑,而不用陷入图形库的细节。可以用简单的字符表示棋子,例如:
0 1 2 3 4 5 6 7 8 9 車馬相仕帥仕相馬車 8 . . . . . . . . . 7 .砲. . . . .砲. 6 兵.兵.兵.兵.兵 5 . . . . . . . . . 4 . . . . . . . . . 3 卒.卒.卒.卒.卒 2 .炮. . . . .炮. 1 . . . . . . . . . 0 俥傌象士將士象傌俥(这里用了Unicode字符,如果控制台不支持,可以用字母代替,如Rfor Rook等)。通过输入坐标(如2,7到2,0)来走棋。这个阶段的目标是让Game和Board的交互完全跑通。
5.2 使用轻量级图形库:SFML为例
当你需要更友好的界面时,SFML是一个极佳的跨平台选择。它轻量、易用,专注于多媒体和游戏。
- 资源准备:你需要一套棋子图片(红黑各7种共14张)和一张棋盘图片。或者,也可以用SFML的绘图原语(矩形、圆形、文字)来绘制。
- SFML集成:创建一个
SFMLUI类,继承自一个抽象的UI接口。在其主循环中:- 处理事件(鼠标点击、窗口关闭)。
- 将鼠标点击的屏幕坐标转换为棋盘内部坐标
(x, y)。 - 调用
Game类的接口来提交走法。 - 根据
Board的状态,在屏幕上绘制棋盘和棋子。
- 坐标转换:这是界面层的核心计算。你需要知道棋盘在窗口中的左上角位置
(boardOffsetX, boardOffsetY)以及每个格子的像素尺寸cellSize。那么,鼠标位置(mouseX, mouseY)对应的棋盘坐标就是:int boardX = (mouseX - boardOffsetX) / cellSize; int boardY = (mouseY - boardOffsetY) / cellSize; // 注意检查是否越界,以及Y轴方向(屏幕坐标Y轴向下,棋盘内部坐标Y轴向上可能需要翻转)。 - 走子交互:通常采用“两次点击”模式。第一次点击选中棋子(高亮显示),第二次点击目标位置。
SFMLUI类需要内部维护一个selectedPiecePos的状态。
5.3 项目构建与跨平台考量
构建系统:不要只用IDE的构建按钮。学习使用CMake来管理你的项目。一个简单的CMakeLists.txt可以让你的项目在Windows(Visual Studio)、Linux(GCC)和macOS(Clang)上轻松编译。这对于使用SFML等第三方库尤其方便。
cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 17) # 查找SFML库 find_package(SFML 2.5 COMPONENTS graphics window system REQUIRED) # 添加你的源代码文件 add_executable(ChineseChess main.cpp Game.cpp Board.cpp ... SFMLUI.cpp) # 链接SFML库 target_link_libraries(ChineseChess sfml-graphics sfml-window sfml-system)目录结构:保持清晰。
ChineseChess/ ├── CMakeLists.txt ├── include/ │ ├── Piece.h │ ├── Board.h │ ├── Game.h │ └── UI.h ├── src/ │ ├── Piece.cpp (以及各派生类的实现) │ ├── Board.cpp │ ├── Game.cpp │ └── SFMLUI.cpp (或其他UI实现) ├── assets/ (存放图片、字体) │ ├── board.png │ ├── red_rook.png │ └── ... └── tests/ (单元测试) └── test_board.cpp个人体会:从零开始实现这个项目,最大的收获不是学会了某个图形库,而是对软件架构和问题分解有了肌肉记忆。如何将“下象棋”这个模糊的需求,拆解成
Piece、Board、Game、UI这几个界限清晰、职责分明的模块,并在模块间设计简洁高效的接口,这个思考过程的价值远超代码本身。当你未来面对更复杂的系统时,这种能力会让你游刃有余。
6. 常见问题排查与性能优化
即使逻辑正确,在开发和运行中也可能遇到各种问题。
6.1 编译与链接问题
- 第三方库链接错误:使用SFML时,确保CMake正确找到了库,并且链接了所有必要的组件(graphics, window, system)。在Windows上,还需要将SFML的DLL文件放到可执行文件旁边。
- 未定义引用(undefined reference):检查是否将所有
.cpp文件都添加到了add_executable命令中,或者是否在正确的源文件中实现了类的成员函数。 - C++标准问题:在
CMakeLists.txt和编译器设置中统一使用C++11或更高标准(本项目建议C++17,方便使用std::optional、结构化绑定等现代特性)。
6.2 运行时逻辑错误
- 棋子走法诡异:首先怀疑坐标系统是否混乱。在棋盘、界面、走法验证等多个模块中,务必统一坐标原点和方向。在关键函数入口处打印坐标日志是有效的调试手段。
- 将军检测失灵:重点检查
Board::isAttacked(int kingX, int kingY, Player attacker)函数。这个函数需要模拟对方所有棋子的攻击范围。确保在检查“炮”的攻击时,正确计算了炮架和目标的逻辑。 - 悔棋后状态异常:这是深拷贝与浅拷贝的经典问题。如果你在
Move中只保存了被吃棋子的指针,悔棋时该指针可能已悬空。要么保存棋子的副本,要么改变设计,让棋盘始终持有所有棋子对象(包括被吃的),只是将其标记为“已移除”。
6.3 性能优化点
对于象棋程序,性能瓶颈主要出现在generateAllLegalMoves和AI搜索时。
- 避免重复计算:在
isAttacked或generateAllLegalMoves中,不要每次都重新计算所有棋子的位置。Board类可以维护一个“棋子列表”,按颜色分类,遍历时效率更高。 - 使用局面哈希(Zobrist Hashing):这是一个高级优化,为每个可能的棋盘状态生成一个几乎唯一的哈希值。可以用于快速检测重复局面(判长将、长捉),也是实现高性能AI(置换表)的基础。但对于初级项目,这不是必须的。
- 预计算移动范围:对于“车”、“炮”这类直线行走的棋子,可以预计算其在空棋盘上能到达的所有位置,然后根据实际棋盘上的阻挡物进行裁剪。但这会增加代码复杂度,在9x10的棋盘上,直接遍历检查的代价是可以接受的。
最后,一个让程序更专业的小技巧是加入行棋动画。在SFML中,你可以记录棋子的起始屏幕坐标和目标屏幕坐标,在主循环的每一帧中,让棋子的精灵位置向目标位置移动一小段距离,直到到达。这能极大提升用户体验。动画期间,应锁定游戏状态,不接受新的输入。
实现一个完整的中国象棋项目,就像完成一次精致的雕刻。从粗糙的数据模型开始,逐步打磨出精确的规则引擎,最后为其披上交互的外衣。这个过程会反复考验你对C++语言特性、面向对象设计以及算法逻辑的掌握。当你最终看到两个AI在你写的程序里对弈,或者和朋友通过你的程序隔空手谈时,那种成就感是无与伦比的。希望这份超详细的指南,能成为你动手实践时案头最有用的参考。
