多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Qt+C++多人同时在线文字修仙游戏开发:从协议设计到并发实战

Qt+C++多人同时在线文字修仙游戏开发:从协议设计到并发实战 简介这是一套基于Qt与C实现的多人同时在线文字修仙游戏源码面向计算机相关专业的毕业设计、课程设计及项目开发学习者尤其适合希望以网络编程与桌面客户端为切入点完成实战项目的同学。资源包共31个文件约3.36MB以cpp与h源文件为核心配合ui界面文件、qrc资源文件、pro工程文件及png、svg、jpg等图片素材并附README说明文档整体结构清晰便于快速导入Qt Creator编译运行。项目采用客户端与服务端分离设计涵盖用户注册登录、角色信息管理、数据库交互、窗口切换与自定义列表渲染等模块可帮助读者理解多人在线通信、界面与逻辑解耦、数据持久化等关键实现思路。目前已有283人学习下载源码经过严格测试可直接参考并在此基础上扩展玩法、优化交互或补充新功能适合作为项目开发与答辩展示的起点。1. 从一张“服务器列表”截图说起QtC 怎么撑起多人同时在线文字修仙很多人第一次看到“基于 QtC 开发的多人同时在线文字修仙游戏”这个标题脑子里冒出来的画面是黑底绿字的 MUD或者一个带按钮的桌面窗口。真正动手做过的人会告诉你这个项目的核心难点根本不在“修仙”两个字而在“多人同时在线”这五个字上——它逼着你把 Qt 从“画界面的库”重新理解成“带事件循环的网络程序框架”。文字修仙只是外壳底层要解决的是多个客户端同时连上一个服务端服务端怎么管理连接、怎么广播消息、怎么保证一个玩家打坐的时候另一个玩家不能抢他的灵石。这套东西做出来课程设计和毕业设计都能交差但更重要的是它能让你真正搞懂 Qt 的 QTcpServer、QTcpSocket、信号槽跨线程通信这几块硬骨头。适合有 C 基础、学过 Qt 控件但没写过网络程序的人也适合想找一个“能跑起来、能演示、能讲清楚”的课设题目的同学。下面按我实际搭过一遍的顺序从环境到协议到并发把这条路走通。2. 环境选型与工程骨架为什么 Qt 5.15 CMake 比 qmake 更省心2.1 Qt 版本和编译器怎么选才不翻车热词里频繁出现 “qt 5.15.2 下载安装” 和 “qt 安装”说明很多人卡在第一步。我的建议很直接Windows 上用 Qt 5.15.2 的 MinGW 64-bit 套件不要用 MSVC 套件去配 Visual Studio除非你已经有 VS 环境并且熟悉 “microsoft visual c redistributable” 那一套运行时依赖。MinGW 套件自带编译器装完 Qt Creator 就能直接编省掉 “vscode 配置 c/c 环境” 的折腾。Linux 上如果遇到 “qt.qpa.plugin: could not find the qt platform plugin” 这类报错八成是缺平台插件或者 DISPLAY 没设对装 libqt5gui5 和对应的 platform 插件包即可跟代码本身没关系。版本上不要追新。Qt 6 的信号槽语法和网络模块有改动网上大部分课设参考代码是 Qt 5 的混用会出现 “cannot mix incompatible qt library” 这种运行时崩溃。统一用 5.15.2服务端和客户端用同一个套件编译能避开大量玄学问题。2.2 用 CMake 组织服务端和客户端两个 target工程结构我一般这样分一个顶层 CMakeLists.txt下面 server 和 client 两个子目录各自一个可执行目标共享一个 protocol 目录放消息定义。这样服务端和客户端能复用同一套协议解析代码改一处两边都生效。# 顶层 CMakeLists.txt cmake_minimum_required(VERSION 3.16) project(XiuXianOnline LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 让 Qt 的 moc 自动处理带 Q_OBJECT 的类 find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) add_subdirectory(protocol) add_subdirectory(server) add_subdirectory(client)CMAKE_AUTOMOC ON是关键没有它任何带Q_OBJECT宏的类都不会生成 moc 文件链接时会报 undefined reference to vtable这是新手最常见的翻车点之一。find_package里服务端其实只需要 Core 和 Network客户端才需要 Widgets但为了简单可以统一引入链接时再按 target 区分。# server/CMakeLists.txt add_executable(xiuxian_server main.cpp server.cpp) target_link_libraries(xiuxian_server PRIVATE protocol Qt5::Core Qt5::Network)服务端不链接 Widgets意味着它可以做成无界面的控制台程序方便以后丢到服务器上跑。客户端才链接 Widgets 做界面。这个拆分让“多人同时在线”的服务端逻辑和界面彻底解耦调试网络问题时不用被界面干扰。2.3 协议层先定好别等联调再改文字修仙的消息类型不多登录、移动、打坐、攻击、聊天、状态同步。我习惯用一个枚举加一个 JSON 体Qt 自带 QJsonDocument序列化反序列化都方便不用自己写二进制协议。// protocol/message.h #pragma once #include QString #include QJsonObject enum class MsgType { Login 1, // 客户端请求登录 LoginAck, // 服务端返回登录结果 Move, // 移动请求 StateSync, // 服务端广播状态 Chat, // 聊天 Error // 错误通知 }; struct Message { MsgType type; QJsonObject payload; QByteArray toBytes() const { QJsonObject root; root[type] static_castint(type); root[payload] payload; return QJsonDocument(root).toJson(QJsonDocument::Compact) \n; } };每条消息以换行符结尾服务端用QTcpSocket::canReadLine()配合readLine()做粘包处理这是最省事的做法。JSON 的字段名统一用英文小写避免编码问题。协议一旦定下来服务端和客户端就按这个结构各写各的联调时只对字段不对实现。3. 服务端并发模型QTcpServer 怎么管住几十个同时打坐的玩家3.1 单线程事件循环够不够用文字修仙是 IO 密集型不是计算密集型。玩家发一条“打坐”指令服务端改个状态再广播出去CPU 几乎不干活。所以我的选择是服务端跑单线程事件循环用 QTcpServer 的newConnection信号接客每个连接对应一个 QTcpSocket全部挂在同一个线程里。这样没有锁没有跨线程信号槽的坑代码量少一半。// server/server.h class GameServer : public QObject { Q_OBJECT public: explicit GameServer(QObject* parent nullptr); bool start(quint16 port); private slots: void onNewConnection(); void onReadyRead(); void onDisconnected(); private: QTcpServer* m_server; QHashQTcpSocket*, QString m_socketToUser; // 连接 - 用户名 QHashQString, QJsonObject m_userState; // 用户名 - 状态 };m_socketToUser和m_userState两张表是整个服务端的心脏。前者解决“这个包是谁发的”后者解决“这个人现在什么状态”。单线程下直接读写不需要 QMutex。3.2 连接建立、消息分发、断线清理的完整链路void GameServer::onNewConnection() { while (m_server-hasPendingConnections()) { QTcpSocket* sock m_server-nextPendingConnection(); connect(sock, QTcpSocket::readyRead, this, GameServer::onReadyRead); connect(sock, QTcpSocket::disconnected, this, GameServer::onDisconnected); m_socketToUser.insert(sock, QString()); // 先占位登录后填用户名 } } void GameServer::onReadyRead() { QTcpSocket* sock qobject_castQTcpSocket*(sender()); if (!sock) return; while (sock-canReadLine()) { QByteArray line sock-readLine().trimmed(); QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(line, err); if (err.error ! QJsonParseError::NoError) { sendError(sock, bad json); continue; } dispatch(sock, doc.object()); } }canReadLine()循环是处理粘包的标准写法一次 readyRead 可能带来半条消息或多条消息必须循环读到没有完整行为止。dispatch里根据type字段分发登录消息特殊处理校验用户名是否已在线通过后写入m_socketToUser再回一条 LoginAck。断线清理容易被忽略。玩家直接关窗口socket 触发 disconnected你要把m_socketToUser和m_userState里对应的条目删掉否则这个用户名永远登不上来表现为“账号被占用”。void GameServer::onDisconnected() { QTcpSocket* sock qobject_castQTcpSocket*(sender()); if (!sock) return; QString user m_socketToUser.take(sock); if (!user.isEmpty()) { m_userState.remove(user); broadcastState(); // 通知其他人该玩家下线 } sock-deleteLater(); }deleteLater()而不是delete是因为当前还在这个 socket 的信号槽调用栈里直接 delete 会导致悬空指针崩溃。3.3 状态广播别给每个玩家单独发全量数据几十个人在线时如果每次有人移动就遍历所有 socket 发全量状态带宽和 CPU 都会浪费。我的做法是状态变更时只广播变更的那一条客户端自己维护本地状态表。void GameServer::broadcastState() { QJsonObject payload; payload[users] m_userState; // 简化版全量玩家少时够用 Message msg{MsgType::StateSync, payload}; QByteArray data msg.toBytes(); for (QTcpSocket* sock : m_socketToUser.keys()) { if (sock-state() QAbstractSocket::ConnectedState) { sock-write(data); } } }玩家数量在 50 以内时全量广播完全没问题一条状态 JSON 也就几百字节。真要到几百人再考虑增量同步和分区域广播课设阶段不用过度设计。注意write之后不用手动 flushQt 会在事件循环里自动发。4. 客户端界面与网络线程别让界面卡住 socket4.1 QTcpSocket 放主线程还是子线程热词里有 “qt 数据库 thread1workder(); void thread2workder();” 这种多线程写法但网络这块我建议客户端也把 socket 放主线程。原因很简单文字游戏的网络流量极小主线程事件循环完全扛得住放子线程反而要处理跨线程信号槽容易出 “QObject::connect: Cannot queue arguments” 这类问题。界面卡顿通常不是网络造成的是你在主线程里写了死循环或者同步等待。// client/networkclient.h class NetworkClient : public QObject { Q_OBJECT public: explicit NetworkClient(QObject* parent nullptr); void connectToServer(const QString host, quint16 port); void send(const Message msg); signals: void stateReceived(const QJsonObject state); void loginResult(bool ok, const QString reason); private slots: void onReadyRead(); private: QTcpSocket* m_sock; QByteArray m_buffer; };m_buffer用来处理半包socket 收到的数据不一定以换行结尾先追加到 buffer再按行切分。4.2 登录、移动、打坐三条指令的收发闭环void NetworkClient::onReadyRead() { m_buffer m_sock-readAll(); int idx; while ((idx m_buffer.indexOf(\n)) ! -1) { QByteArray line m_buffer.left(idx); m_buffer.remove(0, idx 1); QJsonDocument doc QJsonDocument::fromJson(line); QJsonObject root doc.object(); int type root[type].toInt(); QJsonObject payload root[payload].toObject(); if (type static_castint(MsgType::StateSync)) { emit stateReceived(payload); } else if (type static_castint(MsgType::LoginAck)) { emit loginResult(payload[ok].toBool(), payload[reason].toString()); } } }界面层收到stateReceived后刷新玩家列表和日志区。发送侧就是构造 Message 再m_sock-write(msg.toBytes())。整个闭环里界面只负责显示和收集输入网络只负责收发两边通过信号槽解耦改界面不影响协议。4.3 用 QListWidget 和 QTextEdit 快速搭出可演示的界面课设演示不需要花哨的绘图。左边一个 QListWidget 显示在线玩家和血量右边一个 QTextEdit 显示战斗和聊天日志底部一个 QLineEdit 输入指令。用 Qt Designer 拖出来或者纯代码建都行。关键是日志区要能滚动到底部ui-logEdit-moveCursor(QTextCursor::End)一行搞定。热词里的 “qt designer 下载” 其实 Qt 安装时自带不用单独下。5. 避坑与排查那些让课设卡三天的具体问题5.1 客户端连不上服务端但服务端显示已启动现象服务端listen返回 true客户端connectToHost一直转圈最后超时。原因通常是服务端绑定了QHostAddress::LocalHost只监听 127.0.0.1而客户端填了本机局域网 IP。解决服务端用QHostAddress::Any监听所有网卡客户端填 127.0.0.1 做本机测试跨机测试再换局域网 IP。另外检查防火墙有没有拦端口。5.2 中文用户名或聊天内容变成乱码现象客户端发“张三”服务端收到的是问号或乱码。原因QJsonDocument 默认按 UTF-8 处理但如果你的源文件编码是 GBK字符串字面量在编译期就已经错了。解决所有 .cpp/.h 文件统一存为 UTF-8Qt Creator 里设置“文件编码”为 UTF-8必要时在 main 里加QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8))。Qt 5 默认就是 UTF-8多数情况是源文件编码问题。5.3 程序运行时报 “cannot mix incompatible qt library”现象编译通过一运行就崩提示版本不匹配。原因系统里装了多个 Qt 版本运行时加载了错误的动态库。解决用 Qt Creator 的套件编译和运行不要手动把 exe 拷到别处双击。发布时用windeployqt把依赖库拷到 exe 同目录保证运行时用的是同一套库。5.4 玩家下线后重新登录提示“用户名已存在”现象关掉客户端再开登录被拒。原因服务端onDisconnected没清理m_socketToUser或者客户端异常退出没触发 disconnected。解决确保 disconnected 信号连上清理槽再加一个心跳机制服务端定时给所有 socket 发 ping超时未响应的主动断开并清理。心跳用 QTimer 每 30 秒跑一次即可。5.5 广播时程序崩溃报段错误现象多人同时操作时服务端随机崩溃。原因遍历m_socketToUser.keys()时某个 socket 在遍历过程中触发了 disconnected 被 delete导致迭代器失效。解决广播前先把 keys 拷一份出来遍历或者用deleteLater延迟删除保证当前事件循环内对象不消失。6. 进阶技巧用 QTest 给协议解析写一个最小回归测试课设答辩时老师最爱问“你怎么保证协议解析没问题”。与其口头解释不如现场跑一个测试。Qt 自带 QTest 框架给 protocol 模块写一个测试 target几行代码就能覆盖正常包、半包、坏 JSON 三种情况。// tests/test_protocol.cpp #include QtTest #include message.h class TestProtocol : public QObject { Q_OBJECT private slots: void testRoundTrip() { Message msg{MsgType::Chat, QJsonObject{{text, hello}}}; QByteArray bytes msg.toBytes(); QVERIFY(bytes.endsWith(\n)); QJsonDocument doc QJsonDocument::fromJson(bytes.trimmed()); QCOMPARE(doc.object()[type].toInt(), static_castint(MsgType::Chat)); QCOMPARE(doc.object()[payload].toObject()[text].toString(), QString(hello)); } void testBadJson() { QJsonParseError err; QJsonDocument::fromJson({not json, err); QVERIFY(err.error ! QJsonParseError::NoError); } }; QTEST_MAIN(TestProtocol) #include test_protocol.mocQTEST_MAIN自动生成 main 函数#include test_protocol.moc是因为测试类写在了 .cpp 里moc 需要这个包含。CMake 里加enable_testing()和add_testctest一跑绿了就说明协议层没退化。这个测试不依赖网络纯内存操作跑一次几毫秒改协议后先跑它再联调能省掉大量“改了 A 忘了改 B”的时间。再进一步可以把服务端的状态机抽出来做成不依赖 socket 的纯逻辑类测试里直接调handleMessage(user, msg)断言状态变化。这样“多人同时在线”的核心逻辑就能脱离网络单独验证答辩时演示测试比演示界面更有说服力。我自己踩过最深的坑是早期把状态管理和 socket 绑死测试没法写改一个字段要开两个客户端手动点半天。后来把状态抽出来才发现大部分 bug 在单元测试阶段就能拦住。如果你也在做这个方向先把协议和状态机测通再往上堆界面和玩法后面会轻松很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表