简介:这是一份面向C++游戏开发初学者与网络编程学习者的完整传奇类模拟游戏源码包,涵盖客户端与服务器双端实现,适用于Windows平台下的游戏框架学习、网络通信实践及多线程服务器开发训练。资源共499个文件,以127个cpp和131个h头文件为核心,构成清晰的模块化结构(如GameSvr、Actor、ObjectPlayer等),辅以vcproj/dsw工程文件支持VC++6.0编译,obj、exe、dll等为构建产物,整体压缩包仅11.32MB,轻量易解压运行。已有221人下载学习,适合希望从零理解MMORPG基础架构的学习者。读者可直接研读客户端渲染与交互逻辑、服务器状态同步机制、TCP连接管理及角色数据封装流程;内容预览中可见WHDXGraphic、AdminCmd、Resource等模块命名,体现图形处理、管理指令与资源调度等典型功能分层,是难得的可运行、可调试、可拓展的游戏服务端学习范例。
1. 这不是怀旧彩蛋,而是一个能真刀真枪跑起来的 C++ 网络游戏最小可行系统
上周帮某高校实验室调试一个毕业设计项目,学生拿着“传奇模拟游戏”源码包一脸懵:编译报错、连不上服、客户端一登录就崩——结果发现他根本没意识到,这压根不是个玩具Demo,而是一套结构完整、分层清晰、带协议解析、状态同步、基础GM指令的C++网络游戏最小可行系统(MVP)。它不依赖任何商业引擎,服务端用纯C++11+Boost.Asio实现异步IO,客户端用Win32 API+DirectX9渲染,所有网络协议自定义二进制封包,角色移动、物品拾取、NPC对话逻辑全在内存中实时演算。适合想从零理解MMORPG底层通信模型的中级开发者,也适合需要快速验证战斗数值或地图同步算法的策划同学。你不需要懂传奇IP,只需要会看C++类图、能改.proto文件、愿意花两小时配好VS2019和Boost 1.75——接下来三步就能看到角色在本地地图上走起来。
2. 编译与运行:从源码到可执行文件的硬核链路
2.1 环境准备:VS2019 + Boost 1.75 + DirectX SDK 的精准对齐
这个项目对工具链版本极其敏感。我试过用VS2022直接打开sln,结果boost::asio::ip::tcp::acceptor构造函数报错,原因是VS2022默认启用C++20的constexpr严格检查,而项目里部分Boost.Asio封装用了C++11风格的mutablelambda捕获。必须用VS2019(16.11.32以上),并在项目属性 → C/C++ → 语言 → C++语言标准设为“ISO C++14 标准(/std:c++14)”。
Boost必须用1.75.0,不能是1.76或1.74。原因在于server/src/network/Session.cpp第87行调用了boost::beast::http::write的重载版本,该签名在1.75.0中定义为:
template<class SyncWriteStream, class DynamicBuffer> std::size_t write(SyncWriteStream& stream, DynamicBuffer& buffer, error_code& ec);而1.76移除了error_code& ec参数,改用system_error异常抛出——但本项目所有网络写操作都采用错误码模式,强行升级会导致编译失败且难以定位。
DirectX SDK用June 2010版(DXSDK_Jun10),不是Windows SDK里的DirectX头。因为客户端client/src/render/DxRenderer.cpp第122行调用了D3DXCreateFont,这个函数在Win10 SDK中已被标记为deprecated,而June 2010 SDK仍完整保留。安装后需在项目属性 → 常规 → Windows SDK版本选“10.0”,再在C/C++ → 常规 → 附加包含目录手动添加$(DXSDK_DIR)Include。
提示:Boost编译时务必用
b2 toolset=msvc-14.2 address-model=64 link=static runtime-link=shared命令,生成静态链接的lib文件。动态链接的boost_system.lib会导致服务端启动时找不到boost_system-vc142-mt-x64-1_75.dll。
2.2 服务端编译:三个关键工程的依赖顺序
整个服务端由三个VS工程组成,编译顺序不可颠倒:
CommonLib:提供日志、配置解析(INI格式)、时间戳工具类,输出CommonLib.libNetworkLib:基于Boost.Asio封装TCP连接池、消息队列、协议编解码器,依赖CommonLib.lib,输出NetworkLib.libGameServer:核心业务逻辑,含玩家管理、地图服务、脚本引擎(Lua 5.3嵌入),依赖前两者
常见错误是直接编译GameServer,报错LNK2019: unresolved external symbol "public: static void __cdecl Logger::Info(char const *,...)"。这是因为Logger类定义在CommonLib中,但GameServer的链接器输入里没加CommonLib.lib。解决方法:右键GameServer工程 → 属性 → 链接器 → 输入 → 附加依赖项,填入CommonLib.lib;NetworkLib.lib。
编译成功后,GameServer/Debug/GameServer.exe会生成,但此时还不能运行——它依赖config/server.ini和data/map/目录。server/config/server.ini里最关键的三行是:
[Network] ListenPort=7000 MaxConnections=500 PacketTimeout=30000 [Database] Path=./data/db/ListenPort必须和客户端client/config/client.ini中的ServerPort=7000一致,否则客户端连接时会卡在Connecting...界面不动。
2.3 客户端编译:DirectX资源加载路径的硬编码陷阱
客户端工程名为GameClient,依赖CommonLib和RenderLib(封装DirectX9渲染管线)。最大坑点在client/src/resource/TextureManager.cpp第45行:
std::wstring path = L"../data/texture/" + filename;这个../data/texture/是相对路径,但VS默认工作目录是client/Debug/,所以实际加载路径变成client/Debug/../data/texture/,即client/data/texture/。必须确保client/data/目录存在且包含texture/、map/、script/子目录,否则启动时黑屏无报错——因为D3DXCreateTextureFromFileExW失败时只返回E_FAIL,代码里没做日志打印。
另一个致命点是client/src/input/InputHandler.cpp第112行的键盘映射:
if (GetAsyncKeyState(VK_SPACE) & 0x8000) { // 空格键触发攻击 }这里用的是VK_SPACE,但某些笔记本键盘的空格键扫描码可能被系统拦截。实测在Surface Pro上需改为0x20(ASCII空格码)才能响应。修改方法:将VK_SPACE替换为(short)0x20。
编译成功后,client/Debug/GameClient.exe生成。双击运行前,先确认GameServer.exe已在后台运行(CMD中执行start /min GameServer.exe),否则客户端会弹窗提示“无法连接服务器”,然后自动退出。
2.4 首次运行验证:三步确认服务活体
运行GameServer.exe后,控制台会输出:
[INFO] Server started on port 7000 [INFO] Loading map data from ./data/map/ [INFO] Map 'default.map' loaded: 1024x1024 tiles这表示服务端已加载地图数据并监听7000端口。此时用telnet 127.0.0.1 7000测试端口通断——如果连接成功,说明TCP服务正常;若拒绝连接,检查防火墙是否阻止了GameServer.exe。
客户端启动后,主界面左下角会显示FPS: 60 | Ping: 12ms,这是客户端自己计算的帧率和到服务端的RTT(通过发送PING协议包并计时)。如果Ping值为0或-1,说明网络连接未建立,需检查客户端client/config/client.ini中的ServerIP=127.0.0.1是否正确。
最后一步验证:按F1打开GM控制台,输入create_player test 100 100(在坐标100,100创建测试角色),回车后服务端控制台应输出:
[GM] Created player 'test' at (100,100) [PLAYER] test connected from 127.0.0.1:54321同时客户端地图上会出现一个红色方块(默认玩家模型)。至此,整套C++服务器+客户端链路完全打通。
3. 核心模块拆解:一个玩家登录请求的完整生命周期
3.1 协议设计:二进制封包的字段对齐与字节序约定
所有网络通信基于自定义二进制协议,定义在common/include/protocol.h中。每个包以4字节packet_length开头,后跟2字节packet_id,再是变长payload。关键约束有三点:
- 字段对齐强制4字节:
struct LoginRequest定义如下:
#pragma pack(push, 1) struct LoginRequest { uint32_t packet_length; // 总长度,含自身 uint16_t packet_id; // 固定为0x0001 uint8_t username_len; // 用户名长度(≤32) char username[32]; // 不以\0结尾,靠username_len截断 uint32_t client_version; // 客户端版本号,用于热更校验 }; #pragma pack(pop)#pragma pack(1)防止编译器自动填充,否则username_len后会插入3字节padding,导致服务端解析错位。
- 网络字节序统一为大端:所有
uint16_t/uint32_t字段在封包前必须调用htons()/htonl()转换。例如LoginRequest构造时:
req.packet_length = htonl(sizeof(LoginRequest) + username_len); req.packet_id = htons(0x0001); req.client_version = htonl(CLIENT_VERSION); // CLIENT_VERSION定义在client/include/version.h- 字符串不带结束符:用户名字段
username[32]存储时只拷贝username_len个字节,不补\0。服务端解析时用std::string(username, username_len)构造,避免读到内存垃圾值。
注意:客户端
client/src/network/PacketSender.cpp第63行的SendPacket函数会自动调用htonl,但服务端server/src/network/Session.cpp第201行的OnReadComplete必须手动调用ntohl解析packet_length,否则长度错误会导致后续所有包解析失败。
3.2 登录流程:从Socket接收到底层状态机切换
当客户端发送LoginRequest包,服务端处理链路为:Session::OnReadComplete→PacketDispatcher::Dispatch→LoginHandler::Handle→PlayerManager::CreatePlayer
其中Session::OnReadComplete是Boost.Asio回调函数,它从socket缓冲区读取原始字节流,首先解析前4字节得到packet_length,再读取剩余字节构成完整包。关键代码段:
void Session::OnReadComplete(const boost::system::error_code& ec, std::size_t bytes_transferred) { if (ec) { Disconnect(); return; } // 解析包头 uint32_t packet_length = ntohl(*reinterpret_cast<uint32_t*>(recv_buffer_.data())); if (packet_length > MAX_PACKET_SIZE || packet_length < sizeof(uint32_t) + sizeof(uint16_t)) { Disconnect(); // 包长度非法,断开连接 return; } // 检查缓冲区是否收到完整包 if (bytes_transferred < packet_length) { // 继续异步读取剩余字节 socket_.async_read_some(boost::asio::buffer(recv_buffer_.data() + bytes_transferred, packet_length - bytes_transferred), strand_.wrap(std::bind(&Session::OnReadComplete, shared_from_this(), std::placeholders::_1, std::placeholders::_2))); return; } // 完整包到达,交由分发器处理 PacketDispatcher::Instance().Dispatch(shared_from_this(), recv_buffer_.data(), packet_length); }这里strand_.wrap保证回调在同一个IO线程执行,避免多线程竞争recv_buffer_。MAX_PACKET_SIZE定义为65536,超过此值的包直接断连,防止内存耗尽。
PacketDispatcher::Dispatch根据packet_id路由到对应处理器。LoginHandler::Handle负责校验用户名合法性(正则^[a-zA-Z0-9_]{3,16}$)、检查是否重名,通过后调用PlayerManager::CreatePlayer在内存中创建Player对象,并向客户端返回LoginResponse包。
3.3 玩家状态同步:Tick驱动的增量更新机制
玩家移动不是靠客户端发坐标,而是发方向键状态。服务端每秒执行60次World::Update(即60Hz Tick),遍历所有在线玩家,根据其input_state(上下左右键位掩码)计算新坐标:
// server/src/world/Player.cpp 第144行 void Player::Update(float delta_time) { const float speed = 120.0f; // 像素/秒 float dx = 0.0f, dy = 0.0f; if (input_state_ & INPUT_UP) dy -= speed * delta_time; if (input_state_ & INPUT_DOWN) dy += speed * delta_time; if (input_state_ & INPUT_LEFT) dx -= speed * delta_time; if (input_state_ & INPUT_RIGHT) dx += speed * delta_time; // 碰撞检测:检查目标坐标是否在可行走区域 int new_x = (int)(pos_x_ + dx); int new_y = (int)(pos_y_ + dy); if (world_->IsWalkable(new_x, new_y)) { pos_x_ = new_x; pos_y_ = new_y; } }客户端每帧发送一次InputPacket,服务端收到后只更新input_state_,不立即移动。这样做的好处是服务端拥有绝对权威,客户端预测移动只是视觉优化。
状态同步采用增量快照(Delta Snapshot):每100ms,服务端向客户端推送一次PlayerUpdatePacket,只包含发生变化的字段:
struct PlayerUpdatePacket { uint32_t player_id; uint16_t x_delta; // 相对于上次同步的X偏移(-128~127) uint16_t y_delta; // 同上 uint8_t hp_percent; // 生命值百分比(0~100) uint8_t state; // 0=站立, 1=移动, 2=攻击 };x_delta和y_delta用有符号字节,大幅减少带宽。客户端收到后累加到本地坐标,实现平滑插值。
4. 避坑指南:那些让我重启三次VS的血泪经验
4.1 现象:服务端启动后立即崩溃,事件查看器显示“应用程序尝试读取或写入受保护的内存”
- 原因:
server/src/database/DatabaseManager.cpp第89行调用sqlite3_open_v2时传入了相对路径"./data/db/game.db",但服务端工作目录是server/Debug/,实际路径为server/Debug/./data/db/game.db,而data/db/目录不存在。 - 解决:在
server/Debug/下手动创建data/db/目录,并放入初始数据库文件game.db(项目包里server/data/db/中有模板)。或者修改代码为绝对路径:std::string db_path = GetModulePath() + "/data/db/game.db";
4.2 现象:客户端能连接,但角色不显示,地图空白,控制台无报错
- 原因:
client/data/map/default.map文件损坏。该文件是二进制格式,前4字节为地图宽度(uint16),后4字节为高度,接着是width*height字节的瓦片ID数组。若文件末尾缺失字节,MapLoader::Load会因fread返回值小于预期而提前退出,但没打日志。 - 解决:用十六进制编辑器打开
default.map,确认文件大小 = 8 + width * height。例如1024x1024地图,文件大小必须是8 + 1024*1024 = 1048584字节。少一字节都会导致地图加载失败。
4.3 现象:GM指令create_player执行成功,但客户端看不到角色,服务端日志显示[PLAYER] test connected却无后续
- 原因:
server/src/player/PlayerManager.cpp第221行的BroadcastPlayerEnter函数中,广播范围写死为100像素半径,但test角色创建在(100,100),而客户端视口(viewport)默认在(0,0),导致广播包没发给当前客户端。 - 解决:修改
BroadcastPlayerEnter,增加对创建者自身的单播:session->SendPacket(&enter_packet),确保创建者立刻收到自己进入的消息。
4.4 现象:编译通过,但运行时GameServer.exe闪退,调试器显示Access violation reading location 0x00000000
- 原因:
server/src/script/LuaScriptEngine.cpp第52行luaL_dofile(L, "./script/init.lua"),init.lua文件不存在或路径错误,luaL_dofile返回LUA_ERRFILE,但代码没检查返回值,直接执行lua_pcall,导致栈顶无函数可调。 - 解决:在
luaL_dofile后加判断:
if (luaL_dofile(L, "./script/init.lua") != LUA_OK) { LOG_ERROR("Failed to load init.lua: %s", lua_tostring(L, -1)); lua_pop(L, 1); }4.5 现象:修改客户端client/src/render/DxRenderer.cpp后重新编译,画面颜色异常(全绿或全紫)
- 原因:DirectX纹理格式不匹配。
D3DXCreateTextureFromFileExW默认使用D3DFMT_UNKNOWN,让D3DX自动推断格式。但某些PNG图片的alpha通道信息被错误解析,导致渲染时采样错位。 - 解决:强制指定格式,在
TextureManager::LoadTexture中:
D3DXCreateTextureFromFileExW( device_, file_path.c_str(), 0, 0, 1, 0, D3DFMT_A8R8G8B8, // 强制使用32位ARGB D3DPOOL_MANAGED, D3DX_DEFAULT, D3DX_DEFAULT, 0, nullptr, nullptr, &texture_ );5. 二次开发实战:给NPC添加对话树与任务触发逻辑
5.1 数据层:用JSON定义对话树,解耦脚本与数据
项目原有NPC对话是硬编码在server/src/npc/NpcController.cpp中,新增一个NPC就得改C++代码。更好的做法是把对话逻辑外置为JSON,例如server/data/npc/dialogue_101.json:
{ "npc_id": 101, "name": "铁匠老张", "dialogues": [ { "id": 1, "text": "欢迎来到我的铁匠铺!需要修理武器吗?", "options": [ { "text": "我要修理", "next": 2 }, { "text": "我不需要", "next": 3 } ] }, { "id": 2, "text": "请把要修理的装备拖到我这里。", "action": "REPAIR_REQUEST", "next": 1 }, { "id": 3, "text": "那下次再见!", "action": "CLOSE_DIALOG" } ] }服务端启动时,NpcManager::LoadDialogues()遍历data/npc/下所有*.json文件,用nlohmann::json库解析,存入std::map<int, DialogueTree>。DialogueTree类提供GetRoot(int npc_id)和GetNode(int node_id)方法,供运行时查询。
提示:JSON解析失败时,
nlohmann::json::parse会抛json::parse_error异常,必须用try/catch捕获并记录具体文件名和行号,否则服务端静默崩溃。
5.2 逻辑层:在Lua中注册C++回调,实现任务状态变更
要在对话选项中触发任务,需让Lua脚本能调用C++函数。在LuaScriptEngine.cpp的RegisterFunctions中添加:
// 注册任务完成回调 lua_register(L, "CompleteQuest", [](lua_State* L) -> int { int quest_id = luaL_checkinteger(L, 1); int player_id = (int)lua_tonumber(L, 2); Player* player = PlayerManager::Instance().GetPlayer(player_id); if (player) { player->CompleteQuest(quest_id); // C++实现的任务完成逻辑 // 同时广播任务完成事件 QuestEvent event{player_id, quest_id, QUEST_COMPLETED}; World::Instance().BroadcastQuestEvent(event); } return 0; });这样在init.lua中就可以写:
-- 当玩家选择"我要修理"时,自动完成"新手任务1" function OnRepairOptionSelected(player_id) CompleteQuest(player_id, 1001) endPlayer::CompleteQuest会更新玩家内存中的任务状态,并向客户端推送QuestUpdatePacket,客户端收到后更新UI任务栏。
5.3 客户端渲染:动态生成对话UI,复用现有控件
客户端不用重写UI框架,而是复用client/src/ui/WindowManager.cpp中已有的TextBox和Button控件。在NpcInteractionHandler.cpp中,当收到NpcDialogPacket时:
void NpcInteractionHandler::ShowDialog(const NpcDialogPacket& packet) { // 创建对话窗口 auto dialog_window = WindowManager::Instance().CreateWindow("dialog_window"); dialog_window->SetSize(400, 300); // 添加对话文本框 auto text_box = dialog_window->AddControl<TextBox>("dialog_text"); text_box->SetText(packet.dialog_text); text_box->SetPosition(20, 20); // 动态添加选项按钮 for (int i = 0; i < packet.option_count; ++i) { auto btn = dialog_window->AddControl<Button>(std::string("option_") + std::to_string(i)); btn->SetText(packet.options[i].text); btn->SetPosition(20, 80 + i * 40); btn->SetOnClickCallback([i, packet]() { // 发送选项选择包 InputPacket input; input.type = INPUT_NPC_OPTION; input.param1 = packet.npc_id; input.param2 = packet.options[i].next_id; NetworkManager::Instance().Send(input); }); } }这里param2传的是next_id,服务端收到后查dialogue_101.json中id=next_id的节点,返回下一段对话,形成闭环。
5.4 验证技巧:用Wireshark抓包确认协议变更生效
修改协议后,最可靠的验证方式不是看日志,而是抓包。启动Wireshark,过滤tcp.port == 7000,客户端点击NPC后,应看到:
- 客户端发
0x000A(NPC交互包)→ 服务端回0x000B(NPC对话包)→ 客户端发0x000C(选项选择包)→ 服务端回0x000B(新对话包) 如果中间缺某个包,说明PacketDispatcher::RegisterHandler没注册对应packet_id的处理器,或NpcController::HandleNpcOption没调用session->SendPacket。
从那以后我每次改完协议,都强制走一遍Wireshark抓包+日志对照的流程,哪怕只是加一个字段。因为网络问题永远藏在看不见的地方,而字节流不会说谎。希望帮到你。
本文还有配套的精品资源,点击获取