支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册

This commit is contained in:
2026-09-28 00:41:28 +08:00
parent fb5a730ef3
commit adb31eec68
17 changed files with 854 additions and 367 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ func RegisterHandlers(hub *wsgateway.Hub, mt *Matcher, mgr *room.Manager) {
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
return
}
if err := mt.Enqueue(c.UserID(), req.GameID); err != nil { // 入队
if err := mt.Enqueue(c.UserID(), req.GameID, req.Seats); err != nil { // 入队(人数由客户端指定)
c.SendError(env.Seq, 0, err.Error()) // 回错误(未注册 / 已在房间 / 已在排队)
return
}
+92 -32
View File
@@ -1,10 +1,11 @@
// Package hall 提供大厅能力:按游戏分队列匹配,凑满人数后建房间并通知玩家。
// 本包同样玩法无关:人数来自玩法 Meta,成局后交给 room.Manager。
// Package hall 提供大厅能力:按「游戏 + 桌人数」分队列匹配,凑满人数后建房间并通知玩家。
// 排队过久仍未凑满时,自动用 AI 补齐空位,且对外完全不可感知。
package hall
import (
"context" // 标准库:Run 的生命周期控制
"errors" // 标准库:重复排队、已在房间等错误
"fmt" // 标准库:队列键拼接与人数越界提示
"log/slog" // 标准库:记录入队、成局、超时
"sync" // 标准库:保护队列表
"time" // 标准库:排队时刻、tick 周期、超时判定
@@ -20,15 +21,15 @@ import (
type item struct {
uid int64 // 用户 ID
gameID string // 游戏 ID(超时通知时要用)
enqueueAt time.Time // 入队时刻,用于排队超时
seats int // 该排队者要开的桌人数(决定补几个 AI)
enqueueAt time.Time // 入队时刻,用于排队超时与补 AI 判定
}
// queue 是某个游戏的一条匹配队列。
// TODO(扩展):若将来要区分排位/娱乐等模式,可把队列键改为「gameID|mode」并在上行体中携带 mode。
// queue 是「某个游戏 + 某个桌人数」的一条匹配队列(如 snake|4 表示四人场)。
type queue struct {
key string // 队列键:当前即 gameID
key string // 队列键:gameID|seats
gameID string // 游戏 ID
seats int // 凑满几人开局,取自玩法 Meta.MinSeats
seats int // 凑满几人开局(客户端指定,已校验在玩法人数范围内)
items []*item // 排队者,先进先出
}
@@ -51,32 +52,39 @@ func NewMatcher(hub *wsgateway.Hub, manager *room.Manager) *Matcher {
}
}
// Enqueue 把用户放入对应游戏的队列。
func (m *Matcher) Enqueue(uid int64, gameID string) error {
meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数
// Enqueue 把用户放入「游戏 + 人数」对应的队列(如贪吃蛇四人场)。
// seats 为 0 时按玩法最少人数处理;超出玩法人数范围直接拒绝。
func (m *Matcher) Enqueue(uid int64, gameID string, seats int) error {
meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数范围
if err != nil { // 未注册的游戏
return err
}
if seats <= 0 { // 未指定人数:按最少人数
seats = meta.MinSeats
}
if seats < meta.MinSeats || (meta.MaxSeats > 0 && seats > meta.MaxSeats) { // 人数越界
return fmt.Errorf("该玩法只支持 %d~%d 人,收到 %d", meta.MinSeats, meta.MaxSeats, seats)
}
if _, ok := m.manager.RoomOf(uid); ok { // 已在房间内:不允许同时排队
return errors.New("已在房间内,请先离开")
}
key := gameID // 队列键:按游戏分队列
m.mu.Lock() // 加锁
defer m.mu.Unlock() // 返回时解锁
key := fmt.Sprintf("%s|%d", gameID, seats) // 队列键:游戏 + 人数
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}
q = &queue{key: key, gameID: gameID, seats: seats}
m.queues[key] = q
}
q.items = append(q.items, &item{uid: uid, gameID: gameID, enqueueAt: time.Now()}) // 入队
m.waiting[uid] = key // 记录索引
q.items = append(q.items, &item{uid: uid, gameID: gameID, seats: seats, enqueueAt: time.Now()}) // 入队
m.waiting[uid] = key // 记录索引
slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "size", len(q.items))
slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "seats", seats, "size", len(q.items))
return nil
}
@@ -121,15 +129,16 @@ func (m *Matcher) Run(ctx context.Context) {
}
}
// scan 扫描全部队列:先清超时,再凑人成局。
// scan 扫描全部队列:清超时 → 凑人成局 → 等待过久的用 AI 补齐。
func (m *Matcher) scan(now time.Time) {
m.mu.Lock() // 加锁
var timeout []*item // 超时者
// 1) 清理排队超时(未开启补 AI 时才会真的发生)
var timeout []*item
for key, q := range m.queues { // 遍历队列
kept := q.items[:0] // 原地过滤,复用底层数组
for _, it := range q.items { // 逐个判断
if now.Sub(it.enqueueAt) > matchTimeout() { // 排队超时
if now.Sub(it.enqueueAt) > matchTimeout() && !aiEnabled() { // 超时且未开启补 AI
delete(m.waiting, it.uid) // 清除索引
timeout = append(timeout, it)
continue
@@ -142,7 +151,8 @@ func (m *Matcher) scan(now time.Time) {
}
}
var groups [][]*item // 本次成局的分组
// 2) 凑满人的分组:真人成局
var groups [][]*item
for _, q := range m.queues { // 遍历队列凑人
for len(q.items) >= q.seats { // 人数足够
group := append([]*item(nil), q.items[:q.seats]...) // 取前 seats 个
@@ -153,41 +163,78 @@ func (m *Matcher) scan(now time.Time) {
groups = append(groups, group) // 记录分组
}
}
// 3) 等待过久仍未凑满:用 AI 补齐空位(对外不可感知)
var aiGroups [][]*item
if aiEnabled() {
for key, q := range m.queues { // 遍历未满队列
if len(q.items) == 0 { // 空队列跳过
continue
}
if now.Sub(q.items[0].enqueueAt) < aiWait() { // 队首等待时长还不够
continue
}
take := len(q.items) // 取出当前全部排队者(最多一桌)
if take > q.seats {
take = q.seats
}
group := append([]*item(nil), q.items[:take]...) // 取走
q.items = q.items[take:] // 剩余继续排队
for _, it := range group { // 清除索引
delete(m.waiting, it.uid)
}
if len(q.items) == 0 { // 队列已空
delete(m.queues, key) // 删除
}
aiGroups = append(aiGroups, group) // 记录需补 AI 的分组
}
}
m.mu.Unlock() // 解锁
for _, it := range timeout { // 通知排队超时(锁外)
m.notifyCancelled(it.uid, it.gameID, "timeout")
}
for _, g := range groups { // 逐个分组成局(锁外,建房间较慢)
m.createRoom(g)
for _, g := range groups { // 真人成局(锁外,建房间较慢)
m.createRoom(g, 0)
}
for _, g := range aiGroups { // AI 补位成局(锁外)
m.createRoom(g, g[0].seats-len(g))
}
}
// createRoom 为一组成局玩家创建房间并通知。
func (m *Matcher) createRoom(g []*item) {
// createRoom 为一组成员创建房间并通知;aiCount 为需要补位的 AI 数量。
func (m *Matcher) createRoom(g []*item, aiCount int) {
if len(g) == 0 { // 空分组
return
}
uids := make([]int64, 0, len(g)) // 收集用户 ID
uids := make([]int64, 0, len(g)) // 收集真人用户 ID
for _, it := range g {
uids = append(uids, it.uid)
}
if aiCount < 0 { // 防御:人数不会为负
aiCount = 0
}
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)
r, err := m.manager.Create(g[0].gameID, uids, aiCount) // 创建房间(真人 + AI 一起入座)
if err != nil { // 创建失败(如玩法不支持 AI、房间数超限)
slog.Error("match create room failed", "game_id", g[0].gameID, "ai", aiCount, "error", err)
return
}
data, err := wsgateway.Encode(wsgateway.TypeHallMatched, 0, dto.WSHallMatchedPayload{ // 编码匹配成功通知
RoomID: r.ID(), GameID: r.GameID(), Seats: len(uids), Users: uids,
RoomID: r.ID(),
GameID: r.GameID(),
Seats: len(uids) + aiCount,
Users: r.UserIDs(),
Players: r.Roster(),
})
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)
m.hub.Broadcast(uids, data) // 只广播给真人(AI 无连接,Hub 会自然跳过)
slog.Info("match room created", "room_id", r.ID(), "game_id", g[0].gameID,
"users", uids, "ai", aiCount)
}
// notifyCancelled 下发取消通知。
@@ -216,3 +263,16 @@ func matchTimeout() time.Duration {
}
return 60 * time.Second // 兜底 60 秒
}
// aiEnabled 是否开启「匹配超时补 AI」。
func aiEnabled() bool {
return global.WSSetting != nil && global.WSSetting.AIEnabled
}
// aiWait 排队超过多久开始补 AI。
func aiWait() time.Duration {
if global.WSSetting != nil && global.WSSetting.AIWaitSec > 0 { // 配置了
return time.Duration(global.WSSetting.AIWaitSec) * time.Second
}
return 30 * time.Second // 兜底 30 秒
}