☰
从epoll到AOI:可编译C++ MMO服务端源码实战拆解
2026/11/6 2:55:05 网站建设 项目流程

简介:这份传奇模拟游戏完整源码包面向具备一定 C++ 基础、希望了解客户端与服务器端协同开发的学习者,包含完整的客户端和服务器源程序,适合用于课程设计、旧项目维护入门及网络编程练手。压缩包共 499 个文件,约 11.32MB,以 131 个 h 头文件、127 个 cpp 源文件为主体,并附 obj、sbr 等编译中间文件以及 vcproj、dsw、dsp 等 VC++6.0 工程配置,另有少量 exe、dll、mdb 与资源文件,可直接还原工程结构。目前已有 222 人学习下载。读者可从中梳理角色控制、场景渲染、玩家状态同步、游戏规则执行与数据存储等模块的组织方式,并借客户端请求与服务器响应的实际代码理解网络通信、多线程及数据封装流程;工程目录和源文件命名也为对照阅读、拆解旧版 C++ 游戏框架提供了可操作的样本,对学习服务器端逻辑分层与客户端界面组织均有参考意义。

1. 一份能编译的客户端加服务端,到底值不值得跑

把一个复古 2D 等距视角的 MMO 模拟项目拆开看,最值钱的不是美术资源,而是那份能编译通过的 C++ 服务器源码——连接接入、地图、战斗、存盘这几件事被用最朴素的方式串成一条线,没有框架黑匣子,也没有一堆中间件。客户端负责渲染和输入,服务端负责一切判定,两边共用一份协议头。它适合两类人:想搞明白一个 MMO 服务端从接收到心跳到底怎么转起来的新手,和准备接手一套老式 C++ 网络代码、需要快速定位“改哪儿”的熟手。往下按目录、通信层、世界逻辑、踩坑、二次开发的顺序拆。

2. 源码结构与运行骨架:三条线程怎么把服务端撑起来

一份客户端加服务端的完整源码,最容易让人卡住的地方不是代码难,而是不知道从哪个文件开始读。我的习惯是先看目录划分,把“网络、逻辑、持久化、资源”四件事分开,再去看每个模块的入口函数。这个模拟项目的组织方式比较传统:服务端是一个独立进程,客户端是另一个独立进程,中间靠share/目录下一份共用协议头对齐消息号,任何一边改了包结构,另一边必须同步编译,否则联调时就是一堆“收到未知消息”的日志。

2.1 目录划分:服务端、客户端、共用协议各管一段

先把树状结构列出来,比对着读代码效率高很多。

# 顶层只分三块,边界清楚,改哪边一目了然 模拟项目X/ ├── server/ # 服务端:只依赖 share/ 与 conf/ │ ├── src/ │ │ ├── net/ # epoll 封装、连接对象、收发缓冲 │ │ ├── game/ # 玩家、地图、怪物、战斗、背包 │ │ ├── db/ # 角色读写、脏数据落库 │ │ ├── script/ # NPC 对话与任务条件 │ │ └── main.cpp # 启动顺序全在这里 │ ├── conf/ # server.conf、刷怪表、掉落表 │ └── CMakeLists.txt ├── client/ # 客户端:渲染 + 输入 + 网络 │ ├── src/render/ # 地图分层绘制、精灵动画 │ ├── src/net/ # 与服务端同构的拆包逻辑 │ └── res/ # 图集索引、地图文件、音效 └── share/protocol.h # 客户端与服务端共用的包结构与消息号

share/这个目录是整套代码能不能顺利联调的关键。消息号、包体结构、枚举值全放这里,两边#include同一份文件,避免出现服务端按 4 字节解析、客户端按 2 字节打包这种低级但极其耗时的错位。conf/下的配置全部是纯文本,改数值不用重新编译,这一点在做数值调试时非常省事——我一般会把刷怪间隔、掉落概率这类参数全丢进配置,重编译一次几十秒,改配置只要重启。

