简介:这份压缩包是经典网络游戏《征途》的完整开发源码集,包含服务端、客户端与配套数据库,面向对大型网游架构感兴趣的C/C++开发者、游戏客户端与服务端工程师。服务端源码可研究网络通信、任务调度与负载均衡;客户端源码覆盖渲染、动画与AI算法;数据库部分则呈现玩家数据、道具存储与查询优化思路。包内共2000个文件,以917个h头文件、878个cpp源文件、752个xml配置为主,附带lua脚本、asm汇编、hpp头文件及SQL数据库脚本等,压缩包约133MB。随包提供的说明文本对目录结构、编译依赖与配置流程做了梳理,便于快速上手。已有2949人学习下载。通过完整源码与配套数据,开发者可系统了解一款成熟MMORPG从服务器逻辑到客户端表现的整体实现,为自研项目或性能优化提供扎实参考。
1. 一套带完整客户端的国产MMORPG服务端:C++ 写的《征途》源码,能跑也能改
拿到源码包,第一反应基本都是“能不能跑起来”。这份《征途》服务端源码加客户端源码再加数据库脚本的三件套,属于那种少有的一整套全给的资源——C++ 写的服务端,独立的 2D 客户端工程,还有建完库就能登录的完整脚本。
对想研究国战玩法、同屏千人广播、双端联调的人来说,它的参考价值远超只贴几个类的教学 Demo;对刚学 C++ 服务端的人来说,它也是一个能真实跑起来的完整骨架,从收包、解包、会话管理到角色落库,每一步都有实际代码可以对着读。认清这套老代码的进程边界和依赖顺序,是把它跑起来的第一步。
2. 整体架构与工程布局:服务端进程、客户端工程和数据库怎么分工
老一代 MMORPG 服务端几乎不会做成单进程大单体,常见做法是按职责拆成多个进程,进程之间再用内部 TCP 互相连接。这套源码里最核心的是四个进程:登录服、网关服、游戏服和记录服。它们各有各的职责边界,读源码之前先把这个边界理清楚,后面看任何一段代码都知道它在整条链路里处于什么位置。
2.1 服务端进程划分:Login、Gate、Game、Record 各管一段
LoginServer 管账号密码校验、角色列表和排队公告。客户端第一次连接的就是它,验证通过后才把连接跳转到网关。这里有一个容易被忽略的点:登录服只做身份认证,不下发角色详情,角色数据要等客户端连上网关之后再去取,分开的原因是为了让多台游戏服可以共用一套登录认证。
GateServer 是面向玩家的长连接入口。所有客户端的 socket 都挂在它上面,断线重连、心跳检测都在这一层处理。它同时承担流量转发,根据包里的玩家 ID 把操作包转到对应的 GameServer,一个网关背后可以挂多台游戏服,这就是最早期的“网关 + 场景服”架构雏形。
GameServer 是核心玩法的承载者,移动、战斗、技能、NPC、掉落全在它这里算。一台 GameServer 可以管一张或者多张地图,跨地图跳转时由一台轻量的世界服务器协调。RecordServer 负责把玩家数据异步写回数据库,避免游戏主流程阻塞在磁盘 IO 上,玩家上线时也由它把存档拉起来。
消息流大致是客户端 → 网关 → 游戏服 → 记录服 → 数据库,回包按原路反向走。把这套链路看懂,读代码时遇到一个函数就能立刻回答“它属于哪个进程、上游是谁、下游是谁”这三个问题。
2.2 客户端工程:D2D 渲染、界面层与网络模块
客户端是经典的 D2D 渲染架构,工程目录分层大致如下。不同版本的源码可能会有目录命名差异,但分层思路基本一致。
Client/ ├─ Engine/ # D2D渲染、图片资源管理、特效 ├─ Network/ # socket封装、收发线程、封包/解包 ├─ Scene/ # 地图加载、单位管理、相机 ├─ UI/ # 界面布局、快捷栏、背包窗口 └─ Main.cpp # 引擎初始化后进入消息循环一开始看客户端源码,我建议直奔 Network 目录。客户端和服务端能不能对上号,核心就是封包格式,只要包格式一致,引擎那部分看不懂也不影响联调。UI 目录里全是窗口回调,做得比较“重”,适合以后要改界面的人去啃,刚上手可以先跳过。Scene 目录里最值得看的是单位管理,里面的移动缓存和服务器下发的位置包紧密相关,排查瞬移、不同步问题时你会反复进出这块代码。
2.3 数据库设计:账号、角色、背包三张核心表的关联
整套库围绕“账号 — 角色 — 背包”这条主线展开,建库脚本是 T-SQL 写法,导入 SQL Server 后生成十几张基础表。核心的几张表的关联关系如下。
-- 账号表:只负责登录凭证 CREATE TABLE account ( account_id INT PRIMARY KEY IDENTITY(1,1), login_name VARCHAR(32) NOT NULL UNIQUE, pass_hash VARCHAR(64) NOT NULL, create_time DATETIME DEFAULT GETDATE() ); -- 角色表:一个账号最多三个角色,当前位置冗余在表里 CREATE TABLE player ( role_id INT PRIMARY KEY IDENTITY(1,1), account_id INT NOT NULL REFERENCES account(account_id), role_name VARCHAR(32) NOT NULL, level INT NOT NULL DEFAULT 1, exp BIGINT NOT NULL DEFAULT 0, map_id INT NOT NULL DEFAULT 1001, pos_x INT NOT NULL DEFAULT 100, pos_y INT NOT NULL DEFAULT 100 ); -- 背包表:一格子一条记录 CREATE TABLE bag_item ( id BIGINT IDENTITY(1,1) PRIMARY KEY, role_id INT NOT NULL REFERENCES player(role_id), item_id INT NOT NULL, slot_idx TINYINT NOT NULL, count INT NOT NULL DEFAULT 1 );注意角色表和背包表是分开的,角色当前位置直接冗余在 player 表里,选角色进游戏时不用再查地图表就能快速拉起场景。背包单独建表是因为物品数量大,登录时不做全量加载,只在打开背包或拾取物品时按需读写。这种“高频核心字段冗余在主表、低频大字段拆分出去”的设计,在现在的游戏库里依然常见。如果你要加充值、邮件、排行榜功能,都是在这个基础上继续加表,而不是改动这几张核心表的结构。
2.4 协议封包:长度、命令号、玩家 ID 和序列号
客户端和服务端之间用的是自定义二进制协议,包头固定长度,组织方式是“包长 + 命令号 + 玩家 ID + 包体”。封包结构体在客户端和服务端各有一份定义,两边都按 4 字节对齐编,所以不存在大小端问题。
#pragma pack(push, 1) struct PacketHeader { uint32_t len; // 整个包长度,包含包头自身 uint32_t cmd; // 命令号,客户端和服务端共用同一张协议表 uint32_t uid; // 玩家唯一ID,网关转发时填进去 uint16_t seq; // 序列号,客户端自增,用于检测丢包/乱序 uint16_t crc; // 简单校验和,防止内存脏数据 }; #pragma pack(pop)cmd 是双方约定好的命令编号,登录、选角色、进场景、移动分别对应不同值。seq 主要用于开发期统计丢包,线上如果嫌包头太胖,常见做法是把它砍成 1 字节。crc 在这里只是兜底校验,不能当安全机制用。读代码时先在工程里搜索这个结构体的定义,再把协议表(一般是 cmd 和函数指针的映射数组)搜出来,整个项目的消息驱动脉络基本就摸清了。
3. 核心模块拆解:从登录到同屏广播的完整数据链路
前半部分把工程结构讲完了,现在落到真实代码上。这一章选三个关键截面展开:登录验证、玩家对象与数据库的交互、同屏广播。这三块分别对应一次玩家会话的建立、持续、扩散三个阶段,是 MMORPG 服务端最核心的骨架。
3.1 登录验证:从收包、查重到异步校验
客户端连上登录服务器后发的第一个包就是账号密码。常见做法是收到包先查内存中的在线表,防止同一账号重复登录,然后丢给一个 DB 线程池异步校验,主收包线程绝不阻塞在数据库 IO 上。
// LoginServer 处理登录包的入口(libevent 风格回调,示意) void OnLoginPacket(Conn* conn, const char* body, size_t size) { LoginReq req; memcpy(&req, body, sizeof(LoginReq)); // 1. 先查内存在线表,拒绝重复登录 if (online_map_.Find(req.account_id)) { LoginResult res = { ERR_ALREADY_ONLINE, 0, 0 }; SendPacket(conn, CMD_LOGIN_RESULT, &res, sizeof(res)); return; } // 2. 交给 DB 工作线程异步校验,不阻塞收包线程 auto* task = new VerifyTask(req.account_id, req.pass_hash, conn->Ctx()); db_pool_->Post(task); } // DB 线程校验完成后回到主线程的 callback void OnVerifyDone(VerifyTask* task, bool ok) { LoginResult res; if (!ok) { res.code = ERR_WRONG_PASSWORD; SendPacket(task->conn_ctx, CMD_LOGIN_RESULT, &res, sizeof(res)); return; } // 3. 下发临时 token,客户端凭 token 去连网关 uint32_t token = GenerateSessionToken(task->account_id); res.code = ERR_OK; res.token = token; SendPacket(task->conn_ctx, CMD_LOGIN_RESULT, &res, sizeof(res)); }这里每个字段都有讲究:LoginReq 是客户端上送的账号密码包,字段顺序必须和客户端封包严格一致;err_code 是各错误码常量,ERR_ALREADY_ONLINE 和 ERR_WRONG_PASSWORD 分别对应“重复登录”和“密码错误”。token 是一次性票据,带过期时间,客户端连网关时把 token 附上去,网关验过就放行。这个“登录服验证、网关验 token”的模式在现在的游戏服务端里依然大量沿用,读懂它以后再去看其它项目的登录协议会非常快。
3.2 玩家对象:内存状态和数据库的落点边界
玩家在线时的等级、经验、坐标都存在 GameServer 内存里,只有关键节点才落库。这里用一个很实用的“脏标记”机制,把写库频率压到最低。
class Player { public: void SetLevel(int new_level) { if (level_ != new_level) { level_ = new_level; dirty_.level = true; // 只标记,不立刻写库 } } // 心跳中把脏数据批量丢给 RecordServer void UpdateHeartbeat(uint64_t now_ms) { if (dirty_.level || dirty_.exp) { SaveTask* task = new SaveTask(role_id_, level_, exp_); record_client_->Post(task); // 异步落库 dirty_.Reset(); } } private: int role_id_; int level_; int64_t exp_; int map_id_, pos_x_, pos_y_; DirtyFlags dirty_; // 每个字段占一个标记位 };如果你要在这套项目上加新属性,比如“攻击速度”或“声望”,标准流程是:Player 里加成员变量 → 加对应脏标记位 → 数据库表加字段 → RecordServer 的写入 SQL 加字段。四步缺一不可,漏掉最后一步就会出现角色上线后属性为 0 的诡异问题。我当年调类似问题调了一下午,最后发现就是写入 SQL 没同步字段。这个玩法对做二次开发的人来说几乎是必学的一课。
3.3 同屏广播与 AOI:只把消息发给看得见的人
这种大地图国战玩法,最忌讳的就是所有移动都在全服广播。这套游戏服用的是 AOI(Area of Interest)方案,常见实现是网格法,把地图切成格子,玩家只同步所在格子和相邻 9 格内的其他人。
// 网格AOI的移动更新入口(示例逻辑) void AOIManager::OnPlayerMove(Player* p, int new_x, int new_y) { GridCoord old_g = GridCoord::FromPixel(p->pos_x(), p->pos_y()); GridCoord new_g = GridCoord::FromPixel(new_x, new_y); if (old_g == new_g) { // 没跨格:把小范围内的位置包广播给视线内玩家 auto enters = GetNearbyPlayers(new_g); BroadcastMove(p, enters, SELF); return; } // 跨格:旧格子的邻居要收到“离开视野”,新格子的邻居收到“进入视野” auto leaves = GetNearbyPlayers(old_g); auto enters = GetNearbyPlayers(new_g); NotifyLeave(p, leaves); BroadcastMove(p, enters, ENTER_VIEW); }这里的关键是用“格子坐标”而不是“像素坐标”比较,像素坐标每帧都变,格子坐标只有跨格时才触发邻居关系变更。格子尺寸是一个重要调参点:格子越小,广播包量越小,但跨格事件越频繁;格子越大,包量越大,跨格事件变少。二次开发时想减少服务端带宽,先调这个参数看效果,不要一上来就改逻辑。
4. 从源码到进游戏:编译、导库、启动的完整流程
前面讲的是怎么读代码,这一章讲怎么真正把服务端跑起来。这套东西毕竟是老工程,工具链、依赖、启动顺序都讲究,按下面的顺序走能省很多来回折腾的时间。
4.1 编译准备:工程版本、依赖库与生成顺序
打开源码先别急着点编译,先把工具链对齐。工程文件是 VS2008 时代的 .sln,直接用 VS2008 编译最省事;用高版本 VS 打开会弹转换向导,转换后一般能编过,但个别工程要手动处理“安全函数已废弃”的编译告警。
编译顺序有讲究:先编公共基础库(网络库、工具库、加密库),再编各 Server,最后编客户端。各进程共享的公共头文件如果在原机器上配置的是绝对路径,换到你机器上就会报“无法打开包括文件”,需要去每个工程的“附加包含目录”里改成相对路径。客户端工程依赖 DirectX SDK,得提前装对应版本,否则编到渲染模块时直接提示找不到 d3d9.h。装错 SDK 版本这一类问题,我一般先看编译报错里第一个缺失头文件是什么,再反推需要哪个版本,比盲目装最新版靠谱得多。
4.2 数据库导入:脚本执行顺序与测试账号初始化
用 SSMS 打开数据库脚本时不要整个文件一口闷,按“先建库、再建表、再灌配置数据、最后插账号”的顺序分步执行。配置数据(地图、怪物、物品表)必须完整导入,否则 GameServer 启动时加载地图配置失败会直接退出。
注意:执行脚本前先备份原始文件。老脚本经常在执行过程中小步报错,有了干净备份,重来一次的成本很低。
-- 手动创建测试账号,密码字段是服务端约定的哈希 INSERT INTO account(login_name, pass_hash) VALUES ('test01', '25d55ad283aa400af464c76d713c07ad');这里有一个经常踩的坎:pass_hash 到底加不加盐,取决于 LoginServer 里 VerifyTask 调用的哈希函数。如果源码做了加盐处理,你直接插上面这种无盐哈希,登录时就会一直报密码错误。血泪经验:凡是登录报密码错,先去看哈希函数,再决定要不要补盐到 insert 语句里,别一上来就怀疑数据库连不上。
4.3 启动顺序与验证:端口、配置和日志
服务端启动有固定顺序:先起 RecordServer(因为它要连数据库并保持连接池),再起 LoginServer,然后 GateServer,最后 GameServer。每起一个进程,盯它自己的日志文件,看到类似“listen ok”或“server started”再起下一个。
| 进程 | 默认端口 | 验证方式 |
|---|---|---|
| LoginServer | 8000 | 本机 telnet 8000,能通说明监听成功 |
| GateServer | 9000 | 查看日志中的 bind 信息 |
| GameServer | 9100 | 查看日志中的地图加载与 bind 信息 |
| RecordServer | 9200 | 查看 DB 连接池初始化日志 |
IP 和端口一般都集中在各进程的 ini 配置文件里,常见配置项包括 server_id、listen_ip、listen_port、db_host、db_user、db_pass。有几个注意点:listen_ip 配 0.0.0.0 表示监听本机所有网卡,方便用局域网 IP 联调;各进程端口不能冲突;改完配置必须重启对应进程,老代码里不少进程在启动时才读取配置。
客户端连接前,把客户端配置文件里的服务器地址改成 127.0.0.1。注意客户端的登录流程是先连 LoginServer,验证通过才跳转 GateServer,所以登录器里登录服地址和网关地址都要配对,只配网关不配登录服就永远卡在“正在连接服务器”。看日志时多留意当前卡在哪一步,比瞎猜强。
5. 避坑指南:这台老源码最容易踩的四个坑
这一章按时间顺序排列,从编译到进游戏,每条按现象、原因、解决三个维度写。我实际跑这类老源码项目时,八成问题都落在下面这几类里。
5.1 编译期:莫名其妙的无法打开头文件
现象:VS 打开工程后编译,报“无法打开包括文件 common.h”或 fatal error C1083,而且报错文件是同一个头文件。
原因:工程文件里记录的附加包含目录是原作者机器上的绝对路径,比如 D 盘某个目录,换到你的机器上自然找不到。多台 Server 共享的公共头文件目录只要有一个没改对,就会连带报一堆错。
解决:打开每个工程的“属性 → C/C++ → 常规 → 附加包含目录”,把路径改成相对路径,比如 ....\Common,同时检查“附加库目录”里的 .lib 路径。改完重新编译,再遇到缺失头文件就顺着报错一个一个补。
5.2 数据库期:脚本执行到一半就报错
现象:用 SSMS 执行建库脚本,跑到一半报“对象名无效”或“列名无效”,后面的语句全部没执行。
原因:脚本里的建表和插数据顺序不兼容,某条语句引用了还没创建的实体,或者同批次里依赖关系错乱。老脚本常见做法是整段塞在一起跑,一旦前面的语句没成功,后面全部连锁失败。
解决:把脚本拆成三份独立执行:建库、建表、插入基础数据。每跑完一份验证一次表是否创建成功,再跑下一份。完成后把整库“生成脚本”导出一份自己的备份,后面每次改表结构都在这个备份上操作,出问题可以随时回滚。
5.3 启动期:GameServer 弹一下窗口就闪退
现象:启动 GameServer,窗口一闪就没了,日志目录里没有任何输出,看起来像没跑起来,玄学感很强。
原因:进程启动时依赖的目录或文件不存在,常见的是 logs 日志目录没建、地图配置路径配错了,或者依赖的配置文件缺失。这类老进程在初始化失败时经常不写日志,导致看起来像“闪退”。
解决:把服务端目录下所有进程可能用到的目录(logs、map、config)手动建一遍,再确认 ini 里 map_path 指向的地图配置真实存在。最后用命令行方式直接启动进程,让错误信息输出到控制台而不是日志文件,基本能定位到是哪一行初始化失败。
5.4 连接期:客户端一直转圈“正在连接服务器”
现象:客户端打开后一直显示“正在连接服务器”,没有任何错误弹窗,telnet 端口却是通的。
原因:客户端登录器配置的服务器地址和端口与启动的服务端不一致。更隐蔽的是只配了网关地址,没配登录服地址,导致登录阶段根本没连对进程。
解决:先用 telnet 逐端口确认 LoginServer 和 GateServer 都在监听;再检查客户端配置文件里的地址,确认登录服地址指向 8000 端口、网关地址指向 9000 端口;最后确认 Windows 防火墙没有拦截这几个端口。这类问题九成是配置没对齐,不全是代码的锅,按端口和配置逐项排除比改代码更快。
如果上面四条都检查过还是卡住,就把四个进程的日志按时间线对齐看一遍,卡在哪个进程、哪个阶段,问题就在谁身上,日志永远比玄学靠谱。
6. 二次开发路线:从改一个技能到做一张新地图
跑通只是开始,拿来改才是这套源码最大的价值。这里给一条最小可用的改造路线:先改配置,再改代码,最后用日志和抓包验证。
6.1 技能改动:先找配置表,再找代码触发点
技能伤害、冷却、消耗这类数值大多是配置驱动,技能表里改了数值就生效。加一个全新机制才需要动 C++ 代码,触发点一般在角色的施法入口。
// 技能触发的统一入口(示意) void RoleSkill::Cast(uint32_t skill_id, Player* caster, Player* target) { const SkillCfg& cfg = skill_cfg_table_->Find(skill_id); if (cfg.damage_type == DAMAGE_PHYSIC) { int dmg = cfg.base_damage + caster->atk() - target->def(); target->TakeDamage(dmg > 0 ? dmg : 1); } if (cfg.flag & SKILL_FLAG_DRAIN) { // 吸血机制追加,不破坏原有结算流程 int heal = cfg.drain_percent * cfg.base_damage / 100; caster->Heal(heal); } }6.2 新地图:复制一份配置,改 ID 和传送点
做新场景最省力的方式是复制现有地图目录,改地图 ID、尺寸和出生点,然后在传送配置表里加一条入口,最后把该地图的刷怪配置补上。GameServer 启动时加载地图和刷怪配置,改完重启就生效,不需要动客户端逻辑。
6.3 验证改动:日志调 DEBUG、抓包看命令号
改完别急着上线,先把服务端日志级别调到 DEBUG,看技能触发和新地图加载有没有报错;再用抓包工具过滤客户端到网关的封包,按命令号核对新技能的发送和回包是否成对。配置表改多了之后很容易出现“表里改了没生效”,抓包能看到客户端明明发了包、服务端没响应,那就是服务端没重新加载配置。
从那以后,我每次改这种老项目都强制走一遍“先看配置表再改 C++ 最后抓包验证”的流程,踩过的坑基本都能在半个小时内定位。希望这一套流程也能帮到你。
本文还有配套的精品资源,点击获取