简介:这套传奇模拟游戏完整源码包含客户端与服务器端程序,面向具备C++基础、希望理解游戏C/S架构与网络编程的开发者,采用VC++6.0编写,适合课程设计、项目参考及旧系统维护学习,也可作为从单机逻辑迈向C++服务器开发的实践入口。压缩包共499个文件,约11.32MB,以131个.h头文件和127个.cpp实现文件为核心,辅以vcproj、dsw、dsp等工程配置,以及rc、res、ico等界面资源与obj、sbr等编译中间文件,目录覆盖客户端与服务端主要模块,便于按工程结构检索与阅读。已有218人学习下载。读者可对照客户端角色控制、场景渲染、交互实现,梳理服务器端状态同步、规则执行与数据存储的衔接方式,理解请求响应、数据封装传输、多线程及性能优化等主题,从而掌握一套可运行的游戏C/S框架与调试思路。
1. 一套能登录、能刷怪、能打架的复古 MMO 模拟器源码
去年有个朋友把一套复古风 MMO 模拟器的客户端和服务端 C++ 源码丢给我,说想自己搭个能跑起来的版本,结果卡在登录服连不上网关服这一步,折腾了两天。这套东西的形态很经典:战法道三职业、2D 俯视角、地图刷怪、装备掉落、行会聊天,服务端用 C++ 写成多个进程,客户端也是 C++,走 TCP 长连接、自定义二进制协议。它不是玩具 Demo,账号、角色、背包、战斗、存盘这条链路都是通的,适合两类人:一类是想搞懂网络游戏服务端到底怎么分层、怎么处理粘包、怎么把玩家数据落库的开发者;另一类是手上有这套源码、想改出自己玩法的二次开发者。下面按“它长什么样 → 怎么跑起来 → 怎么动手改”的顺序拆。
2. 架构与技术选型:四个进程撑起一整套服务端
2.1 为什么登录、网关、游戏、存储要拆开
这套服务端最值得先看懂的是它的进程划分,而不是某个具体算法。账号校验、连接维持、地图战斗、数据落库被拆成 LoginServer、GateServer、GameServer、DBServer 四个独立进程,各自跑在单独的可执行文件里,进程之间用内网 TCP 短连接或长连接通信。这样拆的理由很实际:网关是唯一暴露给公网的一层,它只做连接管理、心跳、包长度校验和转发,不解析业务;游戏服可以按地图分组开多份,跑到 CPU 打满就加进程;数据库服是单点写入口,所有落库请求排队处理,避免多进程同时写同一张角色表。
我一般会把这种结构理解成“接入层薄、逻辑层重、存储层单点”。接入层薄意味着网关的代码量应该很小,任何人往网关里塞业务判断都是给以后挖坑;逻辑层重意味着 GameServer 是整套代码里最厚的部分,地图、怪物、技能、掉落、行会全堆在这里;存储层单点意味着 DBServer 必须扛得住排队,一旦它阻塞,玩家下线保存会全部卡住。
进程间通信的协议和客户端协议是两套。客户端到网关用的是对外的二进制包,网关到游戏服、游戏服到数据库服通常用内部包,字段更随意,可能直接就是结构体 memcpy。这套代码里两套协议共用同一个包头结构,只是命令字的取值范围不同,好处是写代码时解析逻辑可以复用,坏处是删减命令字时要格外小心,别把内部命令和外部命令的编号撞了。
| 进程 | 职责 | 是否暴露公网 | 崩溃影响 |
|---|---|---|---|
| LoginServer | 账号注册、密码校验、封号检查 | 是(或经网关) | 新登录失败,在线玩家不受影响 |
| GateServer | 连接维持、心跳、包校验、转发 | 是 | 全体掉线,最需要做守护 |
| GameServer | 地图、战斗、背包、行会、任务 | 否 | 该地图玩家数据丢失风险 |
| DBServer | 角色读写、物品流水、防回档 | 否 | 全服无法保存,最该先起 |
2.2 线程模型:一个 tick 驱动的游戏世界
服务端不是“一个连接一个线程”,那样几百人在线就废了。常见做法是 IO 线程负责收发包,逻辑线程单线程跑固定 tick,所有实体状态只在逻辑线程里改。这样做的核心收益是不用给每个怪物、每个玩家加锁,战斗结算、Buff 计时、AI 寻路全部在一个线程里顺序执行,天然没有竞态。代价是单线程必须够快,任何一个 tick 里出现耗时操作(比如同步查库、大文件读盘)都会让整个世界卡顿。
下面这段是 GameServer 主循环的骨架,tick 定在 50ms,也就是每秒 20 帧的逻辑更新:
// GameServer 主循环:IO 非阻塞收包 + 固定 tick 世界更新 + 统一下发 void GameServer::Run() { const int64_t kTickMs = 50; int64_t nextTick = NowMs(); while (running_) { int64_t now = NowMs(); if (now < nextTick) { // 睡 1ms 让出 CPU,比 sleep(50) 精度高,也不至于空转烧核 std::this_thread::sleep_for(std::chrono::milliseconds(1)); continue; } nextTick += kTickMs; net_.Poll(0); // 非阻塞收包,把完整包塞进待处理队列 DispatchPackets(); // 在本线程内消费队列,保证实体只被单线程改 world_.Update(kTickMs); // 怪物 AI、Buff 倒计时、地图事件、刷新 session_.Flush(); // 把这一帧产生的回包批量写出去 if (now - nextTick > 500) { // 落后超过半秒说明这帧过载了 LOG_WARN("tick overrun, lag=%lld ms", (long long)(now - nextTick)); nextTick = now; // 丢掉积压,别让延迟雪崩 } } }这段代码有几个参数值得盯着改。kTickMs决定世界更新频率,改成 100 会让怪物移动和技能判定变粗糙,但能省一半 CPU;改成 20 会明显更跟手,代价是逻辑线程压力翻倍,玩家过千容易 overrun。Poll(0)的 0 是非阻塞超时,如果写成 -1 就会阻塞在收包上,整个 tick 节奏被网络拖死,这是新手最容易翻车的地方。最后那个 500ms 的过载保护很像“后悔药”,没有它,一次卡顿之后 nextTick 会越落越远,服务端看起来没崩但所有人延迟飙到几秒。
2.3 协议设计:定长包头 + 命令字 + 二进制体
对外协议用的是最经典的定长包头方案。包头 8 字节,包含包体长度、命令字、序号,包体是业务字段按约定顺序二进制序列化,不用 JSON 也不走 protobuf。这样做的好处是解析快、包小,缺点是加字段必须客户端和服务端同步改,版本对不上就解析错位。
#pragma pack(push, 1) struct NetHeader { uint16_t len; // 包体字节数,不含包头自身 uint16_t cmd; // 命令字,高 4 位区分模块(1 登录 2 角色 3 地图 4 战斗) uint32_t seq; // 客户端自增序号,用于回包匹配与重复包丢弃 }; #pragma pack(pop) static const uint16_t kMaxPacket = 8192; // 单包上限,防止恶意大包打爆内存#pragma pack(1)不能省。默认对齐下uint16_t len后面会补 2 字节,包头变成 12 字节,客户端按 8 字节算就会整体错位,症状是“能连上、能发登录包、但服务端回的都是乱码”。kMaxPacket是必须设的硬上限,没有它,一个伪造的 len=65535 的包就能让服务端预分配 64KB 缓冲,几百个连接就能把内存吃干净。
命令字的设计上,高 4 位分模块、低 12 位分动作,是为了让分发代码好写:switch (cmd >> 12)先落到模块,再在内层 switch 里落到具体处理函数。序号seq的主要用途不是可靠传输(TCP 已经保证了),而是回包匹配和防重放,客户端把请求序号记在待回表里,超时没回就重发,服务端对同一个 seq 的重复请求直接丢弃,避免“点一次攻击打两下”的玄学问题。
3. 目录结构与配置表:先认清东西放在哪
3.1 源码树逐层说明
这套工程解压之后文件不少,但结构是清晰的,基本可以按“公共库 / 各服务进程 / 客户端 / 数据 / 配置 / 脚本”六块来分。第一次读不要从 main 开始,先从 Common 里的网络和协议看起,那是所有进程共用的地基。
| 目录 | 内容 | 读代码优先级 |
|---|---|---|
| Common/ | 网络封装、包头、缓冲、日志、定时器、工具函数 | 最高,先看这里 |
| LoginServer/ | 账号校验、登录排队、会话票据 | 中 |
| GateServer/ | 连接管理、心跳踢人、包转发 | 高,链路枢纽 |
| GameServer/ | 地图、实体、战斗、背包、行会、任务 | 最高,代码最厚 |
| GameServer/World/ | 地图管理器、AOI 九宫格、刷怪点 | 高 |
| DBServer/ | 角色读写、物品流水、定时存盘 | 中 |
| Client/ | 渲染、输入、协议解析、UI | 中,主要看协议层 |
| Data/ | 地图掩码、怪物、技能、掉落、刷怪点配置 | 高,改玩法主要动这里 |
| Conf/ | 各进程 ini 配置、端口、数据库连接 | 高,跑起来先改这里 |
| Script/ | 启动脚本、备份脚本、GM 命令脚本 | 低 |
读代码的顺序我建议是:Common/Net→GateServer的转发逻辑 →GameServer的OnPacket分发 → 具体某个业务(比如攻击)。这个顺序能把“一个包从网卡进来、到变成一次扣血”的完整路径一次串起来,比从登录流程读要直观得多。
3.2 玩家对象与地图格子:两个最该先看懂的结构体
服务端所有逻辑最终都落在两个数据结构上:玩家(以及怪物等实体)和地图格子。这两个结构设计得好不好,直接决定后面加功能是顺手还是痛苦。
struct Player { uint64_t rid; // 角色唯一 ID,全服唯一,落库主键 uint32_t mapId; // 当前地图 uint16_t x, y; // 格子坐标,注意是 16 位,地图尺寸不能超过 65535 uint16_t hp, mp; uint16_t maxHp, maxMp; uint8_t level; uint8_t job; // 职业:1 战士 2 法师 3 道士 uint32_t exp; uint32_t lastAttackMs; // 上次攻击时间,用于攻速校验 std::array<Item, 48> bag; // 定长背包,避免高频 new/delete int32_t viewIdx[9]; // 九宫格视野索引,-1 表示该格无实体 };背包用std::array定长是刻意的选择。玩家背包格数是固定的,用 vector 会带来频繁扩容和内存碎片,而服务端跑几天不重启是常态,碎片累积起来就是内存曲线一路向上。viewIdx这 9 个整数是 AOI 的简化实现,玩家移动时只更新自己周围九格,广播时只遍历这九格里的实体,而不是遍历全地图玩家。地图上人一多,全图广播就是灾难,几百人同屏的服如果没有视野裁剪,一个技能包能放大成几百份。
struct MapCell { uint16_t attr; // 0 可走 1 阻挡 2 水面 3 安全区 4 传送点 int32_t firstEntity; // 该格实体链表头,-1 为空 }; class GameMap { public: bool CanWalk(int x, int y) const { if (x < 0 || y < 0 || x >= width_ || y >= height_) return false; return cells_[y * width_ + x].attr == 0 || cells_[y * width_ + x].attr == 4; } void Broadcast(int cx, int cy, uint16_t cmd, const std::string& body, uint64_t except); private: int width_, height_; std::vector<MapCell> cells_; };attr的值域是改玩法时最先动的地方:想把某张图改成安全区,把格子 attr 批量置 3 就行,战斗结算里判断attr == 3直接拒绝攻击。CanWalk里传送点(attr=4)被算作可走,是因为传送点本身要能站上去,这个细节很多半成品实现会漏,结果玩家走到传送点边缘就卡住。
3.3 配置表加载:csv 到内存表的通用加载器
玩法数值不写在代码里,这是这套工程做对的一件事。怪物属性、技能伤害、掉落概率、刷怪点、地图传送,全部放在 Data 目录下的 csv 里,服务端启动时一次性读进内存的哈希表。好处是调数值不用重编译,坏处是格式错了只能在启动日志里发现,所以加载器一定要把出错行号打出来。
// 通用 csv 加载:首行字段名,逗号分隔,允许行尾注释 bool LoadCsv(const std::string& path, std::vector<std::unordered_map<std::string, std::string>>& out) { std::ifstream in(path); if (!in) { LOG_ERROR("open csv failed: %s", path.c_str()); return false; } std::string line; std::vector<std::string> fields; int lineNo = 0; while (std::getline(in, line)) { ++lineNo; if (line.empty() || line[0] == '#') continue; auto cols = Split(line, ','); // 简单 split,不支持字段内逗号 if (fields.empty()) { fields = cols; continue; } // 首行当表头 if (cols.size() != fields.size()) { LOG_ERROR("%s line %d: column count %zu != %zu", path.c_str(), lineNo, cols.size(), fields.size()); return false; // 直接失败,别带着半张表启动 } std::unordered_map<std::string, std::string> row; for (size_t i = 0; i < cols.size(); ++i) row[fields[i]] = Trim(cols[i]); out.push_back(std::move(row)); } LOG_INFO("loaded %s, rows=%zu", path.c_str(), out.size()); return true; }这个加载器故意做得“脆”:列数对不上直接返回 false,让进程启动失败。听起来激进,但比带着错误配置跑起来强得多——少一列掉落概率,可能表现为“某些怪永远不掉东西”,排查起来要翻半天代码。实际工程里我一般会在返回 false 之后让上层打印表名并退出,绝不允许降级启动。
字段名用字符串做 key 而不是列下标,是为了后面加列不破坏兼容。比如技能表原来有 8 列,后来加了一列“冷却毫秒”,用下标的代码全要改,用字段名的只要row.count("cd_ms")判断一下就行,老表也能跑。
4. 核心链路拆解:一次攻击从按键到扣血的完整路径
4.1 客户端发包:序列化与发送队列
客户端按下攻击键,做的第一件事不是send,而是把请求塞进发送队列。原因有两个:一是渲染线程和网络线程必须分离,直接在渲染回调里send会阻塞画面;二是 TCP 的send可能只写进去一部分,需要自己维护缓冲区把剩下的补齐。
// 客户端:把攻击请求序列化后压入发送队列,由网络线程统一写出 void ClientNet::SendAttack(uint64_t targetId) { std::string body; WriteU64(body, targetId); // 目标实体 ID WriteU8(body, 0); // 攻击类型:0 普攻 1 技能 WriteU16(body, 0); // 技能 ID,普攻填 0 EnqueuePacket(C2G_ATTACK, body); // 自动补包头和自增 seq } void ClientNet::EnqueuePacket(uint16_t cmd, const std::string& body) { NetHeader hd; hd.len = (uint16_t)body.size(); hd.cmd = cmd; hd.seq = ++seq_; std::lock_guard<std::mutex> lk(sendMutex_); sendBuf_.append((const char*)&hd, sizeof(hd)); sendBuf_.append(body); }序列化用的是手写WriteU64/WriteU16而不是结构体直接 memcpy,因为结构体在不同编译器下对齐规则可能不同,客户端和服务端如果一边 MSVC 一边 GCC,直接 memcpy 结构体就会踩到大小端和对齐的坑。手写读写函数虽然啰嗦,但字节序固定成小端、字段偏移完全可控,跨平台联调时省下大量排查时间。
seq_自增的作用前面说过,这里补一句:它必须是连接级而不是请求级。如果每次请求都从 1 开始,服务端就没法区分重发的包和新包,会出现“连续按两次攻击,第二次被当成重复包丢掉”的现象。
4.2 网关收包:粘包拆包与转发
TCP 是字节流,一次recv可能读到半个包,也可能读到三个半包,这是所有长连接服务端都要面对的问题。这套代码的做法是每个连接维护一个接收缓冲,收到数据就追加,然后循环判断“缓冲里够不够一个包头、够不够包头声明的包体长度”。
bool PacketBuffer::Append(const char* data, size_t n) { buf_.append(data, n); while (buf_.size() >= sizeof(NetHeader)) { NetHeader hd; memcpy(&hd, buf_.data(), sizeof(hd)); if (hd.len > kMaxPacket) { LOG_WARN("illegal packet len=%u, close conn", hd.len); return false; // 非法长度,直接断开 } if (buf_.size() < sizeof(hd) + hd.len) break; // 半包,等下次数据 std::string body(buf_.data() + sizeof(hd), hd.len); OnPacket(hd.cmd, hd.seq, body); // 完整包交给业务 buf_.erase(0, sizeof(hd) + hd.len); // 消费掉 } return true; }这里while不能写成if,写成if就会在“一次收到两个包”的情况下只处理一个,剩下的留在缓冲里等下一次 recv,表现是“操作有延迟、偶发性的技能没反应”,典型的偶现 bug,很难复现。buf_.erase(0, n)在包量大时会有拷贝开销,玩家几千人在线时可以把缓冲改成环形队列加读指针,但那是优化阶段的事,先把功能跑通更重要。
网关收到完整包之后做的事非常少:
void GateSession::OnPacket(uint16_t cmd, uint32_t seq, const std::string& body) { if (!authed_ && cmd != C2G_LOGIN) { // 未登录只放行登录包 LOG_WARN("unauthed packet cmd=%u from %s", cmd, ip_.c_str()); Close(); return; } NetHeader hd{ (uint16_t)body.size(), cmd, seq }; std::string out((const char*)&hd, sizeof(hd)); out += body; gameConn_.Send(out); // 原样透传,网关不解析业务字段 }authed_这个判断是必须的。没有它,客户端跳过登录直接发攻击包,游戏服会收到一个没有任何角色绑定的请求,轻则崩溃,重则产生脏数据。gameConn_.Send是网关到游戏服的长连接,如果这条连接断了要立刻把该网关上的玩家全部踢下线,否则玩家会看到“人还在线、但所有操作无响应”的假死状态。
4.3 游戏服结算:攻速校验、命中判定与视野广播
包到了 GameServer,先按命令字分发,攻击请求最终落到OnAttack。这个函数里每一步都是在堵漏洞,顺序不能乱。
void GameServer::OnAttack(Player& p, const std::string& body) { Reader r(body); uint64_t targetId = r.ReadU64(); uint8_t atkType = r.ReadU8(); uint16_t skillId = r.ReadU16(); // 1. 攻速校验:防止客户端改包加速 int64_t now = NowMs(); if (now - (int64_t)p.lastAttackMs < p.attackInterval) { SendAttackCd(p, p.attackInterval - (now - p.lastAttackMs)); return; } p.lastAttackMs = (uint32_t)now; // 2. 目标合法性:存在、同图、距离在攻击范围内 Entity* target = map_->FindEntity(targetId); if (!target || target->mapId != p.mapId) return; if (Chebyshev(p.x, p.y, target->x, target->y) > kAttackRange) return; if (map_->IsSafeZone(p.x, p.y)) { SendTip(p, "安全区无法攻击"); return; } // 3. 结算伤害 int dmg = CalcDamage(p, *target, atkType, skillId); target->hp = (target->hp > dmg) ? target->hp - dmg : 0; // 4. 视野内广播,攻击者自己也要收到(客户端要播动作) map_->Broadcast(p.x, p.y, S2C_ATTACK_RESULT, PackAttackResult(p.rid, target->id, dmg, target->hp), 0); // 5. 死亡处理:经验、掉落、刷新计时 if (target->hp == 0) OnEntityDie(*target, p.rid); }Chebyshev距离(切比雪夫距离,取 dx 和 dy 的较大值)是格子游戏里最常用的距离度量,因为它天然对应八方向移动,kAttackRange填 1 就是近战贴身、填 7 就是法师中等距离。用欧氏距离会出现“斜着站能打到、正着站打不到”的诡异手感。
广播参数里的except填 0 表示所有人都收,包括攻击者自己。有的实现为了省流量把攻击者排除掉,结果客户端只能靠本地预测播动作,网络一抖就出现“打了一半动作没了”。这一步多花几十字节换手感稳定,值。
CalcDamage是二次开发改动最频繁的函数,一般长这样:基础攻击减目标防御,乘以技能倍率,加上随机浮动,最后做最小伤害保底。参数上要注意防御别做成直接相减,不然低等级玩家打高防怪会全是 0 或 1 点,体验极差,常见做法是dmg = atk * atk / (atk + def)这种衰减公式。
4.4 落库与回写:DBServer 的异步保存
玩家数据不能每次变动都写库,那样数据库扛不住;也不能只在下线时写,那样进程一崩就回档。这套代码用的是“脏标记 + 定时批量保存”:玩家属性变动时打标记,DBServer 每隔一段时间扫一遍脏角色,批量写回。
// GameServer 侧:只打标记,不直接碰数据库 void Player::MarkDirty() { dirty_ = true; lastChangeMs_ = NowMs(); } // 定时任务:每 30 秒把脏玩家打包发给 DBServer void GameServer::FlushDirtyPlayers() { std::vector<PlayerSaveData> batch; for (auto& kv : players_) { Player& p = *kv.second; if (!p.dirty_) continue; batch.push_back(packSaveData(p)); p.dirty_ = false; // 先清标记,失败再补 } if (!batch.empty()) dbConn_.Send(SaveReq{ std::move(batch) }); }这里的坑在于“先清标记再发送”。如果发送失败或者 DBServer 处理失败,这批标记就丢了,玩家数据停在旧版本。稳妥做法是发送成功后由 DBServer 回一个确认,GameServer 收到确认才清标记,超时没确认就重新入队。代价是多一次往返,但比起回档,这点开销值得。
下线保存是另一条路径,必须同步等待结果再断开连接,否则玩家点退出、客户端立刻断网,保存请求还在路上,就会出现“下线前刚捡的装备没了”。这种问题玩家一定会反馈,而且极难复现,因为取决于网络时序。
5. 编译联调与六类翻车现场排查
5.1 编译:CMake 与依赖
工程用 CMake 组织,服务端和客户端分开构建。服务端只依赖标准库、系统 socket 库和可选的数据库客户端库;客户端额外依赖图形库和音频库。第一次编译建议先只编服务端,把四个进程跑通,再折腾客户端,不然两边同时报错会很难定位。
# 服务端构建:先 Debug 跑通,再 Release 压测 mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug -DBUILD_CLIENT=OFF cmake --build . -j 8 # 产物一般落在 bin/ 下,四个进程各一个可执行文件 ls bin/ # dbserver gateserver gameserver loginserver-DBUILD_CLIENT=OFF这个开关很重要,客户端依赖多,容易在服务端还没跑起来的时候就卡在图形库缺失上。-j 8按自己机器核数改,老机器上开太高会把内存打满导致编译进程被杀,症状是“编译到某个文件突然 Killed”,看系统日志才知道是 OOM。
如果用的是 Windows 加 MSVC,要注意源码文件编码。这套代码里中文字符串不少,如果源码是 GBK 而编译器按 UTF-8 解析,日志里的中文全是乱码,sizeof("中文")算出来的长度也会错,直接影响协议里带中文的字段(比如角色名)。统一转成 UTF-8 无 BOM 是最省事的做法。
5.2 启动顺序与配置项
四个进程有严格依赖:DBServer 最先,LoginServer 和 GameServer 依赖它;GateServer 依赖 GameServer 的地址。顺序错了不会崩,但会表现为“能连上、登录没反应”,排查时容易怀疑到协议上去。
# 标准启动顺序:存储 → 登录 → 游戏 → 网关,最后才是客户端 ./bin/dbserver -c conf/db.ini ./bin/loginserver -c conf/login.ini ./bin/gameserver -c conf/game.ini ./bin/gateserver -c conf/gate.ini# conf/game.ini 里几个必须对上的项 [game] listen_port = 7200 ; 游戏服对内端口,网关连这个 db_addr = 127.0.0.1:7300 ; 必须和 db.ini 的 listen_port 一致 max_player = 2000 ; 单进程承载上限,超了拒绝进入 tick_ms = 50 ; 世界更新间隔,和代码里 kTickMs 对应 view_range = 1 ; AOI 九宫格半径,改 2 就是 25 格 save_interval = 30 ; 脏数据批量保存间隔(秒) [gate] listen_port = 7100 ; 客户端连这个 game_addr = 127.0.0.1:7200 heartbeat_ms = 10000 ; 超过这个时间没心跳就踢view_range从 1 改成 2 会让广播范围从 9 格变成 25 格,同屏人数多的时候带宽直接翻几倍,这是性能调优时第一个该回退的参数。heartbeat_ms设太大,玩家断网后会挂在线上占位几十秒;设太小,弱网玩家会频繁被踢,一般 10 到 15 秒是平衡点。
5.3 排查清单:六条血泪踩坑记录
第一条:客户端一直卡在“正在连接”,服务端日志干干净净
现象是客户端连接超时,但四个服务端进程都在跑,日志里连一条连接记录都没有。原因基本是两个:一是网关监听绑在了127.0.0.1而不是0.0.0.0,本机以外连不进来;二是系统防火墙拦了端口。解决方式是查conf/gate.ini里的 bind 地址,改成0.0.0.0,再看netstat -anp | grep 7100确认监听状态是LISTEN而不是只有本地回环。
第二条:登录成功但角色列表是空的
现象是账号密码校验通过,进到选角色界面一片空白。原因通常出在 DBServer:要么它没启动(LoginServer 连不上会静默返回空列表),要么角色表的字符集和连接串不匹配,中文角色名查询时被过滤掉。解决办法是先看 DBServer 日志有没有查询记录,再看数据库连接串里的字符集参数,统一成 utf8mb4,别一边 utf8 一边 latin1。
第三条:能看见怪、能点怪,但打不掉血
这是最经典的一条,八成是坐标系不一致。客户端地图从 0 开始算,服务端掩码文件从 1 开始算,或者反过来,结果就是“客户端看着贴身,服务端算出来距离是 2”,超出近战范围判定直接 return,连日志都不打。排查方式是临时在距离判定失败的分支加一行 warn 日志,把双方坐标打出来对比,一眼就能看出来。
第四条:人一多就卡,单个玩家操作延迟飙到几百毫秒
现象是两三个人测试很流畅,喊了十几个朋友一起上线就开始飘。原因多半是广播写成了全图遍历,或者每个玩家移动都触发一次全地图实体扫描。解决方式是把广播改成九宫格视野裁剪,移动时只更新自己覆盖的格子链表,广播只遍历周围格。改完之后同屏 50 人的包量能降一个数量级。
第五条:玩家正常下线没事,服务端一重启就集体回档
现象是 kill 进程之后重启,所有人回到几十分钟前的状态。原因是只在下线时保存,进程被强杀时脏数据全丢。解决方式是加上定时批量保存(前面 4.4 那段),并且在进程收到退出信号时先执行一次全量 flush 再退出,别让它走到默认的强杀逻辑。
第六条:日志和界面里的中文全是问号或方块
现象是服务端日志中文乱码,客户端角色名显示成方块。原因是源码文件编码、编译选项里的执行字符集、客户端字体三者不一致。解决方式是源码统一 UTF-8 无 BOM,MSVC 加/utf-8编译选项,客户端字体换成带中文的字体文件。这一条看着简单,实际能把人卡一整天,因为每处的表现都不一样。
5.4 用日志和抓包自证,别靠猜
联调阶段最忌讳的是“我觉得应该是这里的问题”。这套代码的日志分级做得还算全,log_level开到 2 能看到包收发,开到 3 能看到每个 tick 的实体数量。定位问题的标准动作是:先在网关日志确认包进来了没有,再看游戏服日志确认包被分发到了哪个处理函数,最后看回包有没有写出去。三个位置一对比,问题在哪一层立刻清楚。
抓包工具在这类项目里也很有用,尤其是排查“客户端发的和服务端收的不一致”。用本机回环抓包,把十六进制贴出来,对着包头结构一个字节一个字节看,长度字段对不对、命令字对不对、包体里目标 ID 是不是 8 字节。这种笨办法比读代码快得多,我一般遇到协议相关的疑难问题第一件事就是抓包。
提示:抓包前先把
kMaxPacket临时调大,有些调试包会超过默认上限,被抓包工具截断后反而误导判断。
6. 二次开发:加一个新技能和一个新地图
改这套代码,改动点永远是“配置表 → 枚举 → 逻辑分支 → 客户端表现”四层,少改一层就跑不通。以加一个叫“裂地斩”的战士群攻技能为例,第一步在技能表里加一行,填好技能 ID、职业限制、消耗蓝量、冷却毫秒、伤害倍率、作用范围;第二步在协议枚举里加技能 ID 常量,服务端和客户端两边的头文件都要改,这是最容易漏的地方;第三步在OnCastSkill里加一个分支,判断技能类型是“范围”就遍历周围格子里的敌人逐个结算,别忘了对每个目标做一次距离和阵营校验;第四步客户端加图标、加动画 ID 映射、加范围内的特效播放。
| 改动层 | 文件位置 | 容易漏的点 |
|---|---|---|
| 配置表 | Data/skill.csv | 列数必须和表头一致,多一个逗号整张表加载失败 |
| 协议枚举 | Common/protocol.h | 客户端和服务端各有一份,必须同步 |
| 服务端逻辑 | GameServer/Skill.cpp | 冷却校验要在扣蓝之前,不然会出现蓝扣了技能没放 |
| 客户端表现 | Client/Ui/SkillBar | 冷却遮罩的时长要读配置,别写死 |
加地图更简单也更琐碎:在 map.csv 里加一行地图 ID 和尺寸,把地图掩码文件放进 Data/map/ 目录(每个格子一个字节表示 attr),在传送点表里配好入口坐标,在刷怪表里配好怪物 ID、坐标、刷新间隔、刷新数量。改完之后不要直接开客户端试,先用 GM 命令把自己传送到新地图,看服务端日志里地图有没有加载成功、掩码尺寸和配置里的宽高对不对得上。尺寸对不上是最常见的翻车点,掩码文件是按 200x200 生成的,配置里写了 256x256,读出来的格子全是错位的,表现是“人能走到墙上、怪卡在空气里”。
验证改动我一般走三步:先用 GM 命令造一个满级角色,手动把新技能放一遍,看伤害数字、蓝量、冷却是否都对;再开两个客户端互打,确认广播范围正确、对方能看到特效;最后挂 20 个机器人跑十分钟,看服务端 tick 有没有 overrun、内存有没有持续上涨。第三步很多人在自己机器上懒得做,结果上线第一天就出问题。
从那以后我每次动这套代码,都强制自己先把“配置表列数校验 + 协议枚举两边同步 + 新地图尺寸比对”这三件事走一遍,哪怕只是改一个数值。这三步花不了五分钟,能挡掉八成的低级翻车。希望帮到你。
本文还有配套的精品资源,点击获取