简介:这是一份面向C++游戏开发初学者与进阶者的传奇模拟游戏完整源码包,包含客户端与服务器端两大部分,采用VC++6.0编写,适合用于学习游戏开发全流程、网络通信与多线程编程。压缩包共499个文件,约11.32MB,以131个h头文件、127个cpp源文件为核心,辅以obj、sbr等编译中间文件及vcproj、dsw、dsp等工程配置,另有少量exe、dll、lib与资源文件,目录结构清晰,便于按模块研读。目前已有215人学习下载。客户端侧重界面展示、角色控制与场景渲染,服务器端负责玩家状态同步、游戏规则执行与数据存储管理,读者可从中掌握请求响应流程、数据封装传输及并发处理思路,是理解C++服务器实现与客户端交互的实用参考案例。
1. 一套能跑起来的传奇模拟游戏:客户端与服务器源码拆解
很多号称“完整源码”的游戏项目,下载下来要么缺资源,要么编译报错,要么服务器根本连不上。这套传奇模拟游戏源码不太一样——它把客户端和服务器两端都摆出来了,C++ 写的服务端逻辑,配套的客户端资源,目录结构清晰,适合想搞明白“一个 MMO 类游戏到底怎么跑起来”的开发者。你可以把它当成一个可运行的网络游戏骨架:登录、角色、地图、战斗、背包这些核心循环都在,服务端用 C++ 处理并发连接和状态同步,客户端负责渲染和输入。对新手来说,照着编译一遍能理解网络游戏的基本链路;对熟手来说,这套代码的模块划分和通信协议设计有参考价值,尤其是想自己搭一个 C++ 服务器框架的人,能省不少从零造轮子的时间。
2. 架构与目录:先看清客户端和服务端怎么分工
2.1 整体架构:C++ 服务端 + 图形客户端的两层结构
这套源码走的是经典 C/S 架构。服务端是一个独立的 C++ 进程,负责所有逻辑裁决——谁打谁、掉多少血、爆什么装备,全在服务端算,客户端只做表现和输入转发。这种设计的好处是防作弊,坏处是服务端压力大,所以代码里能看到不少针对网络 IO 和对象管理的优化痕迹。
服务端大致分四层:网络层(处理 socket 连接和封包解包)、逻辑层(角色、地图、战斗、物品)、数据层(玩家数据持久化)、配置层(地图数据、怪物数据、物品数据)。客户端则分渲染层、输入层、网络层和资源管理。两边通过自定义的二进制协议通信,包头带长度和消息号,包体是序列化后的结构体。
常见做法是服务端用 epoll 或 IOCP 做事件驱动,这套代码里能看到类似 select 或 epoll 的封装,具体取决于你拿到的版本。如果你要改,先别动网络层,从逻辑层入手最安全。
2.2 目录结构:每个文件夹对应什么
拿到源码后,先别急着编译,花十分钟把目录过一遍,能省掉后面很多“这文件在哪”的时间。典型结构如下:
/Server /Network # socket 封装、连接管理、封包解包 /GameLogic # 角色、地图、战斗、物品、任务 /Database # 数据存取接口,可能是 MySQL 或文件存储 /Config # 地图配置、怪物配置、物品配置 /Common # 公共工具类、协议定义、日志 main.cpp # 服务端入口 /Client /Render # 图形渲染,可能是 DirectX 或 OpenGL /Input # 键盘鼠标事件处理 /Network # 客户端网络模块,和服务端协议对应 /Resource # 图片、音效、地图资源 /UI # 界面逻辑 main.cpp # 客户端入口 /Shared /Protocol # 两端共用的协议定义 /Utils # 共用工具函数这个划分不是绝对的,不同版本可能合并或拆分,但核心思路一致:协议定义必须两端共用,否则改一个字段两边不同步,联调时就是玄学问题。我一般会先看/Shared/Protocol里的消息号定义,那是整个项目的“字典”。
2.3 通信协议:一个请求从客户端到服务端的完整链路
协议是这套源码里最值得细看的部分。客户端发一个“移动”请求,服务端收到后校验合法性,更新角色坐标,再广播给周围玩家。整个过程涉及封包、解包、序列化、反序列化。
先看协议定义,通常是一个枚举加结构体:
// Shared/Protocol/Message.h #pragma once #include <cstdint> // 消息号,两端必须一致 enum class MsgId : uint16_t { LoginReq = 1001, // 客户端请求登录 LoginResp = 1002, // 服务端返回登录结果 MoveReq = 1003, // 客户端请求移动 MoveBroadcast = 1004, // 服务端广播移动 AttackReq = 1005, // 客户端请求攻击 AttackResp = 1006, // 服务端返回攻击结果 }; // 包头,固定 4 字节:2 字节长度 + 2 字节消息号 struct PacketHeader { uint16_t length; // 包体长度 + 包头长度 uint16_t msgId; // 对应 MsgId }; // 移动请求包体 struct MoveReqBody { uint32_t playerId; float x; float y; uint8_t direction; };服务端收到数据后,先读包头,根据msgId分发到对应的处理函数。下面是一个简化的分发逻辑:
// Server/Network/PacketDispatcher.cpp void PacketDispatcher::Dispatch(Session* session, const char* data, uint16_t len) { if (len < sizeof(PacketHeader)) return; // 包不完整,丢弃 PacketHeader header; memcpy(&header, data, sizeof(PacketHeader)); // 根据消息号路由到不同处理器 switch (static_cast<MsgId>(header.msgId)) { case MsgId::LoginReq: HandleLogin(session, data + sizeof(PacketHeader), header.length - sizeof(PacketHeader)); break; case MsgId::MoveReq: HandleMove(session, data + sizeof(PacketHeader), header.length - sizeof(PacketHeader)); break; case MsgId::AttackReq: HandleAttack(session, data + sizeof(PacketHeader), header.length - sizeof(PacketHeader)); break; default: LogWarn("unknown msgId: %u", header.msgId); break; } }这里有几个关键点:length字段是包含包头在内的总长度,这样收包时可以先读 4 字节拿到长度,再决定还要收多少;msgId用uint16_t,最多 65535 个消息,对中小型游戏够用;包体直接按结构体内存布局解析,省去了 JSON 或 protobuf 的开销,但要求两端字节序和内存对齐一致。如果你要加新消息,在MsgId里加一个枚举值,然后在Dispatch里加一个case,再写对应的HandleXxx函数就行。
注意:结构体直接 memcpy 解析依赖编译器的内存对齐,如果两端编译选项不同,可能出现字段错位。稳妥做法是手动按字节读写,或者用
#pragma pack(1)强制紧凑排列。
3. 编译与运行:把服务端和客户端跑起来
3.1 环境准备:Windows 和 Linux 下的依赖差异
这套源码大概率是 Windows 下用 Visual Studio 开发的,因为传奇类项目的客户端通常依赖 DirectX。服务端可能跨平台,也可能只支持 Windows。先确认你拿到的版本里有没有.sln文件或CMakeLists.txt。
Windows 下需要:Visual Studio 2017 或更高版本,Windows SDK,如果客户端用 DirectX 9 或 11,还要装对应的运行时。服务端如果连 MySQL,需要装 MySQL Connector/C++。Linux 下需要:g++ 7 以上,make 或 cmake,如果服务端用了 epoll 则只能跑在 Linux。
我一般会先看README或编译说明.txt,如果没有,就看main.cpp的 include 列表,缺什么补什么。常见坑是字符集问题——Windows 下项目可能用多字节字符集,而你默认用了 Unicode,编译时报一堆const char*和LPCWSTR不匹配。
3.2 编译服务端:从 main.cpp 到可执行文件
服务端入口通常长这样:
// Server/main.cpp #include "Network/Server.h" #include "GameLogic/GameWorld.h" #include "Config/ConfigLoader.h" int main() { // 1. 加载配置 if (!ConfigLoader::Instance().Load("Config/")) { LogError("load config failed"); return -1; } // 2. 初始化游戏世界 GameWorld::Instance().Init(); // 3. 启动网络服务,监听端口 Server server; if (!server.Start(7000)) { // 7000 是监听端口,可改 LogError("server start failed"); return -1; } // 4. 主循环 while (server.IsRunning()) { server.Update(); // 处理网络事件 GameWorld::Instance().Update(); // 更新游戏逻辑 Sleep(10); // 控制循环频率,约 100 帧/秒 } return 0; }编译时,如果用的是 Visual Studio,直接打开.sln生成即可。如果只有源码没有工程文件,就自己建一个控制台项目,把所有.cpp加进去,注意包含路径要指向Server和Shared的根目录。Linux 下用 g++ 的话,大概是这样:
g++ -std=c++17 -I./Server -I./Shared \ Server/*.cpp Server/Network/*.cpp Server/GameLogic/*.cpp \ -o game_server -lpthread -lmysqlclient-I指定头文件搜索路径,-lpthread是线程库,-lmysqlclient是 MySQL 客户端库,如果你不用 MySQL 可以去掉。编译报错先看是不是缺头文件,再检查 C++ 标准版本——老代码可能用 C++11 甚至 C++98 的写法,用 C++17 编译可能报错。
3.3 启动客户端并连接服务端
客户端入口类似:
// Client/main.cpp #include "Render/Renderer.h" #include "Network/Client.h" int main() { // 1. 初始化渲染窗口 Renderer renderer; if (!renderer.CreateWindow(800, 600, "Legend Demo")) { return -1; } // 2. 连接服务端 Client client; if (!client.Connect("127.0.0.1", 7000)) { // 改成你的服务端 IP 和端口 MessageBox(nullptr, "connect failed", "error", MB_OK); return -1; } // 3. 主循环 while (renderer.IsRunning()) { renderer.PollEvents(); // 处理输入 client.Update(); // 收发包 renderer.Render(); // 渲染画面 } return 0; }先启动服务端,看到日志输出“server started on port 7000”之类,再启动客户端。如果客户端连不上,先 ping 一下服务端 IP,再检查防火墙有没有拦端口。Windows 下第一次运行服务端时,系统会弹防火墙提示,要允许专用网络访问,否则本机客户端也连不上。
提示:如果服务端和客户端在同一台机器,用
127.0.0.1最稳。跨机测试时,服务端监听地址要写0.0.0.0而不是127.0.0.1,否则只接受本机连接。
3.4 验证运行:登录、移动、战斗的最小闭环
跑起来之后,按正常流程走一遍:输入账号密码登录,选角色进地图,点地面移动,找怪打一下。如果登录成功但进不去地图,看服务端日志有没有“map load failed”——通常是地图配置文件路径不对。如果移动时角色瞬移或卡住,检查客户端和服务端的坐标同步逻辑,可能是广播范围没设对。
一个最小验证清单:登录返回 1002 且结果码为 0;移动后服务端日志打印新坐标;攻击后怪物血量减少;怪物死亡后掉落物品出现在背包。这四步走通,说明核心链路没问题,剩下的就是内容填充和细节打磨。
4. 核心模块拆解:角色、地图、战斗的代码实现
4.1 角色对象:从登录到进入地图的状态流转
角色对象是服务端最核心的数据结构,通常包含唯一 ID、名字、等级、血量、魔法、坐标、背包、装备等字段。登录成功后,服务端从数据库加载角色数据,创建Player对象,放入GameWorld管理。
// Server/GameLogic/Player.h class Player { public: uint32_t id; std::string name; int level; int hp; int maxHp; float x, y; // 当前坐标 uint16_t mapId; // 当前地图 ID std::vector<Item> inventory; // 背包 // 从数据库加载 bool LoadFromDB(uint32_t playerId); // 保存到数据库 bool SaveToDB(); // 移动,服务端校验 bool Move(float newX, float newY); // 受到伤害 void TakeDamage(int damage); };Move函数里要做合法性校验:新坐标是否在地图可行走区域内、移动速度是否超过上限(防加速挂)。常见做法是记录上次移动时间,算距离除以时间得到速度,超过阈值就拒绝并记日志。
bool Player::Move(float newX, float newY) { // 检查地图边界 if (!GameWorld::Instance().GetMap(mapId)->IsWalkable(newX, newY)) { return false; } // 检查速度,防止瞬移 auto now = std::chrono::steady_clock::now(); float dist = std::sqrt((newX - x) * (newX - x) + (newY - y) * (newY - y)); float elapsed = std::chrono::duration<float>(now - lastMoveTime).count(); if (elapsed > 0 && dist / elapsed > MAX_SPEED) { LogWarn("player %u speed hack detected", id); return false; } x = newX; y = newY; lastMoveTime = now; return true; }MAX_SPEED是个常量,根据游戏设定调整。这个校验很粗糙,但能挡住大部分低级加速。更严的做法是服务端自己算移动路径,客户端只发目标点,但那样客户端手感会变差,需要做预测和回滚,复杂度高很多。
4.2 地图与 AOI:怎么让玩家只看到周围的对象
地图模块负责加载地图数据、管理地图上的怪物和物品、处理 AOI(感兴趣区域)。AOI 决定了你把一个包广播给哪些玩家——如果全地图广播,人一多服务端就炸了。
常见做法是九宫格 AOI:把地图切成格子,玩家只关心自己所在格和周围 8 格里的对象。代码里通常有一个Grid结构,每个格子里存玩家和怪物的列表。
// Server/GameLogic/Map.h class Map { public: uint16_t id; int width, height; int gridSize; // 每个格子的边长,比如 64 像素 std::vector<std::vector<Grid>> grids; // 获取某个坐标所在的格子 Grid* GetGrid(float x, float y); // 获取周围 9 个格子的所有玩家 std::vector<Player*> GetNearbyPlayers(float x, float y); // 广播给周围玩家 void BroadcastToNearby(float x, float y, const char* data, uint16_t len, Player* exclude); };BroadcastToNearby遍历 9 个格子,把包发给格子里的玩家,跳过自己。这样移动包只会发给附近的人,而不是全地图。格子大小要权衡:太小了格子多,遍历开销大;太大了广播范围广,浪费带宽。64 到 128 像素是常见值。
4.3 战斗结算:伤害计算与死亡处理
战斗模块是逻辑最密集的地方。一次攻击请求过来,服务端要校验攻击者状态(是否活着、是否在攻击距离内、冷却是否好了)、计算伤害、扣血、判断死亡、处理掉落。
// Server/GameLogic/Combat.cpp void HandleAttack(Session* session, const char* body, uint16_t len) { AttackReq req; memcpy(&req, body, sizeof(req)); Player* attacker = session->GetPlayer(); if (!attacker || attacker->hp <= 0) return; // 死了不能打 // 检查攻击冷却 if (!attacker->CanAttack()) return; // 找目标 Monster* target = GameWorld::Instance().FindMonster(req.targetId); if (!target || target->hp <= 0) return; // 检查距离 float dist = Distance(attacker->x, attacker->y, target->x, target->y); if (dist > ATTACK_RANGE) return; // 计算伤害:基础攻击 - 目标防御 + 随机浮动 int damage = attacker->attack - target->defense; damage = std::max(1, damage); // 至少打 1 点 damage += rand() % 5 - 2; // ±2 随机 target->hp -= damage; attacker->SetAttackCooldown(); // 广播伤害数字给周围玩家 AttackResp resp; resp.attackerId = attacker->id; resp.targetId = target->id; resp.damage = damage; resp.targetHp = target->hp; GameWorld::Instance().GetMap(attacker->mapId) ->BroadcastToNearby(attacker->x, attacker->y, (char*)&resp, sizeof(resp), nullptr); // 死亡处理 if (target->hp <= 0) { HandleMonsterDeath(target, attacker); } }HandleMonsterDeath里做掉落:读怪物配置里的掉落表,按概率生成物品,放进攻击者背包,然后从地图移除怪物,广播死亡消息。掉落概率通常配在配置文件里,改起来不用重新编译。
注意:
rand()不是线程安全的,如果服务端多线程处理战斗,要用std::mt19937或者加锁。另外伤害计算里的随机浮动范围别设太大,否则玩家会觉得伤害不稳定。
5. 避坑与排查:编译、连接、同步的常见问题
5.1 编译报错:找不到头文件或链接失败
现象:编译时提示fatal error: xxx.h: No such file or directory,或者链接时提示undefined reference to 'xxx'。
原因:包含路径没设对,或者某个.cpp没加入编译。老项目经常把源文件放在多个子目录里,工程文件里漏加一个就会链接失败。
解决:先看报错的头文件名,在项目目录里搜一下它在哪,然后把那个目录加到包含路径。链接失败就看缺失的符号属于哪个类,把对应的.cpp加进工程。Linux 下用find . -name "*.cpp"列出所有源文件,确保都编译了。
5.2 客户端连不上服务端:连接超时或拒绝
现象:客户端启动后卡在连接界面,或者弹窗提示连接失败。
原因:服务端没启动、端口不对、防火墙拦截、监听地址绑成了127.0.0.1。
解决:先确认服务端进程在跑,netstat -an | grep 7000看端口有没有监听。如果监听地址是127.0.0.1:7000,改成0.0.0.0:7000才能接受外部连接。Windows 下检查防火墙入站规则,把服务端程序加进允许列表。客户端填的 IP 如果是localhost,试试换成127.0.0.1,有时候 DNS 解析会出幺蛾子。
5.3 移动不同步:客户端位置和服务端位置不一致
现象:客户端看到角色在 A 点,但服务端认为在 B 点,打怪时提示距离不够。
原因:客户端做了本地预测移动,服务端校验没通过但客户端没回滚;或者广播范围没覆盖到,其他玩家看不到移动。
解决:在Move函数里加日志,打印客户端发的坐标和服务端校验后的坐标。如果服务端拒绝了移动,要发一个纠正包给客户端,把角色拉回服务端认为的位置。广播范围检查GetNearbyPlayers的格子半径,确保移动的玩家和目标玩家在彼此的 AOI 内。
5.4 数据库读写失败:角色数据加载不出来
现象:登录后角色列表为空,或者进游戏后等级、背包都是初始值。
原因:数据库连接配置不对、表结构不匹配、SQL 语句报错但被吞了。
解决:看服务端启动日志有没有“database connect failed”。检查配置文件里的 IP、端口、用户名、密码、库名。用 MySQL 客户端手动执行一条查询,确认表存在且字段名和代码里的一致。如果代码里用 ORM,检查映射关系;如果是裸 SQL,检查拼写。常见坑是数据库字符集设成了latin1,中文角色名存进去变乱码。
5.5 内存泄漏与崩溃:跑一段时间后服务端挂掉
现象:服务端运行几小时后内存持续上涨,最终崩溃;或者某个玩家下线后,指针没清,再上线时崩溃。
原因:new出来的对象没delete,或者容器里存了裸指针,对象销毁后容器没清理。
解决:用valgrind或 Visual Studio 的内存诊断工具跑一遍,看哪里泄漏。玩家下线时,确保从GameWorld的玩家列表、地图的格子列表、会话管理里都移除了。如果项目里大量用裸指针,考虑逐步换成std::shared_ptr,但注意循环引用。我一般会在Player析构里加日志,确认下线时确实被销毁了。
6. 二次开发:加一个新功能并验证它
假设你要加一个“每日签到”功能:玩家每天第一次登录时,自动发一个奖励物品。这个功能涉及数据库(记录签到日期)、登录流程(检查并发放)、背包(加物品)。下面是我会走的步骤。
第一步,在数据库角色表里加一个字段last_sign_date,类型DATE。然后在Player类里加一个成员std::string lastSignDate,在LoadFromDB和SaveToDB里读写这个字段。
第二步,在登录处理函数里,角色加载完成后,比较lastSignDate和今天日期。如果不同,发奖励并更新lastSignDate。
// Server/GameLogic/LoginHandler.cpp void HandleLogin(Session* session, const char* body, uint16_t len) { LoginReq req; memcpy(&req, body, sizeof(req)); Player* player = new Player(); if (!player->LoadFromDB(req.playerId)) { // 发送登录失败 return; } // 每日签到检查 std::string today = GetTodayDate(); // "2025-01-15" 格式 if (player->lastSignDate != today) { Item reward; reward.id = 1001; // 假设 1001 是签到奖励物品 reward.count = 1; player->inventory.push_back(reward); player->lastSignDate = today; player->SaveToDB(); // 通知客户端 SendSignRewardNotify(session, reward); } // 其余登录逻辑... }第三步,客户端收到SignRewardNotify后,在聊天框或弹窗里显示“签到成功,获得 xxx”。协议里加一个消息号,客户端网络模块加对应的处理。
第四步,验证。改完编译,启动服务端和客户端,用同一个账号登录两次。第一次应该收到奖励,第二次不应该。然后查数据库,确认last_sign_date已经更新。如果第一次没收到,看服务端日志有没有“sign reward”相关输出;如果第二次还收到,检查日期比较逻辑,可能是时区问题——服务端用的 UTC 时间,而你以为用的是本地时间。
这里有个血泪经验:改数据库字段后,别忘了同步更新LoadFromDB和SaveToDB里的 SQL 语句,否则新字段读出来是空,写回去被忽略。我一般会在SaveToDB后加一条日志,打印受影响行数,确认写入成功。
再一个技巧:加新功能时,尽量别动核心循环和网络层。从逻辑层切进去,加独立的处理函数,用现有的广播和数据库接口。这样即使新功能有 bug,也不会把整个服务端搞崩。验证时先在本机跑通,再考虑多客户端并发测试。如果要做压力测试,写个简单的机器人客户端,模拟 50 个连接同时登录和移动,看服务端 CPU 和内存曲线。
从那以后我每次加新功能,都强制走一遍“改表 → 改加载 → 改保存 → 加协议 → 加处理 → 本机验证 → 查库确认”的流程,少一步都可能埋雷。希望这套源码能帮你省下搭框架的时间,把精力花在玩法上。
本文还有配套的精品资源,点击获取