2.2 线程模型与启动顺序:逻辑单线程是刻意的选择

服务端常见做法是三条线程:一条 IO 线程跑epoll_wait收发包,一条逻辑线程按固定帧率跑世界更新,一条存盘线程专门处理数据库写入。这个项目也是这个路子。逻辑放单线程不是为了省事,而是为了不写锁——玩家移动、怪物寻路、战斗结算全都在这条线程里顺序执行,不需要考虑两个玩家同时捡同一件装备的竞态。

// server/src/main.cpp:启动顺序错了会出现“玩家进图但地图是空的” int main(int argc, char** argv) { Config cfg = Config::Load("conf/server.conf"); // 端口、DB 连接串、地图列表 if (!DbPool::Instance().Init(cfg.db)) { LOG_ERROR("db init failed"); // 连不上库直接退出,别带着半残状态跑 return 1; } // 静态数据一次性读进内存:物品、怪物、技能、刷怪点 World::Instance().LoadStaticData(cfg.data_dir); for (const auto& m : cfg.maps) { World::Instance().LoadMap(m); // 地图阻挡格 + 传送点 + 出生点 } NetServer net(cfg.port); net.Start(); // 起 IO 线程,开始 accept World::Instance().Run(); // 主线程进入 100ms 逻辑帧,阻塞在这里 return 0; }

参数上要盯住两个:cfg.port是客户端登录要连的端口,cfg.maps决定服务端加载哪几张图。顺序不能颠倒——先把静态数据灌进内存再开网络端口,否则第一个玩家连进来的瞬间,逻辑线程访问的还是空表,轻则进图黑屏,重则直接崩在map->Find(x, y)上。这个坑我在别的项目里踩过一次,排查了半天才发现是启动顺序问题。

编译用 CMake,服务端和客户端分开构建:

# 依赖只有三样:C++17 编译器、MySQL 客户端库、pthread mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DMYSQL_ROOT=/usr/local/mysql make -j$(nproc) # 产物:build/server/gameserver 与 build/client/gameclient

CMAKE_BUILD_TYPE=Release一定要带上,Debug 版在怪物数量上千的地图上帧时间会明显拉长,误以为是逻辑写挂了。-DMYSQL_ROOT指向本机客户端库的位置,如果你的库在系统默认路径,这个参数可以省略。

2.3 客户端资源加载:先读索引,再映射大文件

客户端这边容易出问题的是资源。图集通常拆成两个文件:一个索引文件记录每条记录的 id、偏移、尺寸,一个是几十兆的大图数据。常见做法是索引全读进内存,大文件用只读映射打开,用到哪张图就按偏移去取,而不是把几十兆整个读进堆里。

// client/src/render/sprite_lib.cpp:索引 + 映射,避免启动时内存飙高 bool SpriteLib::Open(const std::string& idx_path, const std::string& dat_path) { if (!idx_.Load(idx_path)) return false; // 每条记录:id / offset / size / w / h dat_.Open(dat_path, /*readonly=*/true); // 只读映射,不占堆 return dat_.IsOpen() && idx_.Count() > 0; } // 按 id 取一帧,返回的是映射区内的裸指针,生命周期跟着 dat_ 走 const SpriteFrame* SpriteLib::Get(uint32_t id) const { const auto* rec = idx_.Find(id); if (!rec) return nullptr; // 缺图直接返回空,渲染层画占位方块 return reinterpret_cast<const SpriteFrame*>(dat_.Base() + rec->offset); }

登录链路是三步:连服务端拿会话、发账号密码、选角色进图。进图之后客户端只负责把服务端广播过来的移动包插值播放,位置永远以服务端为准。这一条如果反过来做(客户端先动、再上报),局域网里看不出问题,一旦延迟上来就会看到角色瞬移回退,那不是 bug,是设计如此。

3. 通信层拆解:一个移动包从 epoll 到角色坐标

