尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++面向对象实战:从零构建中国象棋游戏引擎与图形界面

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::arraystd::arraystd::unique_ptrPiece, 9, 10或者std::vectorstd::vectorstd::unique_ptrPiece。使用std::unique_ptr可以自动管理棋子对象的内存避免内存泄漏。空位置用nullptr表示。坐标系统强烈建议在内部统一使用(x, y)坐标其中x代表列0-8y代表行0-9。原点(0,0)可以设定为棋盘左下角黑方底线左角或左上角红方底线左角但必须在整个系统中保持一致。界面显示时再将此内部坐标转换为屏幕坐标或用户熟悉的“九路”坐标如“炮二平五”。走法表示如何记录一步棋可以定义一个简单的Move结构体struct Move { int fromX, fromY; // 起点 int toX, toY; // 终点 Piece* capturedPiece nullptr; // 被吃掉的棋子如果有 // 还可以加入棋步说明字符串如“炮二平五” };Game类可以维护一个std::vectorMove作为棋谱用于实现悔棋、复盘功能。注意事项在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. 检查是否为直线运动 (dx0 || dy0) // 2. 检查路径上起点和终点之间不含终点是否有任何棋子阻挡 // 3. 如果无阻挡则移动合法 }3.2 复杂规则实现详解马、炮、象马Horse“马走日”和“蹩马腿”是重点。首先计算位移dx abs(toX - x),dy abs(toY - y)。合法的“日”字步是(dx1 dy2) || (dx2 dy1)。然后检查“马腿”对于(dx2, dy1)的情况马腿在(x (toX-x)/2, y)对于(dx1, dy2)的情况马腿在(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::vectorMove Game::generateAllLegalMoves(Player player) { std::vectorMove moves; for (每个属于 player 的棋子) { for (棋盘上的每个目标位置) { Move move {棋子位置, 目标位置}; if (validateMove(move)) { // 这里包含了“送将”检查 moves.push_back(move); } } } return moves; }这个函数会比较耗时但它是实现智能AI如极小化极大算法和自动判定的关键。4.3 功能扩展悔棋、存盘与复盘悔棋只需要在Game类中维护一个std::stackMove作为历史走法栈。执行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可以让你的项目在WindowsVisual Studio、LinuxGCC和macOSClang上轻松编译。这对于使用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和编译器设置中统一使用C11或更高标准本项目建议C17方便使用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在你写的程序里对弈或者和朋友通过你的程序隔空手谈时那种成就感是无与伦比的。希望这份超详细的指南能成为你动手实践时案头最有用的参考。
返回列表