c++游戏后端开源框架学习——wukong(六、大厅)
·
Lobby:大厅服
对应代码目录:
lobby/
一、Lobby 是什么?
Lobby 是玩家业务逻辑的主载体。每个在线玩家在 Lobby 中有一个 LobbyObject,它负责:
- 处理客户端发来的游戏消息
- 维护玩家数据并定期同步给 Record 服
- 通过心跳维持与 Gateway 和 Record 的连接
- 管理战斗状态
打个比方:如果 Gateway 是大楼前台,那 Lobby 就是玩家的办公室——玩家的大部分业务都在这里处理,数据存在这里的文件柜里(定期送到档案室Record存档)。
二、核心设计
2.1 核心类
| 类 | 文件 | 职责 |
|---|---|---|
LobbyServer | lobby_server.h | 单例,初始化RPC/Agent/Redis/事件/热更新 |
LobbyObject | lobby_object.h | 每个在线玩家一个,玩家业务核心 |
LobbyObjectManager | lobby_object_manager.h | 管理所有LobbyObject |
LobbyServiceImpl | lobby_service.h | RPC服务实现 |
MessageHandler | message_handler.h | 游戏消息处理器 |
2.2 LobbyObject 核心属性
class LobbyObject : public MessageTarget {
UserId userId_;
RoleId roleId_;
ServerId serverId_; // 逻辑服ID
ServerId gatewayId_; // 关联的网关服(0=离线)
ServerId recordId_; // 关联的记录服
std::string lToken_; // 游戏对象唯一标识
std::string sceneId_; // 当前场景ID(空=在大厅)
std::unique_ptr<LobbyObjectData> data_; // 玩家业务数据
BattleLobbyPhase battleLobbyPhase_; // 战斗状态
Cond cond_; // 协程条件变量
EventEmitter emiter_; // 本地事件
int enterTimes_; // 重登计数器(防误判)
int heartbeatFailNum_; // 心跳失败计数
Timer *leaveGameTimer_; // 离线定时器
};
2.3 三个核心协程
LobbyObject 启动后运行三个协程,各司其职:
LobbyObject.start()
├── 心跳协程 heartbeatRoutine() 每20秒
├── 存盘协程 syncRoutine() 每1秒
└── 更新协程 updateRoutine() 每 updatePeriod 毫秒
三、角色加载流程
当 Gateway 认证通过后,会调用 Lobby 的 loadRole RPC 加载角色:
Gateway Lobby Record Redis
│ │ │ │
│── loadRole RPC ─────────────→│ │ │
│ (roleId, userId, lToken, │ │ │
│ serverId) │ │ │
│ │ │ │
│ 检查角色是否已存在 │ │
│ (防止重复加载) │ │
│ │ │ │
│ GetRecordAddress ──────────────────────────────────────────→│
│ (查询Record位置) │ │
│ │←── {recordAddr or ""} ────────────────────────────────│
│ │ │ │
│ 若Record地址不存在: │ │
│ ├── randomServer随机选Record │ │
│ └── 生成rToken │ │
│ │ │ │
│ SetLobbyAddress ────────────────────────────────────────────→│
│ (注册Lobby位置,hsetnx原子操作) │ │
│ (TTL 60秒) │ │
│ │ │ │
│ │── loadRoleData RPC ─────────→│ │
│ │ (roleId, userId, lToken, │ │
│ │ serverId) │ │
│ │ │── LoadRole ──────────→│
│ │ │ (从Redis或MySQL加载) │
│ │ │←── roleData ──────────│
│ │←── {roleData} ───────────────│ │
│ │ │ │
│ createLobbyObject() │ │
│ ├── 用DemoLobbyObjectData初始化数据 │ │
│ ├── 设置gatewayId, recordId │ │
│ ├── 注册到map │ │
│ └── start() 启动三个协程 │ │
│ │ │ │
│←── loadRole 响应 ────────────│ │ │
│ │ │ │
│── enterGame RPC ────────────→│ │ │
│ (通知Lobby玩家已进入游戏) │ │ │
│ │ │ │
│ onEnterGame() │ │
│ ├── buildAllDatas 构建全量数据 │ │
│ ├── 发送 S2C_ENTERGAME 给客户端 │ │
│ └── 发送 S2C_ENTERLOBBY 给客户端 │ │
关键设计
- 防重复加载:先检查角色是否已存在(
existRole),避免同一角色加载两次 - Record地址查询:优先从Redis查询Record位置,不存在则随机分配
- 原子注册:
SetLobbyAddress用hsetnx保证原子性,防止重复注册 - lToken唯一标识:整个游戏期间用于校验消息归属
四、心跳机制
4.1 心跳协程
// lobby_object.cpp:252-360
void LobbyObject::heartbeatRoutine() {
while (running_) {
cond_.wait(TOKEN_HEARTBEAT_PERIOD); // 等20秒
// 1. 刷新Lobby地址TTL(保持Location不过期)
RedisUtils::SetLobbyAddressTTL(cache, roleId_, lToken_);
// 2. 向Gateway心跳
int ret = heartbeatToGateway();
switch (ret) {
case 1: // 成功
heartbeatFailNum_ = 0;
break;
case 0: // 网关对象不存在(客户端断了)
gatewayId_ = 0;
onOffline();
break;
case -1: // Gateway连不上
case -2: // RPC超时
heartbeatFailNum_++;
if (heartbeatFailNum_ >= 3) {
gatewayId_ = 0;
onOffline();
}
break;
}
// 3. 向Record心跳
ret = heartbeatToRecord();
if (失败) {
leaveGame(); // Record心跳失败直接销毁对象
}
}
}
4.2 心跳失败处理策略
| 失败对象 | 失败次数 | 处理 |
|---|---|---|
| Gateway | 1-2次 | 累计计数,继续等待 |
| Gateway | 3次 | gatewayId_=0,触发onOffline |
| Gateway | 返回0 | 客户端已断线,立即onOffline |
| Record | 1次 | 直接leaveGame销毁对象 |
4.3 防误判机制
enterTimes_ 计数器:玩家重登过程中(旧对象还在、新对象在创建),心跳可能返回异常结果。通过 enterTimes_ 标记重登过程,避免误判离线。
五、离线与销毁
5.1 离线流程
心跳检测到Gateway不可达
│
▼
onOffline()
│
├── 不立即销毁!启动5秒定时器
│ leaveGameTimer_ = Timer::create(5000, leaveGame)
│
├── 5秒内重连?
│ └── 是 → 取消定时器,恢复正常
│
└── 5秒超时 → leaveGame()
├── 通知战斗服移除玩家(如果在战斗中)
├── LobbyObjectManager.leaveGame()
│ ├── stop() → 各协程退出
│ ├── 清理事件和定时器
│ ├── onDestory()
│ ├── RemoveLobbyAddress(从Redis移除Lobby位置)
│ └── 从map中删除
└── 对象销毁
5.2 为什么等5秒?
网络抖动可能导致短暂断线,5秒缓冲期让玩家有机会断线重连,避免频繁创建/销毁对象带来的开销和数据同步问题。
六、数据存盘
6.1 存盘协程
// lobby_object.cpp:362-386
void LobbyObject::syncRoutine() {
while (running_) {
cond_.wait(SYNC_PERIOD); // 等1秒
// 收集脏数据
std::vector<DataFragment> datas;
data_->buildSyncDatas(datas); // 只收集有变更的数据
if (!datas.empty()) {
// 通过RPC同步给Record服
recordAgent->sync(recordId_, roleId_, lToken_, datas);
}
}
}
6.2 脏数据机制
玩家业务操作(如花金币)
│
▼
LobbyObjectData.setCurrency(newValue)
│
├── 更新内存值
└── 标记 dirty_map_["currency"] = true
存盘协程每1秒:
├── buildSyncDatas():遍历dirty_map_,收集标记为dirty的数据
├── RPC发送给Record
└── Record返回成功后清除dirty标记
关键点:只同步有变更的数据(增量同步),减少网络传输和Record处理压力。
七、战斗状态管理
7.1 状态机
setWaitingEnterBattle()
InHall ──────────────────────────────→ WaitingBattleKcp
↑ │
│ │ notifyPlayerBattleState
│ │ (战斗服通知鉴权成功)
│ ▼
└──────────────────────────────────── InBattle
clearBattleStateFromBattleServer
7.2 战斗流程
1. 客户端发 C2S_StartBattle 到 Lobby
2. Lobby 调用 BattleService.requestBattleAssignment RPC
→ Battle服分配房间,返回 roomId、KCP地址、sessionToken
3. Lobby 设置 battleLobbyPhase_ = WaitingBattleKcp
4. Lobby 发送 S2C_BattleEnter 给客户端(含KCP地址和sessionToken)
5. 客户端用KCP连接Battle服,发送鉴权
6. Battle服鉴权成功后,调用 Lobby.notifyPlayerBattleState RPC
→ Lobby 设置 battleLobbyPhase_ = InBattle
7. 战斗结束,Battle服通知Lobby
→ Lobby 设置 battleLobbyPhase_ = InHall
7.3 离线处理
如果玩家在战斗中离线:
onOffline()
└── notifyBattleLeaveIfNeeded()
→ 通知Battle服移除玩家
八、消息处理
8.1 消息接收流程
客户端消息
│
▼
Gateway.forwardIn → LobbyService.forwardIn RPC
│
▼
LobbyObjectManager 根据 roleId 找到 LobbyObject
│
▼
LobbyObject.handleMessage(msgType, tag, rawMsg)
│
├── MessageHandleManager 查找消息注册信息
├── Protobuf反序列化
├── need_wait_?(正在处理中?)
│ ├── 是 → 入等待队列(保证消息串行处理)
│ └── 否 → 继续
├── needCoroutine?
│ ├── 是 → 启动新协程处理
│ └── 否 → 直接调用处理函数
└── 处理完成后,检查等待队列
8.2 Demo 消息处理器
// lobby/src/message_handler.cpp
void registerMessages() {
REGISTER_MESSAGE(C2S_MESSAGE_ID_ECHO, EchoHandle, true); // 回声测试
REGISTER_MESSAGE(C2S_MESSAGE_ID_STARTBATTLE, StartBattleHandle, true); // 开始战斗
REGISTER_MESSAGE(C2S_MESSAGE_ID_LEAVEGAME, LeaveGameHandle, true); // 离开游戏
}
九、LobbyObjectData
9.1 数据抽象
class LobbyObjectData {
virtual void initData(const std::vector<DataFragment> &datas) = 0;
virtual void buildSyncDatas(std::vector<DataFragment> &datas) = 0;
virtual void buildAllDatas(std::vector<DataFragment> &datas) = 0;
protected:
std::map<std::string, bool> dirty_map_; // 脏数据标记
};
9.2 代码生成
DemoLobbyObjectData(由 tools/gameDataCodeGen 自动生成)根据 gameData.json 配置生成:
- 基本类型字段:string/uint32/int32 → 直接值 + dirty标记
- Protobuf消息字段:指针 + dirty标记
- 列表字段:map + getAllKeys/has/get/add/remove + dirty标记
// demo/gameData.json
{
"members": [
{"attr": "name", "type": "string", "need_profile": true},
{"attr": "exp", "type": "uint32"},
{"attr": "lv", "type": "uint32", "need_profile": true},
{"attr": "currency", "type": "demo.pb.Currency"},
{"attr": "card", "type": "demo.pb.Cards", "inner_list": "cards",
"inner_list_item_type": "demo.pb.Card", "inner_list_item_id_key": "cardid"}
]
}
十、解决的问题
| 问题 | Lobby 的解决方案 |
|---|---|
| 玩家业务逻辑承载 | LobbyObject + 三个协程(心跳/存盘/更新) |
| 数据一致性 | 脏数据标记 + 增量同步(每秒) |
| 断线感知 | 心跳3次失败判定离线 + 5秒缓冲期 |
| 消息并发安全 | 同一玩家消息串行处理(等待队列) |
| 重登误判 | enterTimes_ 计数器 |
| 战斗状态管理 | 状态机(InHall/WaitingBattleKcp/InBattle) |
| 数据结构重复编写 | Go代码生成器自动生成LobbyObjectData |
| 离线数据清理 | 5秒定时器 + leaveGame流程 |
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐
所有评论(0)