协议层是整套代码里最值得先读的部分,因为它决定了后面所有业务处理的形状。这个项目用的是最经典的二进制包:固定 8 字节包头加变长包体,小端字节序,一条 TCP 连接上跑所有消息。选 TCP 而不是 UDP,是因为这类游戏对移动的实时性要求没那么极端,但绝对不能接受丢包导致的背包错乱。

3.1 包头设计与序列化约定

// share/protocol.h:两边共用,改动必须同步重编 #pragma pack(push, 1) // 关键:禁止编译器插入对齐填充 struct PacketHeader { uint16_t size; // 整包长度(含包头),上限 8KB uint16_t cmd; // 消息号,见下方枚举 uint32_t seq; // 客户端自增序号,用于请求响应配对 }; #pragma pack(pop) static_assert(sizeof(PacketHeader) == 8, "包头必须是 8 字节");

#pragma pack(1)这行是血泪经验。默认对齐下,这个结构在某些编译器上会变成 12 字节,客户端按 8 字节发、服务端按 12 字节读,表现就是能登录、能走两步、然后莫名其妙断线,日志里连个错误都没有。加一个static_assert把尺寸钉死,编译期就能拦住这类问题。size字段包含包头自身长度,这样拆包时只需要一次比较;seq用不上响应的地方填 0 即可,但保留着对排查“客户端连点两下发送了两个相同请求”特别有用。

3.2 拆包:环形缓冲加一次循环读到 EAGAIN

TCP 是字节流,一个recv可能拿到半个包,也可能拿到三个半包。处理方式统一成一个环形缓冲加一个解析循环。

// server/src/net/session.cpp:一次可读事件尽量多解几个包 void Session::OnReadable() { while (true) { ssize_t n = ring_.ReadFrom(fd_); // 内部 recv,读到 EAGAIN 返回 0 if (n < 0) { Close(); return; } // 真错误,直接关连接 if (n == 0) break; // 本轮读空 // 缓冲里够一个包头就开始尝试解包,可能连续解出多个 while (ring_.Readable() >= sizeof(PacketHeader)) { PacketHeader h{}; ring_.Peek(&h, sizeof(h)); if (h.size < sizeof(h) || h.size > kMaxPacket) { // 非法长度,八成是协议错位 LOG_WARN("bad packet size=%u cmd=%u", h.size, h.cmd); Close(); return; } if (ring_.Readable() < h.size) break; // 半包,等下一次可读事件 ring_.Skip(sizeof(h)); // 头已经解析过,跳过 DispatchPacket(*this, h, ring_.Data(), h.size - sizeof(h)); ring_.Skip(h.size - sizeof(h)); // 包体也消费掉 } } }

两个参数要留意:kMaxPacket我一般设 8192,超过这个长度的包基本可以判定是脏数据或者协议错位,继续解析只会让内存越界;ReadFrom内部必须是循环recv直到EAGAIN,如果只读一次就返回,边沿触发模式下会漏事件,表现出来就是“玩家偶尔卡住不动,过几秒又好了”。

3.3 消息分发:一张表把消息号映射到处理函数

不要在switch里堆几百个 case,用一张注册表更清晰,加消息只需要写一个函数加一行注册。

// server/src/net/dispatcher.h using Handler = std::function<void(Session&, const uint8_t*, size_t)>; class Dispatcher { public: static Dispatcher& Instance() { static Dispatcher d; return d; } void Bind(uint16_t cmd, Handler h) { table_[cmd] = std::move(h); } bool Fire(uint16_t cmd, Session& s, const uint8_t* body, size_t len) { auto it = table_.find(cmd); if (it == table_.end()) { // 未知消息只记日志,不断线 LOG_WARN("unknown cmd=%u len=%zu", cmd, len); return false; } it->second(s, body, len); // 在逻辑线程执行,禁止在这里做阻塞 IO return true; } private: std::unordered_map<uint16_t, Handler> table_; }; // 注册宏:写在 .cpp 顶部,静态初始化期完成绑定 #define REG_MSG(cmd, fn) \ static bool _reg_##fn = (Dispatcher::Instance().Bind(cmd, fn), true)

