☰
C++课程设计实战:基于Qt+MySQL的TCP网络对战系统解析
2026/9/28 3:00:58 网站建设 项目流程

简介:面向北邮C++课程设计场景,这份宠物小精灵对战系统以Qt 5.12.7搭建界面、MySQL保存数据,覆盖登录注册、游戏大厅、背包、精灵信息、对战与结果展示等完整功能模块,是学习C++桌面编程、掌握Qt界面开发以及完成同类大作业时非常实用的参考资料。压缩包共91个文件,大小约38.68MB,内含20个C++源代码文件、20个头文件、12个界面配置文件、18张窗口截图,以及思维导图、Markdown与Word设计文档和可直接运行的exe程序,便于从源码、界面设计到运行效果全面对照。压缩包内附有课程设计报告与总体方案说明,通过模块清单清晰划分开始窗口、登录、用户列表、对战等环节,并涵盖数据库设计和多客户端并发思路,可帮助厘清界面与业务逻辑的联动关系。已有1300人学习,适合需要撰写课程报告、梳理Qt项目结构或进行二次开发的学生参考。

1. 宠物小精灵对战系统:一份能跑通TCP对战的C++课程设计

如果你正在被C++大作业折磨,大概率见过这类题目:做一个带图形界面、能联网对战、还得有数据库存储的“小游戏”。北邮这份宠物小精灵对战系统,正好把这三件套全占了——Qt 5.12.7搭界面,MySQL存账号和精灵数据,TCP多客户端并发支撑两个玩家实时对战。它不是一个只跑控制台打印的demo,而是从登录注册、游戏大厅、背包管理、选择精灵到对战结算的完整闭环,光窗口模块就有11个。对正在写课设、缺一份可参照的完整工程的人,或者想抄一套Qt+网络+数据库整合方案的人来说,这份源码的价值在于:模块划分清楚、文档齐全(课程设计报告.md、数据库设计.md、界面设计.docx、Pokemon.pdf都在包里),照着改比从零起项目省事得多。

2. 总体方案与模块拆解:11个窗口类怎么划分职责

2.1 技术选型:为什么是Qt 5.12.7 + MySQL而不是纯控制台

看课程设计报告里的软件开发环境:Qt 5.12.7、MySQL、Qt Creator、Windows10。这套组合对C++课设来说非常典型,原因有三点。

第一,Qt的信号槽机制让窗口跳转和控件交互的代码量比手写回调少一个量级。比如从登录窗口跳到游戏大厅,本质是emit一个信号然后另一个窗口的槽函数响应,这种松散耦合在界面多的项目里特别重要。第二,MySQL承担了账号注册登录、用户信息、精灵数据的持久化,比用文件读写更接近真实项目形态,答辩时也更好讲。第三,TCP多客户端并发是这份设计里最有分量的部分——对战不是在本机两个进程里模拟,而是走真实的socket通信,这正好回应“宠物小精灵对战系统”里“对战”二字的网络含义。

有一个容易踩的认知误区:以为Qt是“做界面的工具”,网络和数据库是另外两套东西。实际上在这个项目里,QTcpSocket、QTcpServer、QSqlDatabase都是Qt框架自带的模块,代码风格是统一的。也就是说,你只要把Qt的信号槽学会了,网络收发、数据库查询、界面更新用的是同一套思维,学习成本被摊薄了。

2.2 模块清单:从Widget到ResultWidget的职责边界

模块清单是这份资源最值得先看的部分,它直接给出了11个窗口的划分,相当于把整个系统的功能边界画好了。把模块标识符和实际场景对应起来:

模块名称模块标识符职责说明
开始窗口Widget游戏的入口界面,通常承载“开始游戏”“退出”按钮
登陆窗口Login完成登录、注册,连接MySQL校验账号密码
游戏大厅Lobby玩家匹配、房间选择,连接服务器的中转站
背包界面BagWidget展示用户拥有的所有小精灵,支持选中、查看
小精灵信息界面SpiritInfo展示单个精灵的等级、属性、技能等详情
用户列表UserList显示在线用户列表,服务于对战匹配
用户信息窗口UserInfo显示当前用户的个人信息、胜场等数据
选择服务器小精灵窗口Choose服务器端(房主)选择出战精灵
选择玩家小精灵窗口Choose2客户端(挑战者)选择出战精灵
对战界面FightWidget显示战斗过程:技能释放、血量变化、回合制逻辑
结果界面ResultWidget展示对战结果,胜负判定与数据入库

