C++复古MMO服务端源码拆解:架构、协议与联调排错 简介这套传奇模拟游戏完整源码包含客户端与服务器端程序面向具备C基础、希望理解游戏C/S架构与网络编程的开发者采用VC6.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是必须设的硬上限没有它一个伪造的 len65535 的包就能让服务端预分配 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::arrayItem, 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::vectorMapCell cells_; };attr的值域是改玩法时最先动的地方想把某张图改成安全区把格子 attr 批量置 3 就行战斗结算里判断attr 3直接拒绝攻击。CanWalk里传送点attr4被算作可走是因为传送点本身要能站上去这个细节很多半成品实现会漏结果玩家走到传送点边缘就卡住。3.3 配置表加载csv 到内存表的通用加载器玩法数值不写在代码里这是这套工程做对的一件事。怪物属性、技能伤害、掉落概率、刷怪点、地图传送全部放在 Data 目录下的 csv 里服务端启动时一次性读进内存的哈希表。好处是调数值不用重编译坏处是格式错了只能在启动日志里发现所以加载器一定要把出错行号打出来。// 通用 csv 加载首行字段名逗号分隔允许行尾注释 bool LoadCsv(const std::string path, std::vectorstd::unordered_mapstd::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::vectorstd::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_mapstd::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_guardstd::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::vectorPlayerSaveData 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_TYPEDebug -DBUILD_CLIENTOFF cmake --build . -j 8 # 产物一般落在 bin/ 下四个进程各一个可执行文件 ls bin/ # dbserver gateserver gameserver loginserver-DBUILD_CLIENTOFF这个开关很重要客户端依赖多容易在服务端还没跑起来的时候就卡在图形库缺失上。-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 无 BOMMSVC 加/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、内存有没有持续上涨。第三步很多人在自己机器上懒得做结果上线第一天就出问题。从那以后我每次动这套代码都强制自己先把“配置表列数校验 协议枚举两边同步 新地图尺寸比对”这三件事走一遍哪怕只是改一个数值。这三步花不了五分钟能挡掉八成的低级翻车。希望帮到你。本文还有配套的精品资源点击获取