Lobby:大厅服

对应代码目录:lobby/

一、Lobby 是什么?

Lobby 是玩家业务逻辑的主载体。每个在线玩家在 Lobby 中有一个 LobbyObject,它负责:

  • 处理客户端发来的游戏消息
  • 维护玩家数据并定期同步给 Record 服
  • 通过心跳维持与 Gateway 和 Record 的连接
  • 管理战斗状态

打个比方:如果 Gateway 是大楼前台,那 Lobby 就是玩家的办公室——玩家的大部分业务都在这里处理,数据存在这里的文件柜里(定期送到档案室Record存档)。


二、核心设计

2.1 核心类

文件职责
LobbyServerlobby_server.h单例,初始化RPC/Agent/Redis/事件/热更新
LobbyObjectlobby_object.h每个在线玩家一个,玩家业务核心
LobbyObjectManagerlobby_object_manager.h管理所有LobbyObject
LobbyServiceImpllobby_service.hRPC服务实现
MessageHandlermessage_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 给客户端        │                        │

关键设计

  1. 防重复加载:先检查角色是否已存在(existRole),避免同一角色加载两次
  2. Record地址查询:优先从Redis查询Record位置,不存在则随机分配
  3. 原子注册SetLobbyAddresshsetnx 保证原子性,防止重复注册
  4. 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 心跳失败处理策略

失败对象失败次数处理
Gateway1-2次累计计数,继续等待
Gateway3次gatewayId_=0,触发onOffline
Gateway返回0客户端已断线,立即onOffline
Record1次直接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流程
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