Files
zt-online-game-backend/internal/room/room.go
T

557 lines
20 KiB
Go

// 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