传奇类模拟游戏双端源码拆解:C++服务端、封包协议与地图线程 简介传奇模拟游戏源代码包面向具备C基础、希望理解游戏客户端与服务器完整实现流程的开发者采用VC6.0编写包含可参考的客户端与服务器端工程。压缩包共499个文件以h头文件、cpp源文件为主并含obj、sbr等编译中间文件及vcproj、dsw、dsp等工程配置整体约11.32MB便于按客户端界面渲染、服务器逻辑处理与工程组织等模块对照阅读。已有218人学习。读者可从中梳理角色控制、场景渲染、玩家状态同步、规则执行与数据存储等环节的实现思路接触网络通信、多线程、数据结构与性能优化等主题并借助客户端请求与服务器响应的实际案例加深对并发编程和数据封装传输的理解适合作为游戏开发入门与旧项目维护的实践参考。对希望进入游戏服务端开发或维护早期VC工程的读者较有参考价值。1. 传奇类模拟游戏的双端源码真不是编译一下就能玩传奇类模拟游戏的双端源码最容易被人低估成「玩具工程」下载下来看目录客户端一堆贴图和 UI 文件服务端几个 C 工程看着像是随手编译就能跑。真上手才知道登录链路、地图线程、封包校验任何一处没对上你就只能盯着登录界面转圈还分不清是客户端资源没挂上还是 C 服务器端口没起。这份资源给的是客户端与服务器源程序两套完整工程服务端走多进程加网络库的老派写法客户端是渲染循环加强制资源索引的结构。它适合两类人想搞明白老式 MMO 服务端怎么把成百上千玩家塞进几十个地图线程的以及想拿一套能跑的双端骨架改成自己玩法的。下面按「它给了什么 → 怎么跑起来 → 核心代码怎么写的 → 卡住怎么查 → 怎么加功能」的顺序往下拆。2. 双端架构拆解一个移动请求从网关走到地图线程的完整链路先把整体链路摆出来不然看代码会很虚。客户端从登录服拿到票据拿着票据去连网关网关只做连接保持和转发真正处理逻辑的是游戏服游戏服里再按地图切成多个线程。玩家点一下地面这个移动指令要经过客户端封包 → 网关透传 → 游戏服收包队列 → 对应地图线程出队 → 校验坐标 → 九宫格广播 → 结果回包。整条链上一个环节出问题表现都是「人物不动」这也是排查时最费劲的地方。2.1 服务端进程拓扑与源码目录四类进程各管一摊登录服管账号和区服列表网关管长连接和限流游戏服管地图、玩家、战斗工具目录放配置表导出和封包生成脚本。这么拆的理由很实际——游戏服崩了不能把登录也带垮登录服重启也不该让在线玩家掉线。共享的东西只有一份协议头、枚举、错误码放在 Share 目录里被双端同时引用。Server/ ├── Common/ # 公共库Socket 封装、环形缓冲区、日志、定时器 │ ├── Net/ │ ├── Log/ │ └── Timer/ ├── LoginSrv/ # 登录服账号校验、区服列表、票据签发 ├── GateSrv/ # 网关连接维护、票据二次校验、报文转发 ├── GameSrv/ # 游戏服地图线程、玩家实体、战斗、掉落 │ ├── Map/ │ ├── Battle/ │ └── Item/ ├── Tool/ # 配置表导出、协议代码生成 └── Share/ # 双端共用packet.h、errcode.h、enum.h Client/ ├── Engine/ # 渲染、输入、音频、资源加载 ├── Game/ # 场景、角色、UI、网络回调 └── Res/ # 地图掩码、角色图集、配置二进制目录结构里最该先看的是Share/packet.h它决定了双端能不能对上话其次是GameSrv/Map/服务端绝大多数逻辑都压在那边。我一般拿到这种工程先按目录大小排序看一眼哪个模块文件最多基本就是这项目的重心所在。2.2 封包协议包头、序列号与粘包处理老派 MMO 的协议设计很朴素定长包头加变长包体包头里带总长度收包方按长度切。这里容易翻车的是字节序和长度校验——客户端是小端服务端如果跨平台不做转换读出来的长度就是天文数字接收缓冲区瞬间被撑爆。// Share/packet.h所有报文共用的包头1 字节对齐禁止编译器填充 #pragma pack(push, 1) struct PacketHeader { uint16_t size; // 整包长度含包头本身上限 64KB uint16_t cmd; // 命令字登录、移动、战斗各占一段号段 uint32_t seq; // 序列号客户端递增服务端用于防重放和回包匹配 uint32_t checksum; // 包体校验早期用累加和现在多为 crc32 }; #pragma pack(pop) static_assert(sizeof(PacketHeader) 12, header size changed);size是整个包的总长度服务端收到数据先判断够不够 12 字节不够就等下一批够了再按size取整包这就把 TCP 粘包问题在协议层解决掉了。seq有两个作用一是服务端按序列号丢弃重复包二是客户端回包匹配避免异步响应乱序。static_assert那行是血泪经验某次有人往包头里加了个字段没同步客户端双端结构体大小差了 4 字节所有包解析全错位表现是「登录后角色列表一片空白」查了整整一下午。收包侧一般配一个环形缓冲区读指针和写指针分开// Common/Net/Buffer.cpp环形缓冲区避免每次 recv 都做内存搬移 bool RingBuffer::TryPopPacket(Packet out) { if (readable() sizeof(PacketHeader)) return false; // 头都不够等 PacketHeader hdr; Peek(hdr, sizeof(hdr)); if (hdr.size sizeof(PacketHeader) || hdr.size kMaxPacket) { return false; // 长度非法交由上层断开 } if (readable() hdr.size) return false; // 半包等下一批 out.resize(hdr.size); Read(out.data(), hdr.size); // 只在整包到齐时搬一次 return true; }kMaxPacket建议设成 32KB 以内超过这个值的包要么是协议设计有问题要么是有人在往服务端灌垃圾数据。这个函数返回 false 有两种语义——「数据不够」和「数据非法」上层必须区分开前者继续等后者直接断连接否则一个畸形包能让线程空转。2.3 客户端结构渲染循环、场景管理与资源挂载客户端是典型的「固定帧率主循环 逻辑插值」写法渲染帧和逻辑帧不绑死。逻辑帧跑 20 次每秒负责发移动指令、处理服务端回包渲染帧跟着显示器刷新率走中间用插值补出平滑位移。老派 2D MMO 基本都这么干好处是网络包少坏处是插值参数调不好会看到角色「滑步」。// Client/Game/MainLoop.cpp逻辑帧与渲染帧解耦 void GameLoop() { const int64_t kLogicStepMs 50; // 逻辑帧 20fps int64_t last_logic NowMs(); while (!quit_) { int64_t now NowMs(); // 1. 逻辑帧固定步长推进网络包在这里消费 while (now - last_logic kLogicStepMs) { net_.Poll(); // 收包回调进场景 scene_.Tick(kLogicStepMs); // 移动预测、动画状态机 last_logic kLogicStepMs; } // 2. 渲染帧按真实时间插值不碰网络 float alpha float(now - last_logic) / kLogicStepMs; renderer_.Draw(scene_, alpha); input_.Swap(); } }alpha是插值系数取值 0 到 1渲染时用「上一逻辑帧位置 (当前位置 - 上一位置) * alpha」算出显示坐标。这个值如果直接用now - last_logic不除步长角色移动速度会跟着显卡帧率变帧率越高跑得越快——这种玄学 bug 我在不止一个工程里见过。资源挂载这块客户端启动时会先读Res/data/下的索引文件把每个资源 ID 映射到具体文件偏移之后按需加载。所以资源目录和可执行文件必须是同级关系挪了位置就找不到索引表现是进游戏黑屏但进程不崩。3. 编译与首次启动从 CMake 到进图的三段式流程这套工程不像现代项目那样一条命令全自动首次跑通要分三段先把服务端依赖配齐编出来再初始化数据库和配置表最后编客户端并保证资源目录结构正确。三段里任何一段跳步后面都会以一种很奇怪的方式失败。我按自己实际操作的顺序写命令可以直接抄。3.1 服务端依赖、CMake 配置与编译参数服务端依赖不多C17 编译器、CMake 3.16 以上、线程库、MySQL 客户端库。Linux 上装build-essential cmake libmysqlclient-devWindows 上用 vcpkg 拉 mysql-connector-c 或者直接用官方安装包里的 lib。CMakeLists 的关键是把公共库编成静态库再链接别把所有 cpp 塞进一个可执行目标不然改一行日志要全量重编。cmake_minimum_required(VERSION 3.16) project(gamesrv CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # RelWithDebInfo 是为了线上出问题时还能看栈别用 -O3 裸奔 add_compile_options(-O2 -g -Wall -Wextra) find_package(Threads REQUIRED) find_package(mysqlclient REQUIRED) add_library(common STATIC Common/Net/Socket.cpp Common/Net/Buffer.cpp Common/Log/Logger.cpp) target_include_directories(common PUBLIC Common Share) add_executable(loginsrv LoginSrv/Main.cpp LoginSrv/Account.cpp) add_executable(gatesrv GateSrv/Main.cpp GateSrv/Forward.cpp) add_executable(gamesrv GameSrv/Main.cpp GameSrv/MapThread.cpp GameSrv/Battle.cpp) target_link_libraries(loginsrv PRIVATE common Threads::Threads mysqlclient) target_link_libraries(gatesrv PRIVATE common Threads::Threads) target_link_libraries(gamesrv PRIVATE common Threads::Threads mysqlclient)三个可执行目标分别对应三个进程common被重复链接没关系静态库会把用到的符号各拷一份进程之间互不影响。-Wall -Wextra建议一直开着这种老代码里藏着不少「有符号和无符号比较」的隐患等到线上数值溢出再查就晚了。编译和启动# 1. 生成构建目录RelWithDebInfo 兼顾优化和调试信息 cmake -S . -B build -DCMAKE_BUILD_TYPERelWithDebInfo # 2. 并行编译nproc 是核数Windows 换成 %NUMBER_OF_PROCESSORS% cmake --build build -j$(nproc) # 3. 先起登录服再起网关最后起游戏服顺序不能反 ./build/loginsrv -c conf/login.ini ./build/gatesrv -c conf/gate.ini ./build/gamesrv -c conf/game.ini启动顺序是有讲究的游戏服启动时要向网关注册自己负责的区服网关启动时要连登录服拿区服列表。顺序反了游戏服注册失败会静默退出日志里只有一行「register timeout」很多人第一次就是这么卡住的。3.2 数据库初始化与配置表落地账号和角色数据落 MySQL建库建表脚本一般在Tool/sql/下。字符集一定要显式指定utf8mb4不然玩家角色名带 emoji 或者生僻字插入直接报错客户端上表现为「创建角色失败」但没有任何提示。CREATE DATABASE IF NOT EXISTS game_db DEFAULT CHARSET utf8mb4; USE game_db; CREATE TABLE account ( uid BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, account VARCHAR(32) NOT NULL, pwd_hash CHAR(64) NOT NULL, -- sha256(salt pwd)绝不存明文 salt CHAR(16) NOT NULL, -- 每个账号独立盐值 created_at INT UNSIGNED NOT NULL, UNIQUE KEY uk_account (account) ) ENGINEInnoDB; CREATE TABLE player ( pid BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, uid BIGINT UNSIGNED NOT NULL, name VARCHAR(24) NOT NULL, job TINYINT UNSIGNED NOT NULL DEFAULT 0, level SMALLINT UNSIGNED NOT NULL DEFAULT 1, map_id INT NOT NULL DEFAULT 1, x SMALLINT NOT NULL DEFAULT 100, y SMALLINT NOT NULL DEFAULT 100, KEY idx_uid (uid) ) ENGINEInnoDB;uk_account是唯一索引注册时靠它挡重复账号别在代码里先查再插并发下必穿透。player表的idx_uid也必须有登录后拉角色列表是按 uid 查的没索引在几百个角色时就开始慢。配置表走的是「Excel/CSV 导出成二进制」的路子导出工具在Tool/下产物丢进Client/Res/data/和服务端的conf/data/两边必须是同一次导出的结果否则会出现「客户端显示技能伤害 100服务端算出来 80」这种对不上的情况。3.3 客户端编译与资源挂载顺序客户端同一套 CMake 流程编完把可执行文件放到资源目录同级。资源目录的层级不能随便改代码里是按相对路径拼的# 客户端目录结构exe 与 Res 必须同级 Client/ ├── game.exe ├── conf.ini # 服务器 IP、端口、分辨率 └── Res/ ├── data/ # 配置表二进制和服务端同一次导出 ├── map/ # 地图掩码文件名为 map_id.map ├── chr/ # 角色动作图集按 job 分目录 └── ui/ # 界面布局和贴图conf.ini里的服务器地址别写死127.0.0.1就以为完事——很多工程是登录服返回区服列表列表里的 IP 是服务端配置里写死的客户端拿到的还是内网地址。局域网内测没问题一旦换机器就全连不上。我一般会把gate_ip做成配置项而不是编译期常量。4. 核心模块精读登录鉴权、地图广播、战斗结算三段代码服务端代码量不小但真正决定「能不能玩」的就三段登录怎么验、移动怎么同步、伤害怎么算。这三段看懂剩下的背包、任务、商店基本都是同一套模板套出来的。下面每段代码都保留了关键注释参数含义我会在代码后单独说。4.1 登录服账号校验与一次性票据下发登录服的职责很单一验账号密码发一张短时效票据把网关地址告诉客户端。密码校验绝不能明文比对也不能用固定盐工程里用的是每账号独立盐值加 SHA-256。// LoginSrv/Account.cpp处理登录请求 bool HandleLoginReq(Session* s, const LoginReq* req) { // 1. 字段合法性校验长度异常直接丢包别进业务 if (req-account_len 0 || req-account_len 32) return false; // 2. 频率限制同一连接 60 秒内最多 5 次防撞库 if (!s-rate_limiter.Allow(5, 60)) { s-Close(); return false; } AccountInfo info; if (!db_.QueryAccount(req-account, info)) { s-SendAck(ERR_ACCOUNT_NOT_EXIST); return true; // 业务失败不断连接让客户端提示 } if (Sha256(info.salt req-password) ! info.pwd_hash) { s-SendAck(ERR_PASSWORD); return true; } // 3. 签发票据30 秒有效网关拿它去游戏服换正式会话 std::string ticket token_gen_.Issue(info.uid, /*ttl_sec*/30); LoginAck ack{}; ack.code 0; ack.gate_ip cfg_.gate_ip; // 从配置读别写死 ack.gate_port cfg_.gate_port; std::snprintf(ack.ticket, sizeof(ack.ticket), %s, ticket.c_str()); s-Send(ack); return true; }rate_limiter.Allow(5, 60)是滑动窗口前两个参数分别是次数和秒数调小能挡撞库但会误伤弱网重试的玩家我一般先设 10 次 60 秒观察日志再收紧。返回值语义要特别注意协议错误返回 false 断连接业务失败返回 true 保留连接——客户端要在这条连接上收错误码做提示断掉就只剩「连接已断开」这一句废话。票据有效期只有 30 秒是为了防止有人抓包重放。网关收到票据后向游戏服校验一次并立刻作废同一张票第二次用直接拒绝。4.2 地图线程九宫格 AOI 与移动广播游戏服按地图切线程一张地图上的所有玩家实体只归一个线程管天然无锁。跨线程通信靠消息队列不做共享内存。AOI 用九宫格玩家只关心自己周围 3×3 个格子里的实体。// GameSrv/MapThread.cpp移动处理全程在本线程内无锁访问 void MapThread::OnPlayerMove(uint32_t pid, int16_t x, int16_t y) { Player* p players_.Find(pid); if (!p) return; // 1. 只允许走到相邻格挡住了加速外挂最常见的「一步跨半屏」 if (std::abs(x - p-x) 1 || std::abs(y - p-y) 1) return; if (!map_.CanStand(x, y)) { p-SendSyncPos(); return; } // 撞墙回拉 const int old_cell map_.CellIndex(p-x, p-y); const int new_cell map_.CellIndex(x, y); p-dir DirOf(p-x, p-y, x, y); p-x x; p-y y; if (old_cell new_cell) { // 2. 格内移动只广播给同格省带宽 BroadcastToCell(new_cell, MoveNotify{p-id, x, y, p-dir}, pid); } else { // 3. 跨格先重算九宫格观察者再向新视野广播 map_.UpdateObserver(p, old_cell, new_cell); BroadcastToNineGrid(new_cell, MoveNotify{p-id, x, y, p-dir}, pid); } }DirOf算出朝向客户端拿去做动画播放服务端自己不关心表现。BroadcastToCell的最后一个参数是「排除自己」自己的移动客户端已经做了本地预测回包再发一次会看到角色抖一下。UpdateObserver是 AOI 的核心玩家从 A 格走到 B 格A 格视野内的人要收到「该实体消失」B 格视野内的人要收到「该实体出现」。这个函数写漏了表现就是「走到别人旁边看不见人走远了反而冒出来」——典型的视野残留。4.3 战斗与掉落伤害结算和权重表伤害公式走的是「配置驱动」系数全在技能表里代码只负责固定流程。这样改数值不用重编服务端改表重启就行。// GameSrv/Battle.cpp一次物理攻击的结算 DamageResult CalcDamage(const Actor atk, const Actor def, int skill_id) { const SkillCfg sk cfg_.skill[skill_id]; // 1. 命中率夹在 [5, 95]避免出现绝对命中和绝对闪避 const int hit Clamp(atk.hit - def.dodge, 5, 95); if (rng_.Rand(100) hit) return {0, false}; // 2. 基础伤害 攻击*系数 - 防御*减伤至少造成 1 点 int64_t base (int64_t)atk.attack * sk.power / 100 - (int64_t)def.defense * sk.def_rate / 100; base std::maxint64_t(base, 1); int64_t dmg base * rng_.Rand(sk.rand_min, sk.rand_max) / 100; // 3. 暴击最后乘保证数值上限可预期 const bool crit rng_.Rand(100) atk.crit; if (crit) dmg dmg * 150 / 100; return {(int32_t)dmg, crit}; }sk.power是百分比系数100 表示一倍攻击力sk.def_rate是防御减伤比例设 50 就是防御值的一半参与减伤。中间用int64_t是必须的——攻击和系数都上万时32 位中间结果会溢出成负数表现是「打高级怪给自己加血」这种 bug 光看公式看不出来。掉落的权重表按累计权重取// 权重表按顺序累加随机数落在哪个区间就掉哪件 int RollDrop(const std::vectorDropEntry table) { int total 0; for (const auto e : table) total e.weight; if (total 0) return 0; int r rng_.Rand(1, total); // 闭区间 [1, total] for (const auto e : table) { if (r e.weight) return e.item_id; r - e.weight; } return 0; }rng_必须是地图线程私有的随机数引擎别用全局rand()。多线程共用一个rand()序列一是会有数据竞争二是每次重启掉的装备顺序都一样玩家很快就能摸出规律。我习惯用std::mt19937加线程 id 混种子。5. 排查清单五类让你卡在登录界面或进图黑屏的问题这类工程的问题有个共同特征现象和原因隔得很远。下面五条是我自己踩过、也在别人机器上复现过的按「现象 → 原因 → 解决」写遇到类似表现可以直接对照。5.1 连接与登录阶段的典型现象现象一客户端提示「连接服务器失败」服务端日志干净得像没人来过。原因基本是客户端连的地址和网关实际监听的不一致或者网关进程根本没起来。老工程里gate_ip常常写死在配置表里客户端拿到的是内网 IP换了机器就连不上。解决先在本机telnet 127.0.0.1 7000确认端口通再看conf/gate.ini里的listen_port和登录服返回的gate_port是不是同一个值最后检查防火墙。现象二账号密码输对了提示「账号不存在」。不是数据库没连上就是库连上了但连错了库。很多工程把数据库名写在 ini 里默认值和建表脚本里的库名不一致查询永远返回空。解决把服务端启动日志里的数据库连接串打出来看一眼SHOW DATABASES确认库在SELECT COUNT(*) FROM account确认数据在。现象三登录成功角色列表一直转圈。票据校验过了但角色数据拉不出来最常见是player表缺idx_uid索引加上数据量大或者字段类型和代码里的结构体对不上——比如代码里level是int32表里建成tinyint读取时按 4 字节解析就越界了。解决开慢查询日志把角色加载那条 SQL 单独拿出来在客户端执行一遍看耗时和返回类型。5.2 进图之后的行为异常与内存问题现象四进图黑屏人物能操作但什么都看不见。资源没挂上。要么Res/map/下缺map_id.map文件要么配置表导出的是新版本、客户端用的还是旧索引。解决看客户端日志里加载地图时打印的文件路径手动确认文件存在再对比Client/Res/data/和服务端conf/data/的文件修改时间不一致就重新导一次。现象五服务端跑几个小时内存一直涨重启就好。典型是实体析构没清干净。玩家下线时从players_里删了但 AOI 观察者列表、技能冷却表、Buff 列表里还挂着裸指针对象池回收不了。解决给玩家实体加个引用计数下线走统一销毁流程再挂个定时任务每 10 分钟打印一次各容器的size()哪个只涨不降就查哪个。提示排查这类问题别急着改代码先把日志级别开到 debug把收包命令字和玩家 id 打出来。八成问题看一眼日志就能定位剩下两成再动调试器。6. 二次开发给这套双端骨架加一套 Buff 系统想验证自己有没有吃透这套双端结构加一套 Buff 系统是最好的练手。改四处配置表、实体结构、属性计算、协议同步。配置表加一张buff表字段至少要有id、duration_ms、stack_max、attr_key、attr_value、tick_ms。stack_max控制叠加上限attr_key指向属性枚举别用字符串匹配性能和可读性都差。实体上挂一个实例列表用到期时间戳驱动不要给每个 Buff 建一个定时器// GameSrv/Buff.hBuff 实例靠地图线程的 tick 统一驱动 struct BuffInst { int cfg_id; // 对应 buff 表主键 int64_t end_ms; // 绝对到期时间判断时和当前时间比 int stack; // 当前层数超过 stack_max 不再叠加 uint32_t caster_pid; // 施放者用于结算归属 int64_t next_tick_ms; // 持续伤害类 Buff 的下次结算时间 };属性计算要分阶段基础属性 → 装备加成 → Buff 加成 → 临时增益每阶段算完缓存标记脏了才重算。最忌讳的是每帧遍历全部 Buff 加一遍属性几十个 Buff 乘几千个玩家CPU 直接吃满。协议上新增两条命令字S2C_BUFF_ADD和S2C_BUFF_REMOVE只同步cfg_id、剩余时间、层数三个字段客户端负责表现。别把整个 Buff 结构体发过去配置字段客户端自己查表就行。验证方法很土但有效加 GM 命令手动上 Buff连续挂 30 分钟观察属性面板数值能不能回到原始值回不去就是移除流程漏了某一阶段的重算。我踩过最深的一次坑是 Buff 生效写在攻击结算之后导致「先算伤害再上增伤 Buff」玩家反馈增伤第一下不生效。从那以后我每次加状态类功能都强制先跑一遍「上状态 → 打一下 → 看日志里的属性快照」这个流程再谈数值平衡。这套双端骨架的改造空间其实挺大Buff、坐骑、行会都能在现有实体结构上长出来别一上来就重构网络层。希望帮到你。本文还有配套的精品资源点击获取