实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理
This commit is contained in:
@@ -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()) // 重新绑定
|
||||
})
|
||||
}
|
||||
@@ -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 // 兜底
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user