基于QT与C++的中国象棋游戏开发实战:从MVC架构到AI算法

📅 2026/8/3 12:28:32 👁️ 阅读次数 📝 编程学习
基于QT与C++的中国象棋游戏开发实战:从MVC架构到AI算法

1. 项目概述:为什么选择QT与C++来复刻中国象棋?

如果你对桌面应用开发感兴趣,或者想找一个能串联起C++面向对象编程、图形界面设计和游戏逻辑的实战项目,那么用QT和C++来开发一个中国象棋游戏,绝对是一个“黄金级”的练手选择。这不仅仅是因为象棋规则本身逻辑清晰、复杂度适中,更因为通过这个项目,你能把学校里学的那些看似枯燥的C++类、继承、多态、STL容器,以及QT的信号槽、绘图事件、控件布局等知识,全部串联起来,变成一个看得见、摸得着、能运行、能对战的完整程序。

我当初选择这个项目,就是厌倦了控制台的黑白世界。QT框架提供了强大的跨平台图形界面能力,让你能用C++写出媲美现代软件的界面,而象棋的棋盘、棋子、走法规则,恰好是检验你数据结构设计和算法逻辑的绝佳试金石。这个项目麻雀虽小,五脏俱全:它涉及界面渲染(QT绘图)事件处理(鼠标点击)核心算法(棋规判定)对象建模(棋子类),甚至还能扩展到网络对战(Socket通信)AI对战(搜索算法)。对于想找C++/QT方向工作的同学来说,这样一个完整的项目经历写在简历上,比空谈“熟练掌握C++”要有说服力得多。

接下来,我会带你从零开始,拆解整个开发过程。我会重点分享我在实现过程中踩过的坑、做出的设计抉择,以及那些教科书里不会写的调试技巧。我们的目标是:最终得到一个界面美观、逻辑正确、代码结构清晰,并且具备一定可扩展性的中国象棋程序。

2. 整体架构与核心模块设计

在动手写第一行代码之前,花点时间思考整体架构是至关重要的。一个混乱的架构会让后续的添加功能(比如悔棋、网络对战)变得举步维艰。我的设计核心是“模型-视图-控制器(MVC)”的变体,在QT项目中,这通常演化为数据模型、界面视图和业务逻辑分离

2.1 核心类设计

整个项目我主要设计了以下几个核心类:

  1. ChessPiece(棋子类):这是所有棋子的基类。它应该包含棋子的基本属性:颜色(红方或黑方)、类型(车、马、炮等)、在棋盘上的位置(行列坐标)、是否已被吃掉的状态。更重要的是,它需要有一个纯虚函数,比如bool canMoveTo(int targetRow, int targetCol, const ChessBoard& board),用于判断该棋子能否移动到目标位置。然后,为每种棋子(车、马、象、士、将、炮、兵)创建派生类,重写这个移动规则函数。这是多态性的经典应用。

  2. ChessBoard(棋盘类):这是游戏的核心数据模型。它不应该直接处理界面绘制,而是专注于维护游戏状态。其核心成员是一个二维数组(或std::vector<std::vector<std::shared_ptr<ChessPiece>>>)来表示9x10的棋盘格点,存储着棋子对象的指针(空指针表示无子)。它还应该记录当前行棋方(红先黑后)、棋局状态(进行中、红胜、黑胜、和棋),以及一个走法历史记录列表(用于实现悔棋)。

  3. GameController(游戏控制类):这是连接数据(ChessBoard)和界面(MainWindow)的桥梁,是业务逻辑的集中地。它负责响应用户的界面操作(如点击棋子),调用ChessBoardChessPiece的规则进行逻辑判定,并更新棋盘数据,最后通知界面重绘。它也是处理游戏流程(开始、结束、悔棋、重新开始)的总指挥。

  4. MainWindow(主窗口类):由QT的QMainWindow派生而来,主要负责界面展示。它包含一个用于绘制棋盘和棋子的自定义Widget(例如ChessWidget),以及一些按钮(开始、悔棋、认输等)。它的职责是接收鼠标事件,将其转化为逻辑坐标后传递给GameController,并根据控制器的指令更新界面。

2.2 数据流与信号槽

QT的灵魂是信号与槽(Signals & Slots),我们用它将各个模块松耦合地连接起来。

  • 用户交互流:用户在ChessWidget上点击鼠标 ->ChessWidget捕获鼠标事件,计算点击的棋盘格 ->ChessWidget(或MainWindow)发出一个自定义信号,如pieceClicked(int row, int col)->GameController的对应槽函数接收该信号 -> 控制器调用ChessBoard的接口处理走法逻辑 -> 逻辑处理后,控制器发出另一个信号,如boardStateChanged()->ChessWidget接收到状态变化信号,调用update()触发重绘,从最新的ChessBoard数据中读取并绘制棋盘棋子。

  • 设计心得:尽量避免在视图类(ChessWidget)里直接操作模型数据(ChessBoard),也避免在模型类里直接调用界面更新。所有修改都通过控制器中转。这样做的好处是,假设未来我想把界面从QT换成其他库,或者为游戏添加一个命令行界面,我只需要替换视图部分,核心的ChessBoardGameController逻辑几乎不用改动。这就是关注点分离带来的好处。

