C++面向对象编程实践:从零实现中国象棋游戏项目

发布时间:2026/7/22 7:15:38
C++面向对象编程实践:从零实现中国象棋游戏项目 1. 项目概述与核心价值最近在整理自己的代码仓库翻到了一个大学时期写的中国象棋项目当时为了完成课程设计和锻炼C面向对象编程能力前后折腾了小一个月。现在回头看这个项目虽然代码风格略显稚嫩但麻雀虽小五脏俱全从棋盘绘制、棋子移动规则、胜负判定到简单的人机对战逻辑都完整地实现了一遍。对于正在学习C特别是想从控制台小游戏入手来理解类设计、算法和数据结构的同学来说复现一个中国象棋项目是一个非常棒的练手机会。它不像俄罗斯方块或贪吃蛇那么简单也不至于像3D游戏引擎那样复杂到让人望而却步其规则明确、逻辑清晰正好卡在一个能让你充分实践OOP思想又不会轻易被劝退的难度上。这个项目能帮你解决什么问题呢首先它能将你学到的C语法知识类、继承、多态、STL容器等串联起来在一个具体的、有趣的场景中应用而不是停留在书本的例题上。其次象棋的走棋、吃子、将军、困毙等规则天然就是一系列条件判断和状态检查的算法实践对锻炼你的逻辑思维和代码严谨性大有裨益。最后完成一个功能完整的项目所带来的成就感是刷十道LeetCode题目也难以比拟的它能极大地提升你继续深入学习编程的兴趣和信心。无论你是刚学完C基础语法的在校学生还是想找个项目重温面向对象设计的老手这个教程都值得你跟着走一遍。2. 项目整体架构与设计思路2.1 核心类的设计与职责划分一个清晰、高内聚低耦合的类设计是整个项目的基石。在动手写代码之前我们必须先想清楚需要哪些“演员”以及他们各自要“表演”什么。对于中国象棋程序我采用了经典的“模型-视图-控制”思想进行解构但为了简化将视图和控制逻辑做了适度合并。核心类主要有以下几个Piece棋子基类这是一个抽象基类代表了棋盘上所有棋子的共性。它应该包含哪些属性棋子的颜色红方或黑方、棋子的类型车、马、炮等、棋子在棋盘上的位置行和列。更重要的是它需要声明一个纯虚函数比如bool isValidMove(int toRow, int toCol, const Board board) const用于判断从当前位置移动到目标位置是否合法。这个函数是后续所有玩法逻辑的核心。具体的棋子类例如Rook车、Knight马、Cannon炮、Pawn兵等。它们都公开继承自Piece类并必须实现isValidMove这个函数。每个子类的isValidMove函数内部封装的就是该棋子特有的行走规则。比如“马走日”要判断是否蹩马腿“象飞田”要判断是否塞象眼。通过继承和多态我们在处理移动时只需要调用棋子对象的这个函数而不必关心它具体是什么棋子代码会非常优雅。Board棋盘类这是整个游戏的状态管理器。它最核心的数据成员是一个二维数组或者用vectorvectorunique_ptrPiece更好用来存储每个格子上棋子的指针空指针表示无子。Board类负责棋盘的初始化摆盘、根据坐标获取棋子、移动棋子、判断是否将军、判断是否困毙无棋可走等全局性的游戏逻辑。它不关心具体怎么画出来也不直接处理用户输入只维护一个“纯粹”的游戏状态。Game游戏主控类这个类负责串联一切。它包含一个Board对象控制当前轮到哪一方走棋处理用户的输入比如通过命令行输入坐标“车二平五”调用Board的方法来验证并执行移动并在每步之后检查游戏是否结束将死或困毙。同时它也可以负责调用简单的控制台绘图函数来显示棋盘。Game类就像是导演指挥着Board和Piece们完成一整局游戏。设计心得在初期很多人可能会把绘图、规则判断、输入处理全部塞进main函数或者一个巨大的类里这会导致代码难以维护和扩展。采用上述分层设计后如果你想将来把控制台界面换成图形界面比如用Qt那么你几乎不需要修改Piece和Board类只需要重写Game类中与界面交互的部分即可这就是良好设计带来的好处。2.2 数据结构与算法选型棋盘表示最直观的就是一个8行9列的二维数组。数组的每个元素是一个指向Piece对象的智能指针如std::unique_ptrPiece。使用智能指针可以自动管理内存避免内存泄漏。当棋子被吃掉时直接将对应位置的指针重置reset()即可原棋子对象会被自动销毁。走法生成与验证这是算法的核心。验证走法是否合法分几个层次基础规则目标位置是否在棋盘内是否走到自己的棋子上这些检查可以在Piece::isValidMove的基类实现或Game类中先做。棋子特有规则这是各个棋子子类isValidMove函数的内容。例如“车”的走法需要检查起点到终点这条直线上是否有其他棋子阻挡除了吃子点。将军规则移动后不能导致自己的将/帅被对方“将军”。这需要在Board类中实现一个isUnderCheck(Color color)函数遍历棋盘检查对方所有棋子是否存在能攻击到我方将/帅位置的合法走法。在每次尝试移动前需要模拟移动后的状态然后调用此函数检查是否会“送将”如果是则移动非法。胜负判定除了“将死”无法解除将军还有“困毙”无子可走且未被将军。这需要为当前行棋方生成所有可能的合法走法如果列表为空则判负。人机对战初步实现一个简单的AI并不难。一个最基础的思路是“随机走法AI”为电脑方生成所有合法走法然后随机选择一个。虽然很弱但能让游戏跑起来。更进一步可以引入“极大极小搜索”算法即使只搜索2-3层也能做出像样的防守和简单的进攻。这涉及到棋局评估函数的设计比如根据棋子价值车9分马4.5分等和位置优劣来计算当前局面的分数这是一个可以深入优化的方向。3. 核心模块实现详解3.1 棋子基类与具体棋子类的实现让我们先看看棋子基类Piece的头文件大概长什么样// Piece.h #pragma once #include memory #include “Board.h” // 前置声明可能更好这里为了简化 enum class Color { RED, BLACK }; enum class PieceType { GENERAL, ADVISOR, ELEPHANT, HORSE, ROOK, CANNON, PAWN }; class Board; // 前置声明用于成员函数参数 class Piece { public: Piece(Color color, int row, int col, PieceType type); virtual ~Piece() default; Color getColor() const { return color_; } int getRow() const { return row_; } int getCol() const { return col_; } PieceType getType() const { return type_; } void setPosition(int row, int col) { row_ row; col_ col; } // 核心判断移动是否合法的纯虚函数 virtual bool isValidMove(int toRow, int toCol, const Board board) const 0; // 可选获取棋子显示字符用于控制台打印 virtual char getSymbol() const; protected: Color color_; int row_; int col_; PieceType type_; };接下来以最复杂的“马”和“炮”为例看看子类如何实现规则。Knight马类的isValidMove实现逻辑 “马走日”有8个可能的位置。首先计算行差dRow和列差dCol。合法的“日”字移动满足(abs(dRow)2 abs(dCol)1) || (abs(dRow)1 abs(dCol)2)。但这还不够必须检查“蹩马腿”如果abs(dRow)2竖向走日则马腿位置在(row_ dRow/2, col_)如果abs(dCol)2横向走日则马腿位置在(row_, col_ dCol/2)。检查该位置在棋盘上是否为空如果不为空则移动非法。Cannon炮类的isValidMove实现逻辑 炮的移动规则是直线行走吃子时必须隔一个棋子炮架移动时不吃子路径上必须全空。首先判断起点和终点是否在同一直线行相同或列相同。计算从起点到终点路径上不包括终点的棋子数量blockCount。遍历路径上的每个格子检查是否有棋子。判断目标位置(toRow, toCol)是否有棋子targetPiece。如果targetPiece为空移动则要求blockCount 0。如果targetPiece非空且为对方棋子吃子则要求blockCount 1正好有一个炮架。如果targetPiece非空且为己方棋子移动非法。避坑指南实现“车”的规则时很多人会忘记检查起点和终点是同一个点的情况即不走棋这在逻辑上应该返回false。另外所有棋子的规则函数都要注意数组边界检查防止行、列索引越界导致程序崩溃。在写每个棋子的规则时建议先在纸上画出棋盘坐标列举几个典型合法和非法走法进行测试再开始编码。3.2 棋盘类(Board)的状态管理与规则引擎Board类是游戏状态的权威持有者。它的一个关键方法是movePiece(int fromRow, int fromCol, int toRow, int toCol)。// Board.cpp 节选 bool Board::movePiece(int fromRow, int fromCol, int toRow, int toCol) { Piece* piece getPiece(fromRow, fromCol); if (!piece) return false; // 起点无子 // 1. 调用该棋子自身的规则判断 if (!piece-isValidMove(toRow, toCol, *this)) { return false; } // 2. 模拟移动检查是否会导致己方被将军 // 这里需要“模拟”是因为移动可能吃子改变棋盘状态 // 一个技巧是使用“拷贝-交换”或者直接操作指针来模拟 std::unique_ptrPiece targetPiece std::move(board_[toRow][toCol]); // 拿走目标位置的子可能是空 board_[toRow][toCol] std::move(board_[fromRow][fromCol]); // 移动棋子 board_[fromRow][fromCol].reset(); // 清空起点 bool underCheck isUnderCheck(piece-getColor()); // 检查移动后己方是否被将 // 撤销模拟移动 board_[fromRow][fromCol] std::move(board_[toRow][toCol]); board_[toRow][toCol] std::move(targetPiece); if (underCheck) { return false; // 移动导致被将军非法 } // 3. 执行真正的移动与模拟步骤类似但不用撤销 // ... 此处执行真正的移动和吃子逻辑 // 4. 移动后检查是否将死了对方 Color opponentColor (piece-getColor() Color::RED) ? Color::BLACK : Color::RED; if (isCheckmated(opponentColor)) { gameState_ (piece-getColor() Color::RED) ? GameState::RED_WIN : GameState::BLACK_WIN; } else if (isStalemated(opponentColor)) { gameState_ GameState::DRAW; // 困毙 } return true; }isUnderCheck函数的实现就是遍历棋盘上所有对方颜色的棋子对每一个棋子调用其isValidMove函数看它是否能走到我方将/帅的位置。这里有一个性能优化点每次判断将军都全盘遍历效率较低。一个常见的优化是只检查对方那些有“远程”攻击能力的棋子车、炮、马、兵过河后相对于我方将帅的位置关系或者缓存将军状态。3.3 游戏主循环与用户交互Game类的主循环是典型的“输入-更新-渲染”循环。// Game.cpp 节选 void Game::run() { initBoard(); // 初始化棋盘 currentPlayer_ Color::RED; while (gameState_ GameState::PLAYING) { renderBoard(); // 绘制棋盘 std::cout (currentPlayer_ Color::RED ? “红方” : “黑方”) “走棋请输入着法如 车二平五”; std::string moveStr; std::cin moveStr; // 解析着法字符串转换为坐标 (fromRow, fromCol, toRow, toCol) Move move parseMove(moveStr, currentPlayer_); if (!move.valid) { std::cout “着法不合法或格式错误请重新输入。” std::endl; continue; } // 尝试移动 if (board_.movePiece(move.fromRow, move.fromCol, move.toRow, move.toCol)) { // 移动成功切换玩家 currentPlayer_ (currentPlayer_ Color::RED) ? Color::BLACK : Color::RED; } else { std::cout “走棋不符合规则请重新输入。” std::endl; } } // 游戏结束显示结果 renderBoard(); std::cout “游戏结束结果”; switch (gameState_) { case GameState::RED_WIN: std::cout “红方胜”; break; case GameState::BLACK_WIN: std::cout “黑方胜”; break; case GameState::DRAW: std::cout “和棋”; break; } std::cout std::endl; }着法解析是一个有趣的小挑战。我们需要将中文着法“车二平五”解析为内部的坐标。这需要建立棋盘坐标与中文描述的映射关系。例如“二”可能代表红方从己方底线数的第二行内部坐标第几行“平”代表横着走。这部分代码稍微繁琐但逻辑清晰是练习字符串处理和映射表的好机会。交互设计心得在控制台下为了有更好的体验可以考虑使用gotoxy之类的函数Windows下是SetConsoleCursorPosition来实现光标定位避免每次重绘整个屏幕都导致控制台滚动。也可以使用像ncursesLinux或PDCurses跨平台这样的库来制作更丰富的文本界面。对于初学者先从简单的每步清屏重绘开始功能完备后再考虑界面美化。4. 开发环境搭建与工程管理4.1 工具链选择与配置对于C项目一个顺手的开发环境至关重要。目前主流的选择有两个Visual Studio和VSCode CMake。Visual Studio (Windows)这是最“一站式”的方案特别是对于Windows用户。我推荐使用Visual Studio 2022安装时记得勾选“使用C的桌面开发”工作负载。新建项目选择“控制台应用”VS会自动帮你配置好编译器和项目文件。它的调试器非常强大对于排查指针错误、容器越界等问题几乎是神器。对于这个象棋项目用VS可以让你专注于C逻辑本身无需在环境配置上花费太多时间。VSCode CMake (跨平台)如果你在Linux/macOS下开发或者希望项目结构更清晰、跨平台这是更好的选择。你需要先安装编译器Windows下装MinGW-w64或MSVCLinux下用gmacOS用Clang通常已安装。CMake用于生成跨平台的构建文件如Makefile。VSCode插件C/C扩展Microsoft出品和CMake Tools扩展。在项目根目录创建一个CMakeLists.txt文件内容大致如下cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 17) # 使用C17标准 # 将所有的 .cpp 文件添加到可执行文件 add_executable(ChineseChess src/main.cpp src/Game.cpp src/Board.cpp src/Piece.cpp src/Rook.cpp src/Knight.cpp # ... 其他源文件 ) # 如果是Windows可能需要链接一些特定的库比如用于控制台光标操作的 if(WIN32) target_link_libraries(ChineseChess PUBLIC -static -lpthread) endif()然后在VSCode中CMake Tools插件会自动检测到这个文件你可以方便地配置Configure、编译Build和调试Debug。环境配置常见坑在Windows上使用VSCodeMinGW时最常见的错误是“找不到头文件”或“链接错误”。请务必检查你的c_cpp_properties.json文件由C/C插件生成中的compilerPath是否正确指向了你的g.exe。系统环境变量PATH中是否包含了MinGW的bin目录。如果遇到“microsoft visual c 14.0 or greater is required”这类错误通常是因为某些库如某些Python包需要VC运行时与你用g编译C项目无关可以忽略或者单独安装VC可再发行组件包。4.2 项目目录结构与代码组织一个清晰的项目结构能让你的代码更易读、易维护。建议采用如下结构ChineseChess/ ├── CMakeLists.txt # CMake构建脚本 ├── build/ # 构建输出目录建议空目录在此执行cmake ├── include/ # 所有头文件(.h) │ ├── Piece.h │ ├── Board.h │ ├── Game.h │ └── Constants.h # 存放颜色、棋子类型等枚举和常量 ├── src/ # 所有源文件(.cpp) │ ├── main.cpp │ ├── Piece.cpp │ ├── Board.cpp │ ├── Game.cpp │ ├── Rook.cpp │ ├── Knight.cpp │ └── ... └── README.md # 项目说明文档关键点头文件守卫每个头文件都必须使用#pragma once或传统的#ifndef ... #define ... #endif来防止重复包含。前向声明在头文件中如果某个类只被用作指针或引用尽量使用前向声明class Board;而不是直接#include “Board.h”。这可以减少编译依赖加快编译速度。常量集中管理将棋子价值、棋盘尺寸、颜色枚举等全局常量放在一个单独的Constants.h中方便统一修改。5. 进阶功能实现与优化5.1 简单人机对战AI的实现一个“随机走法AI”虽然简单但让我们先跑通人机对战的流程。我们可以在Game类中增加一个模式选择当轮到电脑时调用AI函数。// 在Game类中添加 class Game { // ... 其他成员 bool vsComputer_ false; Color computerColor_ Color::BLACK; // 电脑执黑 Move generateComputerMove(); }; Move Game::generateComputerMove() { std::vectorMove allMoves; // 遍历棋盘所有格子找到所有电脑方棋子的所有合法走法 for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { const auto piece board_.getPiece(r, c); if (piece piece-getColor() computerColor_) { // 为这个棋子生成所有可能的终点 for (int tr 0; tr BOARD_ROWS; tr) { for (int tc 0; tc BOARD_COLS; tc) { if (piece-isValidMove(tr, tc, board_)) { // 模拟移动检查是否送将 if (board_.testMove(r, c, tr, tc, computerColor_)) { allMoves.push_back({r, c, tr, tc}); } } } } } } } if (allMoves.empty()) { return Move{}; // 无棋可走认输 } // 随机选择一个走法 std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dis(0, allMoves.size() - 1); return allMoves[dis(gen)]; }在主循环中如果当前行棋方是电脑就调用generateComputerMove获取走法然后执行。迈向智能极大极小算法与评估函数要让AI变强需要引入搜索和评估。评估函数evaluate(const Board board)给当前局面打分红方优势为正分黑方优势为负分。分数可以基于棋子价值车900马400炮450相/士200兵/卒未过河50过河100将/帅无穷大。棋子位置马在河界附近、车占肋道、兵过河等位置可以加分。极大极小算法伪代码思路int minimax(Board board, int depth, int alpha, int beta, bool maximizingPlayer) { if (depth 0 || 游戏结束) { return evaluate(board); } if (maximizingPlayer) { // 红方最大化方走棋 int maxEval -INFINITY; for (Move move : 生成所有红方合法走法) { board.makeMove(move); // 执行走法 int eval minimax(board, depth - 1, alpha, beta, false); board.undoMove(move); // 撤销走法必须实现悔棋功能 maxEval std::max(maxEval, eval); alpha std::max(alpha, eval); if (beta alpha) break; // Alpha-Beta剪枝 } return maxEval; } else { // 黑方最小化方走棋 // 对称逻辑寻找最小评估值 } }AI在走棋时调用minimax搜索一定深度如3层然后选择评估值最高的走法。实现这个算法需要为Board类添加makeMove和undoMove函数并维护一个走法历史栈用于回溯。这是项目一个非常好的深化方向。5.2 功能扩展与代码优化建议悔棋功能在Board类中维护一个std::stackMoveRecord每次成功移动后将移动前后的状态起点、终点、被吃掉的棋子压栈。悔棋时弹出栈顶记录并恢复状态。这是学习使用STL容器的好例子。局面保存与加载棋谱功能将棋盘状态每个格子的棋子类型和颜色序列化到一个文件或字符串中。可以定义简单的文本格式比如用字符‘R’代表红车‘b’代表黑卒空格代表空位每行代表棋盘一行。实现saveToFile和loadFromFile函数。性能优化走法生成优化不要像随机AI那样每次都全盘双循环生成走法。可以为每个棋子类型预计算其可能的移动方向偏移量或者使用“位棋盘”这种更高效的专业表示方法进阶内容。将军检测优化缓存将/帅的位置检查将军时只遍历对方能攻击到该位置的棋子类型而不是所有棋子。使用Profiler使用性能分析工具如Visual Studio Profiler, Valgrind的callgrind找到代码热点针对性优化。引入设计模式观察者模式可以用于解耦游戏状态改变和界面更新。当棋盘状态变化时通知所有“观察者”如界面渲染器、AI思考线程。工厂模式可以用于根据字符创建对应的棋子对象这在加载棋谱时很有用。6. 调试技巧与常见问题排查开发过程中你一定会遇到各种Bug。以下是一些典型问题及其排查思路程序崩溃Segmentation Fault最常见原因空指针解引用或数组越界。排查在访问指针前加断言assert(piece ! nullptr)。使用调试器如GDB或VS Debugger在崩溃时查看调用栈定位到出错的代码行。检查所有数组索引如board_[row][col]是否在[0, SIZE)范围内。我的踩坑记录在实现“马”的规则时计算“马腿”坐标(row_ dRow/2, col_)时因为dRow是int-2/2在C中等于-1没问题但要确保计算出的马腿坐标没有越界我忘了检查导致当马在棋盘边缘时程序访问了board_[-1][x]而崩溃。走棋规则判断错误现象棋子能走到不该走的位置或不能走到该走的位置。排查为每个棋子的isValidMove函数编写单元测试。创建特定的棋盘局面测试边界情况。例如测试“马”在棋盘四个角、蹩马腿、不蹩马腿的情况。测试“炮”吃子时中间有一个炮架、有多个炮架、无炮架的情况。使用条件断点或打印日志查看函数内部的判断逻辑分支。心得象棋规则复杂不要试图一次性写对所有情况。采用“测试驱动开发”的思路写一个测试用例实现一部分代码确保通过再写下一个。这比全部写完再调试要高效得多。内存泄漏现象程序长时间运行后占用内存越来越大。排查确保使用std::unique_ptr管理所有动态创建的棋子对象。如果必须使用原始指针要成对地new和delete。在Linux下可以使用valgrind --leak-checkfull ./your_program来检测内存泄漏。建议在这个项目中强烈建议全程使用智能指针。Board的棋盘数组使用std::vectorstd::vectorstd::unique_ptrPiece当棋子被吃掉或替换时unique_ptr的赋值操作会自动释放旧内存。输入解析失败现象输入“车二平五”程序无法识别。排查首先检查你的解析函数parseMove的日志。打印出它解析出的中间结果起点棋子、目标位置等。确保你的棋盘坐标与中文描述的映射关系是正确的。注意红黑双方的方向是相反的“车二平五”对红方和黑方对应的内部坐标行号计算方式可能不同。AI思考时间过长或无响应现象实现了极大极小算法后电脑走一步棋要等很久。排查首先检查搜索深度是否设置过大比如超过5层分支因子很大。其次检查评估函数是否过于复杂。最重要的是确保Alpha-Beta剪枝正确实现剪枝能极大减少搜索节点数。可以在搜索函数开头增加一个计数器每进入一次递归就加一走一步棋后打印总数看看剪枝效果。没有剪枝的搜索树节点数是呈指数增长的。这个项目从零到一实现下来你会对C的面向对象、内存管理、标准库使用有更深刻的理解也会对算法设计规则验证、搜索算法有实际的体会。更重要的是你拥有了一个可以持续打磨、扩展的作品。你可以为它添加图形界面比如用SFML或Qt可以强化AI甚至可以做成网络对战版。编程的乐趣就在于这样一步步把想法变成现实的过程。