[C++] 联网单人副本闯关游戏–服务器部分
基本游戏逻辑:http://t.csdn.cn/nZ6xa
在基本游戏逻辑的基础上增添了联网和副本选择功能
实现了玩家的登录注册、场景信息下发、场景信息同步和校验、玩家断线重连、副本得分结算与排行榜逻辑
使用注册的形式将编译时硬编码转为运行时注册优化CMD分发,使用运行时类型判断简化同步信息处理逻辑
业务流程
业务逻辑
基础业务逻辑
玩家登录后,副本探索的基础的业务逻辑包括:
- 玩家选择副本,发送场景加载请求,服务器回复场景信息
- 玩家开始游戏时,发送开始游戏请求,服务器开始计时
- 游戏过程中,根据同步策略发送同步请求,服务器进行数据同步和模糊校验
- 玩家到达终点或倒计时结束后,发送结算请求,服务器校验玩家用时并根据用时和杀敌数计算分数返回
实现时,每个客户端回对应一个场景数据结构,存储客户端的PlayerID,场景ID,开始时间,击杀数,场景物体集合等内容
struct SceneData {
string playerID = "";
string playerName = "";
int SceneID = -1;
bool isStart = false;
time_t startTime = 0;
time_t disconnectTime = 0;
int killCount = 0;
map<int, SceneObject*> objMap;
};
同步逻辑
同步信息有四种,服务器对这四种同步信息进行校验和处理并返回结果,当校验失败时,客户端将根据返回值将场景拉回到正确状态
位置同步
需要位置同步的物体包括角色、幽灵,两者在移动时发送位置同步请求
幽灵信息同步
由于幽灵具有巡逻、追逐主角、死亡三种状态,因此在幽灵状态改变时需要同步幽灵状态以便后续断线重连需要
当幽灵状态改变不合法(如死亡状态下改变巡逻点),服务器返回当前维护的状态让客户端设置幽灵到正确状态
玩家操作同步
当玩家进行操作时(与开宝箱、开关灯、扔捡枪)会发送玩家操作同步请求,服务器首先会校验玩家操作是否合法(如与被互动物体距离是否过远),校验成功后根据玩家操作改变objMap
被操作对象的状态,客户端只有在收到校验成功回复后才完成操作
伤害同步
最后一种同步是伤害同步,包括子弹对幽灵造成伤害、怪物对玩家造成伤害等,根据攻击力扣除被伤害物体血量,若血量归零还会进行相应逻辑处理
断线重连逻辑
当游戏进行过程中玩家断线,服务器会将客户端对应的SceneData
以PlayerID为键加入到一个Map中,当玩家登录时检查玩家ID是否在Map中,若在Map中则在登录返回包中设置结果码,让客户端决定是否需要重连
若客户端需要重连则发送重连请求,服务器返回关卡Json文件和需要更新的物体信息,客户端还原后继续进行游戏
此外,服务器还会定时扫描断线Map,将超时的数据释放掉
服务器实现
服务器内以SceneObject
类保存校验、同步和断线重连所需的场景信息
SceneObject类
SceneObject
SceneObject是所有场景物体的基类,拥有位置信息和_sceneObjectID
用于与客户端通信时定位场景物体
class SceneObject
{
public:
SceneObject(int ID, Vector3 pos) :_sceneObjectID(ID), _pos(pos) {}
Vector3 getPos() { return _pos; }
// 获取重连所需信息
virtual Message* GetReconnectInfo() { return nullptr; }
// 虚拟化的拷贝构造函数
virtual SceneObject* Clone() = 0;
protected:
int _sceneObjectID; // 场景物体的标识,用于于客户端通信
Vector3 _pos; // 位置信息
};
Gargoyle,SlowDown,Lurker,Light
这四种场景物体不需要处理同步请求且断线重连时不需要更新信息,因此直接继承SceneObject
且没有重写GetReconnectInfo
方法
class Gargoyle : public SceneObject
{
public:
Gargoyle(int ID, Vector3 pos, int ATK) :SceneObject(ID,pos), _ATK(ATK) {}
virtual SceneObject* Clone() override { return new Gargoyle(*this); }
private:
int _ATK;
};
Chest
宝箱需要在断线重连时更新信息但不需要处理同步请求,因此直接继承SceneObject
并且重写GetReconnectInfo
方法
class Chest : public SceneObject
{
public:
Chest(int ID, Vector3 pos) :SceneObject(ID, pos) {}
virtual ChestInfo* GetReconnectInfo();
virtual SceneObject* Clone() override { return new Chest(*this); }
bool isOpened = false;
};
Movable
需要进行位置同步的场景物体继承Movable
,定义了PositionSyncHanle
方法用于处理位置同步请求
class Movable : public SceneObject
{
public:
Movable(int ID, Vector3 pos) :SceneObject(ID, pos) {}
virtual PositionSyncRsp PositionSyncHandle(const PositionSyncReq* req, double tolerance = 5);
protected:
bool DistanceCheck(Vector3 other, double tolerance = 5);
bool _needCheck = true;
};
Weapon
Weapon需要同步位置且没有血量信息因此继承Movable
类,并且断线重连时更新信息因此重写GetReconnectInfo
方法
class Weapon : public Movable
{
public:
Weapon(int ID, Vector3 pos = Vector3()) :Movable(ID, pos) { _needCheck = false; }
virtual WeaponInfo* GetReconnectInfo();
virtual SceneObject* Clone() override { return new Weapon(*this); }
private:
};
Character
除进行位置同步还要进行伤害同步(拥有血量)的物体继承Character
,拥有DamageHandle
方法用于处理血量同步请求
class Character : public Movable
{
public:
Character(int HP, int ID, Vector3 pos) :_HP(HP), Movable(ID, pos) {}
virtual DamageSyncRsp DamageHandle(const DamageSyncReq* req) = 0;
bool isDead() { return _HP <= 0; }
protected:
int _HP;
};
Protagonist
主角还需OperationSyncHandle
处理玩家操作同步请求
class Protagonist : public Character
{
public:
Protagonist(int ID, Vector3 pos, int HP) :Character(HP, ID, pos) {}
virtual DamageSyncRsp DamageHandle(const DamageSyncReq* req) override;
OperationSyncRsp OperationSyncHandle(const OperationSyncReq* req, SceneObject* other);
virtual ProtagonistInfo* GetReconnectInfo() override;
virtual SceneObject* Clone() override { return new Protagonist(*this); }
private:
bool _withWeapon = false;
};
Spirit
幽灵还需SpiritSyncHandle
处理幽灵状态同步请求,此外还有一些私有数据成员用于完成复活等逻辑
class Spirit : public Character
{
public:
Spirit(int ID, Vector3 pos, int HP, int ATK) :Character(HP, ID, pos), _ATK(ATK), _defaultHP(HP), _defaultPos(pos) {}
virtual DamageSyncRsp DamageHandle(const DamageSyncReq* req) override;
SpiritSyncRsp SpiritSyncHandle(const SpiritSyncReq* req);
virtual SpiritInfo* GetReconnectInfo();
virtual SceneObject* Clone() override { return new Spirit(*this); }
private:
int _ATK;
int _state = SpiritState::PATROL;
int _wayPointIndex = 0;
time_t _dieTime = 0; // 记录死亡时间
int _defaultHP;
Vector3 _defaultPos;
};
使用运行时类型判断优化同步逻辑
由于同步信息中仅有同步的ID而无法确定类型,并且场景物体对象是以基类指针形式存储的map<int, SceneObject*> objMap;
因此使用运行时类型判断来优化代码逻辑,以位置同步为例:
需要进行位置同步的场景物体都继承自Movable
,因此只需将基类指针转化为Movable指针并调用PositionSyncHandle
即可处理位置同步,其他同步类似
bool Scene::SceneSync(uv_tcp_t* client, const SceneSyncReq* req)
{
SceneData* sceneData = _sceneDataMap[client];
SceneSyncRsp rsp;
// 处理位置同步
for (auto i : req->positionsync()) {
Movable* move = dynamic_cast<Movable*>(sceneData->objMap[i.id()]);
if (move) {
auto add = rsp.add_positionrsp();
add->CopyFrom(move->PositionSyncHandle(&i));
}
}
// 处理幽灵状态同步
for (auto i : req->spiritsync()) {
Spirit* spirit = dynamic_cast<Spirit*>(sceneData->objMap[i.id()]);
if (spirit) {
auto add = rsp.add_spiritrsp();
add->CopyFrom(spirit->SpiritSyncHandle(&i));
}
}
...
}
场景逻辑处理类–Scene
静态场景数据
静态场景数据以Json文件形式存储与服务器,服务器启动时读取Json文件并以SceneID
为键存储于map<int, string> _sceneJsonMap;
此外加载文件时服务器还会解析Json文件并实例化SceneObject
存储于map<int, map<int, SceneObject*>> _defaultObjMap;
用于后续逻辑处理
相关类成员:
// 存储场景Json文件
map<int, string> _sceneJsonMap;
// 存储不同场景的初始SceneObject
map<int, map<int, SceneObject*>> _defaultObjMap;
// 解析json文件,并写入map
int _parseSceneCfg(int sceneID, const char* const monitor);
// 从json文件中加载场景信息到map
bool _load_scece(int sceneID, const char* filePath);
玩家场景数据
每个玩家以Client
为键保存一个SceneData*
结构体,结构体内存储了所有玩家逻辑所需的数据,包括玩家ID、姓名等,还有map<int, SceneObject*> objMap;
用于保存每个玩家拥有的动态的场景物体信息
struct SceneData {
string playerID = "";
string playerName = "";
int SceneID = -1;
bool isStart = false;
time_t startTime = 0;
time_t disconnectTime = 0;
int killCount = 0;
map<int, SceneObject*> objMap;
void reset() {
isStart = false;
startTime = 0;
disconnectTime = 0;
killCount = 0;
}
};
// 每个客户端存储一个SceneData数据结构
map<uv_tcp_t*, SceneData*> _sceneDataMap;
其他数据
// 存储不同场景的排行榜, 从小到大排列
map<int, Rank> _rankMap;
// 待重连的玩家Map(以PlayerID)为键
map<string, SceneData*> _disconnectMap;
使用注册的形式优化CMD分发
当使用Switch Case的形式分发CMD时,每增添一种CMD就要修改消息层代码,既增加了大量重复代码也不便于维护
switch (pack->cmd) {
case CLIENT_LOGIN_REQ:
{
PlayerLoginReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_login(client, &req);
break;
}
case CLIENT_CREATE_REQ:
{
PlayerCreateReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_create(client, &req);
break;
}
...
}
因此使用注册的形式将编译时判断延后到运行时判断,CMDHandle
负责处理CMD分发
CMDHandle
class CMDhandle
{
public:
using handle_t = void (*)(uv_tcp_t* client, Packet* pack);
static CMDhandle* getInstance();
void RegisterHandle(CLIENT_CMD cmd, handle_t handle);
void HandlePack(uv_tcp_t* client, Packet* pack);
private:
CMDhandle() {};
static CMDhandle* instance;
map<CLIENT_CMD, handle_t> _CMDhandleMap;
};
CMDHandle作为一个单例提供了注册Handle和处理CMD两个主要方法,当分发CMD时只需调用HandlePack
而无需Switch Case判断:
/*switch (pack->cmd) {
case CLIENT_LOGIN_REQ:
{
PlayerLoginReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_login(client, &req);
break;
}
case CLIENT_CREATE_REQ:
{
PlayerCreateReq req;
req.ParseFromArray(pack->data, pack->len);
g_playerMgr.player_create(client, &req);
break;
}
...
}*/
// 改进CMD分发
CMDhandle::getInstance()->HandlePack(client, pack);
// 内部直接根据CMD回调对应处理函数
void CMDhandle::HandlePack(uv_tcp_t* client, Packet* pack)
{
auto it = _CMDhandleMap.find((CLIENT_CMD)pack->cmd);
if (it != _CMDhandleMap.end()) {
it->second(client, pack);
}
}
当有业务逻辑需要处理CMD时只需在初始化时注册即可
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_LOAD_REQ, &SceneJsonLoadHandle);
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_START_REQ, &SceneStartHandle);
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_SETTLE_REQ, &SceneSettleHandle);
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_RANK_REQ, &SceneRankHandle);
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_SYNC_REQ, &SceneSyncHandle);
CMDhandle::getInstance()->RegisterHandle(CLIENT_SCENE_RECONNECT_REQ, &ReconnectHandle);