处理函数里第一条永远是长度校验,客户端发来的东西一律不可信。

// server/src/game/handler_move.cpp static void OnMoveReq(Session& s, const uint8_t* body, size_t len) { if (len != sizeof(MoveReq)) return; // 长度对不上直接丢弃 MoveReq req{}; memcpy(&req, body, sizeof(req)); Player* p = s.player(); if (!p || p->IsDead() || p->IsStunned()) return; // 死亡和眩晕状态不许移动 // 客户端只上报方向,目标格由服务端自己算,防止瞬移外挂 int tx = p->x() + ClampInt(req.dx, -1, 1); int ty = p->y() + ClampInt(req.dy, -1, 1); if (!World::Instance().CanWalk(p->map_id(), tx, ty)) { p->SendStop(); // 撞墙回一个停步包,客户端自己纠正 return; } World::Instance().MovePlayer(p, tx, ty); // 内部触发 AOI 广播 } REG_MSG(CMD_MOVE_REQ, OnMoveReq);

ClampInt把方向分量限制在 -1 到 1,这一步看着多余,实际是拦瞬移最有效的一刀——只要服务端不接受客户端上报的绝对坐标,外挂就只能一格一格挪。发送侧同理,所有Send都走连接对象内部的发送队列,由 IO 线程统一写,避免逻辑线程和 IO 线程同时往一个 socket 写导致粘包错乱。

4. 世界逻辑:九宫格、定时器与怪物状态机

通信层打通之后,剩下全是世界怎么更新。这类游戏的地图是一格一格的,玩家视野有限,所以不需要把全服玩家的移动广播给所有人,只要通知“附近九宫格”里的玩家就够了。整套世界逻辑跑在一个 100ms 的逻辑帧里,帧内做四件事:消费网络队列、跑定时器、更新怪物、把脏数据丢给存盘线程。

4.1 主循环:别忙等,也别追帧

// server/src/game/world.cpp void World::Run() { using clock = std::chrono::steady_clock; constexpr int kTickMs = 100; // 10 帧/秒,够用且 CPU 友好 auto next = clock::now(); while (!stop_) { auto now = clock::now(); if (now < next) { // 没到点就睡,别空转烧 CPU std::this_thread::sleep_until(next); continue; } // 落后超过 5 帧说明卡了,直接把基准拉回当前,不补帧 if (now - next > std::chrono::milliseconds(kTickMs * 5)) next = now; next += std::chrono::milliseconds(kTickMs); DrainNetQueue(); // IO 线程投递过来的包,在这一帧统一消费 TickTimers(now); // 复活、buff 到期、刷怪计时 TickMonsters(); // 巡逻、追击、攻击判定 FlushDirtyPlayers(); // 只把标脏的角色交给存盘线程 } }

kTickMs这个值不要随便调小。调到 20ms 看起来更顺滑,但怪物 AI 和定时器的开销是按时钟线性增长的,同屏人数一多,逻辑线程直接吃满一个核,帧时间反而更不稳定。落后超过 5 帧就重置基准,是为了避免“卡了一下之后疯狂补帧”——补帧会让定时器在同一秒内连续触发好几次,表现就是怪物连击、buff 瞬间掉完。

4.2 九宫格 AOI:移动广播只发给看得到的人

