
简介这是一份面向Qt初学者与游戏开发爱好者的中国象棋项目源码基于跨平台C框架Qt实现适合用来练习图形界面设计、事件驱动编程与游戏逻辑组织。压缩包共21个文件约12KB以10个cpp源文件与9个h头文件为主体另有1个pro工程文件和1个user配置涵盖棋盘棋子绘制、走子规则校验、单机与网络对战等模块代码结构清晰、便于按模块阅读。项目围绕QGraphicsView与QGraphicsItem构建棋盘场景借助信号与槽处理点击拖放事件并涉及QThread多线程与QTcpServer、QTcpSocket网络通信可帮助读者理解AI对弈与在线对战的实现思路。目前已有712人学习下载适合希望提升Qt编程技能、了解游戏开发通用流程的读者参考借鉴。1. 从一份能跑起来的 Qt 中国象棋源码说起很多人找 Qt 练手项目翻来覆去就是计算器、记事本、学生管理系统界面丑、逻辑浅做完也说不清自己到底掌握了什么。中国象棋这个题材不一样它同时压到了 Qt 的几块硬骨头——QWidget 自绘棋盘、鼠标事件坐标换算、定时器驱动走子动画、QPainter 双缓冲防闪烁还要把一套完整的走子规则、将军判定、胜负判定用 C 写清楚。这份 Qt 项目开发中国象棋源码就是把这些东西打包成一个能编译、能下、能改的完整工程适合已经看完 Qt 基础语法、想找一个「有真实业务逻辑」的项目来啃的人。它不教你信号槽是什么但它会让你第一次认真思考棋盘状态到底该用什么数据结构存界面刷新和逻辑计算怎么解耦。下面我按自己拆包复现的顺序把这份源码从环境到跑通、从结构到改法讲一遍。2. 环境准备与工程结构先让它在你的机器上编译通过2.1 Qt 版本与编译器怎么选这份源码基于 Qt Widgets 体系没有用 QML所以对 Qt 版本的要求不算苛刻但也不是随便哪个版本都能直接开。常见做法是选 Qt 5.15.2 配 MSVC2019 64 位或者 Qt 5.12 系列配 MinGW。为什么优先推 5.15.2因为它是 Qt 5 最后一个长期维护版本Widgets 模块稳定网上关于:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwid...这类报错的资料也最多遇到问题好查。如果你用的是 Qt 6要注意两点一是部分老写法比如QPainter的某些枚举在 Qt 6 里被标记废弃编译会出警告甚至报错二是QString和QList的一些隐式转换行为收紧了。新手如果不想在环境上耗太久直接上 Qt 5.15.2 最省事。安装时有个坑要提前说Qt 在线安装器默认不一定勾选 MSVC 组件你得手动把MSVC 2019 64-bit和Qt 5.15.2下的Qt Widgets勾上。装完在 Qt Creator 里进「工具 → 选项 → Kits」确认编译器、Qt 版本、调试器三样都变绿否则后面构建会报「No suitable kit」。2.2 用 qmake 还是 CMake 打开工程拿到源码包先看根目录有没有.pro文件。有.pro就是 qmake 工程直接用 Qt Creator 打开.pro如果只有CMakeLists.txt那就是 CMake 工程打开时选 CMake 配置。两种方式我都试过这份源码用 qmake 更顺因为它的资源文件棋盘图片、棋子图标大概率是通过.qrc管理的qmake 对.qrc的处理最直接。打开工程后先别急着点运行。第一步是「构建 → 执行 qmake」让 Qt Creator 重新生成 Makefile。这一步能解决相当一部分「明明代码没动却报找不到头文件」的玄学问题。执行完 qmake再点构建CtrlB。如果构建输出里出现一堆undefined reference to vtable for XXX八成是某个继承 QObject 的类忘了在类声明里加Q_OBJECT宏或者加了宏但没重新执行 qmake。2.3 工程目录的典型分层一个结构清晰的中国象棋工程目录通常长这样目录/文件作用main.cpp程序入口创建 QApplication 和主窗口mainwindow.h/.cpp主窗口负责菜单、状态栏、棋盘控件装载boardwidget.h/.cpp棋盘控件核心自绘与鼠标交互都在这里chesspiece.h/.cpp棋子数据类存类型、阵营、坐标gamemodel.h/.cpp走子规则、将军判定、胜负判定resources.qrc图片、音效等资源索引images/棋盘底图、棋子图标这个分层不是死的但你要能一眼看出「界面」和「规则」是不是分开了。如果所有逻辑都堆在boardwidget.cpp里那这份源码的可读性会打折扣改起来也累。我拆的时候会先看gamemodel这类文件存不存在——有独立规则类说明作者是认真设计过的值得细读。2.4 第一次运行要确认的三件事编译通过不等于跑对。第一次运行重点看三件事第一棋盘是否正常绘制有没有出现半透明残影或者拖动窗口时棋盘闪烁。闪烁说明没做双缓冲后面第 4 章会讲怎么补。第二鼠标点击棋子能不能选中选中后有没有高亮。如果点了没反应多半是鼠标坐标到棋盘格坐标的换算出了问题常见的是没减去棋盘边距或者格子宽高用了整数除法导致累积误差。第三走一步棋后轮到对方走子时界面有没有正确切换。如果红方走完还是红方走那是回合切换的状态变量没更新或者更新了但没触发重绘。提示第一次跑通后先别改代码把整个工程复制一份留作「干净基线」。后面改崩了能随时对照这个习惯能省下大量后悔药。3. 棋盘绘制与鼠标交互QPainter 自绘的核心逻辑3.1 棋盘坐标系怎么建中国象棋棋盘是 9 列 10 行但注意——棋子是落在交叉点上的不是落在格子里。这一点和五子棋一样和很多人的直觉相反。所以坐标换算的基准是「交叉点间距」不是「格子宽高」。常见做法是先确定棋盘左上角第一个交叉点在控件里的像素坐标(marginX, marginY)再确定相邻两个交叉点的水平间距cellW和垂直间距cellH。那么第col列、第row行交叉点的像素坐标就是// 交叉点像素坐标换算 int px marginX col * cellW; int py marginY row * cellH;反过来鼠标点击位置(mouseX, mouseY)要换算成最近的交叉点得先减边距再四舍五入// 鼠标坐标 - 棋盘交叉点索引 int col qRound((mouseX - marginX) / double(cellW)); int row qRound((mouseY - marginY) / double(cellH)); // 越界保护防止点到棋盘外 if (col 0 || col 8 || row 0 || row 9) return;这里cellW和cellH一定要用浮点参与除法否则整数除法会把误差放大点到边缘棋子时经常选不中。这是血泪经验我见过不止一个版本栽在这。3.2 paintEvent 里到底该画什么paintEvent是 QWidget 自绘的入口每次控件需要重绘时被调用。棋盘绘制一般分三层底层棋盘线、中层棋子、顶层选中高亮和提示。画棋盘线用QPainter::drawLine横线 10 条、竖线 9 条注意楚河汉界那一段竖线要断开。画棋子用drawEllipse画圆底再用drawText把「车马炮」写上去字体用QFont设大一点、加粗。选中高亮可以画一个稍大的彩色圆环。关键点是不要在paintEvent里做任何耗时计算或数据库查询。paintEvent会被频繁调用里面塞重逻辑界面必卡。所有状态提前算好存在成员变量里paintEvent只负责「照着状态画」。3.3 双缓冲防闪烁的标准写法默认情况下QWidget 重绘是先擦背景再画内容快速刷新时就会闪。解决办法是双缓冲先画到一张离屏QPixmap再一次性贴到控件上。void BoardWidget::paintEvent(QPaintEvent *event) { // 离屏画布尺寸和控件一致 QPixmap buffer(size()); QPainter painter(buffer); painter.fillRect(rect(), Qt::white); // 先铺底色 drawBoard(painter); // 画棋盘线 drawPieces(painter); // 画棋子 drawHighlight(painter); // 画选中高亮 // 一次性把离屏内容贴到控件 QPainter screenPainter(this); screenPainter.drawPixmap(0, 0, buffer); }逻辑说明buffer是内存里的画布所有绘制先在它上面完成用户看不到中间过程最后用screenPainter把整张图贴上去视觉上就是一次成型不会闪。参数上size()取控件当前尺寸窗口缩放时缓冲图会自动跟着变不用手动维护。3.4 鼠标事件与选中状态机鼠标交互本质是个小状态机空闲 → 选中己方棋子 → 点击目标点 → 合法则走子、非法则取消选中。用mousePressEvent处理void BoardWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; int col qRound((event-x() - marginX) / double(cellW)); int row qRound((event-y() - marginY) / double(cellH)); if (col 0 || col 8 || row 0 || row 9) return; if (!hasSelected) { // 第一次点击尝试选中 if (model-hasPieceAt(col, row) model-pieceAt(col, row).side currentSide) { hasSelected true; selCol col; selRow row; update(); // 触发重绘显示高亮 } } else { // 第二次点击尝试走子 if (model-isLegalMove(selCol, selRow, col, row)) { model-movePiece(selCol, selRow, col, row); currentSide (currentSide Red) ? Black : Red; } hasSelected false; update(); } }逻辑说明hasSelected标记当前有没有选中棋子selCol/selRow记录选中位置。第一次点击只做选中第二次点击才尝试走子走子合法性交给model-isLegalMove判断界面层不掺和规则。参数上currentSide控制只能选自己一方的棋子防止「自己吃自己」。每次状态变化后调update()Qt 会自动安排一次paintEvent。注意update()是异步的它只是标记「需要重绘」真正绘制发生在事件循环下一轮。别在update()后面立刻读界面状态读到的还是旧的。4. 走子规则与胜负判定把象棋逻辑写进 C4.1 棋盘状态用什么数据结构存最直接的是二维数组Piece board[10][9]Piece里存类型车马炮相仕帅兵卒和阵营红黑空格用类型None表示。这个结构直观、访问快board[row][col]一眼就知道是哪。但要注意象棋里「帅」和「将」不能照面这条规则用二维数组判断时需要单独扫一列看中间有没有棋子。常见做法是写一个isFacingKing()函数在每次走子后检查如果照面则判定走子方违规。另一种做法是用一维数组加索引换算省内存但可读性差。练手项目没必要二维数组足够。4.2 每种棋子的走法怎么拆规则代码最容易写成一坨if-else。我的建议是按棋子类型拆成独立函数每个函数只回答一个问题从(fromCol, fromRow)到(toCol, toRow)这一步在不考虑「走后是否被将军」的前提下形状上合不合法。bool GameModel::isLegalMove(int fc, int fr, int tc, int tr) { if (fc tc fr tr) return false; // 原地不动 Piece from board[fr][fc]; Piece to board[tr][tc]; if (from.type None) return false; // 没棋子 if (to.side from.side) return false; // 不能吃己方 bool shapeOk false; switch (from.type) { case Ju: shapeOk checkJu(fc, fr, tc, tr); break; case Ma: shapeOk checkMa(fc, fr, tc, tr); break; case Pao: shapeOk checkPao(fc, fr, tc, tr); break; // ... 其余棋子 } if (!shapeOk) return false; // 形状合法后再模拟走子检查是否导致己方被将军 return !wouldLeaveKingInCheck(fc, fr, tc, tr, from.side); }逻辑说明先做基础排除原地、空子、吃己方再按类型判断走法形状最后做「走后是否被将军」的全局校验。参数上fc/fr是起点tc/tr是终点。把形状判断和将军判断分开代码清晰也方便单独测试。4.3 马腿、炮架、象眼这些特殊规则象棋规则里最容易写错的就是「蹩马腿」「炮翻山」「塞象眼」。马走日字但如果马前进方向紧挨着的那一格有棋子就是蹩马腿不能走。判断方法是看起点和终点在长边方向上的中间格有没有子。炮的走法分两种不吃子时和车一样走直线吃子时必须隔且只隔一个棋子炮架。所以炮的检查函数要数起点终点之间有几个棋子吃子时要求恰好一个不吃子时要求零个。象走田字中间那格有子就是塞象眼。士和帅只能在九宫格内活动兵卒过河前只能前进、过河后可以横走。这些规则一条条写清楚别偷懒合并合并了以后改起来就是灾难。4.4 将军与胜负判定的实现顺序每次走子后按这个顺序判定走子方是否导致己方被将军上面wouldLeaveKingInCheck已经拦了。对方是否被将军。是的话检查对方所有棋子是否存在任何一步能解除将军没有就是将死走子方胜。对方是否无子可动但未被将军那是困毙同样判负。第 2 步的「检查对方所有走法」是个双层循环遍历对方每个棋子、每个可能落点调isLegalMove试一遍。性能上象棋棋盘小这点计算量完全扛得住不用做优化。但要注意递归陷阱isLegalMove里又调wouldLeaveKingInCheck而wouldLeaveKingInCheck内部如果再去调完整的胜负判定就会无限递归。所以将军校验只做「模拟走子后己方帅是否被攻击」不触发完整判定。提示写规则时先写单元测试式的调试代码把每种棋子的合法走法打印出来人工核对。规则 bug 靠肉眼读代码很难发现靠打印一测一个准。5. 避坑与常见问题排查编译、绘制、逻辑三类翻车5.1 编译报 dependent 路径错误现象构建时提示:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwid... does not exist。原因工程里记录的 Qt 头文件路径和你本机实际安装路径不一致通常是源码作者在别的机器上生成的构建缓存被打包进来了。解决删掉工程目录下的build-*文件夹和.pro.user文件重新用 Qt Creator 打开.pro执行 qmake 再构建。.pro.user存的是本机配置跨机器必须删。5.2 棋盘拖动窗口时闪烁现象窗口缩放或快速刷新时棋盘出现明显闪烁、残影。原因paintEvent里直接对控件绘制没有双缓冲系统先擦背景再画内容两次操作之间被用户看到了。解决按 3.3 节改成离屏QPixmap缓冲或者给控件设setAttribute(Qt::WA_OpaquePaintEvent)减少背景擦除。前者更通用推荐。5.3 点击棋子选不中或选错现象点棋子边缘没反应或者点 A 棋子选中了旁边的 B。原因鼠标坐标到交叉点的换算用了整数除法或者没减棋盘边距导致四舍五入偏差。解决换算时用double参与除法再qRound并确认marginX/marginY和绘制时用的是同一套值。绘制和点击必须共用同一组坐标常量别各写各的。5.4 走子后界面不刷新现象逻辑上棋子已经移动但界面上还停在原位。原因改了数据但没调update()或者update()调了但paintEvent里读的是另一份数据副本。解决确认所有改棋盘状态的地方后面都跟了update()确认paintEvent读的就是model里的实时数据不是构造时拷贝的旧快照。5.5 将军判定导致程序卡死或崩溃现象走一步棋后程序无响应或者直接闪退。原因将军校验里出现无限递归或者数组越界访问比如马腿判断时索引算到了 -1 或 10。解决给所有棋盘访问加边界检查索引必须在[0,8]和[0,9]内将军校验只做单层模拟不递归调用完整胜负判定。用调试器打断点看调用栈递归问题一眼就能定位。6. 进阶改法把这份源码变成你自己的项目跑通只是起点真正让这份源码产生价值的是改。我一般会从三个方向下手难度递增。第一个方向是加「悔棋」和「重开」。悔棋的本质是保存历史棋盘状态用一个QStackBoardState每次走子前压栈悔棋时弹栈恢复。BoardState就是棋盘数组加当前回合方的结构体。这个改动小、收益大能让你彻底理解「状态快照」的思路。重开更简单把初始布局重新赋值给棋盘数组清空历史栈update()即可。第二个方向是加「走子提示」。选中一个棋子后把它所有合法落点用半透明圆点标出来。实现上在paintEvent里遍历该棋子的所有可能落点调isLegalMove判断合法的画个小圆。这个功能会逼你把规则代码写得更干净因为要频繁调用。性能上一个棋子最多几十个落点每次重绘算一遍完全无压力。第三个方向是换皮和加音效。棋盘底图、棋子图标换成自己的素材走子、吃子时用QSoundEffect播个短音效。资源通过.qrc管理注意路径别写绝对路径否则换台机器就找不到。音效文件建议用.wavQt 对它的支持最稳。改动方向涉及模块难度建议顺序悔棋/重开GameModel 历史栈低先做走子提示BoardWidget 绘制中次做换皮/音效资源文件 QSoundEffect低随时网络对战需引入 QTcpSocket高最后验证改动是否正确的习惯我固定成一套每加一个功能先用「红方开局炮二平五、黑方马8进7」这种标准开局走几步看规则判定对不对再故意走几步非法棋看拦不拦得住最后把窗口拉大拉小看绘制有没有崩。这套流程走下来基本能覆盖大部分回归问题。从那以后我每次拿到一份 Qt 游戏源码都强制先跑通、再留基线、再按「规则→界面→资源」的顺序改绝不一上来就动核心逻辑。希望这份拆解能帮你少走点弯路把这份中国象棋源码真正变成自己简历上能讲清楚的项目。本文还有配套的精品资源点击获取