实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理

This commit is contained in:
2026-09-28 00:10:11 +08:00
parent 99d1827262
commit fb5a730ef3
19 changed files with 2492 additions and 18 deletions
+94
View File
@@ -0,0 +1,94 @@
package room // 包名声明:房间消息处理,与房间同包
import (
"context" // 标准库:handler 入参上下文
"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/wsgateway"
)
// RegisterHandlers 向 Hub 注册房间相关消息的处理函数。
func RegisterHandlers(hub *wsgateway.Hub, m *Manager) {
hub.RegisterHandler(wsgateway.TypeRoomJoin, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
var req dto.WSRoomJoinRequest // 解析上行体
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
c.SendError(env.Seq, 0, "参数错误: "+err.Error()) // 回错误
return
}
r, ok := m.Get(req.RoomID) // 查房间
if !ok { // 房间不存在
c.SendError(env.Seq, 0, "房间不存在")
return
}
if err := r.Join(c.UserID()); err != nil { // 入座
c.SendError(env.Seq, 0, err.Error()) // 回错误(如房间已满)
return
}
m.bind(c.UserID(), r.ID()) // 绑定用户与房间
})
hub.RegisterHandler(wsgateway.TypeRoomReady, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
var req dto.WSRoomReadyRequest // 解析上行体
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
return
}
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
if !ok { // 不在任何房间
c.SendError(env.Seq, 0, "不在房间内")
return
}
if err := r.Ready(c.UserID(), req.Ready); err != nil { // 设置准备
c.SendError(env.Seq, 0, err.Error())
}
})
hub.RegisterHandler(wsgateway.TypeRoomAction, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
var req dto.WSRoomActionRequest // 解析上行体
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
return
}
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
if !ok { // 不在任何房间
c.SendError(env.Seq, 0, "不在房间内")
return
}
action := game.Action{Name: req.Action, Payload: req.Payload} // 封装为玩法动作
if err := r.Submit(c.UserID(), action); err != nil { // 提交给玩法
c.SendError(env.Seq, 0, err.Error()) // 回错误(如不是你的回合)
}
})
hub.RegisterHandler(wsgateway.TypeRoomLeave, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
if !ok { // 不在任何房间
c.SendError(env.Seq, 0, "不在房间内")
return
}
if err := r.Leave(c.UserID()); err != nil { // 离座
c.SendError(env.Seq, 0, err.Error())
return
}
m.unbind(c.UserID()) // 解绑
})
hub.RegisterHandler(wsgateway.TypeRoomReconnect, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
var req dto.WSRoomReconnectRequest // 解析上行体
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
return
}
r, ok := m.Get(req.RoomID) // 按 ID 取房间
if !ok { // 房间已不存在(可能已关闭)
c.SendError(env.Seq, 0, "房间不存在")
return
}
if err := r.Reconnect(c.UserID()); err != nil { // 恢复在线并下发快照
c.SendError(env.Seq, 0, err.Error())
return
}
m.bind(c.UserID(), r.ID()) // 重新绑定
})
}
+130
View File
@@ -0,0 +1,130 @@
package room // 包名声明:房间管理器与房间同包
import (
"errors" // 标准库:房间数超限、游戏未注册等错误
"log/slog" // 标准库:记录创建与回收日志
// 标准库:生成房间 ID 时去除 UUID 连字符
"sync" // 标准库:保护房间表与用户绑定表
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
)
// Manager 管理全部房间:创建、查询、用户绑定与回收。
type Manager struct {
mu sync.RWMutex // 保护下面两张表
rooms map[string]*Room // roomID -> 房间
userRoom map[int64]string // userID -> roomID(一个用户同时只在一个房间)
hub *wsgateway.Hub // 下发通道,透传给房间
}
// NewManager 创建房间管理器。
func NewManager(hub *wsgateway.Hub) *Manager {
return &Manager{
rooms: make(map[string]*Room), // 初始化房间表
userRoom: make(map[int64]string), // 初始化绑定表
hub: hub, // 保存下发通道
}
}
// Create 创建一个房间并让指定用户入座(匹配成功后由匹配器调用)。
func (m *Manager) Create(gameID string, uids []int64) (*Room, error) {
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数
if err != nil {
return nil, err
}
if m.Count() >= maxRooms() { // 房间数达到上限
return nil, errors.New("too many rooms")
}
m.mu.Lock() // 加写锁
r, err := newRoom(newRoomID(), gameID, m.hub, m.onRoomClosed) // 建房间并启动事件循环
if err != nil { // 创建失败
m.mu.Unlock() // 解锁
return nil, err
}
m.rooms[r.ID()] = r // 登记房间
m.mu.Unlock() // 解锁
for _, uid := range uids { // 逐个用户入座
if err := r.Join(uid); err != nil { // 入座失败(如人数超出座位)
slog.Warn("room join failed on create", "room_id", r.ID(), "user_id", uid, "error", err)
continue // 跳过该用户,房间继续可用
}
m.bind(uid, r.ID()) // 绑定用户与房间
}
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", meta.MinSeats)
return r, nil
}
// Get 按 ID 取房间。
func (m *Manager) Get(id string) (*Room, bool) {
m.mu.RLock() // 加读锁
defer m.mu.RUnlock() // 返回时解锁
r, ok := m.rooms[id] // 查表
return r, ok
}
// RoomOf 取用户当前所在房间(用于重连与防止重复匹配)。
func (m *Manager) RoomOf(uid int64) (*Room, bool) {
m.mu.RLock() // 加读锁
id, ok := m.userRoom[uid] // 查绑定表
m.mu.RUnlock() // 解锁
if !ok { // 未绑定
return nil, false
}
return m.Get(id) // 取房间
}
// Count 返回当前房间数。
func (m *Manager) Count() int {
m.mu.RLock() // 加读锁
defer m.mu.RUnlock() // 返回时解锁
return len(m.rooms) // 表长度
}
// bind 绑定用户与房间。
func (m *Manager) bind(uid int64, roomID string) {
m.mu.Lock() // 加写锁
defer m.mu.Unlock() // 返回时解锁
m.userRoom[uid] = roomID // 记录绑定关系
}
// unbind 解除用户绑定。
func (m *Manager) unbind(uid int64) {
m.mu.Lock() // 加写锁
defer m.mu.Unlock() // 返回时解锁
delete(m.userRoom, uid) // 删除绑定
}
// onRoomClosed 是房间关闭回调:从表中移除并解绑全部成员。
func (m *Manager) onRoomClosed(roomID string, uids []int64) {
m.mu.Lock() // 加写锁
delete(m.rooms, roomID) // 移除房间
m.mu.Unlock() // 解锁
for _, uid := range uids { // 解绑成员
m.unbind(uid)
}
}
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线(进入重连宽限)。
// 由 boot 注册到 Hub 的断连回调上。
func (m *Manager) OnUserDisconnected(uid int64) {
r, ok := m.RoomOf(uid) // 查用户所在房间
if !ok { // 不在任何房间(可能只是普通断开)
return
}
if err := r.MarkOffline(uid); err != nil { // 通知房间标记掉线
slog.Debug("mark offline failed", "user_id", uid, "error", err)
}
}
// maxRooms 返回房间总数上限。
func maxRooms() int {
if global.WSSetting != nil && global.WSSetting.MaxRooms > 0 { // 配置了
return global.WSSetting.MaxRooms
}
return 1000 // 兜底
}
+550
View File
@@ -0,0 +1,550 @@
// Package room 提供玩法无关的房间容器:成员与座位、状态机、事件串行化、下发与超时回收。
// 房间只调用 game.Game 接口,不知道任何具体玩法规则。
package room
import (
"context" // 标准库:房间事件循环的生命周期上下文
"errors" // 标准库:房间不可加入、动作非法等错误
"log/slog" // 标准库:记录结算与关闭日志
"strings"
"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/wsgateway"
"github.com/google/uuid"
)
// Status 是房间状态。
type Status string
const (
StatusWaiting Status = "waiting" // 等待玩家到齐/准备
StatusReady Status = "ready" // 人数达标且全员准备
StatusPlaying Status = "playing" // 对局进行中
StatusPaused Status = "paused" // 有人掉线,重连宽限中
StatusFinished Status = "finished" // 已分出结果
StatusClosed Status = "closed" // 已关闭,不再可用
)
// roomTick 是房间事件循环的 tick 周期:驱动实时玩法与各类超时检查。
const roomTick = time.Second
// member 是房间内一名成员。
type member struct {
uid int64 // 用户 ID
seat int // 座位下标
ready bool // 是否已准备
online bool // 是否在线
offlineAt time.Time // 掉线时刻,用于宽限倒计时
}
// message 是投递到房间事件循环的一次请求。
type message struct {
kind string // 请求类型:join/leave/ready/action/reconnect/offline/close
uid int64 // 发起用户
action *game.Action // 玩法动作(仅 action 用)
ready bool // 准备状态(仅 ready 用)
reason string // 关闭原因(仅 close 用)
result chan error // 同步回传结果,便于 handler 直接回错误
}
// Room 是一局游戏的容器。所有状态变更都经由 reqCh 串行处理,玩法实现因此无需加锁。
type Room struct {
id string // 房间 ID
gameID string // 游戏 ID
g game.Game // 玩法实例
state game.State // 玩法私有状态(透传)
seats []int64 // 座位表:下标即座位号,0 表示空位
members map[int64]*member // userID -> 成员
status Status // 当前状态
reqCh chan message // 请求队列
closed chan struct{} // 关闭信号
closeOnce sync.Once // 关闭幂等
hub *wsgateway.Hub // 下发通道
onClosed func(string, []int64) // 关闭回调:通知 Manager 解绑
lastActive time.Time // 最近活跃时间,用于空闲回收
finish *game.Finish // 对局结果
tickInterval time.Duration // 事件循环驱动周期:实时玩法按 Meta.TickMs 加速
}
// newRoom 创建房间并启动事件循环。
func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
g, err := game.New(gameID) // 按 ID 建玩法实例
if err != nil {
return nil, err
}
meta := g.Meta() // 取玩法元信息
st, err := g.Init(meta.MinSeats) // 按最少人数初始化状态
if err != nil {
return nil, err
}
// 驱动周期:实时玩法通过 Meta.TickMs 指定(如贪吃蛇 200ms),回合制沿用默认 1 秒。
tickInterval := roomTick
if meta.TickMs > 0 {
tickInterval = time.Duration(meta.TickMs) * time.Millisecond
}
r := &Room{
id: id, // 房间 ID
gameID: gameID, // 游戏 ID
g: g, // 玩法实例
state: st, // 玩法状态
seats: make([]int64, meta.MinSeats), // 按人数建座位表
members: make(map[int64]*member), // 成员表
status: StatusWaiting, // 初始为等待
reqCh: make(chan message, 64), // 请求缓冲队列
closed: make(chan struct{}), // 关闭信号
hub: hub, // 下发通道
onClosed: onClosed, // 关闭回调
lastActive: time.Now(), // 初始活跃时间
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
}
go r.run() // 启动单 goroutine 事件循环
return r, nil
}
// ID 返回房间 ID。
func (r *Room) ID() string { return r.id }
// GameID 返回游戏 ID。
func (r *Room) GameID() string { return r.gameID }
// Status 返回房间状态。
func (r *Room) Status() Status { return r.status }
// UserIDs 返回房间内全部用户 ID。
func (r *Room) UserIDs() []int64 {
uids := make([]int64, 0, len(r.members)) // 预分配
for uid := range r.members { // 遍历成员表
uids = append(uids, uid) // 收集
}
return uids
}
// Has 判断用户是否在房间内。
func (r *Room) Has(uid int64) bool {
_, ok := r.members[uid] // 查成员表
return ok
}
// Join 入座(同步等待事件循环处理结果)。
func (r *Room) Join(uid int64) error {
return r.submit(message{kind: "join", uid: uid}) // 提交入座请求
}
// Leave 离座。
func (r *Room) Leave(uid int64) error {
return r.submit(message{kind: "leave", uid: uid}) // 提交离座请求
}
// Ready 设置准备状态。
func (r *Room) Ready(uid int64, ready bool) error {
return r.submit(message{kind: "ready", uid: uid, ready: ready}) // 提交准备请求
}
// Submit 提交一次玩法动作。
func (r *Room) Submit(uid int64, a game.Action) error {
act := a // 拷贝动作,避免调用方后续修改
return r.submit(message{kind: "action", uid: uid, action: &act}) // 提交动作请求
}
// Reconnect 标记用户重新在线。
func (r *Room) Reconnect(uid int64) error {
return r.submit(message{kind: "reconnect", uid: uid}) // 提交重连请求
}
// MarkOffline 标记用户掉线:由 Manager 在收到 Hub 断连回调时调用。
func (r *Room) MarkOffline(uid int64) error {
return r.submit(message{kind: "offline", uid: uid}) // 提交掉线请求
}
// Close 关闭房间:走事件循环串行化,避免在别的 goroutine 里直接改状态。
func (r *Room) Close(reason string) {
_ = r.submit(message{kind: "close", reason: reason}) // 提交关闭请求
}
// submit 把请求投递到事件循环并等待结果。
func (r *Room) submit(m message) error {
m.result = make(chan error, 1) // 建结果通道
select {
case <-r.closed: // 房间已关闭
return errors.New("room closed")
case r.reqCh <- m: // 投递成功
}
select {
case err := <-m.result: // 等待处理结果
return err
case <-time.After(3 * time.Second): // 事件循环繁忙
return errors.New("room busy")
}
}
// run 是房间事件循环:串行处理请求与 tick,保证玩法调用无并发。
func (r *Room) run() {
ticker := time.NewTicker(r.tickInterval) // 按玩法规定的周期驱动(回合制 1 秒,实时玩法更快)
defer ticker.Stop() // 退出时停止定时器
for { // 持续处理
select {
case <-r.closed: // 房间已关闭
return
case m := <-r.reqCh: // 收到请求
r.handle(m)
case <-ticker.C: // 每秒驱动
r.tick(time.Now())
}
}
}
// handle 按类型分发请求,并把结果写回 result 通道。
func (r *Room) handle(m message) {
var err error // 处理结果
switch m.kind { // 按请求类型分发
case "join":
err = r.handleJoin(m.uid)
case "leave":
err = r.handleLeave(m.uid)
case "ready":
err = r.handleReady(m.uid, m.ready)
case "action":
err = r.handleAction(m.uid, m.action)
case "reconnect":
err = r.handleReconnect(m.uid)
case "offline":
err = r.handleOffline(m.uid)
case "close":
r.close(m.reason) // 关闭在循环内执行,状态修改保持串行
}
if m.result != nil { // 有结果通道
m.result <- err // 回传结果(带缓冲,不会阻塞)
}
}
// handleJoin 处理入座:分配座位并通知玩法。
func (r *Room) handleJoin(uid int64) error {
if r.status == StatusClosed || r.status == StatusFinished { // 房间不可用
return errors.New("room not joinable")
}
if m, ok := r.members[uid]; ok { // 已在房间内
m.online = true // 标记为在线
m.offlineAt = time.Time{} // 清空掉线时间
r.broadcastState() // 刷新快照
return nil
}
seat := -1 // 找空位
for i, s := range r.seats { // 遍历座位表
if s == 0 { // 空位
seat = i // 记录下标
break
}
}
if seat < 0 { // 无空位
return errors.New("room is full")
}
r.seats[seat] = uid // 占座
r.members[uid] = &member{uid: uid, seat: seat, online: true} // 登记成员
r.lastActive = time.Now() // 刷新活跃时间
events, err := r.g.OnJoin(uid, r.state) // 通知玩法
if err != nil { // 玩法拒绝(如人数已满)
delete(r.members, uid) // 回滚成员
r.seats[seat] = 0 // 回滚座位
return err
}
r.deliver(events) // 投递玩法事件
r.broadcastState() // 广播最新快照
return nil
}
// handleLeave 处理离座:通知玩法,玩法可据此判负。
func (r *Room) handleLeave(uid int64) error {
m, ok := r.members[uid] // 查成员
if !ok { // 不在房间
return errors.New("not in room")
}
delete(r.members, uid) // 移除成员
if m.seat < len(r.seats) && r.seats[m.seat] == uid { // 释放座位
r.seats[m.seat] = 0
}
events, finish, err := r.g.OnLeave(uid, r.state) // 通知玩法
if err == nil { // 玩法处理成功
r.deliver(events) // 投递事件
if finish != nil && r.status == StatusPlaying { // 玩法判定结束(如判对手胜)
r.finishGame(finish) // 结算
return nil
}
}
r.broadcastState() // 广播快照
return nil
}
// handleReady 处理准备:全员准备且人数达标即开局。
func (r *Room) handleReady(uid int64, ready bool) error {
if r.status != StatusWaiting && r.status != StatusReady { // 仅等待阶段可准备
return errors.New("room not in waiting status")
}
m, ok := r.members[uid] // 查成员
if !ok { // 不在房间
return errors.New("not in room")
}
m.ready = ready // 设置准备状态
r.lastActive = time.Now() // 刷新活跃时间
if r.allReady() && len(r.members) == len(r.seats) { // 全员准备且坐满
r.status = StatusReady // 进入就绪
r.startGame() // 立即开局
} else {
r.status = StatusWaiting // 仍在等待
r.broadcastState() // 广播快照
}
return nil
}
// handleAction 处理玩法动作:透传给玩法插件。
func (r *Room) handleAction(uid int64, a *game.Action) error {
if r.status != StatusPlaying { // 未开局
return errors.New("game not playing")
}
if _, ok := r.members[uid]; !ok { // 非本局成员
return errors.New("not in room")
}
if a == nil { // 缺动作
return errors.New("empty action")
}
events, finish, err := r.g.OnAction(uid, *a, r.state) // 交给玩法处理
if err != nil { // 玩法拒绝(如不是你的回合)
return err
}
r.lastActive = time.Now() // 刷新活跃时间
r.deliver(events) // 投递增量事件
if finish != nil { // 对局结束
r.finishGame(finish)
} else { // 未结束
r.broadcastState() // 广播快照
}
return nil
}
// handleReconnect 处理重连:恢复在线并回到暂停中的对局。
func (r *Room) handleReconnect(uid int64) error {
m, ok := r.members[uid] // 查成员
if !ok { // 不在房间(可能已被清理)
return errors.New("not in room")
}
m.online = true // 标记在线
m.offlineAt = time.Time{} // 清空掉线时间
r.lastActive = time.Now() // 刷新活跃时间
if r.status == StatusPaused { // 从暂停恢复
r.status = StatusPlaying
}
r.broadcastState() // 下发全量快照(客户端据此恢复画面)
return nil
}
// handleOffline 处理掉线:标记离线,对局中则暂停等待重连(宽限超时由 tick 判负)。
func (r *Room) handleOffline(uid int64) error {
m, ok := r.members[uid] // 查成员
if !ok { // 不在房间
return errors.New("not in room")
}
m.online = false // 标记为离线
m.offlineAt = time.Now() // 记录掉线时刻,宽限倒计时起点
if r.status == StatusPlaying { // 对局进行中有人掉线
r.status = StatusPaused // 进入暂停,等待重连或超时判负
}
r.broadcastState() // 广播,让其他人看到掉线状态
return nil
}
// startGame 开局:切换状态并广播。
func (r *Room) startGame() {
r.status = StatusPlaying // 进入对局中
slog.Info("room game started", "room_id", r.id, "game_id", r.gameID)
r.broadcastState() // 广播开局快照
}
// finishGame 结算:记录结果、广播结算、延时关闭房间。
func (r *Room) finishGame(f *game.Finish) {
r.status = StatusFinished // 进入已结束
r.finish = f // 保存结果
// TODO(后续):结算落库(对局记录表由你自行准备,可在 internal/mysqldao 新增 room_record DAO)。
slog.Info("room game finished", "room_id", r.id, "game_id", r.gameID,
"winner", f.Winner, "reason", f.Reason)
r.broadcast(wsgateway.TypeRoomFinished, dto.WSRoomFinishedPayload{ // 广播结算
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Scores: f.Scores, Reason: f.Reason,
})
go func() { // 延时关闭,给客户端留出查看结算的时间
time.Sleep(10 * time.Second) // 等待 10 秒
r.Close("finished") // 经事件循环关闭,避免跨 goroutine 改状态
}()
}
// tick 每秒驱动:空闲回收、掉线宽限判负、实时玩法 tick。
func (r *Room) tick(now time.Time) {
if r.status == StatusClosed { // 已关闭
return
}
// 空闲回收:等待/就绪阶段长时间无操作则关闭,避免僵尸房间占用内存。
if r.status == StatusWaiting || r.status == StatusReady {
if now.Sub(r.lastActive) > idleTimeout() { // 超过空闲上限
slog.Info("room idle timeout", "room_id", r.id)
r.close("idle") // 关闭
return
}
}
// 掉线宽限:暂停期间有人未在宽限期内重连,判超时结束。
if r.status == StatusPaused {
for _, m := range r.members { // 检查每个成员
if !m.online && now.Sub(m.offlineAt) > reconnectGrace() { // 超时未重连
var winner int64 // 默认平局
for _, other := range r.members { // 找仍在线的对手
if other.online { // 在线者判胜
winner = other.uid
break
}
}
slog.Info("room reconnect timeout", "room_id", r.id, "user_id", m.uid, "winner", winner)
r.finishGame(&game.Finish{Winner: winner, Reason: "timeout"}) // 判超时结束
return
}
}
}
// 实时玩法驱动:每秒把时间交给玩法(回合制玩法可实现为空)。
if r.status == StatusPlaying {
events, finish, err := r.g.OnTick(now.Unix(), r.state) // 调用玩法 tick
if err != nil { // 玩法内部错误
slog.Error("room game tick error", "room_id", r.id, "error", err)
return
}
r.deliver(events) // 投递事件
if finish != nil { // 玩法判定结束
r.finishGame(finish)
}
}
}
// close 关闭房间:广播关闭并回调 Manager 解绑。
func (r *Room) close(reason string) {
r.closeOnce.Do(func() { // 幂等:只执行一次
r.status = StatusClosed // 置为已关闭
close(r.closed) // 通知事件循环退出
uids := r.UserIDs() // 先取成员列表
r.broadcast(wsgateway.TypeRoomClosed, dto.WSRoomClosedPayload{RoomID: r.id, Reason: reason})
if r.onClosed != nil { // 有回调
r.onClosed(r.id, uids) // 通知 Manager 清理
}
slog.Info("room closed", "room_id", r.id, "reason", reason)
})
}
// allReady 判断是否全员已准备。
func (r *Room) allReady() bool {
if len(r.members) != len(r.seats) { // 人数未达标
return false
}
for _, m := range r.members { // 遍历成员
if !m.ready { // 有人未准备
return false
}
}
return true // 全员准备
}
// broadcast 向房间全体下发一条消息。
func (r *Room) broadcast(typ string, payload any) {
data, err := wsgateway.Encode(typ, 0, payload) // 编码信封
if err != nil { // 编码失败
slog.Error("room encode failed", "room_id", r.id, "error", err)
return
}
r.hub.Broadcast(r.UserIDs(), data) // 经 Hub 下发
}
// broadcastState 给每个成员单独下发「对其可见」的房间快照。
func (r *Room) broadcastState() {
for uid := range r.members { // 逐个成员生成快照
snap := r.g.Snapshot(uid, r.state) // 玩法按观众裁剪
data, err := wsgateway.Encode(wsgateway.TypeRoomState, 0, dto.WSRoomStatePayload{
RoomID: r.id, GameID: r.gameID, Status: string(r.status), Seats: r.seats,
Ready: r.readyIDs(), State: snap,
})
if err != nil { // 编码失败
slog.Error("room encode state failed", "room_id", r.id, "error", err)
continue
}
r.hub.SendTo(uid, data) // 单独下发
}
}
// deliver 投递玩法产生的事件:未指定收件人则全体广播。
func (r *Room) deliver(events []game.Event) {
if len(events) == 0 { // 无事件
return
}
for _, e := range events { // 逐个事件下发
data, err := wsgateway.Encode(wsgateway.TypeRoomEvent, 0, dto.WSRoomEventPayload{
RoomID: r.id, Event: e.Name, Payload: e.Payload,
})
if err != nil { // 编码失败
slog.Error("room encode event failed", "room_id", r.id, "error", err)
continue
}
if len(e.To) == 0 { // 未指定收件人
r.hub.Broadcast(r.UserIDs(), data) // 房间内全体
} else { // 指定收件人
r.hub.Broadcast(e.To, data) // 定向下发(私有信息)
}
}
}
// readyIDs 返回已准备的用户 ID。
func (r *Room) readyIDs() []int64 {
ids := make([]int64, 0, len(r.members)) // 预分配
for uid, m := range r.members { // 遍历成员
if m.ready { // 已准备
ids = append(ids, uid) // 收集
}
}
return ids
}
// idleTimeout 返回房间空闲回收时长。
func idleTimeout() time.Duration {
if global.WSSetting != nil && global.WSSetting.RoomIdleTimeoutSec > 0 { // 配置了
return time.Duration(global.WSSetting.RoomIdleTimeoutSec) * time.Second
}
return 300 * time.Second // 兜底 5 分钟
}
// reconnectGrace 返回掉线重连宽限时长。
func reconnectGrace() time.Duration {
if global.WSSetting != nil && global.WSSetting.ReconnectGraceSec > 0 { // 配置了
return time.Duration(global.WSSetting.ReconnectGraceSec) * time.Second
}
return 30 * time.Second // 兜底 30 秒
}
// newRoomID 生成房间 ID(32 位十六进制,去连字符)。
func newRoomID() string {
return strings.ReplaceAll(uuid.NewString(), "-", "") // 去连字符后共 32 字符
}
// 保证 context 包被使用(房间事件循环后续可接入统一 ctx 控制)。
var _ = context.Background