3. 核心实现细节与难点攻克

有了架构蓝图,我们来深入几个最关键、也最容易出错的实现细节。

3.1 棋盘与棋子的绘制

绘制是给用户的第一印象。我们使用QT的QPainterChessWidgetpaintEvent函数中完成。

棋盘绘制:计算好棋盘左上角的起始坐标,根据棋盘格大小(比如60像素)绘制9条竖线和10条横线。使用QPainter::drawLine即可。楚河汉界可以用QPainter::drawText来绘制文字。

棋子绘制:这是重点。我推荐使用贴图而非纯色绘制,效果更好。为红黑两方的7种棋子准备14张透明的PNG图片。在绘制时:

  1. 遍历ChessBoard中的二维数组。
  2. 对于每个非空的位置,根据棋子的颜色和类型,选择对应的图片。
  3. 计算该棋子在屏幕上的圆心坐标:centerX = startX + col * gridSize + gridSize/2
  4. 使用QPainter::drawPixmap将图片绘制在指定区域,可以以圆心为基准进行绘制。

踩坑提示1:抗锯齿与高质量渲染直接绘制线条和图片可能会有锯齿。务必在QPainter对象使用前设置渲染提示:

painter.setRenderHint(QPainter::Antialiasing); // 抗锯齿 painter.setRenderHint(QPainter::SmoothPixmapTransform); // 平滑像素图变换

这能让你的棋盘和棋子边缘看起来非常平滑,质感提升一个档次。

棋子选中效果:当用户点击一个己方棋子时,需要高亮它。我常用的方法是在棋子图片外围绘制一个半透明的圆环。在绘制棋子图片后,判断如果该棋子是被选中的棋子,则用QPainter设置一个半透明的笔刷(QBrush(QColor(255, 255, 0, 100)))和较粗的画笔,绘制一个比棋子略大的圆。

3.2 象棋规则的精确定义与实现

这是项目的逻辑核心,必须严谨。每种棋子的移动规则都在其派生类的canMoveTo函数中实现。这里以几个易错点为例:

马的走法(马走日)与蹩马腿: 马可以走“日”字。但关键在于,如果马要前往的某个目标点,其前进方向上的“相邻点”(马走日的第一步落脚点)有任意棋子,则不能走,俗称“蹩马腿”。

// 伪代码示例:判断马从(r1,c1)到(r2,c2)是否合法 int dr = abs(r2 - r1); int dc = abs(c2 - c1); if (!((dr == 2 && dc == 1) || (dr == 1 && dc == 2))) { return false; // 不是日字格 } // 检查蹩马腿 int blockRow, blockCol; if (dr == 2) { // 竖向走日 blockRow = (r1 + r2) / 2; blockCol = c1; } else { // 横向走日 blockRow = r1; blockCol = (c1 + c2) / 2; } if (board.getPieceAt(blockRow, blockCol) != nullptr) { return false; // 马腿被蹩 } return true;

炮的走法(炮打隔山子): 炮移动时,路径上必须无子(和车一样)。但吃子时,目标位置和目标位置与炮之间,必须有且仅有一个棋子(即“炮架”)。

// 伪代码:判断炮的移动/吃子 int piecesBetween = countPiecesBetween(r1, c1, r2, c2, board); ChessPiece* targetPiece = board.getPieceAt(r2, c2); if (targetPiece == nullptr) { // 移动 return piecesBetween == 0; } else { // 吃子 return piecesBetween == 1; }

将/帅的走法与“王不见王”: 将帅只能在九宫内移动,每次一步。此外,还有一个特殊规则:如果双方将帅在同一直线上,且中间没有任何棋子,则不允许形成“对脸”(或称为“王不见王”),此时先手方可以直接获胜(或判负,规则可自定义)。这个规则需要在GameController进行走法验证时,作为一个全局规则额外检查。

踩坑提示2:坐标系统一内部逻辑(数组下标)和界面绘制(像素坐标)一定要建立清晰的映射关系,并封装成函数。我定义棋盘逻辑坐标是(row, col)row从0到9(从上到下),col从0到8(从左到右)。鼠标点击的像素坐标(x, y),需要通过转换函数pixelToLogic(x, y)得到逻辑坐标(r, c),再进行逻辑判断。反之,绘制时需要logicToPixel(r, c)。混乱的坐标系统是BUG的主要来源之一。

