1. 项目概述从棋盘到代码的完整映射最近在整理硬盘里的老项目翻出来一个几年前用C写的中国象棋游戏。这个项目麻雀虽小五脏俱全从棋盘绘制、棋子移动规则、胜负判定到简单的人机对战逻辑都实现了。当时写它一方面是出于对象棋这个国粹的兴趣另一方面也是想用C这个“老伙计”练练手把面向对象、数据结构、算法这些基础概念串起来做一个能实际跑起来的、带点复杂逻辑的桌面应用。如果你正在学习C想找一个比“学生管理系统”更有趣、比“贪吃蛇”更具挑战性的练手项目或者你本身就是个象棋爱好者想看看游戏背后的逻辑是如何运转的那这个项目的源码或许能给你不少启发。这个项目完全使用标准C编写不依赖任何大型游戏引擎如Unity、Unreal核心图形界面用的是Windows APIGDI或者跨平台的SDL库取决于版本确保代码足够“干净”能把焦点集中在游戏逻辑本身。整个工程结构清晰一个ChessBoard类管理棋盘状态一个Piece基类衍生出“车马炮”等各种棋子规则引擎负责校验每一步的合法性而AI部分则实现了一个简单的“极大极小值搜索”算法。接下来我就把这个项目的设计思路、核心实现细节、踩过的坑以及如何扩展它掰开揉碎了跟大家聊聊。2. 项目整体架构与核心类设计一个象棋程序最核心的任务就是准确无误地模拟现实中的棋盘和规则。我的设计原则是“高内聚、低耦合”让每个类只负责一件事并且把易变的规则部分抽象出来。2.1 核心数据模型棋盘与棋子的表示棋盘本质上是一个9x10的网格中国象棋的规格。最直观的方法是用一个二维数组来表示。但直接使用int或char数组虽然简单却不利于扩展和封装。我选择定义一个ChessBoard类它内部维护一个std::vectorstd::vectorPiece*的棋盘矩阵10行9列。空位用nullptr表示。棋子的设计采用了继承体系。定义一个抽象的Piece基类包含所有棋子的共性颜色红方或黑方、位置行、列、是否存活、以及一个纯虚函数bool isValidMove(const ChessBoard board, int toRow, int toCol) const用于判断从当前位置移动到目标位置是否合法。然后为每种棋子创建派生类Rook车、Knight马、Cannon炮、Elephant象、Mandarin士、King将/帅、Pawn兵/卒。// Piece.h 片段 enum class PieceColor { RED, BLACK }; class Piece { protected: PieceColor color; int row, col; bool alive; public: Piece(PieceColor c, int r, int cl) : color(c), row(r), col(cl), alive(true) {} virtual ~Piece() default; virtual PieceType getType() const 0; virtual bool isValidMove(const ChessBoard board, int toRow, int toCol) const 0; // 获取棋子文字表示如“车”、“马” virtual std::string getSymbol() const 0; // Getter/Setter int getRow() const { return row; } int getCol() const { return col; } PieceColor getColor() const { return color; } bool isAlive() const { return alive; } void setPosition(int r, int c) { row r; col c; } void setAlive(bool a) { alive a; } };这种设计的优势在于增加新的棋子类型虽然象棋规则固定或修改某类棋子的走法规则时只需要修改或新增对应的派生类棋盘和其他逻辑完全不用动符合“开闭原则”。2.2 游戏状态管理与规则引擎ChessBoard类是游戏状态的中心枢纽。它不仅要存储棋子指针的矩阵还要负责初始化棋盘在构造函数中按照初始布局创建所有棋子对象并放入矩阵的对应位置。移动棋子提供bool movePiece(int fromRow, int fromCol, int toRow, int toCol)方法。这是最核心的函数之一。它的内部逻辑是检查from位置是否有己方棋子。调用该棋子的isValidMove方法传入当前棋盘状态和目标位置进行规则校验。如果目标位置有对方棋子则“吃子”将对方棋子标记为alivefalse并从棋盘矩阵中移除其指针注意内存管理。更新本方棋子的位置。检查移动后是否导致己方“将帅”被将军甚至被将死。胜负判定每次移动后都需要检查是否构成“将死”或“困毙”。这通过GameState checkGameState(PieceColor currentSide)函数实现。它会模拟当前行棋方所有可能的走法如果没有任何一步能解除“将军”状态则判定为输棋。棋盘状态查询提供接口查询某个位置是否有子、是什么子、是否在某个颜色的攻击范围内等。这些查询是规则判断的基础。规则引擎的逻辑分散在各个Piece派生类的isValidMove中以及ChessBoard的全局规则检查如“将帅不能照面”里。这是整个项目算法最密集的部分。注意内存管理需要小心。棋盘矩阵存储的是原始指针。我选择在ChessBoard的析构函数中遍历矩阵删除所有非空的Piece*。也可以在ChessBoard内部使用std::unique_ptr但当时为了更清晰地展示指针操作和继承关系选择了手动管理。在实际项目中更推荐使用智能指针以避免内存泄漏。2.3 人机对战AI的简易实现为了让游戏能单人玩我实现了一个非常基础的AI。它采用“极大极小值搜索”算法并辅以“Alpha-Beta剪枝”来提升效率。局面评估函数这是AI的“眼睛”。我给每种棋子设定了一个基础价值分例如车500马250炮240兵50将无穷大。评估函数evaluateBoard(const ChessBoard board)会计算红黑双方棋子总价值的差值作为当前局面的分数红方视角正分占优。搜索算法AI会在给定的搜索深度内比如3层枚举己方所有合法走法然后模拟对手会如何应对对手会选择对AI最不利的走法即“极小”再思考自己后续的应对。这样递归下去形成一个博弈树。最终AI会选择那条即使对手最优应对后自己仍能获得最好评估分数的走法路径。Alpha-Beta剪枝在搜索过程中如果发现某条分支已经明显差于当前已知的最好选择就直接“剪掉”不再深入搜索极大减少了需要评估的节点数让搜索更深。// 一个极度简化的极大极小搜索框架 int minimax(ChessBoard board, int depth, int alpha, int beta, bool maximizingPlayer) { if (depth 0 || board.isGameOver()) { return evaluateBoard(board); // 到达叶子节点返回评估值 } if (maximizingPlayer) { int maxEval INT_MIN; auto moves generateAllMoves(board, RED); // 生成红方所有走法 for (auto move : moves) { 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 { // 类似为 minimizingPlayer黑方寻找最小评估值 int minEval INT_MAX; // ... 生成黑方走法并递归 return minEval; } }这个AI虽然谈不上强大深度有限评估函数简单但足以提供一个有基本战术思维的对手用于学习和测试绰绰有余。3. 图形界面与用户交互实现游戏逻辑是大脑图形界面则是脸面。我做了两个版本一个基于Windows原生GDI用于快速验证另一个基于SDL2追求跨平台和更流畅的显示。3.1 基于Windows GDI的图形绘制GDI版本的核心是一个窗口过程函数处理WM_PAINT消息。在OnPaint函数里绘制9x10的棋盘网格线。遍历ChessBoard中的棋子根据其位置、颜色和类型在对应的网格交叉点绘制一个圆代表棋子底座和文字如“車”、“馬”。红子用红色文字黑子用黑色或深灰色文字。处理WM_LBUTTONDOWN和WM_LBUTTONUP消息来实现点击选子和走子。需要将鼠标坐标转换为棋盘的行列索引。// 简化的绘制函数思路 void drawBoard(HDC hdc, const ChessBoard board) { // 1. 绘制棋盘背景和网格 Rectangle(hdc, boardLeft, boardTop, boardRight, boardBottom); for(int i0; i9; i) { // 画横线 MoveToEx(hdc, boardLeft, boardTop i * gridSize, NULL); LineTo(hdc, boardRight, boardTop i * gridSize); } // ... 画竖线、楚河汉界等 // 2. 绘制棋子 for (int r 0; r 10; r) { for (int c 0; c 9; c) { Piece* p board.getPieceAt(r, c); if (p p-isAlive()) { int centerX boardLeft c * gridSize gridSize/2; int centerY boardTop r * gridSize gridSize/2; // 绘制圆形棋子底 HBRUSH brush CreateSolidBrush(p-getColor()RED? RGB(255,200,200): RGB(200,200,255)); SelectObject(hdc, brush); Ellipse(hdc, centerX - pieceRadius, centerY - pieceRadius, centerX pieceRadius, centerY pieceRadius); DeleteObject(brush); // 绘制棋子文字 SetTextColor(hdc, p-getColor()RED? RGB(220,0,0): RGB(0,0,0)); TextOut(hdc, centerX-8, centerY-8, p-getSymbol().c_str(), 1); } } } }实操心得GDI编程中设备上下文DC的状态管理很重要。比如画完棋子后如果修改了画笔、字体记得在函数结束前恢复原状或者使用SaveDC和RestoreDC。否则会影响后续的绘制。3.2 基于SDL2的跨平台界面SDL版本在结构上更清晰。主循环遵循“事件处理 - 逻辑更新 - 渲染绘制”的标准游戏循环。初始化SDL_Init创建窗口和渲染器。资源加载可以加载棋子、棋盘背景的图片纹理比GDI纯绘制更美观。我用位图工具预先做好了一套红黑棋子的图片。事件循环SDL_Event event; bool quit false; while (!quit) { while (SDL_PollEvent(event)) { if (event.type SDL_QUIT) quit true; if (event.type SDL_MOUSEBUTTONDOWN) { int mouseX, mouseY; SDL_GetMouseState(mouseX, mouseY); // 转换坐标处理选子逻辑 handleMouseClick(mouseX, mouseY); } } // 游戏逻辑更新如AI思考 updateGame(); // 渲染 renderer.clear(); drawChessBoard(renderer, boardTexture); drawPieces(renderer, board); renderer.present(); SDL_Delay(16); // 约60FPS }渲染在drawPieces函数中根据棋子位置和类型将对应的纹理渲染到窗口上。SDL版本的优势是性能更好动画如棋子移动平滑过渡更容易实现而且代码可以不经修改或少量修改就在Windows、Linux、macOS上编译运行。踩坑记录在实现棋子移动动画时最初我是在逻辑更新后立即改变棋子在棋盘数据结构中的位置然后重绘。这会导致动画不连贯。正确的做法是逻辑上记录移动的“起始位置”和“目标位置”在渲染循环中根据当前时间插值计算棋子的“绘制位置”直到动画完成才真正更新棋盘数据模型。这分离了逻辑状态和显示状态。4. 核心规则算法的深度解析与实现象棋规则的代码实现是项目的重中之重也是最容易出错的地方。下面以“马走日”和“炮打隔山”为例详细拆解。4.1 “马走日”与“蹩马腿”的精确校验马的走法是先直走一格再斜走一格形成“日”字形。关键在于“蹩马腿”如果马要前往的某个方向其前进路径的起始点紧邻位置有任意棋子无论敌我则不能向那个方向走。bool Knight::isValidMove(const ChessBoard board, int toRow, int toCol) const { int rowDiff toRow - row; int colDiff toCol - col; // 马走日行列差的绝对值组合为(2,1)或(1,2) if (!((abs(rowDiff)2 abs(colDiff)1) || (abs(rowDiff)1 abs(colDiff)2))) { return false; } // 判断蹩马腿 int blockRow, blockCol; if (abs(rowDiff) 2) { // 竖向走日字 blockRow row (rowDiff / 2); // 竖向的中间点 blockCol col; } else { // abs(colDiff) 2横向走日字 blockRow row; blockCol col (colDiff / 2); // 横向的中间点 } // 如果“马腿”位置有子则走法非法 if (board.getPieceAt(blockRow, blockCol) ! nullptr) { return false; } // 目标位置无子或有敌方棋子则合法 Piece* target board.getPieceAt(toRow, toCol); return (target nullptr) || (target-getColor() ! color); }这里的关键是准确计算“马腿”的位置。算法是先判断是“竖日”还是“横日”然后取直走方向上的相邻格点。4.2 “炮”的移动与吃子逻辑分离炮的规则最特殊移动时如车必须直线行走且路径上不能有任何棋子但吃子时必须且只能跨越一个“炮架”任意一方的棋子然后吃掉炮架后的第一个敌方棋子。bool Cannon::isValidMove(const ChessBoard board, int toRow, int toCol) const { // 1. 必须直线移动 if (row ! toRow col ! toCol) return false; // 2. 计算路径和方向 int rowStep (toRow row) ? 0 : ((toRow row) ? 1 : -1); int colStep (toCol col) ? 0 : ((toCol col) ? 1 : -1); int currentR row rowStep; int currentC col colStep; int obstacleCount 0; // 路径上的棋子计数 // 3. 遍历路径上的每一个格子不包括起点和终点 while (currentR ! toRow || currentC ! toCol) { if (board.getPieceAt(currentR, currentC) ! nullptr) { obstacleCount; } currentR rowStep; currentC colStep; } // 4. 判断目标位置情况 Piece* target board.getPieceAt(toRow, toCol); if (target nullptr) { // 移动路径上必须无任何棋子 return obstacleCount 0; } else if (target-getColor() ! color) { // 吃子路径上必须有且仅有一个棋子作为炮架 return obstacleCount 1; } else { // 目标位置是己方棋子非法 return false; } }这个实现清晰地分离了移动和吃子两种情形通过obstacleCount这个变量巧妙地统一了判断逻辑。4.3 “将帅照面”与“长将”的全局规则有些规则超出了单个棋子的移动范畴需要在ChessBoard的movePiece或isValidMove的更高层级进行校验。将帅照面将和帅不能处于同一条直线上且中间没有任何棋子。这需要在移动任何棋子后进行检查。我的做法是在ChessBoard中提供一个bool isKingFacingKing() const方法遍历找到双方将帅的位置如果同列且中间无子则返回true。在movePiece中如果移动后导致“照面”且移动的不是将帅本身则这步棋是允许的因为是你主动“亮将”但如果移动后导致己方将帅与对方照面则这步棋是非法的不能主动送将。长将一方连续不断地“将军”而另一方无法避免规则上判主动长将的一方犯规。这是一个状态检测问题。我采用的方法是在ChessBoard中维护一个历史走子列表std::vectorMove。每次走子后检查当前局面棋盘状态轮到谁走在最近N步内是否重复出现。如果重复出现且总是同一方在“将军”则可以判定为“长将”。实现起来相对复杂需要定义局面的“哈希值”以便快速比较。5. 项目构建、调试与性能优化实战5.1 使用CMake管理跨平台构建为了让项目在WindowsVisual Studio、Linuxg和macOSClang上都能方便地编译我使用了CMake来管理构建过程。cmake_minimum_required(VERSION 3.10) project(ChineseChess) set(CMAKE_CXX_STANDARD 17) # 根据平台选择图形库 if(WIN32 AND NOT USE_SDL) # 默认在Windows上用GDI add_executable(ChineseChess WIN32 src/main_win.cpp src/ChessBoard.cpp src/Piece.cpp # ... 其他源文件 ) target_link_libraries(ChineseChess) else() # 其他平台或指定USE_SDL时用SDL2 find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) # 用于加载图片 add_executable(ChineseChess src/main_sdl.cpp src/ChessBoard.cpp src/Piece.cpp # ... 其他源文件 ) target_include_directories(ChineseChess PRIVATE ${SDL2_INCLUDE_DIRS} ${SDL2_IMAGE_INCLUDE_DIRS}) target_link_libraries(ChineseChess ${SDL2_LIBRARIES} ${SDL2_IMAGE_LIBRARIES}) endif()这样在Linux下直接cmake . make即可生成使用SDL2的可执行文件。在Windows下可以通过cmake -G Visual Studio 16 2019 ..生成VS工程文件。5.2 调试技巧与常见问题排查开发过程中棋盘状态错乱、规则判断异常是最常见的问题。打印调试法为ChessBoard实现一个printBoard()函数以文本形式在控制台打印棋盘用字母代表棋子这是最直观的调试手段。在关键操作前后打印棋盘状态。断言Assert在movePiece等函数开头加入断言检查传入的行列索引是否在[0,8]和[0,9]范围内。这能快速捕捉到因坐标计算错误导致的越界访问。单元测试为Piece的各个派生类的isValidMove编写测试用例。例如针对“马”创建多个测试棋盘测试正常走日、蹩马腿、吃子、走非法位置等情况。虽然最初没引入Google Test等框架但一个简单的测试函数集合极大地提升了规则代码的可靠性。内存泄漏检测在Visual Studio中可以使用_CrtDumpMemoryLeaks()在Linux下可以用Valgrind。确保ChessBoard析构时所有new出来的Piece对象都被delete。5.3 AI搜索的性能优化点最初的AI搜索深度只能到2层思考时间就很长了。通过以下优化我将搜索深度提升到了4-5层响应速度在可接受范围内。走法生成优化不要每层搜索都重新生成全部走法。可以缓存每个位置、每种棋子的可能走法预生成表。但更有效的是实现一个“增量式”走法生成器或者至少对生成的走法进行排序——把“吃子”、“将军”等可能好的走法放在前面这样Alpha-Beta剪枝能更早发生效率更高。置换表这是一个更高级的优化。将搜索过的局面及其评估结果、最佳走法、搜索深度等信息存储在一个哈希表中。当再次遇到相同局面时可以直接查表避免重复搜索。这需要为棋盘局面计算一个高效的哈希值Zobrist Hashing是常用技术。评估函数缓存局面评估函数可能被频繁调用。可以对评估结果进行缓存。当棋盘状态未发生变化时直接返回缓存值。开局库与残局库对于前几步固定的开局和子力极少的残局直接查预定义的最佳走法表无需搜索。这能大幅提升开局和结束阶段的AI水平。我的实测数据在Release模式下使用基础优化走法排序、Alpha-Beta在普通家用电脑上搜索深度4层AI思考时间大约在1-3秒对于休闲对弈来说已经足够。深度5层则需要5-10秒开始有可感知的延迟。6. 功能扩展与项目进阶方向这个基础框架有很多可以扩展和深化的地方能让项目从一个“作业”升级为一个“作品”。6.1 网络对战功能的实现实现双人网络对战需要引入网络通信模块。一个简单的设计是采用客户端-服务器C/S架构。服务器负责维护游戏房间、转发棋步、校验规则防止客户端作弊、判定胜负。可以用C配合Boost.Asio或简单的socket编程实现。客户端除了原有的图形界面和本地规则校验增加网络模块。走子后将(fromRow, fromCol, toRow, toCol)序列化发送给服务器。同时客户端需要从服务器接收对手的棋步并更新本地棋盘。协议设计定义简单的应用层协议。例如用字符串MOVE 0 1 2 3表示从(0,1)移动到(2,3)。用CHAT hello表示聊天。用RESIGN表示认输。注意事项网络版必须考虑“状态同步”问题。所有关键的游戏状态改变如走子、吃子、胜负都应以服务器确认为准。客户端在发送走子请求后应进入“等待响应”状态禁止用户操作直到收到服务器的“走子成功”广播。6.2 集成更强大的AI引擎可以抛弃自写的简单AI集成开源的、强大的象棋引擎如“象眼”ElephantEye或国际象棋引擎Stockfish需要适配中国象棋规则变种。通常这些引擎提供UCIUniversal Chess Interface协议。你的C程序可以作为一个“GUI”前端通过标准输入输出与引擎进程通信发送position startpos moves ...和go depth 5这样的命令并解析引擎返回的最佳走法bestmove e2e4。这能瞬间将你的游戏AI提升到职业水准。6.3 复盘与棋谱文件支持中国象棋有标准的棋谱记录格式PGN的一种变体或XQF格式。可以为项目增加保存和加载棋谱的功能。保存在游戏过程中记录每一步的起始位置和目标位置。游戏结束后将这些信息连同对局者、日期等元数据按照特定格式如简单的自定义文本格式或标准的XFQC格式写入文件。加载与复盘从文件读取棋谱然后控制棋盘一步步重新演示对局过程。这需要实现一个独立的“复盘模式”在此模式下用户只能控制“前进”、“后退”到某一步而不能随意走子。实现要点需要在ChessBoard类中实现一个std::vectorMove moveHistory来记录历史。同时要实现bool makeMove(const Move m)和bool undoMove()函数。undoMove需要能够恢复被吃掉的棋子这就要求Move结构体需要包含足够的信息如移动的棋子、起始位置、目标位置、被吃的棋子指针等。这个项目虽然代码量不大但涉及了C面向对象设计、算法、图形界面、甚至初步的AI和网络编程是一个综合性极强的练习。最重要的是它有趣、有挑战并且最终能产生一个你可以实际把玩、并向朋友展示的作品。在调试那些诡异的走法规则时你对象棋规则本身的理解也会加深。如果你对某个扩展方向特别感兴趣比如死磕AI算法让它更强或者研究跨平台图形库的渲染细节那这个项目就是一个绝佳的起点和试验场。