☰
Qt中国象棋源码实战:从编译到走子规则与双缓冲绘制
2026/10/27 22:13:30 网站建设 项目流程

简介:这是一份面向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,再点构建(Ctrl+B)。如果构建输出里出现一堆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. 走子规则与胜负判定:把象棋逻辑写进 C++

4.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 将军与胜负判定的实现顺序

每次走子后,按这个顺序判定:

  1. 走子方是否导致己方被将军(上面wouldLeaveKingInCheck已经拦了)。
  2. 对方是否被将军。是的话,检查对方所有棋子是否存在任何一步能解除将军,没有就是将死,走子方胜。
  3. 对方是否无子可动但未被将军,那是困毙,同样判负。

第 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. 进阶改法:把这份源码变成你自己的项目

跑通只是起点,真正让这份源码产生价值的是改。我一般会从三个方向下手,难度递增。

第一个方向是加「悔棋」和「重开」。悔棋的本质是保存历史棋盘状态,用一个QStack<BoardState>每次走子前压栈,悔棋时弹栈恢复。BoardState就是棋盘数组加当前回合方的结构体。这个改动小、收益大,能让你彻底理解「状态快照」的思路。重开更简单,把初始布局重新赋值给棋盘数组,清空历史栈,update()即可。

第二个方向是加「走子提示」。选中一个棋子后,把它所有合法落点用半透明圆点标出来。实现上,在paintEvent里遍历该棋子的所有可能落点,调isLegalMove判断,合法的画个小圆。这个功能会逼你把规则代码写得更干净,因为要频繁调用。性能上,一个棋子最多几十个落点,每次重绘算一遍完全无压力。

第三个方向是换皮和加音效。棋盘底图、棋子图标换成自己的素材,走子、吃子时用QSoundEffect播个短音效。资源通过.qrc管理,注意路径别写绝对路径,否则换台机器就找不到。音效文件建议用.wav,Qt 对它的支持最稳。

改动方向涉及模块难度建议顺序
悔棋/重开GameModel + 历史栈低先做
走子提示BoardWidget 绘制中次做
换皮/音效资源文件 + QSoundEffect低随时
网络对战需引入 QTcpSocket高最后

验证改动是否正确的习惯,我固定成一套:每加一个功能,先用「红方开局炮二平五、黑方马8进7」这种标准开局走几步,看规则判定对不对;再故意走几步非法棋,看拦不拦得住;最后把窗口拉大拉小,看绘制有没有崩。这套流程走下来,基本能覆盖大部分回归问题。

从那以后我每次拿到一份 Qt 游戏源码,都强制先跑通、再留基线、再按「规则→界面→资源」的顺序改,绝不一上来就动核心逻辑。希望这份拆解能帮你少走点弯路,把这份中国象棋源码真正变成自己简历上能讲清楚的项目。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询