3.3 游戏状态管理与流程控制

GameController是游戏的大脑,它需要处理:

  • 回合切换:每次成功走子后,切换当前行棋方(从红方切换到黑方,或反之)。
  • 胜负判定:在每次走子后,检查是否擒获了对方的将/帅。更复杂的判定还包括长将、长捉等竞赛规则,作为初级项目,可以只实现“将死”判定。
  • 走法历史记录:为了实现悔棋,需要记录每一步的完整信息。我定义了一个MoveStep结构体,包含起始位置、目标位置、被吃掉的棋子(如果有)。使用一个std::vector<MoveStep>来保存历史。悔棋时,从末尾弹出一步,并根据记录的信息恢复棋盘状态。
  • 游戏重置:将ChessBoard的二维数组重置为初始布局,清空历史记录,当前行棋方设为红方。

4. 功能扩展与性能优化思路

一个基础版本完成后,你可以考虑以下扩展,让项目更加出彩:

4.1 网络对战功能

这是从单机游戏迈向联机游戏的关键一步。核心是引入网络模块,采用客户端-服务器(C/S)点对点(P2P)架构。

  • C/S架构:开发一个简单的服务器,负责匹配玩家、转发棋步、校验规则(可选)。两个客户端分别连接服务器,只负责界面和发送/接收棋步。服务器可以用C++配合Boost.Asio或QT自身的QTcpServer/QTcpSocket实现。
  • P2P架构:一方作为主机,另一方连接主机的IP和端口。棋步数据直接在两个客户端间通过Socket传输。QT的QTcpSocket可以胜任。
  • 协议设计:你需要设计一个简单的应用层协议来传递消息。例如,可以用字符串,格式如"MOVE:2,7,4,7"表示从(2,7)移动到(4,7);"CHAT:Hello"表示聊天信息。更规范的做法是使用二进制协议或JSON。

实操心得:网络同步与状态验证网络对战的难点在于状态同步和防作弊。一个简单的策略是:权威服务器主机权威。即只有服务器或主机进行完整的规则校验,客户端只做初步校验(如是否轮到本方走子)和发送意向。主机校验通过后,再将合法的棋步广播给双方,双方根据这个权威指令更新本地棋盘。这样可以避免因客户端被篡改而导致的规则破坏。

4.2 集成简单AI对手

实现一个“人机对战”模式会极大提升项目的技术含量。这涉及到基本的搜索算法。

  • 评估函数:首先,你需要一个函数来评价当前棋盘局面对于某一方的好坏。最简单的评估可以基于棋子价值(车=9,马=4.5,炮=4.5,象/士=2,兵=1,将=无穷大)和棋子位置加分。
  • 搜索算法:从最简单的极大极小搜索(Minimax)开始。AI(黑方)试图最大化评估值,而假设玩家(红方)会最小化评估值。算法递归地模拟未来几步的所有可能走法,选择对AI最有利的。
  • 性能优化:纯Minimax搜索深度非常有限。必须引入Alpha-Beta剪枝来大幅减少需要搜索的节点数。即使这样,搜索深度可能也只能达到4-6层,但对于初级AI已经足够。
  • 实现步骤
    1. 为AI生成所有合法走法。
    2. 对每个走法,模拟执行,调用Minimax(深度-1)递归搜索。
    3. 在搜索叶子节点(深度为0或游戏结束)时,调用评估函数。
    4. 选择评估值最优的走法执行。
// 极小极大算法伪代码框架 int minimax(ChessBoard& board, int depth, bool isMaximizingPlayer) { if (depth == 0 || gameIsOver(board)) { return evaluate(board); // 评估函数 } if (isMaximizingPlayer) { int maxEval = -INFINITY; for (Move move : generateAllMoves(board, BLACK)) { // AI是黑方 board.makeMove(move); int eval = minimax(board, depth - 1, false); board.undoMove(move); maxEval = max(maxEval, eval); } return maxEval; } else { int minEval = INFINITY; for (Move move : generateAllMoves(board, RED)) { // 玩家是红方 board.makeMove(move); int eval = minimax(board, depth - 1, true); board.undoMove(move); minEval = min(minEval, eval); } return minEval; } }

4.3 性能与内存优化

对于象棋游戏,性能瓶颈主要出现在AI搜索时。除了Alpha-Beta剪枝,还可以考虑:

  • 走法生成优化:预计算每种棋子在空棋盘上所有可能的移动偏移量,在实际生成时再结合棋盘状态判断合法性,比每次都从头计算快。
  • 置换表:使用哈希表存储已经搜索过的棋盘局面及其评估结果,避免重复搜索。这需要为棋盘状态生成一个唯一的Zobrist哈希值。
  • 智能指针管理:在ChessBoard中使用std::shared_ptr<ChessPiece>来管理棋子对象,可以方便地处理棋子的创建和销毁(如被吃掉时),避免内存泄漏。注意避免循环引用。

