多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++实现中国象棋:面向对象设计、规则算法与工程实践详解

C++实现中国象棋:面向对象设计、规则算法与工程实践详解 1. 项目概述从棋盘到代码的工程化思考最近在整理硬盘里的老项目翻到了一个几年前用C写的中国象棋游戏。当时写它纯粹是出于兴趣想挑战一下自己看看能不能把棋盘上那些复杂的规则用代码清晰、优雅地表达出来。现在回头看这个项目虽然不大但麻雀虽小五脏俱全它几乎涵盖了C面向对象设计、数据结构应用、算法逻辑和交互实现等多个核心知识点。对于想从“课本习题”过渡到“完整项目”的C学习者或者对游戏逻辑实现感兴趣的朋友这个象棋项目是一个绝佳的练手和分析对象。简单来说这个项目就是一个在控制台命令行环境下运行的中国象棋游戏。两个玩家轮流输入棋子的起点和终点坐标程序负责验证走法是否合法更新棋盘状态并判断胜负。听起来简单但背后涉及到“马走日”的蹩脚马腿判断、“炮”的隔山打牛规则、将帅不能照面等一系列复杂的中国象棋特有规则。如何用代码精准地描述这些规则并保证程序高效、结构清晰正是这个项目的核心价值所在。2. 核心架构与面向对象设计2.1 类的职责划分高内聚低耦合拿到一个项目首要任务是进行合理的类设计。在这个象棋项目中我遵循了“一个类一个责任”的原则主要设计了以下几个核心类Piece棋子基类这是所有棋子的抽象。它定义了棋子的通用属性和行为接口。属性颜色红方或黑方、类型将、士、象等、在棋盘上的位置行、列。核心方法virtual bool isValidMove(int toRow, int toCol, const Board board) const 0;。这是一个纯虚函数意味着每个具体的棋子子类都必须实现自己的走法验证逻辑。通过传入目标位置和当前的棋盘状态来判断这一步棋是否合乎规则。具体的棋子子类例如King将/帅、Advisor士、Elephant象、Horse马、Chariot车、Cannon炮、Pawn兵/卒。每个类都继承自Piece并重写isValidMove函数实现各自独特的移动规则。这是面向对象中“多态”的典型应用。Board棋盘类这是游戏的状态管理中心。核心数据结构一个二维数组或向量例如std::vectorstd::vectorPiece* boardGrid;用于存储每个格子上的棋子指针为空则表示无子。职责初始化在游戏开始时按照传统布局创建并摆放所有棋子对象。状态查询提供接口查询某个位置是否有子、是什么子。走棋执行接收一个“从(fromRow, fromCol)到(toRow, toCol)”的指令首先找到对应棋子调用其isValidMove验证如果合法则更新boardGrid数组移动或吃子。胜负判断每次走棋后检查对方“将”或“帅”是否被吃掉或者是否陷入“困毙”无子可走。Game游戏控制类这是程序的调度中心控制游戏流程。职责主循环维持一个while(!gameOver)循环。玩家交互提示当前玩家输入走棋命令如“h2e2”代表从h2走到e2并解析为坐标。调用棋盘将解析后的坐标传递给Board对象的走棋方法。显示棋盘调用Board的显示方法在控制台打印出当前棋局。回合切换在红黑方之间交替。设计心得这种设计将数据Board、规则Piece及其子类、控制流Game清晰地分离开。Board不关心具体规则只负责存储和状态更新Piece子类不关心棋盘全局只专注于自己的移动逻辑Game则像导演一样协调各方。这使得代码易于阅读、调试和扩展。例如如果你想增加“悔棋”功能只需要在Game或Board中维护一个历史状态栈而无需改动棋子规则。2.2 关键数据结构选型为什么用vector和map在实现中除了基础的二维数组表示棋盘STL容器的合理使用能极大提升代码质量。棋盘存储使用std::vectorstd::vectorPiece*而非原生二维数组。vector是动态数组管理内存更方便并且提供了size()、边界检查如果使用at()方法等安全特性。使用指针或更现代的智能指针如std::unique_ptr是为了实现多态。棋盘格子存储的是基类指针但实际指向的是Horse、Cannon等子类对象。棋子集合管理除了棋盘数组我额外使用了std::map或两个std::vector来分别管理红方和黑方所有“存活”的棋子。这有什么好处快速胜负判断检查对方“将”是否在集合中即可知是否被吃。高效遍历在判断“困毙”时需要遍历一方所有棋子看是否有任何一步合法走法。直接遍历这个棋子集合比扫描整个64格棋盘更高效。序列化/存档如果需要保存游戏将这个容器连同棋子信息写入文件会非常方便。// 示例使用map管理棋子键可以是棋子ID或位置 std::mapstd::string, std::unique_ptrPiece redPieces; std::mapstd::string, std::unique_ptrPiece blackPieces; // 初始化时 redPieces[rj1] std::make_uniqueKing(Color::RED, 9, 4); // 红将 // ...3. 核心规则算法的代码级拆解这是项目的精髓所在也是面试中常被深挖的部分。我们挑几个有代表性的棋子规则看看如何实现。3.1 “马走日”与“蹩马腿”马的走法规则是先沿直线走一格再沿斜线走一格形成“日”字。但如果在前进方向紧邻的位置有任意棋子无论颜色则不能走俗称“蹛马腿”或“蹩马腿”。实现思路计算目标位置与当前位置的行差dRow和列差dCol。合法的“日”字移动(dRow, dCol)的绝对值只能是(2, 1)或(1, 2)这两种组合。判断蹩马腿如果abs(dRow) 2纵向走日则检查“马”的纵向正中间一格row dRow/2, col是否有子。如果abs(dCol) 2横向走日则检查“马”的横向正中间一格row, col dCol/2是否有子。如果蹩马腿位置无子且目标位置无己方棋子则走法合法。bool Horse::isValidMove(int toRow, int toCol, const Board board) const { int dRow toRow - row; int dCol toCol - col; int absRow std::abs(dRow); int absCol std::abs(dCol); // 1. 判断是否为“日”字 if (!((absRow 2 absCol 1) || (absRow 1 absCol 2))) { return false; } // 2. 判断蹩马腿 int blockRow row; int blockCol col; if (absRow 2) { blockRow dRow / 2; // 纵向中间点 } else { blockCol dCol / 2; // 横向中间点 } if (board.getPieceAt(blockRow, blockCol) ! nullptr) { return false; // 马腿被蹩 } // 3. 判断目标位置 Piece* target board.getPieceAt(toRow, toCol); return target nullptr || target-getColor() ! this-color; }避坑指南这里最容易出错的就是蹩马腿坐标的计算。一定要分清是“前进方向”的紧邻点而不是起点和终点连线上的任意点。dRow/2利用了整数除法向零取整的特性巧妙地得到了中间点坐标。3.2 “炮”的隔子打牛炮的规则移动时如直线行走路径上不能有任何棋子吃子时必须且只能跨越一个“炮架”任意棋子然后吃掉炮架后的第一个敌方棋子。实现思路判断起点和目标是否在同一直线行相同或列相同。遍历起点到目标点之间的所有格子不包含起点和目标。统计路径上遇到的棋子数量pieceCount。如果目标位置无子移动则要求pieceCount 0。如果目标位置有子吃子则要求pieceCount 1并且该子是敌方棋子。bool Cannon::isValidMove(int toRow, int toCol, const Board board) const { if (row ! toRow col ! toCol) return false; // 非直线 int rowStep (toRow row) ? 0 : ((toRow row) ? 1 : -1); int colStep (toCol col) ? 0 : ((toCol col) ? 1 : -1); int currentRow row rowStep; int currentCol col colStep; int pieceCount 0; // 遍历路径 while (currentRow ! toRow || currentCol ! toCol) { if (board.getPieceAt(currentRow, currentCol) ! nullptr) { pieceCount; } currentRow rowStep; currentCol colStep; } Piece* target board.getPieceAt(toRow, toCol); if (target nullptr) { // 移动路径上必须无子 return pieceCount 0; } else { // 吃子路径上必须恰好有一子且目标为敌方 return pieceCount 1 target-getColor() ! this-color; } }3.3 “将帅不能照面”与“困毙”判定这是中国象棋特有的规则也是胜负判定的难点。将帅照面如果双方的将/帅位于同一条直线上且中间没有任何棋子遮挡则判犯规通常不允许主动送将造成照面但规则实现上需检查。实现思路在Board的isValidMove整体验证中或在走棋后、切换玩家前需要做一次特殊检查。找到红帅和黑将的位置。如果它们在同一列则检查它们之间的所有格子。如果所有格子都为空则说明“照面”当前走棋方犯规走法无效。困毙判定判断当前行棋方是否没有任何一个棋子可以走出一步合法的棋。这是判断“困毙”输棋的关键。实现思路这是一个计算量稍大的操作通常在一方走棋后检查对方是否被“将死”或“困毙”。遍历对方所有存活棋子。对每个棋子遍历棋盘上所有可能的目标位置64格。调用该棋子的isValidMove方法模拟走到那个位置。在模拟走棋后必须检查是否会造成己方老将被将军这是一个递归或循环检查。如果不会则说明有一步合法走法未被困毙。如果所有棋子、所有目标位置都找不到一步合法的、不导致被将军的走法则判定为“困毙”游戏结束。性能考量困毙判定是“暴力”搜索最坏情况需要检查16个棋子 * 64个目标点 * 每次走法的规则验证复杂度较高。在真实游戏中通常只在检测到“将军”状态后才需要进行深入的困毙判定。这是一个典型的以计算复杂度换取逻辑准确性的权衡。4. 控制台交互与游戏循环实现4.1 输入解析与坐标转换为了让玩家方便输入通常采用两种坐标格式代数记谱法如“炮二平五”。解析这种格式需要维护一个从中文描述到行列索引的映射表对初学者项目来说稍复杂。简化坐标法如“h2e2”。这是我采用的方式用字母a-i表示第0-8列数字0-9表示第0-9行。输入“h2e2”表示从(2,7)移动到(2,4)。注意棋盘左上角为(0,0)红方在下黑方在上是常见编程设定。bool Game::parseMove(const std::string input, int fromRow, int fromCol, int toRow, int toCol) { if (input.length() ! 4) return false; // 例如h-7, 2-2 fromCol input[0] - a; fromRow input[1] - 0; toCol input[2] - a; toRow input[3] - 0; // 检查坐标是否在棋盘范围内 (0-9, 0-8) return isValidCoordinate(fromRow, fromCol) isValidCoordinate(toRow, toCol); }4.2 棋盘可视化与状态显示在控制台打印一个美观的棋盘是提升体验的关键。可以使用ASCII字符组合┌ ─ ┬ ┐ │ ├ ┼ ┤ └ ┴ ┘等制表符绘制边框。用汉字或简单字符代表棋子如帥、俥、馬、炮、仕、相、兵红方將、車、馬、砲、士、象、卒黑方。也可以使用R车、H马、E象等字母并用颜色区分红黑如果终端支持ANSI颜色码。void Board::display() const { std::cout a b c d e f g h i\n; // 列标 for (int row 0; row 10; row) { std::cout row ; for (int col 0; col 9; col) { Piece* p boardGrid[row][col]; if (p nullptr) { // 画棋盘格子楚河汉界可以用特殊符号 if (row 4 || row 5) { std::cout . ; } else { std::cout ; } } else { std::cout p-getSymbol() ; // 返回代表棋子的字符 } } std::cout row \n; } std::cout a b c d e f g h i\n; }4.3 主游戏循环逻辑游戏主循环是程序的心脏它必须清晰、健壮能处理各种边界情况。void Game::run() { board.initialize(); Color currentPlayer Color::RED; bool gameOver false; while (!gameOver) { board.display(); std::cout (currentPlayer Color::RED ? 红方 : 黑方) 走棋请输入着法 (如 h2e2): ; std::string move; std::cin move; int fr, fc, tr, tc; if (!parseMove(move, fr, fc, tr, tc)) { std::cout 输入格式错误请重新输入。\n; continue; } // 尝试走棋 MoveResult result board.tryMove(fr, fc, tr, tc, currentPlayer); switch (result) { case MoveResult::SUCCESS: // 走棋成功检查是否将军 if (board.isCheck(!currentPlayer)) { std::cout 将军\n; // 进一步检查是否将死 if (board.isCheckmate(!currentPlayer)) { std::cout (currentPlayer Color::RED ? 红方 : 黑方) 获胜\n; gameOver true; } } else if (board.isStalemate(!currentPlayer)) { std::cout 困毙 (currentPlayer Color::RED ? 红方 : 黑方) 获胜\n; gameOver true; } // 切换玩家 currentPlayer (currentPlayer Color::RED) ? Color::BLACK : Color::RED; break; case MoveResult::INVALID_MOVE: std::cout 走法不符合规则。\n; break; case MoveResult::NO_PIECE: std::cout 起始位置没有棋子。\n; break; case MoveResult::WRONG_COLOR: std::cout 不能移动对方的棋子。\n; break; case MoveResult::SELF_CHECK: std::cout 移动后会导致己方被将军不允许。\n; break; } } }5. 项目扩展与优化方向一个基础版本完成后可以考虑以下方向进行深化这能让你的项目简历更加出彩。5.1 引入AI对手极小化极大算法与Alpha-Beta剪枝实现一个简单的AI让玩家可以单机对战。最经典的算法是极小化极大算法配合Alpha-Beta剪枝。评估函数设计一个函数evaluateBoard()给当前棋盘局面打分。例如子力价值车9、马4.5、炮4.5、相/士2、兵1、将无穷大加上位置分数马卧槽、车占肋道等。搜索树AI会在有限的深度内模拟双方未来几步的所有可能走法形成一棵树。极小化极大AI最大化玩家会选择让评估分数最高的走法而假设对手最小化玩家会选择让评估分数最低的走法。Alpha-Beta剪枝在搜索过程中如果发现某条分支不可能比已知的最好选择更好就提前停止搜索该分支极大提升搜索效率。// 伪代码框架 int alphaBeta(Board board, int depth, int alpha, int beta, bool maximizingPlayer) { if (depth 0 || board.isGameOver()) { return board.evaluate(); } if (maximizingPlayer) { int value INT_MIN; for (Move move : generateAllMoves(board, currentPlayer)) { board.makeMove(move); value std::max(value, alphaBeta(board, depth-1, alpha, beta, false)); board.undoMove(move); alpha std::max(alpha, value); if (alpha beta) break; // Beta剪枝 } return value; } else { // 类似取最小值进行Alpha剪枝 } }5.2 图形化界面从控制台到图形窗口控制台交互毕竟简陋。可以使用跨平台的图形库如Qt或SFML来重写前端。Qt功能强大自带丰富的UI组件。你可以用QPainter绘制棋盘和棋子用鼠标点击事件来处理走棋。SFML更轻量专注于多媒体和游戏。绘制精灵棋子图片和处理输入事件非常直观。核心改动将原来Game类中的控制台输入循环改为响应图形界面的事件如鼠标点击。棋盘和棋子的数据模型Board,Piece可以几乎不用改动这就是MVC模型-视图-控制器架构的优势——业务逻辑与显示分离。5.3 网络对战功能使用Socket编程如Berkeley套接字或更高级的库如Boost.Asio实现一个简单的客户端-服务器模型。服务器负责维护棋局状态转发玩家的走棋信息验证走法合法性判断胜负。客户端两个。每个客户端负责显示棋盘接收玩家输入并将走棋命令发送给服务器同时从服务器接收对手的走棋信息并更新本地界面。协议设计需要定义简单的应用层协议例如用字符串“MOVE h2e2”表示走棋“STATUS CHECK”表示将军“RESULT WIN RED”表示红方胜。5.4 代码质量提升测试、重构与设计模式单元测试使用Google Test等框架为Piece的各类走法规则、Board的核心方法编写测试用例。确保每次修改后基本规则都不会被破坏。重构审视代码看看是否有重复逻辑。例如车和炮的直线路径判断有相似之处可以抽象出一个LineMoveValidator辅助类。使用智能指针std::unique_ptr管理棋子生命周期避免内存泄漏。设计模式观察者模式Observer可以用于解耦棋盘状态变化和界面更新。当棋盘状态改变时通知所有注册的“观察者”如界面渲染器、AI思考引擎。工厂模式Factory可以用于棋子的创建。6. 开发环境搭建与调试心得6.1 环境配置要点编译器推荐使用MSVC(Visual Studio)、GCC或Clang。确保支持C11及以上标准以便使用auto、范围for循环、智能指针等现代特性。IDE/编辑器Visual Studio在Windows下是首选调试功能强大。VS Code配合CMake Tools和C/C插件也是跨平台的绝佳选择轻量且可定制性高。构建工具对于小项目直接写Makefile或使用IDE的工程文件即可。如果项目结构复杂或有引入外部库如图形库的计划强烈建议学习使用CMake。它能帮你管理依赖、跨平台编译。# 一个简单的CMakeLists.txt示例 cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 11) add_executable(chess src/main.cpp src/board.cpp src/piece.cpp src/game.cpp # ... 其他源文件 ) target_include_directories(chess PRIVATE include)6.2 调试过程中遇到的典型问题规则漏洞最头疼的不是代码语法错误而是逻辑错误。比如“象”的“田”字走法最初忘了检查“象眼”田字中心是否有子。解决方法为每个棋子类编写详尽的单元测试覆盖所有合法和非法走法特别是边界情况。内存错误使用原始指针时容易在棋子被吃或游戏重置时发生内存泄漏或野指针。解决方法尽早使用std::unique_ptr来管理棋子对象的所有权。棋盘boardGrid存储std::unique_ptrPiece当棋子被吃时直接重置该格子的智能指针即可内存自动释放。坐标混淆棋盘索引0-9行0-8列与玩家输入坐标a-i, 0-9以及显示坐标容易搞混。解决方法定义明确的类型或类来封装坐标如struct Position { int row; int col; }并提供与字符串相互转换的函数。所有内部计算都使用Position类型避免直接使用整型。循环依赖Piece类需要知道Board的状态来判断走法而Board又包含了Piece。如果头文件相互包含会导致编译错误。解决方法使用前向声明。在Piece.h中class Board;在成员函数中使用const Board常引用。在Piece.cpp中再#include “board.h”。6.3 性能分析与优化建议对于这个规模的游戏性能通常不是瓶颈。但如果实现了AI搜索深度加深后性能就至关重要。性能热点使用性能分析工具如Visual Studio Profiler, Valgrind的Callgrind。你会发现绝大部分时间花在generateAllMoves生成所有可能走法和evaluateBoard评估局面上。优化手段走法生成优化不要每次都扫描整个棋盘生成所有走法。可以维护一个“棋子-位置”的列表只遍历存活棋子。对于车、炮等直线棋子可以沿四个方向搜索直到遇到边界或棋子而不是检查所有64个格子。评估函数优化使用增量评估。走一步棋后只计算这步棋带来的分数变化而不是重新计算整个棋盘。可以预计算棋子在不同位置的基础位置分表评估时直接查表相加。置换表在AI搜索中使用哈希表存储已经评估过的棋盘局面及其分数、最佳走法。当再次遇到相同局面时直接查表避免重复搜索。写这个项目的过程更像是在用代码搭建一个精密的逻辑世界。每一个棋子的规则都是一个独立的算法模块棋盘是状态机游戏循环是调度器。它让我对面向对象设计中的“抽象”、“封装”、“多态”有了肌肉记忆般的理解。调试规则漏洞的过程也极大地锻炼了逻辑思维和测试能力。如果你正在学习C并且已经掌握了语法基础我强烈建议你亲手实现一遍。从画出第一个棋盘格子到让“马”正确地跳出去再到最终完成两个AI的对弈这个过程中的成就感是任何教程都无法给予的。
返回列表