Qt+C++多人文字修仙游戏:从单机到百人在线的网络编程实战

发布时间:2026/10/7 1:21:55
Qt+C++多人文字修仙游戏:从单机到百人在线的网络编程实战 简介这是一套基于Qt与C实现的多人同时在线文字修仙游戏源码面向计算机相关专业的毕业设计、课程设计及项目开发学习者尤其适合需要完整网络编程与GUI实战案例的中高级开发者。项目采用客户端/服务端双端架构涵盖用户注册登录、角色信息管理、数据库交互、自定义列表渲染及窗口逻辑处理等模块可帮助读者理解多人在线通信、Qt界面设计与数据持久化的整合思路。压缩包共31个文件以cpp与h源码为主体辅以ui界面文件、qrc资源文件、png与svg图标素材及pro工程配置整体约3.36MB结构清晰、便于按模块阅读。资源已经过严格测试可直接运行参考并在此基础上延伸扩展。目前已有283人学习下载适合作为网络游戏开发入门与Qt综合实践的参考素材。1. 从一台笔记本到百人在线QtC文字修仙游戏到底怎么落地很多人第一次听到「基于 QtC 开发的多人同时在线文字修仙游戏」脑子里冒出的画面是一个带窗口的客户端登录进去敲命令打坐、炼丹、斗法服务器那头几十上百号人同时在线数据实时同步。这个标题里其实压着三件事Qt 负责客户端界面与交互C 负责把服务端和客户端逻辑写扎实多人同时在线则要求网络通信、状态同步、并发处理都得过关。它适合两类人一类是拿它当毕业设计或课程设计需要一套能跑、能讲清楚、能改的完整源码另一类是已经会点 C想找一个有真实网络交互、又不至于像 3D 引擎那样劝退的项目练手。文字修仙这个题材的好处是它把「游戏」拆成了文本、数值和状态机没有美术资源也能做出可玩性正好把精力集中在 Qt 界面设计和 C 网络编程上。我见过太多人卡在「能编译但连不上」或者「单机能跑、多人就崩」这两步所以这篇笔记不聊虚的直接按「先跑通、再拆解、后避坑」的顺序把这条链路讲透。2. 先让单机能跑Qt 客户端与 C 服务端的最小闭环2.1 为什么选 Qt Widgets 而不是 QML 做文字游戏文字修仙游戏的界面本质是「大量文本 少量按钮 状态面板」这种场景下 Qt Widgets 比 QML 更省心。QML 擅长动画和触摸交互但文字游戏不需要那些反而需要频繁刷新 QTextEdit、QListWidget 和 QTableWidgetWidgets 的信号槽机制直接对应「服务器推一条消息界面追加一行」这种需求。用 Qt Creator 新建项目时选 Qt Widgets Application基类选 QMainWindow这样主窗口自带菜单栏和状态栏后面加「角色面板」「聊天频道」「战斗日志」都好扩展。C 标准至少用 C17因为后面网络层要用到 std::shared_ptr 管理会话对象避免手动 delete 带来的崩溃。编译器在 Windows 上推荐 MSVC2019 64bit和 Qt 5.15.2 的预编译包匹配省去自己编译 Qt 的麻烦Linux 下用 g 加 Qt 官方在线安装器即可。这里有个血泪经验不要一上来就搞 MVVM 框架文字游戏的数据流很简单过度分层只会让毕业设计答辩时自己都讲不清。2.2 服务端最小原型QTcpServer 接收连接并回显先把服务端跑起来确认端口能通再谈游戏逻辑。下面这段代码是一个最小可运行的服务端监听 8888 端口收到客户端消息后原样回发用来验证网络链路。// server.cpp #include QCoreApplication #include QTcpServer #include QTcpSocket #include QDebug int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QTcpServer server; // 监听所有网卡的 8888 端口局域网内其他机器也能连 if (!server.listen(QHostAddress::Any, 8888)) { qDebug() listen failed: server.errorString(); return -1; } qDebug() server started on port 8888; QObject::connect(server, QTcpServer::newConnection, [server]() { while (server.hasPendingConnections()) { QTcpSocket *client server.nextPendingConnection(); qDebug() new client: client-peerAddress().toString(); QObject::connect(client, QTcpSocket::readyRead, [client]() { QByteArray data client-readAll(); // 回显验证收发链路 client-write(echo: data); }); QObject::connect(client, QTcpSocket::disconnected, client, QTcpSocket::deleteLater); } }); return app.exec(); }逻辑说明QTcpServer 的 newConnection 信号在每次有新客户端接入时触发hasPendingConnections 配合 nextPendingConnection 取出套接字。readyRead 信号表示有数据到达readAll 一次性读完当前缓冲区。参数上QHostAddress::Any 表示监听 IPv4 所有地址如果只想本机测试可以改成 QHostAddress::LocalHost。端口 8888 是常见测试端口正式部署时换成 1024 以上的其他端口。注意 deleteLater 不能省否则客户端断开后套接字对象泄漏跑久了内存上涨。2.3 客户端连接与消息发送QTcpSocket 的三步走客户端这边连接、发送、接收各对应一个信号槽。新建一个 Widget 项目在主窗口构造函数里加以下代码。// mainwindow.cpp 片段 socket new QTcpSocket(this); connect(socket, QTcpSocket::connected, this, [this]() { ui-logEdit-append(已连接到服务器); }); connect(socket, QTcpSocket::readyRead, this, [this]() { QByteArray data socket-readAll(); ui-logEdit-append(QString::fromUtf8(data)); }); connect(socket, QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { ui-logEdit-append(连接错误: socket-errorString()); }); // 连接服务器IP 和端口按实际改 socket-connectToHost(127.0.0.1, 8888);逻辑说明connectToHost 是异步的不会阻塞界面连接成功后触发 connected 信号。readyRead 里用 fromUtf8 转换因为中文修仙文本必须统一 UTF-8 编码否则会出现乱码。errorOccurred 信号在 Qt 5.15 里替代了老版本的 error 信号参数是枚举类型这里只取错误字符串。参数上127.0.0.1 是本机回环地址局域网联机时改成服务端机器的内网 IP。发送消息用 socket-write(ui-inputEdit-text().toUtf8())注意要加换行符或长度前缀否则服务端无法判断一条消息的边界这是后面协议设计要解决的问题。3. 多人同时在线协议设计、状态同步与并发处理3.1 自定义消息协议长度前缀 JSON 体TCP 是字节流没有消息边界。如果直接 write 多条消息接收端 readAll 可能一次读到两条粘在一起这就是粘包。常见做法是「4 字节长度前缀 消息体」消息体用 JSON方便扩展字段。下面是一个打包和解包的实现。// protocol.h #pragma once #include QByteArray #include QJsonObject #include QJsonDocument // 打包4字节大端长度 JSON inline QByteArray packMessage(const QJsonObject obj) { QByteArray body QJsonDocument(obj).toJson(QJsonDocument::Compact); QByteArray packet; QDataStream out(packet, QIODevice::WriteOnly); out.setByteOrder(QDataStream::BigEndian); out (quint32)body.size(); packet.append(body); return packet; } // 解包从缓冲区取出完整消息返回是否成功 inline bool unpackMessage(QByteArray buffer, QJsonObject outObj) { if (buffer.size() 4) return false; QDataStream in(buffer); in.setByteOrder(QDataStream::BigEndian); quint32 len 0; in len; if (buffer.size() 4 (int)len) return false; QByteArray body buffer.mid(4, len); buffer.remove(0, 4 len); outObj QJsonDocument::fromJson(body).object(); return true; }逻辑说明packMessage 先用 QJsonDocument 把对象序列化成紧凑 JSON再用 QDataStream 写入 4 字节长度。unpackMessage 先检查缓冲区是否有 4 字节头再检查消息体是否完整不完整就返回 false 等下次数据到达。参数上BigEndian 是网络字节序跨平台必须统一。JSON 体里至少要有 type 字段区分消息类型比如 login、move、chat、attack。注意 buffer.remove 之后缓冲区会缩小循环解包时要用 while(unpackMessage(buffer, obj)) 直到返回 false。3.2 会话管理用 QMap 存玩家状态并处理断线服务端要为每个连接维护一个玩家对象存角色名、等级、气血、位置等。用 QMapQTcpSocket*, PlayerPtr 管理PlayerPtr 是 std::shared_ptr 。新连接时创建 Player断开时从 map 移除并广播离线消息。// sessionmanager.h 片段 struct Player { QString name; int level 1; int hp 100; QString scene 青云山; }; using PlayerPtr std::shared_ptrPlayer; QMapQTcpSocket*, PlayerPtr g_players; // 新连接 void onNewConnection(QTcpSocket *client) { g_players[client] std::make_sharedPlayer(); // 广播有人进入 QJsonObject msg{{type, system}, {text, 一位道友踏入了修仙界}}; broadcast(packMessage(msg)); } // 断开 void onDisconnected(QTcpSocket *client) { if (g_players.contains(client)) { QString name g_players[client]-name; g_players.remove(client); QJsonObject msg{{type, system}, {text, name 离开了}}; broadcast(packMessage(msg)); } client-deleteLater(); } // 广播给所有在线玩家 void broadcast(const QByteArray packet) { for (auto it g_players.begin(); it ! g_players.end(); it) { it.key()-write(packet); } }逻辑说明QMap 的 key 是套接字指针value 是 shared_ptr断开时 remove 会自动释放 Player。broadcast 遍历所有连接写入数据注意这里没有加锁因为 Qt 的网络信号槽默认在同一个线程的事件循环里执行不存在多线程竞争。参数上scene 字段用来做场景同步后面玩家移动时只广播给同场景的人减少流量。如果在线人数上百broadcast 要改成只发给同场景玩家否则每次有人说话全服都收带宽吃不消。3.3 心跳与超时防止「假在线」占着连接TCP 连接如果客户端异常断电服务端可能长时间不知道这个连接就成了「假在线」。常见做法是客户端每 30 秒发一个心跳包服务端记录最后活跃时间超过 90 秒没收到就主动断开。// 服务端定时检查 QTimer *timer new QTimer(this); connect(timer, QTimer::timeout, this, [this]() { qint64 now QDateTime::currentSecsSinceEpoch(); for (auto it g_players.begin(); it ! g_players.end(); ) { if (now - it.value()-lastActive 90) { it.key()-disconnectFromHost(); it g_players.erase(it); } else { it; } } }); timer-start(30000); // 每30秒检查一次逻辑说明lastActive 在每次收到该玩家消息时更新为当前时间戳。erase 返回下一个迭代器所以循环里用 it erase(it) 而不是 it。参数上90 秒是经验值移动网络下可以放宽到 120 秒。注意 disconnectFromHost 是异步的会触发 disconnected 信号所以 onDisconnected 里要判断 map 里是否还有这个 key避免重复删除。4. 避坑与排查多人联机最容易翻车的五个点4.1 现象客户端显示乱码中文变成问号原因Qt 5 默认字符串编码在 Windows 上是本地编码而网络传输用的是 UTF-8两边不一致。解决所有 toUtf8 和 fromUtf8 成对出现源码文件保存为 UTF-8 带 BOM在 main 函数里加 QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8))。如果用的是 Qt 6QTextCodec 移到了 core5compat 模块建议直接统一用 QString::fromUtf8。4.2 现象服务端收到消息但解析失败JSON 报错原因粘包导致一次 readAll 读到多条消息或者消息不完整。解决不要直接对 readAll 的结果做 fromJson必须先放进缓冲区用 3.1 的 unpackMessage 循环取完整消息。另外检查 packMessage 和 unpackMessage 的字节序是否一致一个 BigEndian 一个 LittleEndian 就会读出天文数字的长度。4.3 现象多人同时登录时程序崩溃原因在 newConnection 的 lambda 里直接操作了界面对象而界面对象属于主线程网络回调可能在别的线程。解决Qt 的 QTcpServer 默认在创建它的线程里发信号只要 server 在主线程创建回调就在主线程不会崩。如果自己开了 QThread 跑网络必须用信号槽跨线程传递不能直接调 ui- 指针。另一个常见原因是迭代 QMap 时删除了元素导致迭代器失效参考 3.3 的 erase 写法。4.4 现象局域网内其他机器连不上服务端原因服务端监听地址写成了 QHostAddress::LocalHost只绑定了 127.0.0.1。解决改成 QHostAddress::Any。如果还是连不上检查 Windows 防火墙是否拦截了 8888 端口在「入站规则」里放行该端口或者临时关闭防火墙测试。Linux 下用 sudo ufw allow 8888 放行。4.5 现象客户端断开后服务端内存持续上涨原因QTcpSocket 对象没有 deleteLater或者 Player 的 shared_ptr 被循环引用。解决在 disconnected 信号里调用 deleteLater并且确保 Player 里没有持有套接字的 shared_ptr。用 Qt Creator 的内存分析器或者 Linux 下的 valgrind 跑一遍看 QTcpSocket 实例数是否随连接数线性增长。5. 从能跑到好用数据持久化与一个压测技巧5.1 用 SQLite 存角色数据重启不丢档文字修仙游戏如果重启就清档没人愿意玩。Qt 自带 QtSql 模块用 SQLite 单文件数据库最省事。下面是一个存角色和读角色的最小示例。// db.h #include QSqlDatabase #include QSqlQuery #include QSqlError void initDb() { QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(xiuxian.db); if (!db.open()) { qDebug() db open failed: db.lastError().text(); return; } QSqlQuery query; query.exec(CREATE TABLE IF NOT EXISTS player ( name TEXT PRIMARY KEY, level INT, hp INT, scene TEXT)); } void savePlayer(const PlayerPtr p) { QSqlQuery query; query.prepare(REPLACE INTO player (name, level, hp, scene) VALUES (?, ?, ?, ?)); query.addBindValue(p-name); query.addBindValue(p-level); query.addBindValue(p-hp); query.addBindValue(p-scene); query.exec(); }逻辑说明REPLACE INTO 在 SQLite 里等价于「存在则更新不存在则插入」适合按角色名做主键。参数用 addBindValue 绑定避免 SQL 注入。注意 QSqlDatabase 不能在多线程间共享如果服务端是多线程的每个线程要单独 addDatabase 并指定不同的连接名。存盘时机建议在玩家下线、升级、切换场景时触发不要每帧都写SQLite 扛不住高频写入。5.2 用 Qt 命令行工具做简易压测毕业设计答辩时老师常问「能支持多少人同时在线」与其拍脑袋说 100不如自己压一下。写一个控制台程序开 50 个 QTcpSocket 同时连服务端每个每秒发一条心跳跑 10 分钟看服务端内存和 CPU。// stress.cpp #include QCoreApplication #include QTcpSocket #include QTimer #include QVector int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QVectorQTcpSocket* clients; for (int i 0; i 50; i) { QTcpSocket *s new QTcpSocket(app); s-connectToHost(127.0.0.1, 8888); clients.append(s); } QTimer timer; QObject::connect(timer, QTimer::timeout, [clients]() { for (auto *s : clients) { if (s-state() QAbstractSocket::ConnectedState) { s-write({\type\:\heartbeat\}\n); } } }); timer.start(1000); return app.exec(); }逻辑说明这个压测程序只测连接保持和心跳收发不测游戏逻辑。参数上50 个连接对单机服务端压力不大可以逐步加到 200、500观察服务端 QMap 大小和内存占用。注意压测程序本身也会占资源最好在另一台机器上跑。如果服务端 CPU 飙升检查是不是每次广播都遍历了全部玩家改成按场景分组广播。5.3 一个我踩过的坑别在信号槽里做耗时操作早期版本我在 readyRead 里直接查数据库存盘结果玩家一多界面卡成幻灯片。原因是 readyRead 在主线程触发查库是同步阻塞的。后来改成把存盘请求丢进队列用一个单独的 QThread 定时批量写界面立刻流畅。这个习惯我一直保留网络回调里只做解析和状态更新任何 IO 操作都异步化。希望帮到你。本文还有配套的精品资源点击获取