实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// Package hall 提供大厅能力:按游戏分队列匹配,凑满人数后建房间并通知玩家。
|
||||
// 本包同样玩法无关:人数来自玩法 Meta,成局后交给 room.Manager。
|
||||
package hall
|
||||
|
||||
import (
|
||||
"context" // 标准库:Run 的生命周期控制
|
||||
"errors" // 标准库:重复排队、已在房间等错误
|
||||
"log/slog" // 标准库:记录入队、成局、超时
|
||||
"sync" // 标准库:保护队列表
|
||||
"time" // 标准库:排队时刻、tick 周期、超时判定
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
||||
)
|
||||
|
||||
// item 是队列中的一个排队者。
|
||||
type item struct {
|
||||
uid int64 // 用户 ID
|
||||
gameID string // 游戏 ID(超时通知时要用)
|
||||
enqueueAt time.Time // 入队时刻,用于排队超时
|
||||
}
|
||||
|
||||
// queue 是某个游戏的一条匹配队列。
|
||||
// TODO(扩展):若将来要区分排位/娱乐等模式,可把队列键改为「gameID|mode」并在上行体中携带 mode。
|
||||
type queue struct {
|
||||
key string // 队列键:当前即 gameID
|
||||
gameID string // 游戏 ID
|
||||
seats int // 凑满几人开局,取自玩法 Meta.MinSeats
|
||||
items []*item // 排队者,先进先出
|
||||
}
|
||||
|
||||
// Matcher 管理全部队列,按 tick 扫描凑人成局。
|
||||
type Matcher struct {
|
||||
mu sync.Mutex // 保护下面两张表
|
||||
queues map[string]*queue // key -> 队列
|
||||
waiting map[int64]string // uid -> key,便于快速判断是否已在排队
|
||||
manager *room.Manager // 成局后创建房间
|
||||
hub *wsgateway.Hub // 下发匹配结果
|
||||
}
|
||||
|
||||
// NewMatcher 创建匹配器。
|
||||
func NewMatcher(hub *wsgateway.Hub, manager *room.Manager) *Matcher {
|
||||
return &Matcher{
|
||||
queues: make(map[string]*queue), // 初始化队列表
|
||||
waiting: make(map[int64]string), // 初始化排队索引
|
||||
manager: manager, // 房间管理器
|
||||
hub: hub, // 下发通道
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue 把用户放入对应游戏的队列。
|
||||
func (m *Matcher) Enqueue(uid int64, gameID string) error {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数
|
||||
if err != nil { // 未注册的游戏
|
||||
return err
|
||||
}
|
||||
if _, ok := m.manager.RoomOf(uid); ok { // 已在房间内:不允许同时排队
|
||||
return errors.New("已在房间内,请先离开")
|
||||
}
|
||||
|
||||
key := gameID // 队列键:按游戏分队列
|
||||
m.mu.Lock() // 加锁
|
||||
defer m.mu.Unlock() // 返回时解锁
|
||||
|
||||
if _, ok := m.waiting[uid]; ok { // 已在排队中
|
||||
return errors.New("已在匹配队列中")
|
||||
}
|
||||
q, ok := m.queues[key] // 取队列
|
||||
if !ok { // 首次出现:建队列
|
||||
q = &queue{key: key, gameID: gameID, seats: meta.MinSeats}
|
||||
m.queues[key] = q
|
||||
}
|
||||
q.items = append(q.items, &item{uid: uid, gameID: gameID, enqueueAt: time.Now()}) // 入队
|
||||
m.waiting[uid] = key // 记录索引
|
||||
|
||||
slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "size", len(q.items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel 取消排队:gameID 为空表示取消该用户的全部排队。
|
||||
func (m *Matcher) Cancel(uid int64, gameID string) {
|
||||
m.mu.Lock() // 加锁
|
||||
var cancelled []*item // 收集被取消者,解锁后再通知
|
||||
for key, q := range m.queues { // 遍历所有队列
|
||||
if gameID != "" && q.gameID != gameID { // 只处理指定游戏
|
||||
continue
|
||||
}
|
||||
for i, it := range q.items { // 查找该用户
|
||||
if it.uid == uid {
|
||||
cancelled = append(cancelled, it) // 记录
|
||||
q.items = append(q.items[:i], q.items[i+1:]...) // 移除
|
||||
delete(m.waiting, uid) // 清除索引
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(q.items) == 0 { // 队列空了
|
||||
delete(m.queues, key) // 删除队列
|
||||
}
|
||||
}
|
||||
m.mu.Unlock() // 解锁
|
||||
|
||||
for _, it := range cancelled { // 锁外通知,避免回调内再操作锁
|
||||
m.notifyCancelled(it.uid, it.gameID, "user")
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动匹配扫描循环,ctx 取消即退出。
|
||||
func (m *Matcher) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(queueTick()) // 按配置周期扫描
|
||||
defer ticker.Stop() // 退出时停止
|
||||
for { // 持续扫描
|
||||
select {
|
||||
case <-ctx.Done(): // 上下文取消(优雅退出)
|
||||
return
|
||||
case <-ticker.C: // 到点扫描
|
||||
m.scan(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scan 扫描全部队列:先清超时,再凑人成局。
|
||||
func (m *Matcher) scan(now time.Time) {
|
||||
m.mu.Lock() // 加锁
|
||||
|
||||
var timeout []*item // 超时者
|
||||
for key, q := range m.queues { // 遍历队列
|
||||
kept := q.items[:0] // 原地过滤,复用底层数组
|
||||
for _, it := range q.items { // 逐个判断
|
||||
if now.Sub(it.enqueueAt) > matchTimeout() { // 排队超时
|
||||
delete(m.waiting, it.uid) // 清除索引
|
||||
timeout = append(timeout, it)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, it) // 保留
|
||||
}
|
||||
q.items = kept // 写回
|
||||
if len(q.items) == 0 { // 队列已空
|
||||
delete(m.queues, key) // 删除
|
||||
}
|
||||
}
|
||||
|
||||
var groups [][]*item // 本次成局的分组
|
||||
for _, q := range m.queues { // 遍历队列凑人
|
||||
for len(q.items) >= q.seats { // 人数足够
|
||||
group := append([]*item(nil), q.items[:q.seats]...) // 取前 seats 个
|
||||
q.items = q.items[q.seats:] // 剩余继续排队
|
||||
for _, it := range group { // 清除索引
|
||||
delete(m.waiting, it.uid)
|
||||
}
|
||||
groups = append(groups, group) // 记录分组
|
||||
}
|
||||
}
|
||||
m.mu.Unlock() // 解锁
|
||||
|
||||
for _, it := range timeout { // 通知排队超时(锁外)
|
||||
m.notifyCancelled(it.uid, it.gameID, "timeout")
|
||||
}
|
||||
for _, g := range groups { // 逐个分组成局(锁外,建房间较慢)
|
||||
m.createRoom(g)
|
||||
}
|
||||
}
|
||||
|
||||
// createRoom 为一组成局玩家创建房间并通知。
|
||||
func (m *Matcher) createRoom(g []*item) {
|
||||
if len(g) == 0 { // 空分组
|
||||
return
|
||||
}
|
||||
uids := make([]int64, 0, len(g)) // 收集用户 ID
|
||||
for _, it := range g {
|
||||
uids = append(uids, it.uid)
|
||||
}
|
||||
|
||||
r, err := m.manager.Create(g[0].gameID, uids) // 创建房间并入座
|
||||
if err != nil { // 创建失败(如房间数超限)
|
||||
slog.Error("match create room failed", "game_id", g[0].gameID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := wsgateway.Encode(wsgateway.TypeHallMatched, 0, dto.WSHallMatchedPayload{ // 编码匹配成功通知
|
||||
RoomID: r.ID(), GameID: r.GameID(), Seats: len(uids), Users: uids,
|
||||
})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("match encode hall.matched failed", "error", err)
|
||||
return
|
||||
}
|
||||
m.hub.Broadcast(uids, data) // 通知全部玩家
|
||||
slog.Info("match room created", "room_id", r.ID(), "game_id", g[0].gameID, "users", uids)
|
||||
}
|
||||
|
||||
// notifyCancelled 下发取消通知。
|
||||
func (m *Matcher) notifyCancelled(uid int64, gameID, reason string) {
|
||||
data, err := wsgateway.Encode(wsgateway.TypeHallCancelled, 0,
|
||||
dto.WSHallCancelledPayload{GameID: gameID, Reason: reason})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("match encode hall.cancelled failed", "error", err)
|
||||
return
|
||||
}
|
||||
m.hub.SendTo(uid, data) // 单独通知
|
||||
}
|
||||
|
||||
// queueTick 返回队列扫描间隔。
|
||||
func queueTick() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.QueueTickMs > 0 { // 配置了
|
||||
return time.Duration(global.WSSetting.QueueTickMs) * time.Millisecond
|
||||
}
|
||||
return time.Second // 兜底 1 秒
|
||||
}
|
||||
|
||||
// matchTimeout 返回排队超时时长。
|
||||
func matchTimeout() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.MatchTimeoutSec > 0 { // 配置了
|
||||
return time.Duration(global.WSSetting.MatchTimeoutSec) * time.Second
|
||||
}
|
||||
return 60 * time.Second // 兜底 60 秒
|
||||
}
|
||||
Reference in New Issue
Block a user