5. 开发环境搭建与调试技巧

工欲善其事,必先利其器。一个顺手的开发环境能事半功倍。

5.1 QT与C++环境配置

  • QT安装:直接从QT官网下载开源版本或安装器。对于初学者,建议安装带有QT Creator IDE的版本,它集成了设计、编码、调试、编译于一体,对QT项目支持非常好。版本选择上,QT 5.15 LTS或QT 6.2+都是稳定可靠的选择。
  • 编译器:在Windows上,安装QT时会自动勾选安装MinGW编译器。你也可以使用MSVC(Visual Studio编译器),这需要你事先安装Visual Studio(即使是Build Tools版本)。在Linux上,使用系统的g++即可。在macOS上,使用Xcode的命令行工具。
  • 项目管理:QT使用.pro文件(qmake)或CMakeLists.txt(CMake)来管理项目。对于新手,QT Creator创建的qmake项目更简单直观。在.pro文件中,你需要添加QT += core gui等模块。
  • VSCode配置:如果你偏爱VSCode,可以安装C++扩展和QT Configure扩展。配置c_cpp_properties.json中的包含路径,使其指向你的QT安装目录下的include文件夹。配置tasks.json来调用qmake和make进行构建。调试则需要配置launch.json

5.2 调试与问题排查实录

在开发过程中,你一定会遇到各种奇怪的问题。以下是我遇到的一些典型问题及解决方法:

  • 问题1:程序运行时崩溃,报错“pure virtual method called”

    • 现象:在操作过程中,程序突然崩溃。
    • 排查:这个错误通常意味着你在基类的构造函数或析构函数中调用了虚函数。在构造/析构期间,对象的虚函数表可能还未完全建立或已被销毁,此时调用虚函数会导致未定义行为。
    • 解决:绝对不要在构造函数和析构函数中调用虚函数。如果需要在初始化时进行多态操作,可以考虑使用“初始化函数”并在构造完成后调用。
  • 问题2:界面刷新不正常,棋子有残影或绘制错位

    • 现象:移动棋子后,原位置有残留图像,或者新位置绘制不正确。
    • 排查:这通常是paintEvent函数逻辑问题或刷新区域计算错误。
    • 解决
      1. 确保在paintEvent中,使用QPainter绘制时,是绘制整个棋盘和所有棋子。不要尝试只绘制“脏矩形”除非你非常确定。
      2. 在棋子移动后,确保同时调用update()来触发整个棋盘区域的重绘,或者计算新旧位置的两个矩形区域,调用update(rect)进行局部更新。
      3. 检查你的坐标转换函数logicToPixel是否正确,特别是行列索引与像素坐标的对应关系。
  • 问题3:鼠标点击事件不灵敏或响应区域错误

    • 现象:点击棋子边缘没反应,或者点击空白处却选中了棋子。
    • 排查:鼠标事件判断逻辑有问题。
    • 解决:在mousePressEvent中,你获取的是鼠标点击的像素坐标(x, y)。你需要将其转换为棋盘逻辑坐标(r, c)。然后,你需要判断这个(r, c)是否在棋盘范围内(0<=r<10, 0<=c<9)。最后,判断该位置是否有棋子。关键点:判断是否有棋子,应该使用逻辑坐标去查询ChessBoard模型,而不是依赖于你绘制的图像区域。因为绘制可能有偏移,但模型数据是精确的。
  • 问题4:发布程序后,在其他电脑上运行缺少DLL

    • 现象:在自己电脑上运行正常,打包发给别人后无法启动,提示缺少Qt5Core.dll等。
    • 解决:这是QT程序发布的常见问题。你需要将程序依赖的QT动态库(DLL文件)复制到可执行文件同级目录。最稳妥的方法是使用QT自带的命令行工具windeployqt(Windows)或macdeployqt(macOS)。在构建目录下,运行windeployqt your_program.exe,它会自动扫描依赖并复制所有必要的库和资源文件。对于Linux,通常需要告知用户安装对应的QT运行时库。

开发这样一个项目,最大的收获不是最终那个可以运行的游戏,而是在解决上述一个个具体问题的过程中,对C++面向对象设计、QT框架机制、以及算法逻辑实现的深刻理解。从棋盘数据结构的定义,到QT信号槽的巧妙连接,再到马蹩腿、炮翻山这些具体规则的代码实现,每一步都充满了挑战和乐趣。当你最终看到两个人在你亲手编写的程序中对弈时,那种成就感是无与伦比的。这个项目完全可以作为你C++/QT学习之路上的一个里程碑,它的代码和设计思路,也能为你今后开发更复杂的桌面应用打下坚实的基础。