这套划分里最有参考价值的是Choose和Choose2分开——服务器端和玩家端各有一个选精灵窗口。很多课设做网络对战,往往会忽略“双方选择的精灵应该分别维护”这件事,结果做成一个窗口两边共用,数据互相覆盖。这份设计用两个窗口类把两端的角色差异显式表达出来,属于典型的“把需求翻译成类”的正确做法。

另外包里还带了窗口界面类继承设计.png和多客户端并发.png这两张图,说明作者在设计阶段就把界面类的继承关系和并发模型画清楚了。我一般拿到这种包,会先看这两张图,再对照模块清单去源码里找对应的类,比直接翻代码文件效率高很多。

2.3 数据流与状态流转:从登录到对战结算走过哪些环节

把11个窗口串起来看,整个系统的运行流程是这样的:启动Widget开始窗口,点击进入后跳到Login做登录注册,登录成功后进入Lobby游戏大厅。在大厅里看到在线用户(UserList),选择对手后,服务器端打开Choose选自己的精灵,客户端打开Choose2选自己的精灵,两边都选完进入FightWidget开始对战,打完后ResultWidget展示结果并把数据写回数据库。

这条链路里隐藏着一个关键设计:状态流转是“谁触发谁”的问题。细看模块清单,BagWidget和SpiritInfo是穿插在Lobby和Choose之间的——玩家在选精灵之前,应该先能浏览自己的背包和精灵详情,否则你根本不知道选哪只上场。所以界面的跳转顺序不是线性的一条,而是带分支的:Lobby既可以进UserList匹配对手,也可以进BagWidget调整队伍。这个分支逻辑在Qt里就是不同按钮触发不同槽函数,但在设计文档里能看出来作者确实考虑了用户操作路径。

值得注意的一点是,这个项目里有“选择服务器小精灵窗口”和“选择玩家小精灵窗口”的区别,意味着对战发起方和接受方在流程上是不对称的。发起方在自己的界面上创建/加入房间,接受方通过大厅的匹配进入同一个房间。这种不对称在单机演示时不容易暴露问题,但一旦两个客户端在不同机器上跑,就能看出窗口设计的价值——双方各自的UI状态由各自的窗口类维护,互不干扰。

3. Qt界面层实现:登录到对战全流程的信号槽联动

3.1 登录注册窗口:MySQL连接与密码校验的实现套路

登录窗口是整个系统对数据库依赖最深的地方。Qt操作MySQL的标准做法是QSqlDatabase搭配QSqlQuery,连接参数通常在main函数或一个专门的初始化函数里配置。项目里大致是这样一个模式:

QSqlDatabase db = QSqlDatabase::addDatabase("QMYSQL"); db.setHostName("127.0.0.1"); db.setPort(3306); db.setDatabaseName("pokemon"); db.setUserName("root"); db.setPassword("123456"); if (!db.open()) { qDebug() << "database open failed:" << db.lastError().text(); return false; }

这段代码的逻辑很直接:先通过addDatabase指定驱动类型QMYSQL,然后逐个设置主机、端口、库名、账号密码,最后调用open()建立连接。注意这里有个课设里非常常见的坑——addDatabase如果不带第二个参数,会加到一个默认连接上,如果你的程序里多处调用addDatabase,后面再open的是同一个连接,容易产生“连接被替换”的怪问题。稳妥做法是给连接起个名字,比如addDatabase("QMYSQL", "local"),每次用QSqlDatabase::database("local")拿连接。

登录校验的核心是查询语句的拼接方式。常见做法是用prepare绑定值,避免SQL注入,同时解决中文乱码:

