game-bass 的实时联机模块用 Hub 模式。所有 WebSocket 连接汇聚到一个 Hub,单 goroutine 事件循环统一调度。房间管理和匹配系统都跑在这个 Hub 上。

房间生命周期

数据结构

房间的核心定义在 internal/realtime/room.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type RoomState int

const (
RoomStateWaiting RoomState = 0 // 等待玩家
RoomStatePlaying RoomState = 1 // 游戏中
RoomStateFinished RoomState = 2 // 已结束
)

type Room struct {
ID string
Players map[string]*Player
Config RoomConfig
State RoomState
CreatedAt time.Time
PlayerStates map[string]*PlayerState
mu sync.RWMutex
}

RoomConfig 支持最大玩家数、游戏类型、是否私有、密码等配置:

1
2
3
4
5
6
7
type RoomConfig struct {
MaxPlayers int
GameType string
IsPrivate bool
Password string
Metadata map[string]interface{}
}

创建房间

RoomManager.CreateRoom 在创建时填充默认值:

1
2
3
4
5
6
7
8
9
10
11
12
func (rm *RoomManager) CreateRoom(id string, config RoomConfig) *Room {
if config.MaxPlayers <= 0 {
config.MaxPlayers = 4
}
room := &Room{
ID: id, Players: make(map[string]*Player), Config: config,
State: RoomStateWaiting, CreatedAt: time.Now(),
PlayerStates: make(map[string]*PlayerState),
}
rm.rooms[id] = room
return room
}

客户端通过 WebSocket 发送 create_room 消息触发创建,Handler 解析配置后调用 Hub:

1
2
3
4
5
6
7
8
func (h *Handler) handleCreateRoom(player *Player, msg *Message) {
config, ok := parseRoomConfig(msg.Content)
roomID := h.hub.CreateRoom(config)
h.hub.JoinRoom(player.ID, roomID)
h.hub.SendToPlayer(player.ID, Message{
Type: "room_created", Room: roomID, ...
})
}

加入房间

加入逻辑在 Hub.joinRoom 中处理,有一个关键细节:如果玩家已经在另一个房间里,会先自动离开旧房间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (h *Hub) joinRoom(roomID string, player *Player) {
room, ok := h.rooms[roomID]
if !ok {
room = &Room{ID: roomID, Players: make(map[string)*Player), ...}
h.rooms[roomID] = room
}
// 自动离开旧房间
if player.Room != "" && player.Room != roomID {
oldRoom.RemovePlayer(player.ID)
}
room.AddPlayer(player)
player.Room = roomID
// 广播 player_joined
}

这样做的好处是客户端不需要显式调用”离开旧房间”接口,简化了前端逻辑。

离开与销毁

玩家断开连接时,Hub 在 handleUnregister 中处理离开和自动销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (h *Hub) handleUnregister(player *Player) {
if player.Room != "" {
room.RemovePlayer(player.ID)
// 广播 player_left
if room.PlayerCount() == 0 {
delete(h.rooms, player.Room) // 空房间自动销毁
}
}
if player.MatchID != "" {
match.RemovePlayer(player.ID)
if match.PlayerCount() == 0 {
delete(h.matches, player.MatchID)
match.Stop()
}
}
}

空房间和空 Match 都会在最后一个玩家离开时自动清理,不需要定时器或 TTL 机制。这种设计的前提是 Hub 是单 goroutine 事件循环——所有状态变更都在同一个 goroutine 内完成,不需要加锁。

房间发现

公开房间通过 ListRooms 查询,支持按 gameType 过滤、排除私有房间:

1
2
3
4
5
6
7
8
9
func (rm *RoomManager) ListRooms(gameType string) []*Room {
var result []*Room
for _, room := range rm.rooms {
if room.Config.IsPrivate { continue }
if gameType != "" && room.Config.GameType != gameType { continue }
result = append(result, room)
}
return result
}

还提供了 FindAvailableRoom,返回第一个未满且处于等待状态的房间,供快速加入使用。

玩家状态追踪

连接层状态

每个玩家有两个层面的状态:连接层和游戏层。

连接层由 Player 结构体管理:

1
2
3
4
5
6
7
8
type Player struct {
ID string
Conn *websocket.Conn
Room string // 当前房间 ID
MatchID string // 当前 Match ID
Send chan []byte
LastPing time.Time // 用于 60s 超时检测
}

LastPing 用于心跳检测——如果超过 60 秒没有收到 pong,Hub 会主动断开连接并触发 handleUnregister 清理。

游戏层状态

游戏层由 PlayerState 管理,存储在 Room 的 PlayerStates map 中:

1
2
3
4
5
6
7
8
type PlayerState struct {
PlayerID string
Position map[string]float64 // x, y, z 坐标
Health int
Score int
Ready bool // 是否准备就绪
Metadata map[string]interface{}
}

客户端发送 state_update 消息更新状态,Handler 收到后更新 Room 数据并广播给同房间的其他玩家:

1
2
3
4
5
6
func (h *Handler) handleStateUpdate(player *Player, msg *Message) {
state, _ := parsePlayerState(player.ID, msg.Content)
h.hub.UpdatePlayerState(player.Room, player.ID, state)
h.hub.SendToRoom(player.Room,
Message{Type: "player_state", Content: msg.Content}, "")
}

最后一个参数是排除的 playerID,传空字符串表示发给房间内所有人(包括发送者自己)。

匹配系统

MatchTicket

匹配的基本单位是 MatchTicket,代表一个玩家的匹配请求:

1
2
3
4
5
6
7
8
9
10
type MatchTicket struct {
PlayerID string
GameType string
Skill float64 // 技能分
MinCount int
MaxCount int
Props map[string]interface{} // region、level 等属性
Query string // 查询表达式,如 "region == asia"
Created time.Time // 30s 超时
}

Props 和 Query 支持属性匹配——比如只匹配同区域的玩家。Query 是一个简单的表达式字符串,按 gameType + "|" + query 分组。

提交与取消

一人一票,同一个 PlayerID 重复提交会覆盖旧 ticket:

1
2
3
4
5
6
7
8
func (h *Hub) SubmitTicket(ticket *MatchTicket) {
h.poolMu.Lock()
h.pool[ticket.PlayerID] = ticket
}

func (h *Hub) CancelTicket(playerID string) {
delete(h.pool, playerID)
}

匹配扫描

Hub 启动时创建一个 2 秒间隔的 ticker,定期调用 scanMatchPool:

1
2
3
4
5
poolTicker: time.NewTicker(2 * time.Second),

// Run() 中:
case <-h.poolTicker.C:
h.scanMatchPool()

匹配算法分三步:

  1. 分组:按 gameType + "|" + query 将 ticket 分入不同组
  2. 排序:每组内按 skill 升序排列(冒泡排序,池子通常很小)
  3. 配对:从排序后的列表中依次取相邻的两个 ticket 配对
1
2
3
4
5
6
7
for len(tickets) >= 2 {
p1, p2 := tickets[0], tickets[1]
tickets = tickets[2:]
h.CancelTicket(p1.PlayerID)
h.CancelTicket(p2.PlayerID)
h.OnMatch([]string{p1.PlayerID, p2.PlayerID}, gameType)
}

超过 30 秒未匹配的 ticket 会被清理:

1
2
3
4
5
for _, t := range h.pool {
if time.Since(t.Created) > 30*time.Second {
delete(h.pool, t.PlayerID)
}
}

匹配回调

匹配成功后触发 OnMatch 回调,默认实现是自动创建房间并加入匹配到的玩家:

1
2
3
4
5
6
7
h.OnMatch = func(players []string, gameType string) string {
roomID := h.CreateRoom(RoomConfig{MaxPlayers: 4, GameType: gameType})
for _, pid := range players {
h.JoinRoom(pid, roomID)
}
return roomID
}

这个回调可以被替换——比如接入 ELO 排位系统,在创建房间前先验证玩家的段位是否匹配。

与 ELO 排位系统的配合

game-bass 的 internal/competitive 模块提供了 ELO 评分和天梯排行,为 skill-based 匹配提供分数来源。

排位系统的核心流程:

  1. 玩家完成一场比赛后,客户端上报比赛结果
  2. 后端通过 Lua 脚本或 Go 内置公式计算 ELO 变化
  3. 更新 Redis 中的分数和段位(Bronze → Diamond 五档)
  4. 赛季结束时,排行榜归档并开启新赛季

匹配时从排位系统读取玩家的 Skill 分数填入 MatchTicket,这样 scanMatchPool 的 skill 排序就能实现水平相近的玩家优先匹配。

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
客户端 WebSocket
│
▼
Handler.handleMessage ─── 按 Type 路由
│
├── create_room ──── Hub.CreateRoom → RoomManager
├── join/leave ───── Hub.joinRoom / Hub.LeaveRoom
├── state_update ─── Hub.UpdatePlayerState → 广播
├── matchmaking ──── Hub.SubmitTicket → 匹配池
│ │
│ poolTicker (2s) → scanMatchPool()
│ │
│ skill 排序 → 配对 → OnMatch 回调
│ │
│ CreateRoom + JoinRoom × N
│
└── disconnect ──── handleUnregister → 空房间/空 Match 自动销毁

设计取舍

取舍比较明确。Hub 用单 goroutine 串行处理所有状态变更,避免锁竞争,但匹配扫描和广播不能做太重的计算。中小规模的独立游戏后端够用。匹配池放内存,不依赖外部消息队列,简单直接。排序用冒泡,池子通常只有几十到几百个 ticket。

目前这套方案在 100 人同时在线的场景下表现稳定,匹配延迟 2-4 秒。更大的规模还没压测过,内存匹配池和冒泡排序大概率撑不住,到时候得换方案。