// server/src/game/world_aoi.cpp void World::MovePlayer(Player* p, int tx, int ty) { int old_cell = CellIndex(p->x(), p->y()); int new_cell = CellIndex(tx, ty); p->set_pos(tx, ty); if (old_cell == new_cell) { // 格子没变,只广播给本格与相邻格 ForEachCellInRange(new_cell, [&](int c) { BroadcastToCell(c, MakeMove(p), p->id()); }); return; } aoi_[old_cell].erase(p->id()); // 先摘出旧格 aoi_[new_cell].insert(p->id()); // 再挂到新格 ForEachCellInRange(new_cell, [&](int c) { BroadcastToCell(c, MakeMove(p), p->id()); }); // 离开视野的格子要补一个“移除角色”包,否则客户端会留下残影 ForEachCellInRange(old_cell, [&](int c) { if (!IsNeighbor(c, new_cell)) BroadcastRemove(c, p->id()); }); } // 负坐标必须向下取整,-1/64 直接除会得 0,和 0 号格混在一起 int World::CellIndex(int x, int y) const { int cx = (x >= 0 ? x : x - kCellSize + 1) / kCellSize; int cy = (y >= 0 ? y : y - kCellSize + 1) / kCellSize; return cy * cell_w_ + cx; }

kCellSize取视野半径,一般等于屏幕能看到的最大格数,这样九宫格刚好覆盖一个客户端的可视范围。负坐标那两行看着琐碎,但地图左上角以外(很多地图坐标原点不在 0)最容易出问题——玩家走到负坐标区,两个不同的格子算成同一个索引,结果就是“人在左边走,右边的人也能看见”。

4.3 怪物状态机与掉落权重

怪物不用复杂 AI,四个状态足够撑起体验:待机、巡逻、追击、攻击。

// server/src/game/monster.cpp void Monster::Tick(int now_ms) { switch (state_) { case State::Idle: if (FindHostileInRange(6)) state_ = State::Chase; break; case State::Chase: if (!TargetAlive()) { state_ = State::Idle; break; } if (DistanceTo(TargetXY()) <= 1) { state_ = State::Attack; AttackCdReset(); } else StepToward(TargetXY()); // 内部做阻挡判断,走不通就换方向 break; case State::Attack: if (!TargetAlive()) { state_ = State::Idle; break; } if (AttackCdReady(now_ms)) DoAttack(); // 冷却由定时器统一推进 break; } }

掉落表和刷怪点放在配置里,改成 JSON 比自定义文本好维护:

{ "monster_id": 10021, "drops": [ { "item_id": 2001, "chance": 4000, "count": [1, 2] }, { "item_id": 3110, "chance": 120, "count": [1, 1] } ] }

chance用万分比整数而不是浮点,一是避免浮点误差导致“明明写 1% 却总也不掉”,二是整数方便做总和校验——一份掉落表里所有概率加起来如果超过 10000,说明配置写错了,加载时打一条警告日志。count是区间,随机时用左闭右闭。

5. 踩坑与排查:五个真金白银换来的记录

代码能跑起来只是第一步,联调阶段真正耗时间的是那些“看起来像是玄学”的问题。下面五条是我在这个项目里实际撞过的,按现象、原因、解决写清楚。

5.1 网络与协议类

现象:能登录、能选角,进图走两步就断线,服务端日志里只有一条“bad packet size”。

原因:包结构没加#pragma pack(1),客户端和服务端编译出来的PacketHeader尺寸不一致,服务端按错误的偏移读size字段,读出来是个天文数字,被长度校验拦掉了。

解决:在share/protocol.h里加pack(1),并且补static_assert(sizeof(PacketHeader) == 8)。凡是客户端和服务端共用的结构体,一律手动序列化成字节流最稳,别把内存布局直接丢过网络。

现象:压力测试下某个玩家突然收不到任何广播,但他人还在线,不报错也不掉线。

原因:IO 线程用的是边沿触发,recv只调用了一次,缓冲里还剩数据没读完,内核不再通知可读事件,这一路连接就“静默”了。

解决:ReadFrom内部必须循环recv直到返回EAGAIN,再加上EPOLLONESHOT配合重新注册。判断方法很简单——在OnReadable里打一行读完后的剩余字节数,如果长期大于包头长度却不再触发,基本就是这个问题。

5.2 世界逻辑类

现象:同屏人一多,有人能看到别人移动,自己却看不到别人;离开视野的角色在客户端留下残影。