QSqlQuery query(db); query.prepare("SELECT password, nickname FROM users WHERE username = ?"); query.addBindValue(username); if (query.exec() && query.next()) { QString pwd = query.value(0).toString(); if (pwd == password) { // 登录成功,记录用户信息并跳转大厅 } }

这里有两个容易被忽略的点:一是MySQL的密码在库里可能不是明文,如果课程设计要求加密存储,项目里可能用了MD5或SHA的哈希对比,你改的时候别把数据库里的哈希值拿来和明文比对;二是QSqlQuery必须绑定到已经open的数据库连接上,而且查询执行完要记得释放,否则连续登录多次可能连接耗尽。我一般会在登录窗口的析构函数里关闭db连接,而不是等到程序退出才关。

3.2 背包与精灵信息面板:QListWidget与QLabel的配合

BagWidget的职责是“显示用户所有的小精灵”,落到Qt上最自然的控件是QListWidget。每个小精灵作为一项列在列表里,点击某一项时,右侧的SpiritInfo面板切换显示对应精灵的详情。这个交互模式在Qt里就是两个信号槽的事:QListWidget的currentRowChanged信号连接到SpiritInfo的更新槽函数。

加载背包数据的逻辑大致是这样:

void BagWidget::loadSpirits(int userId) { QSqlQuery query; query.prepare("SELECT spirit_id, name, level, hp, attack FROM spirits WHERE owner_id = ?"); query.addBindValue(userId); if (!query.exec()) { qDebug() << "load spirits failed:" << query.lastError().text(); return; } ui->listWidget->clear(); while (query.next()) { QString name = query.value("name").toString(); int level = query.value("level").toInt(); QListWidgetItem *item = new QListWidgetItem( QString("%1 Lv.%2").arg(name).arg(level), ui->listWidget); item->setData(Qt::UserRole, query.value("spirit_id").toInt()); } }

这段代码里有几个值得说的设计:第一,item上挂了一个Qt::UserRole的自定义数据,存的是spirit_id,这样列表显示的是“皮卡丘 Lv.10”,但选中时能取到真正的数据库主键,后续对战扣血、升级都是靠这个id去定位记录。第二,query.value("name")用的是字段名而不是序号,可读性更强,也不容易因为SELECT的字段顺序调整而取错列。第三,clear()之前没有判断旧数据,每次重新加载都是全量刷新,这个模式在课设体量下没问题,但如果精灵数量上千,就要考虑增量更新或者加缓存。

界面上的图片资源,项目里有多张PNG截图和.xmind思维导图,说明开发过程中对界面布局是有过设计的。实际运行时,QLabel加载图片用的通常是QPixmap,路径使用相对路径会随着工作目录变化而失效,这是Qt课设的高频翻车点,后面避坑章节专门说。

3.3 对战界面:技能释放与状态更新的信号槽组织方式

对战界面FightWidget是11个窗口里逻辑最重的。一场回合制对战涉及:双方精灵的属性读取、技能伤害计算、血量扣减、战斗文本输出、胜负判定。在Qt里组织这段逻辑,信号槽的划分直接决定代码能不能读下去。

常见的组织方式是把“一次技能释放”封装成一个方法,界面只负责展示结果:

void FightWidget::onAttackButtonClicked() { if (!m_currentTurn->canAct()) { appendLog("当前精灵无法行动"); return; } int damage = m_currentTurn->calculateDamage(m_target); m_target->takeDamage(damage); appendLog(QString("%1 使用 %2,造成 %3 点伤害") .arg(m_currentTurn->name()) .arg(m_currentSkill->name()) .arg(damage)); if (m_target->isDead()) { appendLog(QString("%1 倒下了!").arg(m_target->name())); onBattleEnd(); return; } switchTurn(); }

这里的信号槽联动体现在按钮点击信号连接到onAttackButtonClicked,而onBattleEnd内部会emit一个battleFinished信号,由ResultWidget接收并展示结果。这种“按钮只触发入口,战斗逻辑留在业务方法里”的写法,是我比较推荐的做法——按钮的clicked信号不能直接去改界面上某个QLabel的文字,中间隔着结算逻辑,否则界面和业务揉成一团,后面想加技能特效、道具系统都会很难动。

对战过程中的血量变化和技能动画,项目用的是QLabel和定时器的组合:每次伤害计算完成后,用一个QTimer单次触发延迟刷新UI,模拟出“技能出手—伤害结算—动画播放”的节奏感。用QTimer::singleShot(800, this, [=]{ updateHpBar(); })这种写法比while循环加sleep优雅得多,不会卡死UI线程。如果对战里还有随机暴击、命中率,那就是c++随机数std::mt19937的活,后面进阶章节再展开。

4. TCP并发与数据协议:多客户端对战的通信设计

4.1 选TCP而不是UDP:对战场景对可靠性的刚需

对战系统里两个客户端要交换的数据是“谁放了什么技能、造成了多少伤害、现在双方血量多少”,这类消息少一条,整个战斗状态就对不上了。UDP虽然延迟低,但丢包和乱序在局域网演示环境里也会偶发,一旦战斗文本和血量进度条错乱,答辩现场就会很难看。所以这份设计用TCP是合理的:可靠、有序,QTcpSocket自带缓冲,配合QDataStream做封包解包非常顺手。真要优化延迟,那也是Qt自带socket的写缓冲和读缓冲调优,而不是换UDP协议。

4.2 报文协议与封包格式:QDataStream的版本陷阱

TCP是字节流协议,没有消息边界,所以必须自己定义封包格式。常见做法是自定义一个包头加负载的结构:前四个字节用qint32存消息总长度,后面跟实际数据。这个项目里如果用QDataStream,通常会这么写:

// 发送端 QByteArray block; QDataStream out(&block, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_5_12); out << (qint32)0; // 占位,回头填长度 out << (qint32)MessageType::ATTACK; out << skillId << damage; out.device()->seek(0); out << (qint32)(block.size() - sizeof(qint32)); socket->write(block);
// 接收端 QDataStream in(socket); in.setVersion(QDataStream::Qt_5_12); if (socket->bytesAvailable() < sizeof(qint32)) return; in >> totalLen; if (socket->bytesAvailable() < totalLen) return; qint32 msgType; in >> msgType; // 按消息类型分发处理

这段代码最大的坑已经在里面了:setVersion。Qt的QDataStream序列化格式在不同Qt版本之间不保证兼容,如果你用Qt 5.12写的客户端去连Qt 6的服务器,不设setVersion的话解包大概率直接乱掉。项目开发环境是Qt 5.12.7,所以发收两端都应该固定写成out.setVersion(QDataStream::Qt_5_12),而不是依赖默认版本。另外占位写长度的技巧很实用——先写一个0占住前四字节,所有数据写完再seek到开头把真实长度覆盖进去,这样接收端永远先读到一个有效的qint32长度。

还有一点新手容易忽略:socket->write(block)之后不代表对方立刻收到完整数据。TCP的粘包问题在这个项目里就会出现——两个消息连在一起发,接收端一次性读到超过一个包的数据。所以接收处理必须写成“先判断bytesAvailable够不够包头,再判断够不够整个消息”,一次读不完就等下一次readyRead信号,绝不能假设每个readyRead对应一条完整消息。

4.3 服务器线程模型:QTcpServer与连接管理

多客户端并发在Qt里有两种常见模型:一种是每个连接分配一个QThread,另一种是使用Qt的事件循环配合信号槽,在单线程内用非阻塞方式处理所有连接。课设体量下,第二种更常见也更稳妥,因为QThread加socket的管理复杂度会急剧上升。项目里多客户端并发.png应该就画的是这个模型——QTcpServer在主线程监听,newConnection信号触发后,把QTcpSocket装进一个连接列表:

void Server::onNewConnection() { QTcpSocket *client = m_server->nextPendingConnection(); connect(client, &QTcpSocket::readyRead, this, &Server::onReadyRead); connect(client, &QTcpSocket::disconnected, this, &Server::onClientDisconnected); m_clients.append(client); }

这种写法背后有个重要的信号槽机制:QTcpSocket的readyRead信号会在内核缓冲区有新数据到达时触发,但你必须在槽函数里把所有可读的数据都读完,否则下一个readyRead可能不来了——这是Qt文档里明确写过的行为。很多人的“服务器收不到第二条消息”就是这么来的:第一条处理完没一次性读净,缓冲区还残余数据,但readyRead不会再触发。正确做法是在onReadyRead里用while(socket->bytesAvailable() > 0)循环解析,解析到缓冲区暂时不够一个完整包时就break,等下一个readyRead。

关于并发,有一个更隐蔽的问题:QTcpSocket默认的QReadWriteLock机制保证同一个socket不会同时被多个线程读写,但如果你的服务器逻辑里把socket对象跨线程访问,连接列表不加锁,调试时会随机崩溃。在这个项目里我建议明确规则:所有socket操作都在主线程的事件循环里做,所谓的“并发”由Qt的事件驱动来承载,而不是自己开线程。这块儿如果真想上多线程,至少得保证m_clients的读写都在同一线程,或者加QMutex保护。

5. 编译运行与常见避坑:从源码到可执行文件的五个坑

5.1 环境核对与构建顺序

拿到包后第一件事不是打开.pro就点构建,而是先核对环境。项目要求Qt 5.12.7,你机器上如果是Qt 5.15或者Qt 6,大概率会遇到两类问题:一是QDataStream版本不匹配(前面说过的setVersion),二是某些接口在Qt 6里被移除了,比如QRegExp换成了QRegularExpression。我一般会先看一眼.pro文件里的QT += widgets network sql这几行,确认模块齐全,然后在Qt Creator里用MinGW 64位套件打开工程,等qmake自动执行完再构建。

构建顺序上,建议先编译纯界面部分,再打开网络和数据库相关代码。最简单的方法是先注释掉main函数里和数据库连接的代码,只跑通一个空窗口,确认Qt环境没问题。数据库驱动加载不成功的话,程序往往能编译通过,但运行时打开数据库失败,这类问题编译期完全看不出来。

5.2 常见问题排查记录

现象1:程序一启动就崩溃,报“access violation c0000005”

原因:最常见的是野指针,尤其是窗口切换时把局部窗口对象delete了,但还在用其信号槽连接。Qt的点击事件是异步的,窗口关闭后如果还有pending的信号发往一个已销毁的对象,就会访问非法内存。

解决:把跨窗口跳转的对象用new创建并设置为WA_DeleteOnClose,或者在信号槽连接时用QObject::connect的第五个参数Qt::UniqueConnection和合适的context对象。排查时在崩溃处打断点,看调用栈里有没有已析构的对象,比瞎试快得多。

现象2:控制台输出“QMYSQL driver not loaded”

原因:Qt 5.12.7自带的bin目录下没有qsqlmysql.dll(Qt官方只带QSQLITE驱动),MySQL驱动需要自己用Qt源码编译,或者安装对应版本的ODBC驱动再走QODBC。

解决:最简单的绕法是装MySQL Connector/ODBC,然后把代码里的QSqlDatabase::addDatabase("QMYSQL")换成addDatabase("QODBC"),DSN指向你的MySQL。如果必须用QMYSQL,就得去Qt源码目录下编译qsqlmysql插件,把生成的dll放到Qt的plugins/sqldrivers目录。这里有个细节:MySQL驱动dll依赖libmysql.dll,这个dll要一并放到PATH能找到的地方,否则驱动加载还是失败。

现象3:界面上的中文全部变成乱码或者问号

原因:源文件编码和编译器默认编码不一致。Qt Creator默认用UTF-8,但Windows下如果代码文件被保存成了GBK,MSVC编译器按GBK编译,字符串字面量的字节序列就乱了。

解决:统一源文件编码为UTF-8,并在.pro文件里加上QMAKE_CXXFLAGS += /utf-8(MSVC编译器)。如果用MinGW,一般UTF-8源文件加QMAKE_CXXFLAGS += -finput-charset=UTF-8。还需要在MySQL的连接参数里设置setNames utf8,否则数据库读出来的中文照样乱。

现象4:服务器和客户端在同一台机器上跑,connect失败或者端口被占用

原因:TCP端口被上一个未释放的socket占用,或者客户端、服务器用的是同一个端口。Debug模式下上一次运行没彻底退出,端口处于TIME_WAIT状态。

解决:把服务器端口固定成一个四位数,比如8888,客户端连接时保持一致。调试前先看任务管理器里有没有残留的pokemon.exe进程,杀掉再跑。如果还是持续TIME_WAIT,可以在socket连接前设置setSocketOption(QAbstractSocket::LowDelayOption, 1),或者socket->abort()先断掉旧连接再connectToHost。

现象5:图片资源加载出来一片空白,或者直接找不到文件

原因:Qt的资源加载路径默认是进程的工作目录,双击exe和从Qt Creator点运行,工作目录完全不同。代码里写着"resource/spirit1.png"这种相对路径,在工作目录不同时必炸。

解决:最稳妥的是把图片放进.qrc资源文件,用前缀":/images/spirit1.png"访问,编译时打进二进制,跟工作目录无关。如果不想用qrc,至少用QCoreApplication::applicationDirPath()拼绝对路径,而不是依赖当前工作目录。

5.3 资料文档的配合使用顺序

包里带的课程设计报告.md、数据库设计.md、概要设计.docx、界面设计.docx、Pokemon.pdf不是摆设。正确的打开顺序是:先读README.md确认构建步骤,再打开数据库设计.md看表结构和初始化SQL,然后用课程设计报告.md对照模块清单梳理代码结构,最后写自己的报告时参考界面设计.docx和那张窗口界面类继承设计.png。如果你是要改造成自己的课设,数据库设计.md的价值最大——它把users、spirits、battle_records这类表的关系写清楚了,你改字段时可以直接照搬。

6. 进阶验证与改造技巧:把课设变成能答辩的项目

先做一次完整的验收自测,再决定往哪个方向改。我的习惯是列一张检查单:注册新账号能不能成功写入MySQL、重复用户名有没有被拦截、登录后背包数据是否和数据库一致、选精灵时双方选择的精灵是否各自独立、对战结束时结果有没有写库、两个客户端掉线时服务器会不会崩溃。这六项全过,项目的基本盘就稳了。

改造方向上,三个建议最划算。第一是给对战伤害加随机浮动,用std::mt19937生成一个0.9到1.1的系数乘到基础伤害上,这样每回合伤害不完全一样,演示时不会显得死板,答辩也能多讲一句“引入了随机数机制”:

static std::mt19937 rng{std::random_device{}()}; std::uniform_real_distribution<double> dist(0.9, 1.1); int finalDamage = static_cast<int>(baseDamage * dist(rng));

第二是给对战过程加一份JSON日志,把每个回合的双方精灵、技能、伤害、剩余血量记下来。Qt里有QJsonDocument,直接组装一个QJsonArray,战斗结束后写到logs目录。这个功能加量不大,但非常好讲——“支持赛后复盘”是能一句话说清的亮点。

第三是审视窗口切换的边界条件。最容易被追问的是“两个客户端在选精灵阶段,其中一个人直接关掉窗口,服务器怎么处理”。这时候你需要在onClientDisconnected里把对应用户从对战列表中移除,并通知对手返回大厅。这个逻辑不一定要做得非常完备,但你必须能说出设计的处理方式,否则答辩现场会被问住。

关于验证方式,最实际的做法是开两个Qt Creator实例,一个跑服务器,两个分别跑客户端,三窗口同时开。先把三个窗口都放在本机演示,确认没问题后再找两台机器连局域网跑一遍——能跨机器跑通,说明TCP通信是真的,不是本机自说自话。

我从这份包里学到最深的一课,是窗口跳转时的对象生命周期管理。以前我写Qt课设,经常用局部变量创建窗口,函数一结束窗口对象就被销毁,界面一闪就没了,后来才意识到必须用new配合show(),或者设置WA_DeleteOnClose。从那以后,我每次写窗口切换都强制走一遍“对象归谁管、什么时候销毁、信号连接是否跨生命周期”这三步确认。这一点想通了,Qt课设的很多偶发崩溃都能避免。希望这份拆解能帮你把项目跑通,也能在答辩时讲出自己的理解。

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

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

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

立即咨询