C++传奇游戏服务端源码解析:IOCP+状态机+二进制协议实战
发布时间:2026/10/28 17:30:36 锦皓数字建站

简介这是一份面向C游戏开发初学者与网络编程学习者的完整传奇类模拟游戏源码包涵盖客户端与服务器双端实现适用于理解MMORPG基础架构、VC6.0工程实践及Windows平台高性能服务端开发。资源共499个文件以127个cpp和131个h头文件为核心构成可编译运行的完整项目obj、sbr、idb等为VC6工程中间产物vcproj/dsw/dsp等则体现经典VC6多层解决方案结构另有少量exe、dll、res资源及配置文件整体压缩包仅11.32MB轻量易解压分析。已有218人学习下载适合动手调试、逆向梳理通信流程、研究角色同步与服务端逻辑分发机制。读者可直接构建运行深入掌握Socket网络通信、多线程状态管理、客户端渲染交互与服务器数据持久化等关键环节是少有的兼顾教学性与工程完整性的C服务器实战范例。1. 这不是怀旧彩蛋而是一套可运行、可调试、可二次开发的完整传奇类游戏服务端客户端工程某开发者在整理旧项目时翻出一套完整的“传奇模拟游戏”源码包压缩包里赫然包含server/和client/两个平行主目录不是单个 demo不是教学片段而是从登录认证、地图同步、技能释放、物品掉落、GM指令到客户端渲染循环都走通的闭环系统。它用标准 C11 编写不依赖 MFC 或 Qt 界面层客户端基于 Win32 GDI 实现轻量级 2D 渲染服务器采用 IOCP 模型支撑百人级并发连接——这意味着你下载解压后不用改一行代码就能双击LoginServer.exe和GameServer.exe启动服务再运行Client.exe输入127.0.0.1就能进图打怪。它不是为“复刻私服”而生而是为“理解 MMORPG 底层通信协议如何落地”而设计所有封包结构定义在Protocol.h所有状态机流转写在PlayerState.cpp所有数据库交互抽象成DBManager接口。如果你正卡在“学完网络编程却写不出一个带状态的多人游戏逻辑”或“想给毕业设计加一个可演示的实时交互模块”这套代码就是你缺的那块拼图——它不炫技但每行都在回答“玩家按下一个键服务器到底做了什么”。2. 架构总览与核心模块拆解从进程划分到状态机设计2.1 三进程协同模型LoginServer、GameServer、DBProxy 的职责边界整个服务端由三个独立 Windows 进程组成通过命名管道Named Pipe进行低延迟通信而非全部塞进一个进程搞单体架构。这种拆分不是为了“高大上”而是解决实际问题登录鉴权需对接外部账号系统如模拟的 LDAP 接口游戏逻辑需高频率 Tick默认 50ms/帧而数据库操作必须异步防阻塞。三者关系如下LoginServer.exe只做三件事——接收客户端 TCP 登录请求、校验账号密码查内存缓存 → 查 DBProxy、生成临时 SessionToken 并转发给 GameServer。它不加载地图、不维护角色状态、不处理任何游戏内指令。GameServer.exe真正的游戏世界容器。它持有所有在线玩家的Player对象实例管理地图格子MapCell、怪物 AIMonsterAI、技能冷却SkillCooldown、物品掉落表DropTable。每个玩家对象内部封装了StateContext驱动其在STATE_LOGINING→STATE_IN_MAP→STATE_FIGHTING→STATE_DEAD之间流转。DBProxy.exe作为数据库代理屏蔽底层差异。它启动时读取config/db.ini支持 SQLite开发调试用和 MySQL生产切换只需改配置。所有 SQL 操作被封装为DBTask对象通过线程池异步执行结果回调到 GameServer 的OnDBResult()函数。提示命名管道名硬编码在Common/Config.h中如LOGIN_TO_GAMESERVER_PIPE \\\\.\\pipe\\LegendLoginToGame。若需多开服必须修改此处并确保各进程使用唯一管道名否则启动时会因管道已存在而失败。2.2 协议层设计二进制封包结构与编解码核心所有客户端与服务器通信均基于自定义二进制协议无 JSON/XML 文本传输。封包结构定义在Protocol.h采用“头体”格式#pragma pack(1) struct PacketHeader { uint16_t length; // 整个包长度含 header uint16_t cmd; // 命令号如 CMD_LOGIN_REQ 0x0001 uint32_t seq; // 请求序号用于客户端匹配响应 uint8_t encrypt; // 加密标识0不加密1RC4 }; #pragma pack()关键点在于length是整个包字节数不是 body 长度cmd使用十六进制常量定义避免字符串解析开销seq由客户端自增服务器原样回传客户端靠它把CMD_MOVE_ACK和之前发的CMD_MOVE_REQ关联起来。编解码逻辑集中在PacketCodec.cpp// 发送前编码计算 length填充 header可选加密 bool PacketCodec::Encode(Packet* pkt) { if (!pkt) return false; pkt-header.length sizeof(PacketHeader) pkt-body.size(); // 若 encrypt1对 body 执行 RC4 加密密钥来自 config/server.key if (pkt-header.encrypt) { RC4Crypt(pkt-body.data(), pkt-body.size(), GetRC4Key()); } return true; } // 接收后解码先读 header再按 length 读 body再解密 bool PacketCodec::Decode(const char* raw, size_t len, Packet* out) { if (len sizeof(PacketHeader)) return false; memcpy(out-header, raw, sizeof(PacketHeader)); if (out-header.length MAX_PACKET_SIZE || out-header.length sizeof(PacketHeader)) { return false; // 防止畸形包攻击 } size_t bodyLen out-header.length - sizeof(PacketHeader); if (len out-header.length) return false; // 数据未收全 out-body.assign(raw sizeof(PacketHeader), bodyLen); if (out-header.encrypt) { RC4Crypt(out-body.data(), bodyLen, GetRC4Key()); } return true; }逻辑说明Encode()在发送前填充length并加密bodyDecode()先验证length合法性防缓冲区溢出再检查是否收全数据TCP 粘包处理最后解密。参数说明MAX_PACKET_SIZE定义为 8192超过此值的包直接丢弃GetRC4Key()从config/server.key读取 16 字节密钥文件不存在时用默认密钥开发阶段够用上线前必须替换。2.3 玩家状态机从登录到战斗的七种状态与流转条件Player类的核心是StateContext它持有一个当前状态指针m_pCurrentState所有网络事件、定时器事件、AI 事件都通过HandleEvent()路由给当前状态处理。状态定义在PlayerState.henum PlayerStateType { STATE_NULL 0, STATE_LOGINING, // 等待 LoginServer 返回账号验证结果 STATE_WAITING_MAP, // 已验证等待 GameServer 分配地图 STATE_IN_MAP, // 在地图中可移动、攻击、拾取 STATE_FIGHTING, // 正在攻击目标受攻速限制 STATE_CASTING, // 施法中有施法时间 STATE_DEAD // 死亡状态3秒后自动复活 };状态流转不是靠switch-case硬编码而是每个状态实现IPlayerState接口class IPlayerState { public: virtual void OnEnter(Player* pPlayer) 0; virtual void OnExit(Player* pPlayer) 0; virtual void HandleEvent(Player* pPlayer, EventType event, void* data) 0; virtual void OnTimer(Player* pPlayer, uint32_t elapsedMs) 0; };例如StateInMap.cpp的HandleEvent处理移动请求void StateInMap::HandleEvent(Player* pPlayer, EventType event, void* data) { switch (event) { case EVENT_MOVE_REQ: { MoveRequest* req static_castMoveRequest*(data); // 1. 校验目标坐标是否在地图范围内 if (!pPlayer-GetMap()-IsValidPosition(req-x, req-y)) { pPlayer-SendError(ERR_INVALID_POSITION); return; } // 2. 校验是否被定身/眩晕状态异常 if (pPlayer-HasDebuff(DEBUFF_STUN)) { pPlayer-SendError(ERR_CANNOT_MOVE); return; } // 3. 发送移动确认并更新客户端视野 pPlayer-SendMoveAck(req-x, req-y); pPlayer-UpdateViewRange(); // 触发周围玩家/怪物同步 } break; case EVENT_ATTACK_REQ: { // ... 攻击逻辑此处略 } break; } }逻辑说明状态类只处理本状态下的合法事件非法事件如在STATE_LOGINING时收到EVENT_ATTACK_REQ会被忽略。参数说明MoveRequest结构体包含x/y坐标和方向dirERR_INVALID_POSITION是预定义错误码在ErrorCode.h中声明UpdateViewRange()是关键函数它计算以玩家为中心的 9x9 格视野调用Map::BroadcastToViewers()向视野内所有玩家广播该玩家的新位置。3. 快速启动指南从零开始跑通服务端与客户端3.1 环境准备与依赖安装本项目编译环境锁定为 Visual Studio 2019v142 工具集不兼容 VS2022 默认工具集。你需要安装 Visual Studio 2019 Community 免费勾选 “使用 C 的桌面开发” 工作负载安装 Windows SDK 版本 10.0.19041.0在 VS 安装器 → 修改 → 个体组件中搜索安装下载并解压 SQLite3 DLL sqlite3.dll放入server/和client/目录下运行时动态链接可选若要启用 MySQL 支持需下载 MySQL Connector/C 6.1 将libmysql.dll放入server/目录并修改config/db.ini中typemysql。注意项目未使用 vcpkg 或 Conan 管理第三方库所有依赖如 zlib、openssl均已静态链接进.exe因此你无需额外安装运行库。但sqlite3.dll必须存在否则 DBProxy 启动失败。3.2 编译步骤与输出目录说明打开LegendSolution.sln解决方案包含 5 个项目项目名输出文件说明LoginServerserver/LoginServer.exe登录服务进程GameServerserver/GameServer.exe游戏世界主进程DBProxyserver/DBProxy.exe数据库代理进程Clientclient/Client.exeWin32 GDI 客户端Toolstools/MapEditor.exe地图编辑器可选编译顺序必须为先DBProxy→ 再LoginServer→ 最后GameServer。因为LoginServer和GameServer的启动代码中会尝试连接DBProxy的命名管道若DBProxy未就绪它们会等待 5 秒后退出。编译成功后输出目录结构如下server/ ├── LoginServer.exe ├── GameServer.exe ├── DBProxy.exe ├── config/ │ ├── server.ini # 服务器端口、线程数等 │ ├── db.ini # 数据库类型、连接串 │ └── map.ini # 初始地图ID、出生点坐标 ├── data/ │ ├── monster.db # 怪物属性表SQLite │ ├── item.db # 物品掉落表SQLite │ └── map/ # 地图文件.map 二进制格式 └── log/ # 运行日志按天分割3.3 首次运行全流程与关键日志验证按顺序启动三个服务端进程启动 DBProxy双击server/DBProxy.exe窗口显示DBProxy started on pipe: \\.\pipe\LegendDBProxy并在log/dbproxy_20240501.log中写入INFO: Connected to SQLite database: data/monster.db启动 LoginServer双击server/LoginServer.exe窗口显示LoginServer listening on 0.0.0.0:7000日志中出现INFO: Waiting for GameServer connection on pipe \\.\pipe\LegendLoginToGame启动 GameServer双击server/GameServer.exe窗口显示GameServer listening on 0.0.0.0:7100日志中应有INFO: Loaded map 001.map with size 256x256和INFO: DBProxy connected successfully启动客户端双击client/Client.exe输入服务器地址127.0.0.1端口7000点击登录。若看到角色出现在土城地图中央且能用方向键移动则表示全流程跑通。关键验证点若客户端卡在“连接中”检查LoginServer日志是否报ERROR: Failed to connect to GameServer pipe—— 说明 GameServer 未启动或管道名不匹配若角色进入地图后无法移动检查GameServer日志是否有WARN: Player xxx sent invalid move request to (500,500)—— 表示坐标超出地图范围需检查data/map/001.map文件或config/map.ini中的map_width/height若打怪无反应检查GameServer日志中INFO: Spawned monster Orc at (120,80)是否出现以及Client.exe窗口标题是否变为Legend Client - Online标题变更代表已进入游戏状态。4. 避坑指南新手必踩的五个血泪现场与修复方案4.1 现象双击GameServer.exe窗口一闪而逝无任何日志原因GameServer启动时强制检查config/server.ini中的max_connections值若该值大于系统允许的最大句柄数Windows 默认 500CreateIoCompletionPort调用失败进程立即退出。而错误日志写在log/gameserver_xxx.log中但文件尚未创建导致“无声崩溃”。解决用记事本打开server/config/server.ini将max_connections1000改为max_connections200保存后重试。后续如需提升并发需在代码中增加句柄数检查并打印更友好提示。4.2 现象客户端登录成功但进入地图后角色静止不动F12 打开控制台显示ERR_NO_MAP_DATA原因GameServer加载地图时会按config/map.ini中的map_file001.map去server/data/map/目录查找。若该文件不存在或文件损坏如只有 1KBMapLoader::LoadMap()返回nullptr玩家状态机卡在STATE_WAITING_MAP不会进入STATE_IN_MAP。解决确认server/data/map/001.map存在且大小约 65KB标准 256x256 地图。若丢失可运行tools/MapEditor.exe新建一张空地图并保存为001.map或从项目源码的assets/maps/目录复制一份。4.3 现象DBProxy.exe启动报错ERROR: Cannot open database: unable to open database file原因DBProxy默认连接server/data/monster.db但该路径是相对路径其基准目录是DBProxy.exe所在目录。若你将DBProxy.exe复制到其他文件夹运行data/目录就不存在了。解决务必在server/目录下双击运行DBProxy.exe不要移动 exe 文件。或者修改server/config/db.ini中的database_path.\data\monster.db为绝对路径如database_pathC:\legend\server\data\monster.db。4.4 现象客户端能移动但攻击怪物时服务器日志无任何输出怪物不掉血原因攻击指令CMD_ATTACK_REQ的封包命令号在Protocol.h中定义为0x000A但客户端Client.exe的攻击按钮事件中误用了0x000B这是CMD_SKILL_REQ的命令号。这是一个典型的“客户端-服务端协议不一致”问题。解决打开client/Source/Network/ClientPacket.cpp找到SendAttackRequest()函数将pkt.header.cmd CMD_SKILL_REQ;改为pkt.header.cmd CMD_ATTACK_REQ;重新编译Client项目。4.5 现象修改了server/config/server.ini中的listen_port7100但GameServer.exe仍监听 7100新端口无效原因GameServer的端口读取逻辑在ServerApp::Initialize()中但它调用ConfigManager::GetInt(server, listen_port, 7100)时ConfigManager默认只加载config/server.ini而server.ini文件名是硬编码的。若你重命名了配置文件ConfigManager会回退到默认值 7100。解决确保配置文件名为server/config/server.ini且其中listen_port项位于[server]段落下。正确格式[server] listen_port7200 max_connections2005. 二次开发实战为游戏添加“自动拾取”功能的完整改造链5.1 需求分析与模块定位“自动拾取”指当玩家角色与地面上的物品距离 ≤ 2 格时无需手动点击系统自动将其收入背包。这涉及三个模块联动客户端需持续检测角色周围 2 格内的物品ItemObject发现即发送CMD_AUTO_PICKUP_REQ服务端需在StateInMap::HandleEvent中处理该请求校验距离、背包空间、物品绑定状态协议层需新增命令号、封包结构并保证客户端和服务端同步更新。这不是一个“改一行代码”的功能而是一条贯穿网络、逻辑、UI 的改造链。下面我带你走完从协议定义到 UI 勾选的全过程。5.2 协议扩展定义新命令与封包结构首先在Protocol.h中追加命令号和结构体// 新增命令号 #define CMD_AUTO_PICKUP_REQ 0x000C #define CMD_AUTO_PICKUP_ACK 0x000D #define CMD_AUTO_PICKUP_NACK 0x000E // 自动拾取请求封包 #pragma pack(1) struct AutoPickupRequest { uint32_t item_id; // 物品唯一ID由服务器生成 uint16_t x; // 物品X坐标用于距离校验 uint16_t y; // 物品Y坐标 }; #pragma pack() // 自动拾取响应封包 #pragma pack(1) struct AutoPickupResponse { uint32_t item_id; uint8_t result; // 0成功1背包满2距离超限3已绑定 uint8_t count; // 拾取数量堆叠物品 }; #pragma pack()然后在PacketCodec.cpp的Encode()和Decode()函数中为CMD_AUTO_PICKUP_REQ添加分支。注意AutoPickupRequest作为Packet::body的一部分需在Encode()中序列化Decode()中反序列化。5.3 服务端逻辑注入在状态机中处理自动拾取打开server/Source/Player/StateInMap.cpp在HandleEvent的switch中添加case EVENT_AUTO_PICKUP_REQ: { AutoPickupRequest* req static_castAutoPickupRequest*(data); // 1. 校验物品是否存在于地图上 ItemObject* pItem pPlayer-GetMap()-GetItemById(req-item_id); if (!pItem) { pPlayer-SendNack(CMD_AUTO_PICKUP_NACK, ERR_ITEM_NOT_FOUND); return; } // 2. 校验距离欧氏距离平方 4即距离2 int dx pPlayer-GetX() - req-x; int dy pPlayer-GetY() - req-y; if (dx*dx dy*dy 4) { pPlayer-SendNack(CMD_AUTO_PICKUP_NACK, ERR_DISTANCE_TOO_FAR); return; } // 3. 校验背包空间 if (!pPlayer-CanAddItem(pItem-GetTemplateId(), pItem-GetCount())) { pPlayer-SendNack(CMD_AUTO_PICKUP_NACK, ERR_INVENTORY_FULL); return; } // 4. 执行拾取从地图移除加入背包 pPlayer-AddItem(pItem-GetTemplateId(), pItem-GetCount()); pPlayer-GetMap()-RemoveItem(pItem); // 5. 广播拾取成功 AutoPickupResponse ack {req-item_id, 0, pItem-GetCount()}; pPlayer-SendPacket(CMD_AUTO_PICKUP_ACK, ack, sizeof(ack)); } break;逻辑说明这里没有调用数据库因为ItemObject是内存中的临时对象拾取后直接销毁CanAddItem()是Player类的成员函数检查背包剩余格数和堆叠上限SendNack()是封装好的错误响应发送函数统一处理CMD_AUTO_PICKUP_NACK。5.4 客户端改造实现拾取检测与请求发送客户端逻辑在client/Source/Logic/PlayerController.cpp。我们利用每帧Update()函数做距离检测// 在 PlayerController 类中添加成员变量 private: std::vectorItemObject* m_vNearbyItems; void PlayerController::Update(float deltaTime) { // ... 原有移动、动画逻辑 // 新增每帧扫描周围2格物品 m_vNearbyItems.clear(); int px (int)m_playerPos.x; int py (int)m_playerPos.y; for (int x px-2; x px2; x) { for (int y py-2; y py2; y) { ItemObject* pItem g_Map-GetItemAt(x, y); if (pItem !pItem-IsPicked()) { // 计算欧氏距离平方 int dx x - px, dy y - py; if (dx*dx dy*dy 4) { m_vNearbyItems.push_back(pItem); } } } } // 若开启自动拾取且有物品则发送请求 if (g_Config.bAutoPickupEnabled !m_vNearbyItems.empty()) { for (auto pItem : m_vNearbyItems) { SendAutoPickupRequest(pItem-GetId(), pItem-GetX(), pItem-GetY()); } m_vNearbyItems.clear(); // 防止一帧多次发送 } } void PlayerController::SendAutoPickupRequest(uint32_t itemId, int x, int y) { Packet pkt; pkt.header.cmd CMD_AUTO_PICKUP_REQ; AutoPickupRequest req {itemId, (uint16_t)x, (uint16_t)y}; pkt.body.assign((char*)req, sizeof(req)); g_Network.Send(pkt); }同时在client/Source/UI/OptionsDialog.cpp中为设置界面添加一个复选框// 在 OptionsDialog::OnInitDialog() 中 CheckDlgButton(IDC_CHECK_AUTO_PICKUP, g_Config.bAutoPickupEnabled ? BST_CHECKED : BST_UNCHECKED); // 在 OptionsDialog::OnBnClickedOk() 中 g_Config.bAutoPickupEnabled (IsDlgButtonChecked(IDC_CHECK_AUTO_PICKUP) BST_CHECKED);最后别忘了在client/Source/Config.h中声明bAutoPickupEnabled为全局配置项。5.5 验证与调试技巧如何快速确认功能生效功能上线后最怕“看起来动了其实没走通”。我的验证链路是客户端日志在PlayerController::SendAutoPickupRequest()中加OutputDebugString(LSending auto pickup...\n)用 DebugView 捕获确认请求发出服务端日志在StateInMap.cpp的EVENT_AUTO_PICKUP_REQ分支开头加LOG_INFO(Player %d requested auto pickup for item %d, pPlayer-GetId(), req-item_id);确认服务端收到网络抓包用 Wireshark 过滤tcp.port 7100查看是否有0x000C命令号的包进出内存断点在GameServer的StateInMap::HandleEvent函数入口设断点F5 启动客户端触发拾取看 VS 是否命中断点。从那以后我每次加新功能都强制走一遍这四步验证链——哪怕只是改一个变量名也要确保它真的流到了服务端内存里。因为在这套代码里90% 的“功能没生效”问题根源都不是逻辑错而是协议没对齐、包没发出去、或者日志没打开。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。