// 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 加速 roster []dto.PlayerBrief // 玩家名册(昵称/头像/座位):客户端据此渲染,不含任何 AI 标记 } // newRoom 创建房间并启动事件循环。 func newRoom(id, gameID string, seats int, roster []dto.PlayerBrief, 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(seats) // 按本局实际人数初始化状态 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, seats), // 按本局人数建座位表 members: make(map[int64]*member), // 成员表 status: StatusWaiting, // 初始为等待 reqCh: make(chan message, 64), // 请求缓冲队列 closed: make(chan struct{}), // 关闭信号 hub: hub, // 下发通道 onClosed: onClosed, // 关闭回调 lastActive: time.Now(), // 初始活跃时间 roster: roster, // 玩家名册(含 AI,对外无标识) 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, ready: game.IsAIUID(uid)} // 登记成员(AI 入座即自动准备) 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, Players: r.roster, 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 { r.driveAI(now.Unix()) // 先让 AI 玩家行动(对外不可感知) if r.status != StatusPlaying { // AI 行动后可能已分出胜负 return } 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, Players: r.roster, }) 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