原因:AOI 只做了“进入新格子时广播”,没做“离开旧格子范围的移除包”,客户端的角色列表只增不减。另外负坐标取整用了截断除法,两个格子算出同一个索引。

解决:移动时同时算新旧两个格子的九宫格范围,差集部分补发移除包;CellIndex里对负坐标做向下取整。验证方式是两个客户端并排走,一个人往左上角出屏,看另一个人屏幕上角色是不是干净消失。

现象:怪物偶尔连着打两下,或者 buff 显示还剩 3 秒就提前掉了。

原因:逻辑线程卡顿后追帧,定时器在同一帧内被连续推进多次,TickTimers里把所有到期项一并触发。

解决:主循环里加落后重置基准的保护(超过 5 帧直接next = now),同时定时器推进只做一次累加,不循环补触发。宁可让 buff 晚掉 100ms,也不要让它一次掉两层。

5.3 资源与构建类

现象:服务端重启后角色等级回到前一天,背包里刚打的东西也没了。

原因:存盘只在收到退出信号时刷一次,进程被kill -9或者崩溃时什么都没写;FlushDirtyPlayers里只标记不落库。

解决:改成每秒把脏角色丢给存盘线程批量写,落库失败重试三次再打错误日志;同时给角色表加一个updated_at字段,排查回档时能直接看出最后一次写盘时间。数据库连接一定要放在独立线程里,逻辑线程做同步写会在高峰期直接把帧率拖垮。

注意:客户端资源路径不要依赖工作目录。用可执行文件所在目录去拼res/,否则换个启动方式就是黑屏。

6. 二次开发:加一个技能要动的五处,和我的验证习惯

真要在这套代码上加东西,最典型的练手是“加一个主动技能”。它横跨协议、配置、服务端逻辑、客户端表现四层,改完一遍基本就把整个链路摸熟了。

改动点位置关键说明
消息号share/protocol.h新增CMD_SKILL_REQ/CMD_SKILL_BROADCAST,两边同步重编
技能配置conf/skill.json冷却、耗蓝、伤害系数、特效 id
服务端处理game/handler_skill.cpp校验冷却与蓝量,结算伤害,触发 AOI 广播
战斗结算game/combat.cpp命中判定读一次随机数,别在多个函数里各读一次
客户端表现client/src/render/skill_fx.cpp按特效 id 播动画,播放期间不阻塞输入

改动顺序建议从配置开始,再写服务端处理函数,最后接客户端表现。原因是服务端能先用日志把伤害数值打出来验证,客户端动画晚一点接不影响正确性。

服务端处理函数的骨架大致这样,重点是所有校验都在服务端做:

static void OnSkillReq(Session& s, const uint8_t* body, size_t len) { if (len != sizeof(SkillReq)) return; SkillReq req{}; memcpy(&req, body, sizeof(req)); Player* p = s.player(); if (!p || p->IsDead()) return; const SkillCfg* cfg = SkillTable::Find(req.skill_id); if (!cfg) return; // 配置不存在,直接丢 if (!p->CanCast(cfg)) { p->SendSkillFail(req.skill_id); return; } // 冷却/蓝量/距离 p->ConsumeMp(cfg->mp_cost); p->StartCooldown(cfg->skill_id, cfg->cd_ms); Combat::Cast(p, cfg, req.target_id); // 内部结算并广播结果 } REG_MSG(CMD_SKILL_REQ, OnSkillReq);

验证方法我一直用同一套:开两个客户端,一个原地挨打,一个放技能,看伤害数字、蓝量、冷却时间三者是否对得上;然后把其中一个客户端的网络延迟调高,确认技能特效和伤害结算以服务端广播为准,而不是客户端先自己扣血。这套流程跑下来,八成的问题能在提交前暴露。

从那以后我每次动服务端逻辑,都强制先在两个客户端上把走路和放技能各跑一遍,再去看日志有没有未知消息,最后才提交。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询