diff --git a/internal/boot/boot.go b/internal/boot/boot.go new file mode 100644 index 0000000..27e9899 --- /dev/null +++ b/internal/boot/boot.go @@ -0,0 +1,47 @@ +// Package boot 负责 WebSocket 各模块的接线:注册玩法、创建房间管理器与匹配器、注册消息处理器。 +package boot + +import ( + "context" // 标准库:匹配循环的生命周期上下文 + "log/slog" // 标准库:记录初始化完成 + + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game/gobang" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game/snake" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game/tictactoe" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway" +) + +// 模块句柄:便于后续 HTTP 接口或运维命令查询在线数、房间数。 +var ( + Hub *wsgateway.Hub // 连接注册表 + Rooms *room.Manager // 房间管理器 + Matcher *hall.Matcher // 匹配器 +) + +// InitWS 完成 WS 模块初始化与接线,应在配置加载之后、服务启动之前调用。 +func InitWS() { + // 注册玩法:每新增一个玩法就在此加一行,网关 / 大厅 / 房间代码无需改动。 + game.Register("gobang", func() game.Game { return gobang.New() }) // 五子棋:1v1 回合制 + game.Register("snake", func() game.Game { return snake.New() }) // 双人贪吃蛇:1v1 实时(200ms 一步) + game.Register("tictactoe", func() game.Game { return tictactoe.New() }) // 井字棋:1v1 回合制(示例) + + Hub = wsgateway.DefaultHub // 使用网关默认 Hub + Rooms = room.NewManager(Hub) // 创建房间管理器 + room.RegisterHandlers(Hub, Rooms) // 注册 room.* 消息处理器 + + Matcher = hall.NewMatcher(Hub, Rooms) // 创建匹配器(成局后委托 Rooms 建房间) + hall.RegisterHandlers(Hub, Matcher, Rooms) // 注册 hall.* 消息处理器 + + // 断连回调:连接断开时通知房间,进入掉线宽限,重连逻辑因此生效。 + Hub.OnDisconnect(func(userID int64) { Rooms.OnUserDisconnected(userID) }) + + slog.Info("ws modules initialized") +} + +// Start 启动 WS 后台任务(匹配队列扫描)。 +func Start(ctx context.Context) { + go Matcher.Run(ctx) // 后台扫描,ctx 取消即退出 +} diff --git a/internal/dto/dev.go b/internal/dto/dev.go new file mode 100644 index 0000000..6fee91b --- /dev/null +++ b/internal/dto/dev.go @@ -0,0 +1,13 @@ +package dto // 包名声明:与 user.go、ws.go 同属 dto 包 + +// DevTokenRequest 开发调试:按用户 ID 直接换取令牌的请求(仅 debug 模式可用)。 +type DevTokenRequest struct { + UserID int64 `form:"user_id"` // 要 impersonate 的用户 ID;<=0 时回落为 1 +} + +// DevTokenResponse 开发调试:直接换取的令牌对,跳过微信登录。 +type DevTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` // access_token 过期时间(绝对 Unix 秒时间戳) +} diff --git a/internal/dto/ws.go b/internal/dto/ws.go new file mode 100644 index 0000000..21a9e6a --- /dev/null +++ b/internal/dto/ws.go @@ -0,0 +1,91 @@ +package dto // 包名声明:与 user.go 同属 dto 包,集中放置对外数据结构 + +import "encoding/json" // 标准库:玩法动作的自定义参数保持原始 JSON,由玩法插件自行解析 + +// ===== 大厅上行 ===== + +// WSHallMatchRequest 是 hall.match 的上行体:请求进入匹配队列。 +type WSHallMatchRequest struct { + GameID string `json:"game_id"` // 要玩的游戏 ID,必须是已注册的玩法 +} + +// WSHallCancelRequest 是 hall.cancel 的上行体:取消当前排队。 +type WSHallCancelRequest struct { + GameID string `json:"game_id"` // 要取消的游戏 ID;空表示取消该用户全部排队 +} + +// ===== 房间上行 ===== + +// WSRoomJoinRequest 是 room.join 的上行体:按房间 ID 加入。 +type WSRoomJoinRequest struct { + RoomID string `json:"room_id"` // 房间 ID +} + +// WSRoomReadyRequest 是 room.ready 的上行体:切换准备状态。 +type WSRoomReadyRequest struct { + Ready bool `json:"ready"` // true=准备,false=取消准备 +} + +// WSRoomActionRequest 是 room.action 的上行体:一次玩法动作,透传给玩法插件。 +type WSRoomActionRequest struct { + Action string `json:"action"` // 动作名,由玩法约定(如 "place") + Payload json.RawMessage `json:"payload,omitempty"` // 动作参数:原样透传,网关与房间都不解析 +} + +// WSRoomLeaveRequest 是 room.leave 的上行体:主动离开房间。 +type WSRoomLeaveRequest struct{} + +// WSRoomReconnectRequest 是 room.reconnect 的上行体:断线后回到原房间。 +type WSRoomReconnectRequest struct { + RoomID string `json:"room_id"` // 断线前所在房间 ID +} + +// ===== 大厅下行 ===== + +// WSHallMatchedPayload 是 hall.matched 的下行体:告知匹配成功与房间信息。 +type WSHallMatchedPayload struct { + RoomID string `json:"room_id"` // 新建房间 ID + GameID string `json:"game_id"` // 游戏 ID + Seats int `json:"seats"` // 总座位数(2=双人,>2=多人) + Users []int64 `json:"users"` // 同局用户 ID,顺序即座位顺序 +} + +// WSHallCancelledPayload 是 hall.cancelled 的下行体:告知排队已取消。 +type WSHallCancelledPayload struct { + GameID string `json:"game_id"` // 被取消的游戏 ID + Reason string `json:"reason"` // 取消原因:user(主动)/ timeout(排队超时) +} + +// ===== 房间下行 ===== + +// WSRoomStatePayload 是 room.state 的下行体:房间全量快照。 +type WSRoomStatePayload struct { + RoomID string `json:"room_id"` // 房间 ID + GameID string `json:"game_id"` // 游戏 ID + Status string `json:"status"` // 状态:waiting/ready/playing/paused/finished/closed + Seats []int64 `json:"seats"` // 座位顺序(0 表示空位) + Ready []int64 `json:"ready"` // 已准备的用户 ID + State any `json:"state"` // 玩法对该用户可见的状态(Game.Snapshot 生成,可做信息隐藏) +} + +// WSRoomEventPayload 是 room.event 的下行体:玩法产生的增量事件。 +type WSRoomEventPayload struct { + RoomID string `json:"room_id"` // 房间 ID + Event string `json:"event"` // 事件名,由玩法约定 + Payload any `json:"payload"` // 事件内容 +} + +// WSRoomFinishedPayload 是 room.finished 的下行体:对局结束与结算。 +type WSRoomFinishedPayload struct { + RoomID string `json:"room_id"` // 房间 ID + GameID string `json:"game_id"` // 游戏 ID + Winner int64 `json:"winner"` // 获胜用户 ID,0 表示平局 + Scores map[int64]int `json:"scores"` // 各用户得分,可为空 + Reason string `json:"reason"` // 结束原因:normal/opponent_leave/timeout +} + +// WSRoomClosedPayload 是 room.closed 的下行体:房间已关闭。 +type WSRoomClosedPayload struct { + RoomID string `json:"room_id"` // 房间 ID + Reason string `json:"reason"` // 关闭原因:dissolved/idle/finished +} diff --git a/internal/game/engine.go b/internal/game/engine.go new file mode 100644 index 0000000..4df4d63 --- /dev/null +++ b/internal/game/engine.go @@ -0,0 +1,88 @@ +// Package game 定义玩法插件的统一接口与注册表。 +// 房间只通过该接口驱动玩法:新增一个游戏 = 新增实现 + 注册,网关/大厅/房间零改动。 +package game + +import ( + "encoding/json" // 标准库:动作参数以原始 JSON 透传给玩法 + "fmt" // 标准库:未注册玩法时返回明确错误 + "sync" // 标准库:保护注册表并发读写 +) + +// Meta 描述玩法的匹配与房间形态:人数与节奏差异全部由它声明。 +type Meta struct { + GameID string // 游戏唯一 ID,匹配队列与房间都用它索引 + MinSeats int // 最少人数:双人=2,多人=4/6... + MaxSeats int // 最多人数 + Realtime bool // true=实时玩法(依赖 OnTick 驱动),false=回合制 + AllowJoin bool // 是否允许开局后中途加入 + TickMs int // 房间驱动周期(毫秒):0 表示用房间默认 1 秒;实时玩法设小值(如贪吃蛇 200) +} + +// Action 是一次玩家动作的通用载体:房间只透传,不解析内容。 +type Action struct { + Name string // 动作名,玩法内部约定(如 "place") + Payload json.RawMessage // 动作参数原样 JSON,由玩法自行解析 +} + +// Event 描述「发给谁、发什么」:投递由房间完成,玩法不接触连接。 +type Event struct { + To []int64 // 收件人;nil 或空表示房间内全体广播 + Name string // 事件名,客户端据此分发 + Payload any // 事件内容 +} + +// Finish 是对局结果:由玩法返回,房间负责广播与结算。 +type Finish struct { + Winner int64 // 获胜用户 ID,0 表示平局 + Scores map[int64]int // 各用户得分,可为空 + Reason string // 结束原因:normal/opponent_leave/timeout +} + +// State 是玩法私有状态:房间只做透传,具体类型由玩法在 Init 中创建。 +type State any + +// Game 是所有玩法必须实现的接口,房间按序调用这些方法驱动一局游戏。 +type Game interface { + Meta() Meta // 元信息:人数、是否实时等 + Init(seatCount int) (State, error) // 开局初始化私有状态 + OnJoin(uid int64, s State) ([]Event, error) // 玩家入座 + OnLeave(uid int64, s State) ([]Event, *Finish, error) // 玩家离座;可返回 Finish 判负 + OnAction(uid int64, a Action, s State) ([]Event, *Finish, error) // 玩家动作;返回 nil Finish 表示未结束 + OnTick(now int64, s State) ([]Event, *Finish, error) // 每秒驱动:实时玩法与超时判定 + Snapshot(uid int64, s State) any // 按观众裁剪的可见状态(重连/观战用) +} + +// Factory 创建玩法实例:每局一个新实例,保证状态隔离。 +type Factory func() Game + +var ( + mu sync.RWMutex // 保护注册表 + registry = map[string]Factory{} // gameID -> 构造函数 +) + +// Register 注册一个玩法;同名 ID 重复注册会覆盖(便于测试替换)。 +func Register(gameID string, f Factory) { + mu.Lock() // 加写锁 + defer mu.Unlock() // 返回时解锁 + registry[gameID] = f +} + +// New 按 ID 创建玩法实例。 +func New(gameID string) (Game, error) { + mu.RLock() // 加读锁 + f, ok := registry[gameID] // 查注册表 + mu.RUnlock() // 解锁 + if !ok { // 未注册 + return nil, fmt.Errorf("game %q not registered", gameID) + } + return f(), nil // 返回新实例 +} + +// LookupMeta 取已注册玩法的元信息:匹配器用它决定队列人数。 +func LookupMeta(gameID string) (Meta, error) { + g, err := New(gameID) // 先建实例 + if err != nil { // 未注册 + return Meta{}, err + } + return g.Meta(), nil // 返回元信息 +} diff --git a/internal/game/gobang/gobang.go b/internal/game/gobang/gobang.go new file mode 100644 index 0000000..3561779 --- /dev/null +++ b/internal/game/gobang/gobang.go @@ -0,0 +1,202 @@ +// Package gobang 是五子棋玩法:1v1 回合制,15×15 棋盘,横竖斜任一方向先连成五子者胜。 +// 结构与 tictactoe 一致:实现 game.Game 接口,由房间驱动,不接触连接与数据库。 +package gobang + +import ( + "encoding/json" // 标准库:解析 place 动作参数 + "errors" // 标准库:返回玩法内校验错误 + "time" // 标准库:记录上一手落子时刻,用于可选的回合超时 + + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game" +) + +const ( + gameID = "gobang" // 玩法 ID:匹配队列与房间都以它索引 + boardSize = 15 // 棋盘边长 + winCount = 5 // 连成几子获胜 + + // turnTimeoutSec 单步思考时长上限(秒):0 表示不限制。 + // 需要「超时判负」时改成 90 之类的正数,OnTick 会在超时后判对手胜。 + turnTimeoutSec = 0 +) + +// placePayload 是 place 动作的参数。 +type placePayload struct { + Row int `json:"row"` // 行下标 0-14 + Col int `json:"col"` // 列下标 0-14 +} + +// state 是本玩法私有状态,房间只透传不解析。 +type state struct { + Board []int8 `json:"board"` // 棋盘:长度 225,0=空,1=黑(先手),2=白(后手) + Players []int64 `json:"players"` // 座位顺序:下标 0 执黑先手 + Turn int `json:"turn"` // 当前应落子方下标 + Winner int64 `json:"winner"` // 获胜用户 ID,0 表示未分出 + Moves int `json:"moves"` // 已落子数 + LastMove int64 `json:"last_move"` // 上一手落子时刻(秒),回合超时判定用 +} + +// Game 实现 game.Game 接口。 +type Game struct{} + +// New 创建玩法实例:每局一个,状态互不影响。 +func New() *Game { return &Game{} } + +// Meta 声明本玩法为 2 人回合制。 +func (g *Game) Meta() game.Meta { + return game.Meta{GameID: gameID, MinSeats: 2, MaxSeats: 2, Realtime: false, AllowJoin: false} +} + +// Init 按座位数初始化空棋盘。 +func (g *Game) Init(seatCount int) (game.State, error) { + if seatCount != 2 { // 五子棋固定两人 + return nil, errors.New("gobang requires 2 seats") + } + return &state{ + Board: make([]int8, boardSize*boardSize), // 225 格全空 + LastMove: time.Now().Unix(), // 起手时刻:回合超时从开局算起 + }, nil +} + +// OnJoin 玩家入座:先到者执黑(1),后到者执白(2)。 +func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) { + st, ok := s.(*state) // 断言为本玩法状态 + if !ok { + return nil, errors.New("invalid state type") + } + if len(st.Players) >= 2 { // 已满员 + return nil, errors.New("room is full") + } + st.Players = append(st.Players, uid) // 记录座位顺序 + color := len(st.Players) // 1=黑,2=白 + return []game.Event{{Name: "seat", Payload: map[string]any{ + "user_id": uid, "seat": color - 1, "color": color, + }}}, nil +} + +// OnLeave 玩家离座:判对手胜。 +func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + for i, p := range st.Players { // 找到离座者 + if p == uid { + var winner int64 // 默认无人获胜 + if len(st.Players) == 2 { // 双人对局:判对手胜 + winner = st.Players[1-i] + } + st.Winner = winner + return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}}, + &game.Finish{Winner: winner, Reason: "opponent_leave"}, nil + } + } + return nil, nil, nil // 非本局成员:无事件 +} + +// OnAction 处理一次落子:校验轮次与位置,判定胜负或平局。 +func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + if len(st.Players) != 2 { // 人数不足 + return nil, nil, errors.New("game not started") + } + if uid != st.Players[st.Turn] { // 不是该玩家的回合 + return nil, nil, errors.New("not your turn") + } + if a.Name != "place" { // 未知动作 + return nil, nil, errors.New("unsupported action: " + a.Name) + } + + var p placePayload // 解析动作参数 + if err := json.Unmarshal(a.Payload, &p); err != nil { + return nil, nil, errors.New("invalid place payload") + } + if p.Row < 0 || p.Row >= boardSize || p.Col < 0 || p.Col >= boardSize { // 越界 + return nil, nil, errors.New("position out of range") + } + idx := p.Row*boardSize + p.Col // 一维下标 + if st.Board[idx] != 0 { // 该点已有子 + return nil, nil, errors.New("cell occupied") + } + + mark := int8(st.Turn + 1) // 执黑=1,执白=2 + st.Board[idx] = mark // 落子 + st.Moves++ // 步数加一 + st.LastMove = time.Now().Unix() // 刷新最后落子时刻 + + events := []game.Event{{Name: "placed", Payload: map[string]any{ + "user_id": uid, "row": p.Row, "col": p.Col, "color": mark, + }}} + + if checkWin(st.Board, p.Row, p.Col, mark) { // 连成五子 + st.Winner = uid + return events, &game.Finish{Winner: uid, Reason: "normal"}, nil + } + if st.Moves == boardSize*boardSize { // 棋盘填满:平局 + return events, &game.Finish{Winner: 0, Reason: "normal"}, nil + } + + st.Turn = 1 - st.Turn // 轮转到对手 + return events, nil, nil +} + +// OnTick 每秒驱动:回合制默认不做事;打开 turnTimeoutSec 后用于超时判负。 +func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) { + if turnTimeoutSec <= 0 { // 未开启超时判负 + return nil, nil, nil + } + st, ok := s.(*state) // 断言状态 + if !ok || len(st.Players) != 2 || st.Winner != 0 { // 未开局或已分胜负 + return nil, nil, nil + } + if now-st.LastMove < int64(turnTimeoutSec) { // 未超时 + return nil, nil, nil + } + + loser := st.Turn // 超时方 + winner := st.Players[1-loser] // 判对手胜 + st.Winner = winner // 记录结果 + return []game.Event{{Name: "timeout", Payload: map[string]any{"user_id": st.Players[loser]}}}, + &game.Finish{Winner: winner, Reason: "timeout"}, nil +} + +// Snapshot 生成对指定玩家可见的状态(五子棋无隐藏信息,返回全量)。 +func (g *Game) Snapshot(uid int64, s game.State) any { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil + } + return map[string]any{ + "board": st.Board, // 棋盘(225) + "players": st.Players, // 座位:0=黑,1=白 + "turn": st.Turn, // 当前轮次 + "winner": st.Winner, // 获胜者 + "moves": st.Moves, // 步数 + "size": boardSize, // 棋盘边长,便于客户端渲染 + } +} + +// dirs 是四个判定方向:横、竖、右下、右上。 +var dirs = [4][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}} + +// checkWin 以落点为中心,统计四个方向的同色连续长度是否达到 winCount。 +func checkWin(board []int8, row, col int, mark int8) bool { + for _, d := range dirs { // 遍历四个方向 + count := 1 // 含落点自身 + for _, sign := range [2]int{1, -1} { // 向两侧延伸 + r, c := row+d[0]*sign, col+d[1]*sign + for r >= 0 && r < boardSize && c >= 0 && c < boardSize && board[r*boardSize+c] == mark { + count++ // 连续同色 + r += d[0] * sign // 继续延伸 + c += d[1] * sign + } + } + if count >= winCount { // 达到五连 + return true + } + } + return false // 未连成 +} diff --git a/internal/game/snake/snake.go b/internal/game/snake/snake.go new file mode 100644 index 0000000..b6e4d18 --- /dev/null +++ b/internal/game/snake/snake.go @@ -0,0 +1,389 @@ +// Package snake 是双人贪吃蛇玩法:两人同场竞技的实时玩法。 +// 与回合制玩法(gobang / tictactoe)不同,本玩法由房间的 tick 循环持续推进: +// 玩家只发「转向」指令,位置推进、吃食物、碰撞判定都在 OnTick 中完成。 +package snake + +import ( + "encoding/json" // 标准库:解析 turn 动作参数 + "errors" // 标准库:返回玩法内校验错误 + "math/rand" // 标准库:随机生成食物位置 + + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game" +) + +const ( + gameID = "snake" // 玩法 ID:匹配队列与房间都以它索引 + boardSize = 21 // 棋盘边长(21×21) + tickMs = 200 // 推进周期(毫秒):每秒 5 步,通过 Meta.TickMs 交给房间 + initLen = 3 // 初始蛇长 + targetScore = 20 // 先吃满该分数直接获胜;0 表示只比谁活得久 +) + +// 方向:上右下左(顺时针);反向为 (dir+2)%4。 +const ( + dirUp = iota + dirRight + dirDown + dirLeft +) + +// dirOffsets 是四个方向对应的「行、列」增量。 +var dirOffsets = [4][2]int{{-1, 0}, {0, 1}, {1, 0}, {0, -1}} + +// turnPayload 是 turn 动作的参数。 +type turnPayload struct { + Dir string `json:"dir"` // up / down / left / right +} + +// snakeState 是单条蛇的状态。 +type snakeState struct { + UserID int64 `json:"user_id"` // 操控该蛇的用户 + Body []int `json:"body"` // 身体坐标(一维下标),头在 [0] + Dir int `json:"dir"` // 当前方向 + Alive bool `json:"alive"` // 是否存活 + Score int `json:"score"` // 吃到食物数 +} + +// state 是本玩法私有状态,房间只透传不解析。 +type state struct { + Size int `json:"size"` // 棋盘边长 + Snakes []*snakeState `json:"snakes"` // 两条蛇 + Foods []int `json:"foods"` // 食物坐标(一维下标) + Over bool `json:"over"` // 是否已结束 +} + +// Game 实现 game.Game 接口。 +type Game struct{} + +// New 创建玩法实例:每局一个,状态互不影响。 +func New() *Game { return &Game{} } + +// Meta 声明本玩法为 2 人实时玩法,并指定 200ms 的推进周期。 +func (g *Game) Meta() game.Meta { + return game.Meta{ + GameID: gameID, MinSeats: 2, MaxSeats: 2, + Realtime: true, AllowJoin: false, TickMs: tickMs, + } +} + +// Init 初始化棋盘:两条蛇分别从左右两侧中间出发,方向相对。 +func (g *Game) Init(seatCount int) (game.State, error) { + if seatCount != 2 { // 固定两人 + return nil, errors.New("snake requires 2 seats") + } + mid := boardSize / 2 + st := &state{ + Size: boardSize, + Snakes: []*snakeState{ + {Body: bodyFrom(mid, 3, dirRight), Dir: dirRight, Alive: true}, // 左侧出发,向右 + {Body: bodyFrom(mid, boardSize-4, dirLeft), Dir: dirLeft, Alive: true}, // 右侧出发,向左 + }, + } + st.Foods = []int{st.spawnFood()} // 初始一个食物 + return st, nil +} + +// bodyFrom 按「头位置 + 方向」生成初始长度为 initLen 的身体(头在 [0])。 +func bodyFrom(row, col, dir int) []int { + body := make([]int, 0, initLen) + for i := 0; i < initLen; i++ { + r := row - dirOffsets[dir][0]*i // 从头部向反方向延伸 + c := col - dirOffsets[dir][1]*i + body = append(body, r*boardSize+c) + } + return body +} + +// idx 把行列换算成一维下标。 +func (s *state) idx(row, col int) int { return row*s.Size + col } + +// rowOf / colOf 把一维下标还原为行列。 +func (s *state) rowOf(i int) int { return i / s.Size } +func (s *state) colOf(i int) int { return i % s.Size } + +// spawnFood 在空格中随机生成食物;无空格返回 -1。 +func (s *state) spawnFood() int { + occupied := make(map[int]bool, s.Size*s.Size) + for _, sn := range s.Snakes { // 蛇身占位 + for _, b := range sn.Body { + occupied[b] = true + } + } + for _, f := range s.Foods { // 已有食物占位 + occupied[f] = true + } + free := make([]int, 0, s.Size*s.Size) + for i := 0; i < s.Size*s.Size; i++ { // 收集空格 + if !occupied[i] { + free = append(free, i) + } + } + if len(free) == 0 { // 棋盘已满 + return -1 + } + return free[rand.Intn(len(free))] // 随机取一个 +} + +// OnJoin 玩家入座:按顺序绑定到某条蛇。 +func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) { + st, ok := s.(*state) // 断言为本玩法状态 + if !ok { + return nil, errors.New("invalid state type") + } + for i, sn := range st.Snakes { // 找未绑定的蛇 + if sn.UserID == 0 { + sn.UserID = uid + return []game.Event{{Name: "seat", Payload: map[string]any{ + "user_id": uid, "seat": i, "dir": dirName(sn.Dir), + }}}, nil + } + } + return nil, errors.New("room is full") +} + +// OnLeave 玩家离座:直接判对手胜。 +func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + for _, sn := range st.Snakes { // 找到离座者 + if sn.UserID == uid { + sn.Alive = false + st.Over = true + var winner int64 // 另一条蛇的主人获胜 + for _, other := range st.Snakes { + if other.UserID != uid { + winner = other.UserID + } + } + return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}}, + &game.Finish{Winner: winner, Scores: scores(st), Reason: "opponent_leave"}, nil + } + } + return nil, nil, nil // 非本局成员:无事件 +} + +// OnAction 处理转向指令:只允许改变方向,位置推进交给 OnTick。 +func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + if st.Over { // 已结束 + return nil, nil, errors.New("game over") + } + if a.Name != "turn" { // 未知动作 + return nil, nil, errors.New("unsupported action: " + a.Name) + } + + var p turnPayload // 解析动作参数 + if err := json.Unmarshal(a.Payload, &p); err != nil { + return nil, nil, errors.New("invalid turn payload") + } + dir, ok := parseDir(p.Dir) // 方向名转枚举 + if !ok { + return nil, nil, errors.New("invalid direction") + } + + for _, sn := range st.Snakes { // 找到该玩家操控的蛇 + if sn.UserID != uid { + continue + } + if !sn.Alive { // 已死亡 + return nil, nil, errors.New("snake is dead") + } + // 禁止 180 度掉头(长度为 1 时允许) + if len(sn.Body) > 1 && (sn.Dir+2)%4 == dir { + return nil, nil, errors.New("cannot reverse") + } + sn.Dir = dir // 更新方向 + return []game.Event{{Name: "turn", Payload: map[string]any{"user_id": uid, "dir": p.Dir}}}, nil, nil + } + return nil, nil, errors.New("not in game") +} + +// OnTick 推进一步:计算新头位置 → 判定碰撞 → 移动与吃食物 → 判定胜负。 +func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok || st.Over { // 类型错误或已结束 + return nil, nil, nil + } + for _, sn := range st.Snakes { // 人数未齐(有人未入座)不推进 + if sn.UserID == 0 { + return nil, nil, nil + } + } + + // 1. 计算每条蛇的新头位置(-1 表示撞墙) + newHead := make([]int, len(st.Snakes)) + for i, sn := range st.Snakes { + if !sn.Alive { // 已死亡不参与 + newHead[i] = -1 + continue + } + r, c := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0]) + nr, nc := r+dirOffsets[sn.Dir][0], c+dirOffsets[sn.Dir][1] + if nr < 0 || nr >= st.Size || nc < 0 || nc >= st.Size { // 越界即撞墙 + newHead[i] = -1 + continue + } + newHead[i] = st.idx(nr, nc) + } + + // 2. 标记本回合是否吃到食物(决定是否增长) + grow := make([]bool, len(st.Snakes)) + for i, sn := range st.Snakes { + if !sn.Alive || newHead[i] < 0 { + continue + } + for _, f := range st.Foods { + if f == newHead[i] { + grow[i] = true + } + } + } + + // 3. 死亡判定:撞墙、头对头、撞到任意蛇身 + dead := make([]bool, len(st.Snakes)) + for i, sn := range st.Snakes { + if !sn.Alive { // 之前已死 + dead[i] = true + continue + } + if newHead[i] < 0 { // 撞墙 + dead[i] = true + continue + } + for j := range st.Snakes { // 头对头相撞:双方都死 + if j != i && newHead[j] == newHead[i] { + dead[i] = true + } + } + for oi, other := range st.Snakes { // 撞到蛇身 + for k, b := range other.Body { + // 对方本回合不吃食物时,尾节点会让位,不算撞 + if k == len(other.Body)-1 && !grow[oi] { + continue + } + if b == newHead[i] { + dead[i] = true + } + } + } + } + + // 4. 移动:吃到食物则增长并补充食物,否则去掉尾节点 + for i, sn := range st.Snakes { + if !sn.Alive { // 已死不动 + continue + } + if dead[i] { // 本回合死亡 + sn.Alive = false + continue + } + sn.Body = append([]int{newHead[i]}, sn.Body...) // 新头插到最前 + if grow[i] { // 吃到食物:保留尾巴即增长 + sn.Score++ + for fi, f := range st.Foods { // 移除被吃掉的食物 + if f == newHead[i] { + st.Foods = append(st.Foods[:fi], st.Foods[fi+1:]...) + break + } + } + if nf := st.spawnFood(); nf >= 0 { // 补充新食物 + st.Foods = append(st.Foods, nf) + } + } else { // 未吃到:去掉尾节点 + sn.Body = sn.Body[:len(sn.Body)-1] + } + } + + // 5. 组装事件:每步一次 tick(含全量可渲染数据),死亡额外发 died + events := []game.Event{{Name: "tick", Payload: map[string]any{ + "size": st.Size, "snakes": st.Snakes, "foods": st.Foods, + }}} + for i, sn := range st.Snakes { + if dead[i] { + events = append(events, game.Event{Name: "died", Payload: map[string]any{"user_id": sn.UserID}}) + } + } + + // 6. 胜负判定:先达标者胜;只剩一条存活则该蛇胜;全灭为平局 + for _, sn := range st.Snakes { + if targetScore > 0 && sn.Alive && sn.Score >= targetScore { + st.Over = true + return events, &game.Finish{Winner: sn.UserID, Scores: scores(st), Reason: "normal"}, nil + } + } + var alive []*snakeState + for _, sn := range st.Snakes { + if sn.Alive { + alive = append(alive, sn) + } + } + if len(alive) <= 1 { // 分出结果 + st.Over = true + var winner int64 // 零值表示平局(同时死亡) + if len(alive) == 1 { + winner = alive[0].UserID + } + return events, &game.Finish{Winner: winner, Scores: scores(st), Reason: "normal"}, nil + } + + return events, nil, nil // 对局继续 +} + +// Snapshot 生成对指定玩家可见的状态(贪吃蛇无隐藏信息,返回全量)。 +func (g *Game) Snapshot(uid int64, s game.State) any { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil + } + return map[string]any{ + "size": st.Size, + "snakes": st.Snakes, + "foods": st.Foods, + "over": st.Over, + "tick": tickMs, // 客户端据此设定渲染节奏 + } +} + +// scores 汇总各玩家得分。 +func scores(st *state) map[int64]int { + out := make(map[int64]int, len(st.Snakes)) + for _, sn := range st.Snakes { + out[sn.UserID] = sn.Score + } + return out +} + +// parseDir 把方向名解析为枚举。 +func parseDir(name string) (int, bool) { + switch name { + case "up": + return dirUp, true + case "right": + return dirRight, true + case "down": + return dirDown, true + case "left": + return dirLeft, true + } + return 0, false +} + +// dirName 把方向枚举转回名称(事件与快照里可读)。 +func dirName(dir int) string { + switch dir { + case dirUp: + return "up" + case dirRight: + return "right" + case dirDown: + return "down" + case dirLeft: + return "left" + } + return "right" +} diff --git a/internal/game/tictactoe/tictactoe.go b/internal/game/tictactoe/tictactoe.go new file mode 100644 index 0000000..05f0580 --- /dev/null +++ b/internal/game/tictactoe/tictactoe.go @@ -0,0 +1,155 @@ +// Package tictactoe 是井字棋示例玩法:1v1 回合制。 +// 新玩法照此结构新增子包并实现 game.Game,然后在 boot.InitWS 中注册即可。 +package tictactoe + +import ( + "encoding/json" // 标准库:解析 place 动作参数 + "errors" // 标准库:返回玩法内校验错误 + + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game" +) + +const gameID = "tictactoe" // 玩法 ID:匹配队列与房间都以它索引 + +// placePayload 是 place 动作的参数。 +type placePayload struct { + Index int `json:"index"` // 落子位置 0-8 +} + +// state 是本玩法私有状态,房间只透传不解析。 +type state struct { + Board [9]int8 `json:"board"` // 棋盘:0=空,1=先手,2=后手 + Players []int64 `json:"players"` // 座位顺序:下标 0 为先手 + Turn int `json:"turn"` // 当前应落子的玩家下标 + Winner int64 `json:"winner"` // 已产生的获胜者,0 表示未分出 + Moves int `json:"moves"` // 已落子数,用于判平局 +} + +// Game 实现 game.Game 接口。 +type Game struct{} + +// New 创建玩法实例:每局一个,状态互不影响。 +func New() *Game { return &Game{} } + +// Meta 声明本玩法为 2 人回合制。 +func (g *Game) Meta() game.Meta { + return game.Meta{GameID: gameID, MinSeats: 2, MaxSeats: 2, Realtime: false, AllowJoin: false} +} + +// Init 按座位数初始化私有状态。 +func (g *Game) Init(seatCount int) (game.State, error) { + if seatCount != 2 { // 井字棋固定两人 + return nil, errors.New("tictactoe requires 2 seats") + } + return &state{}, nil // 返回空棋盘 +} + +// OnJoin 玩家入座:按加入顺序确定先后手。 +func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) { + st, ok := s.(*state) // 断言为本玩法状态 + if !ok { + return nil, errors.New("invalid state type") + } + if len(st.Players) >= 2 { // 已满员 + return nil, errors.New("room is full") + } + st.Players = append(st.Players, uid) // 记录座位顺序 + return []game.Event{{Name: "seat", Payload: map[string]any{"user_id": uid, "seat": len(st.Players) - 1}}}, nil +} + +// OnLeave 玩家离座:示例策略为判对手胜,真实玩法可改为托管或判负。 +func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + for i, p := range st.Players { // 找到离座者 + if p == uid { + var winner int64 // 默认 0(无人获胜) + if len(st.Players) == 2 { // 双人对局:判对手胜 + winner = st.Players[1-i] + } + return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}}, + &game.Finish{Winner: winner, Reason: "opponent_leave"}, nil + } + } + return nil, nil, nil // 非本局成员:无事件 +} + +// OnAction 处理一次落子:校验轮次与位置,判定胜负或平局。 +func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *game.Finish, error) { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil, nil, errors.New("invalid state type") + } + if len(st.Players) != 2 { // 人数不足 + return nil, nil, errors.New("game not started") + } + if uid != st.Players[st.Turn] { // 不是该玩家的回合 + return nil, nil, errors.New("not your turn") + } + if a.Name != "place" { // 未知动作 + return nil, nil, errors.New("unsupported action: " + a.Name) + } + + var p placePayload // 解析动作参数 + if err := json.Unmarshal(a.Payload, &p); err != nil { + return nil, nil, errors.New("invalid place payload") + } + if p.Index < 0 || p.Index > 8 { // 越界 + return nil, nil, errors.New("index out of range") + } + if st.Board[p.Index] != 0 { // 该格已有子 + return nil, nil, errors.New("cell occupied") + } + + mark := int8(st.Turn + 1) // 先手=1,后手=2 + st.Board[p.Index] = mark // 落子 + st.Moves++ // 步数加一 + + events := []game.Event{{Name: "placed", Payload: map[string]any{"user_id": uid, "index": p.Index, "mark": mark}}} + + if w := checkWin(st.Board); w != 0 { // 出现连线 + st.Winner = st.Players[w-1] // 换算为用户 ID + return events, &game.Finish{Winner: st.Winner, Reason: "normal"}, nil + } + if st.Moves == 9 { // 棋盘下满:平局 + return events, &game.Finish{Winner: 0, Reason: "normal"}, nil + } + + st.Turn = 1 - st.Turn // 轮转到对手 + return events, nil, nil +} + +// OnTick 每秒驱动:回合制无需驱动,直接返回空。 +func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) { + return nil, nil, nil +} + +// Snapshot 生成对指定玩家可见的状态(有隐藏信息的玩法应在此裁剪)。 +func (g *Game) Snapshot(uid int64, s game.State) any { + st, ok := s.(*state) // 断言状态 + if !ok { + return nil + } + return map[string]any{ + "board": st.Board, // 棋盘 + "players": st.Players, // 座位 + "turn": st.Turn, // 当前轮次 + "winner": st.Winner, // 获胜者 + "moves": st.Moves, // 步数 + } +} + +// lines 是全部八条连线(三横、三竖、两斜)。 +var lines = [8][3]int{{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, {0, 4, 8}, {2, 4, 6}} + +// checkWin 返回连成一线的标记(1/2),无则返回 0。 +func checkWin(b [9]int8) int8 { + for _, l := range lines { // 遍历八条线 + if b[l[0]] != 0 && b[l[0]] == b[l[1]] && b[l[1]] == b[l[2]] { // 三格相同且非空 + return b[l[0]] // 返回获胜标记 + } + } + return 0 // 未分出胜负 +} diff --git a/internal/hall/handlers.go b/internal/hall/handlers.go new file mode 100644 index 0000000..64236c3 --- /dev/null +++ b/internal/hall/handlers.go @@ -0,0 +1,34 @@ +package hall // 包名声明:大厅消息处理,与 matcher.go 同包 + +import ( + "context" // 标准库:handler 入参上下文 + + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway" +) + +// RegisterHandlers 向 Hub 注册大厅相关消息的处理函数。 +func RegisterHandlers(hub *wsgateway.Hub, mt *Matcher, mgr *room.Manager) { + hub.RegisterHandler(wsgateway.TypeHallMatch, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) { + var req dto.WSHallMatchRequest // 解析上行体 + if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误 + c.SendError(env.Seq, 0, "参数错误: "+err.Error()) + return + } + if err := mt.Enqueue(c.UserID(), req.GameID); err != nil { // 入队 + c.SendError(env.Seq, 0, err.Error()) // 回错误(未注册 / 已在房间 / 已在排队) + return + } + _ = c.SendEnvelope(wsgateway.TypeEvent, env.Seq, map[string]any{"event": "queued"}) // 回执:已进入队列 + }) + + hub.RegisterHandler(wsgateway.TypeHallCancel, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) { + var req dto.WSHallCancelRequest // 解析上行体 + if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误或空体:按取消全部处理 + req = dto.WSHallCancelRequest{} + } + mt.Cancel(c.UserID(), req.GameID) // 取消排队 + _ = c.SendEnvelope(wsgateway.TypeEvent, env.Seq, map[string]any{"event": "canceled"}) // 回执 + }) +} diff --git a/internal/hall/matcher.go b/internal/hall/matcher.go new file mode 100644 index 0000000..12e8244 --- /dev/null +++ b/internal/hall/matcher.go @@ -0,0 +1,218 @@ +// Package hall 提供大厅能力:按游戏分队列匹配,凑满人数后建房间并通知玩家。 +// 本包同样玩法无关:人数来自玩法 Meta,成局后交给 room.Manager。 +package hall + +import ( + "context" // 标准库:Run 的生命周期控制 + "errors" // 标准库:重复排队、已在房间等错误 + "log/slog" // 标准库:记录入队、成局、超时 + "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/room" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway" +) + +// item 是队列中的一个排队者。 +type item struct { + uid int64 // 用户 ID + gameID string // 游戏 ID(超时通知时要用) + enqueueAt time.Time // 入队时刻,用于排队超时 +} + +// queue 是某个游戏的一条匹配队列。 +// TODO(扩展):若将来要区分排位/娱乐等模式,可把队列键改为「gameID|mode」并在上行体中携带 mode。 +type queue struct { + key string // 队列键:当前即 gameID + gameID string // 游戏 ID + seats int // 凑满几人开局,取自玩法 Meta.MinSeats + items []*item // 排队者,先进先出 +} + +// Matcher 管理全部队列,按 tick 扫描凑人成局。 +type Matcher struct { + mu sync.Mutex // 保护下面两张表 + queues map[string]*queue // key -> 队列 + waiting map[int64]string // uid -> key,便于快速判断是否已在排队 + manager *room.Manager // 成局后创建房间 + hub *wsgateway.Hub // 下发匹配结果 +} + +// NewMatcher 创建匹配器。 +func NewMatcher(hub *wsgateway.Hub, manager *room.Manager) *Matcher { + return &Matcher{ + queues: make(map[string]*queue), // 初始化队列表 + waiting: make(map[int64]string), // 初始化排队索引 + manager: manager, // 房间管理器 + hub: hub, // 下发通道 + } +} + +// Enqueue 把用户放入对应游戏的队列。 +func (m *Matcher) Enqueue(uid int64, gameID string) error { + meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数 + if err != nil { // 未注册的游戏 + return err + } + if _, ok := m.manager.RoomOf(uid); ok { // 已在房间内:不允许同时排队 + return errors.New("已在房间内,请先离开") + } + + key := gameID // 队列键:按游戏分队列 + 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} + m.queues[key] = q + } + q.items = append(q.items, &item{uid: uid, gameID: gameID, enqueueAt: time.Now()}) // 入队 + m.waiting[uid] = key // 记录索引 + + slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "size", len(q.items)) + return nil +} + +// Cancel 取消排队:gameID 为空表示取消该用户的全部排队。 +func (m *Matcher) Cancel(uid int64, gameID string) { + m.mu.Lock() // 加锁 + var cancelled []*item // 收集被取消者,解锁后再通知 + for key, q := range m.queues { // 遍历所有队列 + if gameID != "" && q.gameID != gameID { // 只处理指定游戏 + continue + } + for i, it := range q.items { // 查找该用户 + if it.uid == uid { + cancelled = append(cancelled, it) // 记录 + q.items = append(q.items[:i], q.items[i+1:]...) // 移除 + delete(m.waiting, uid) // 清除索引 + break + } + } + if len(q.items) == 0 { // 队列空了 + delete(m.queues, key) // 删除队列 + } + } + m.mu.Unlock() // 解锁 + + for _, it := range cancelled { // 锁外通知,避免回调内再操作锁 + m.notifyCancelled(it.uid, it.gameID, "user") + } +} + +// Run 启动匹配扫描循环,ctx 取消即退出。 +func (m *Matcher) Run(ctx context.Context) { + ticker := time.NewTicker(queueTick()) // 按配置周期扫描 + defer ticker.Stop() // 退出时停止 + for { // 持续扫描 + select { + case <-ctx.Done(): // 上下文取消(优雅退出) + return + case <-ticker.C: // 到点扫描 + m.scan(time.Now()) + } + } +} + +// scan 扫描全部队列:先清超时,再凑人成局。 +func (m *Matcher) scan(now time.Time) { + m.mu.Lock() // 加锁 + + var timeout []*item // 超时者 + for key, q := range m.queues { // 遍历队列 + kept := q.items[:0] // 原地过滤,复用底层数组 + for _, it := range q.items { // 逐个判断 + if now.Sub(it.enqueueAt) > matchTimeout() { // 排队超时 + delete(m.waiting, it.uid) // 清除索引 + timeout = append(timeout, it) + continue + } + kept = append(kept, it) // 保留 + } + q.items = kept // 写回 + if len(q.items) == 0 { // 队列已空 + delete(m.queues, key) // 删除 + } + } + + var groups [][]*item // 本次成局的分组 + for _, q := range m.queues { // 遍历队列凑人 + for len(q.items) >= q.seats { // 人数足够 + group := append([]*item(nil), q.items[:q.seats]...) // 取前 seats 个 + q.items = q.items[q.seats:] // 剩余继续排队 + for _, it := range group { // 清除索引 + delete(m.waiting, it.uid) + } + groups = append(groups, group) // 记录分组 + } + } + m.mu.Unlock() // 解锁 + + for _, it := range timeout { // 通知排队超时(锁外) + m.notifyCancelled(it.uid, it.gameID, "timeout") + } + for _, g := range groups { // 逐个分组成局(锁外,建房间较慢) + m.createRoom(g) + } +} + +// createRoom 为一组成局玩家创建房间并通知。 +func (m *Matcher) createRoom(g []*item) { + if len(g) == 0 { // 空分组 + return + } + uids := make([]int64, 0, len(g)) // 收集用户 ID + for _, it := range g { + uids = append(uids, it.uid) + } + + 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) + return + } + + data, err := wsgateway.Encode(wsgateway.TypeHallMatched, 0, dto.WSHallMatchedPayload{ // 编码匹配成功通知 + RoomID: r.ID(), GameID: r.GameID(), Seats: len(uids), Users: uids, + }) + 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) +} + +// notifyCancelled 下发取消通知。 +func (m *Matcher) notifyCancelled(uid int64, gameID, reason string) { + data, err := wsgateway.Encode(wsgateway.TypeHallCancelled, 0, + dto.WSHallCancelledPayload{GameID: gameID, Reason: reason}) + if err != nil { // 编码失败 + slog.Error("match encode hall.cancelled failed", "error", err) + return + } + m.hub.SendTo(uid, data) // 单独通知 +} + +// queueTick 返回队列扫描间隔。 +func queueTick() time.Duration { + if global.WSSetting != nil && global.WSSetting.QueueTickMs > 0 { // 配置了 + return time.Duration(global.WSSetting.QueueTickMs) * time.Millisecond + } + return time.Second // 兜底 1 秒 +} + +// matchTimeout 返回排队超时时长。 +func matchTimeout() time.Duration { + if global.WSSetting != nil && global.WSSetting.MatchTimeoutSec > 0 { // 配置了 + return time.Duration(global.WSSetting.MatchTimeoutSec) * time.Second + } + return 60 * time.Second // 兜底 60 秒 +} diff --git a/internal/room/handlers.go b/internal/room/handlers.go new file mode 100644 index 0000000..b5ec9c8 --- /dev/null +++ b/internal/room/handlers.go @@ -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()) // 重新绑定 + }) +} diff --git a/internal/room/manager.go b/internal/room/manager.go new file mode 100644 index 0000000..f108a69 --- /dev/null +++ b/internal/room/manager.go @@ -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 // 兜底 +} diff --git a/internal/room/room.go b/internal/room/room.go new file mode 100644 index 0000000..e8ee226 --- /dev/null +++ b/internal/room/room.go @@ -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 diff --git a/internal/routers/api/v1/dev.go b/internal/routers/api/v1/dev.go new file mode 100644 index 0000000..8b16310 --- /dev/null +++ b/internal/routers/api/v1/dev.go @@ -0,0 +1,52 @@ +package v1 + +import ( + "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/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt" + "github.com/gin-gonic/gin" + "time" // 标准库:计算令牌过期时间 +) + +// DevHandler 开发调试接口:便于跳过微信登录做联调。 +// 路由仅在 RunMode=debug 时注册(见 routers.NewRouter),生产模式不存在该入口。 +type DevHandler struct{} + +// NewDevHandler 创建调试 handler。 +func NewDevHandler() *DevHandler { + return &DevHandler{} +} + +// Token 为指定用户直接签发一对令牌。 +func (h *DevHandler) Token(c *gin.Context) { + response := app.NewResponse(c) + + // 二次校验:即便路由被误注册到非 debug 环境,也要挡住。 + if global.ServerSetting == nil || global.ServerSetting.RunMode != gin.DebugMode { + response.ToErrorResponse(errcode.Forbidden) + return + } + + var request dto.DevTokenRequest + if err := c.ShouldBindQuery(&request); err != nil { // 参数解析失败 + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + if request.UserID <= 0 { // 未指定:回落为 1 + request.UserID = 1 + } + + accessToken, refreshToken, err := lzcwyjwt.GenerateTokens(request.UserID) + if err != nil { // 签发失败 + response.ToErrorResponse(errcode.UnauthorizedTokenGenerate) + return + } + + response.ToResponse(dto.DevTokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpireTime: time.Now().Unix() + int64(global.JWTSetting.AccessTokenExpire), + }) +} diff --git a/internal/routers/router.go b/internal/routers/router.go index 546429a..9d23aad 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -3,6 +3,7 @@ package routers import ( "net/http" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware" v1 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers/api/v1" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway" @@ -49,5 +50,17 @@ func NewRouter() *gin.Engine { auth.POST("/user/avatar/upload", userHandler.AvatarUpload) } + // ===== 开发调试路由(仅 debug 模式注册)===== + // 1) /api/v1/dev/token:跳过微信登录直接换取令牌,便于联调 + // 2) /test:WebSocket 联调页面(单页面双开两个玩家) + if global.ServerSetting != nil && global.ServerSetting.RunMode == gin.DebugMode { + devHandler := v1.NewDevHandler() + apiV1.GET("/dev/token", devHandler.Token) + + r.GET("/test", func(c *gin.Context) { + c.File("web/ws-test.html") // 相对工作目录(项目根)读取静态页面 + }) + } + return r } diff --git a/internal/wsgateway/envelope.go b/internal/wsgateway/envelope.go index 1571a99..807e4d4 100644 --- a/internal/wsgateway/envelope.go +++ b/internal/wsgateway/envelope.go @@ -13,18 +13,27 @@ import ( const ( TypePing = "ping" // 应用层心跳:客户端周期性发送,服务端原样回 pong - // TODO(第二步 大厅):在此补充 TypeHallMatch = "hall.match"、TypeHallCancel = "hall.cancel" - // TODO(第二步 房间):在此补充 TypeRoomJoin = "room.join"、TypeRoomReady = "room.ready"、 - // TypeRoomAction = "room.action"、TypeRoomLeave = "room.leave"、 - // TypeRoomReconnect = "room.reconnect" + TypeHallMatch = "hall.match" + TypeHallCancel = "hall.cancel" + TypeRoomJoin = "room.join" + TypeRoomReady = "room.ready" + TypeRoomAction = "room.action" + TypeRoomLeave = "room.leave" + TypeRoomReconnect = "room.reconnect" ) // ===== 下行消息类型:服务端 → 客户端 ===== const ( - TypeHello = "hello" // 建连成功后下发的首条消息,携带 userID 与心跳建议间隔 - TypePong = "pong" // 对上行 ping 的应答 - TypeEvent = "event" // 业务事件统一信封:房间状态、对局事件等都走这个类型,payload 由各模块自定义 - TypeError = "error" // 错误下行:参数错误、未支持类型、业务错误统一走这里 + TypeHello = "hello" // 建连成功后下发的首条消息,携带 userID 与心跳建议间隔 + TypePong = "pong" // 对上行 ping 的应答 + TypeEvent = "event" // 业务事件统一信封:房间状态、对局事件等都走这个类型,payload 由各模块自定义 + TypeError = "error" // 错误下行:参数错误、未支持类型、业务错误统一走这里 + TypeHallMatched = "hall.matched" // 匹配成功,携带房间 ID + TypeHallCancelled = "hall.cancelled" // 匹配已取消 + TypeRoomState = "room.state" // 房间全量快照 + TypeRoomEvent = "room.event" // 玩法增量事件 + TypeRoomFinished = "room.finished" // 对局结束与结算 + TypeRoomClosed = "room.closed" // 房间已关闭 ) // Envelope 是上下行消息的统一信封。 diff --git a/internal/wsgateway/hub.go b/internal/wsgateway/hub.go index 7dbbbdc..da9ce70 100644 --- a/internal/wsgateway/hub.go +++ b/internal/wsgateway/hub.go @@ -3,7 +3,7 @@ package wsgateway // 包名声明:与 envelope.go、connection.go、server.go import ( "context" // 标准库:handler 的入参上下文,连接关闭即取消 "log/slog" // 标准库:结构化日志,未注册消息类型时打 debug 日志 - "sync" // 标准库:RWMutex,保护 Hub 内部的两张表 + "sync" // 标准库:RWMutex,保护 Hub 内部的表与回调列表 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" // 本项目:读取 global.WSSetting 判断是否允许多设备 ) @@ -18,9 +18,10 @@ var DefaultHub = NewHub() // 包初始化时即创建,server.go 与路由共 // Hub 是连接注册表与消息路由器,本身不理解任何玩法。 type Hub struct { - mu sync.RWMutex // 读写锁:保护下面两个 map,读多写少所以用 RWMutex - conns map[int64]*Connection // userID -> 连接:一个用户当前对应的那条连接 - handlers map[string]Handler // 消息类型 -> 处理器:决定上行消息由谁处理 + mu sync.RWMutex // 读写锁:保护下面三张表/列表 + conns map[int64]*Connection // userID -> 连接:一个用户当前对应的那条连接 + handlers map[string]Handler // 消息类型 -> 处理器:决定上行消息由谁处理 + disconnectHooks []func(userID int64) // 断连回调:连接移除后通知业务层(房间据此进入重连宽限) } // NewHub 构造一个空的 Hub。 @@ -32,13 +33,20 @@ func NewHub() *Hub { } // RegisterHandler 注册某类消息的处理函数。 -// TODO(第二步):hall 注册 hall.match / hall.cancel;房间注册 room.join / room.action / room.leave 等。 +// 已由 hall 注册 hall.match / hall.cancel;由 room 注册 room.join / room.action / room.leave 等。 func (h *Hub) RegisterHandler(msgType string, fn Handler) { h.mu.Lock() // 加写锁:修改 handlers 表 defer h.mu.Unlock() // 函数返回时解锁 h.handlers[msgType] = fn // 覆盖式注册:同名类型以最后注册者为准 } +// OnDisconnect 注册断连回调:连接从表中移除后触发(业务层据此处理掉线)。 +func (h *Hub) OnDisconnect(fn func(userID int64)) { + h.mu.Lock() // 加写锁:修改回调列表 + defer h.mu.Unlock() // 返回时解锁 + h.disconnectHooks = append(h.disconnectHooks, fn) // 追加回调 +} + // Register 把新建连接登记到 Hub;MultiDevice=false 时先踢掉同账号的旧连接。 func (h *Hub) Register(c *Connection) { h.mu.Lock() // 加写锁:可能修改 conns 表 @@ -46,7 +54,7 @@ func (h *Hub) Register(c *Connection) { if global.WSSetting == nil || !global.WSSetting.MultiDevice { // 未配置或明确禁止多设备:执行踢线 if old, ok := h.conns[c.UserID()]; ok && old != c { // 该账号已在线且不是同一条连接 - slog.InfoContext(c.Context(), "ws kick old connection", "user_id", c.UserID()) // 记录踢线日志,便于排查重复登录 + slog.InfoContext(c.Context(), "ws kick old connection", "user_id", c.UserID()) // 记录踢线日志 old.Close() // 关闭旧连接:旧连接的读写泵会随之退出 delete(h.conns, c.UserID()) // 从表中移除旧连接,给新连接腾位置 } @@ -55,11 +63,17 @@ func (h *Hub) Register(c *Connection) { } // Unregister 从表中注销连接;只有表中仍是自己时才删除,避免误删踢线后新建的连接。 +// 回调在解锁之后执行:避免业务回调内部再次操作 Hub 造成死锁。 func (h *Hub) Unregister(c *Connection) { h.mu.Lock() // 加写锁:修改 conns 表 - defer h.mu.Unlock() // 返回时解锁 + var hooks []func(int64) // 待触发的回调 if cur, ok := h.conns[c.UserID()]; ok && cur == c { // 表中存在且指向同一条连接 - delete(h.conns, c.UserID()) // 安全删除 + delete(h.conns, c.UserID()) // 安全删除 + hooks = append(hooks, h.disconnectHooks...) // 拷贝回调列表 + } + h.mu.Unlock() // 解锁后再执行回调 + for _, fn := range hooks { // 逐个触发断连回调 + fn(c.UserID()) // 通知业务层(如房间标记掉线) } } @@ -103,8 +117,7 @@ func (h *Hub) SendTo(userID int64, data []byte) bool { return c.Send(data) // 入队发送 } -// Broadcast 向指定用户集合下发同一条消息。 -// TODO(第二步):房间内广播就走这里——玩法产出的 Event 由房间翻译成信封后调用本方法。 +// Broadcast 向指定用户集合下发同一条消息(房间内广播与私有事件都走这里)。 func (h *Hub) Broadcast(userIDs []int64, data []byte) { h.mu.RLock() // 加读锁 targets := make([]*Connection, 0, len(userIDs)) // 预分配切片,容量等于目标数 diff --git a/main.go b/main.go index d18e46b..997be9b 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "time" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/boot" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers" "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/cos" @@ -36,11 +37,15 @@ func init() { } setupCosClient() + + boot.InitWS() // 初始化 WebSocket 模块:房间管理器、匹配器与消息路由 } func main() { gin.SetMode(global.ServerSetting.RunMode) + boot.Start(context.Background()) // 启动匹配队列扫描(随进程退出结束) + router := routers.NewRouter() s := &http.Server{ diff --git a/storage/logs/app.log b/storage/logs/app.log new file mode 100644 index 0000000..8b1960b --- /dev/null +++ b/storage/logs/app.log @@ -0,0 +1,114 @@ +{"time":"2026-09-27T21:24:04.209455886+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/boot.InitWS","file":"/root/zt-online-game-backend/internal/boot/boot.go","line":39},"msg":"ws modules initialized"} +{"time":"2026-09-27T21:24:04.20988947+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":68},"msg":"server started","port":"8082"} +{"time":"2026-09-27T21:24:11.430568692+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"02eb7e35-9394-4776-97e3-5dd3f85520c8","method":"GET","path":"/","query":"","status":404,"ip":"127.0.0.1","latency":"38.763µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:24:11.975988866+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"4128e171-2536-4f4f-b0a5-87a882ade47b","method":"GET","path":"/favicon.ico","query":"","status":404,"ip":"127.0.0.1","latency":"5.71µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:24:13.404923539+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"25e39f89-ef12-42f6-b64c-1baee575f0cd","method":"GET","path":"/","query":"","status":404,"ip":"127.0.0.1","latency":"10.109µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:24:23.533101988+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"f72ac4c9-3ca1-4cc8-b592-b60a43927248","method":"GET","path":"/test","query":"","status":404,"ip":"127.0.0.1","latency":"7.364µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:24:49.198836499+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":76},"msg":"shutting down server..."} +{"time":"2026-09-27T21:24:49.199141131+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":88},"msg":"server exited"} +{"time":"2026-09-27T21:24:49.972538627+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/boot.InitWS","file":"/root/zt-online-game-backend/internal/boot/boot.go","line":39},"msg":"ws modules initialized"} +{"time":"2026-09-27T21:24:49.972783377+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":68},"msg":"server started","port":"8082"} +{"time":"2026-09-27T21:24:55.279318133+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"3dd992b0-5c91-4545-8304-a02310fa5b53","method":"GET","path":"/test","query":"","status":200,"ip":"127.0.0.1","latency":"3.569821ms","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:24:58.429856337+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"917189e1-45f2-40a8-9fa7-57de29262213","method":"GET","path":"/api/v1/dev/token","query":"user_id=1","status":200,"ip":"127.0.0.1","latency":"220.032µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:25:01.234305918+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":1} +{"time":"2026-09-27T21:25:04.396396347+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"tictactoe","size":1} +{"time":"2026-09-27T21:25:06.397298057+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"eb402a98-7a77-4378-822d-5d9f75621e7d","method":"GET","path":"/api/v1/dev/token","query":"user_id=2","status":200,"ip":"127.0.0.1","latency":"76.854µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:25:07.784757778+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-27T21:25:09.504838239+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"tictactoe","size":2} +{"time":"2026-09-27T21:25:09.973326341+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"d4d16b99a0bd427ab2e63937ee4dad1b","game_id":"tictactoe","seats":2} +{"time":"2026-09-27T21:25:09.973396604+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"d4d16b99a0bd427ab2e63937ee4dad1b","game_id":"tictactoe","users":[1,2]} +{"time":"2026-09-27T21:25:38.384892853+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":365},"msg":"room game started","room_id":"d4d16b99a0bd427ab2e63937ee4dad1b","game_id":"tictactoe"} +{"time":"2026-09-27T21:25:50.376159598+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":375},"msg":"room game finished","room_id":"d4d16b99a0bd427ab2e63937ee4dad1b","game_id":"tictactoe","winner":0,"reason":"normal"} +{"time":"2026-09-27T21:26:00.377438225+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":445},"msg":"room closed","room_id":"d4d16b99a0bd427ab2e63937ee4dad1b","reason":"finished"} +{"time":"2026-09-27T21:26:05.988908526+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"tictactoe","size":1} +{"time":"2026-09-27T21:26:06.552858984+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"tictactoe","size":2} +{"time":"2026-09-27T21:26:06.973799694+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"3daa399bfce843d4b09359f06717b672","game_id":"tictactoe","seats":2} +{"time":"2026-09-27T21:26:06.973879874+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"3daa399bfce843d4b09359f06717b672","game_id":"tictactoe","users":[1,2]} +{"time":"2026-09-27T21:26:10.172849171+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":365},"msg":"room game started","room_id":"3daa399bfce843d4b09359f06717b672","game_id":"tictactoe"} +{"time":"2026-09-27T21:26:16.01804269+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":375},"msg":"room game finished","room_id":"3daa399bfce843d4b09359f06717b672","game_id":"tictactoe","winner":1,"reason":"normal"} +{"time":"2026-09-27T21:26:26.01887631+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":445},"msg":"room closed","room_id":"3daa399bfce843d4b09359f06717b672","reason":"finished"} +{"time":"2026-09-27T21:26:27.097137746+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"702a9291-001c-4312-a699-91df90361295","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m25.863107756s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T21:26:27.118350805+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"ca9d3270-4363-4ef9-b725-00a82ae6f926","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m19.333795088s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:16:33.30468875+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":76},"msg":"shutting down server..."} +{"time":"2026-09-27T23:16:33.304973293+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":88},"msg":"server exited"} +{"time":"2026-09-27T23:16:35.512980175+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/boot.InitWS","file":"/root/zt-online-game-backend/internal/boot/boot.go","line":39},"msg":"ws modules initialized"} +{"time":"2026-09-27T23:16:35.513331805+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":68},"msg":"server started","port":"8082"} +{"time":"2026-09-27T23:16:37.946182165+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"9eeea735-ecc8-4337-b870-67a7fb274db2","method":"GET","path":"/test","query":"","status":200,"ip":"127.0.0.1","latency":"3.937472ms","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:16:38.312084078+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"92c9a534-20cc-48a2-8559-953561c3feab","method":"GET","path":"/favicon.ico","query":"","status":404,"ip":"127.0.0.1","latency":"10.31µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:16:42.080134449+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"e898107b-5f90-4fac-9e15-0a98cfa68b03","method":"GET","path":"/api/v1/dev/token","query":"user_id=1","status":200,"ip":"127.0.0.1","latency":"296.326µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:16:42.590772532+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"23c869c7-cc3d-4e59-a1f0-416c1fc31b81","method":"GET","path":"/api/v1/dev/token","query":"user_id=2","status":200,"ip":"127.0.0.1","latency":"77.036µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:16:43.333251176+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":1} +{"time":"2026-09-27T23:16:44.222543609+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-27T23:16:44.734123189+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"gobang","size":1} +{"time":"2026-09-27T23:16:45.344213793+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"gobang","size":2} +{"time":"2026-09-27T23:16:45.513886224+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"d3fadaf68dc54ed0a7679344b9960f5f","game_id":"gobang","seats":2} +{"time":"2026-09-27T23:16:45.513991171+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"d3fadaf68dc54ed0a7679344b9960f5f","game_id":"gobang","users":[1,2]} +{"time":"2026-09-27T23:16:52.886232649+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":365},"msg":"room game started","room_id":"d3fadaf68dc54ed0a7679344b9960f5f","game_id":"gobang"} +{"time":"2026-09-27T23:17:19.766190226+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":375},"msg":"room game finished","room_id":"d3fadaf68dc54ed0a7679344b9960f5f","game_id":"gobang","winner":1,"reason":"normal"} +{"time":"2026-09-27T23:17:29.766991437+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":445},"msg":"room closed","room_id":"d3fadaf68dc54ed0a7679344b9960f5f","reason":"finished"} +{"time":"2026-09-27T23:18:23.333619151+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Connection).WritePump","file":"/root/zt-online-game-backend/internal/wsgateway/connection.go","line":241},"msg":"ws idle timeout, close connection","user_id":1,"idle":"1m3.567494068s"} +{"time":"2026-09-27T23:18:23.423307674+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"85401d25-e8bf-4023-ab80-baa88e418302","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m40.090429268s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:18:24.222758087+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Connection).WritePump","file":"/root/zt-online-game-backend/internal/wsgateway/connection.go","line":241},"msg":"ws idle timeout, close connection","user_id":2,"idle":"1m5.099743003s"} +{"time":"2026-09-27T23:18:24.288154109+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"8a49fbc0-a817-41b9-92ce-73a0d62ca673","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m40.06582352s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-27T23:21:23.750367755+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":76},"msg":"shutting down server..."} +{"time":"2026-09-27T23:21:23.750678749+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":88},"msg":"server exited"} +{"time":"2026-09-28T00:07:00.944012504+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/boot.InitWS","file":"/root/zt-online-game-backend/internal/boot/boot.go","line":41},"msg":"ws modules initialized"} +{"time":"2026-09-28T00:07:00.944216928+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":68},"msg":"server started","port":"8082"} +{"time":"2026-09-28T00:07:05.470866527+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"14676857-d02c-4c2a-b27f-6bc12b2c38bc","method":"GET","path":"/","query":"","status":404,"ip":"127.0.0.1","latency":"6.402µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:07.051856935+08:00","level":"WARN","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":72},"msg":"GIN_REQUEST","request_id":"e75ed862-cdc8-444a-aa55-8e83f7c19100","method":"GET","path":"/favicon.ico","query":"","status":404,"ip":"127.0.0.1","latency":"10.048µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:11.146429156+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"b8488808-005b-45f9-8ff9-8595a5f4c6f4","method":"GET","path":"/test","query":"","status":200,"ip":"127.0.0.1","latency":"4.297647ms","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:14.216164026+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"7be2ac19-e7e5-456b-8308-b21dd31b6b17","method":"GET","path":"/api/v1/dev/token","query":"user_id=1","status":200,"ip":"127.0.0.1","latency":"277.541µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:14.647759531+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"5b8701b2-924a-4c26-8c45-a1b48727838f","method":"GET","path":"/api/v1/dev/token","query":"user_id=2","status":200,"ip":"127.0.0.1","latency":"139.372µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:15.205037771+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":1} +{"time":"2026-09-28T00:07:16.057518353+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-28T00:07:16.581733343+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"snake","size":1} +{"time":"2026-09-28T00:07:17.243089923+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"snake","size":2} +{"time":"2026-09-28T00:07:17.944969486+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"fddb8dc24995468993e3698f8bcb8cb7","game_id":"snake","seats":2} +{"time":"2026-09-28T00:07:17.945057391+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"fddb8dc24995468993e3698f8bcb8cb7","game_id":"snake","users":[1,2]} +{"time":"2026-09-28T00:07:21.782563436+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":373},"msg":"room game started","room_id":"fddb8dc24995468993e3698f8bcb8cb7","game_id":"snake"} +{"time":"2026-09-28T00:07:25.545378331+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":383},"msg":"room game finished","room_id":"fddb8dc24995468993e3698f8bcb8cb7","game_id":"snake","winner":1,"reason":"normal"} +{"time":"2026-09-28T00:07:35.545662799+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":453},"msg":"room closed","room_id":"fddb8dc24995468993e3698f8bcb8cb7","reason":"finished"} +{"time":"2026-09-28T00:07:57.399120047+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"01e49f6e-1499-463a-8f46-fd80fffef007","method":"GET","path":"/api/v1/dev/token","query":"user_id=1","status":200,"ip":"127.0.0.1","latency":"127.219µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:58.022567328+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"37c42c99-7523-487a-b3ff-f952af8131f3","method":"GET","path":"/api/v1/dev/token","query":"user_id=2","status":200,"ip":"127.0.0.1","latency":"91.592µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:58.563350102+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Hub).Register","file":"/root/zt-online-game-backend/internal/wsgateway/hub.go","line":57},"msg":"ws kick old connection","user_id":2} +{"time":"2026-09-28T00:07:58.563634195+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-28T00:07:58.563735826+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"41a62560-b954-4597-bbc2-52818d48fce0","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"42.506442145s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:59.357335088+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Hub).Register","file":"/root/zt-online-game-backend/internal/wsgateway/hub.go","line":57},"msg":"ws kick old connection","user_id":1} +{"time":"2026-09-28T00:07:59.357547327+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":1} +{"time":"2026-09-28T00:07:59.35765044+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"a7c5e499-01f1-40cf-8083-9c45a07f9469","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"44.152956845s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:07:59.605356033+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"snake","size":1} +{"time":"2026-09-28T00:08:00.101252271+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"snake","size":2} +{"time":"2026-09-28T00:08:00.945121956+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"a205974d604d4173a7ea7a37ed27e7e7","game_id":"snake","seats":2} +{"time":"2026-09-28T00:08:00.945196936+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"a205974d604d4173a7ea7a37ed27e7e7","game_id":"snake","users":[1,2]} +{"time":"2026-09-28T00:08:04.47357998+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":373},"msg":"room game started","room_id":"a205974d604d4173a7ea7a37ed27e7e7","game_id":"snake"} +{"time":"2026-09-28T00:08:06.546171783+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":383},"msg":"room game finished","room_id":"a205974d604d4173a7ea7a37ed27e7e7","game_id":"snake","winner":1,"reason":"normal"} +{"time":"2026-09-28T00:08:16.547008231+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":453},"msg":"room closed","room_id":"a205974d604d4173a7ea7a37ed27e7e7","reason":"finished"} +{"time":"2026-09-28T00:08:22.026115177+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"snake","size":1} +{"time":"2026-09-28T00:08:22.631178956+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"snake","size":2} +{"time":"2026-09-28T00:08:22.944883411+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"df946f341ddf4133b470cfad0399aed9","game_id":"snake","seats":2} +{"time":"2026-09-28T00:08:22.944990513+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"df946f341ddf4133b470cfad0399aed9","game_id":"snake","users":[1,2]} +{"time":"2026-09-28T00:08:23.770044815+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":373},"msg":"room game started","room_id":"df946f341ddf4133b470cfad0399aed9","game_id":"snake"} +{"time":"2026-09-28T00:08:34.745210976+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":383},"msg":"room game finished","room_id":"df946f341ddf4133b470cfad0399aed9","game_id":"snake","winner":2,"reason":"normal"} +{"time":"2026-09-28T00:08:44.74643559+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":453},"msg":"room closed","room_id":"df946f341ddf4133b470cfad0399aed9","reason":"finished"} +{"time":"2026-09-28T00:09:00.835656527+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"68b3a66a-3c1c-4b5f-86e0-ed3bf47285ba","method":"GET","path":"/api/v1/dev/token","query":"user_id=1","status":200,"ip":"127.0.0.1","latency":"88.747µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:01.503861553+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"7c37285f-076f-4155-8e75-915d706d7d52","method":"GET","path":"/api/v1/dev/token","query":"user_id=2","status":200,"ip":"127.0.0.1","latency":"85.811µs","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:02.124657147+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Hub).Register","file":"/root/zt-online-game-backend/internal/wsgateway/hub.go","line":57},"msg":"ws kick old connection","user_id":1} +{"time":"2026-09-28T00:09:02.124865097+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":1} +{"time":"2026-09-28T00:09:02.124963362+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"2ea905e2-5f12-4238-8482-a0d03580e2d7","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m2.767891678s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:02.566337553+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Hub).Register","file":"/root/zt-online-game-backend/internal/wsgateway/hub.go","line":57},"msg":"ws kick old connection","user_id":2} +{"time":"2026-09-28T00:09:02.566512141+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-28T00:09:02.566568477+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"0665e807-23a2-4536-85f9-1892fa23c1d1","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1m4.003437146s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:03.039148662+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":1,"game_id":"snake","size":1} +{"time":"2026-09-28T00:09:04.052033462+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.(*Hub).Register","file":"/root/zt-online-game-backend/internal/wsgateway/hub.go","line":57},"msg":"ws kick old connection","user_id":2} +{"time":"2026-09-28T00:09:04.052206747+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway.ServeWS","file":"/root/zt-online-game-backend/internal/wsgateway/server.go","line":53},"msg":"ws connected","user_id":2} +{"time":"2026-09-28T00:09:04.052290865+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"28672a8e-91a4-4905-b562-03e06c51daea","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"1.486177763s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:04.697013898+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).Enqueue","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":79},"msg":"match enqueue","user_id":2,"game_id":"snake","size":2} +{"time":"2026-09-28T00:09:04.945703137+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Manager).Create","file":"/root/zt-online-game-backend/internal/room/manager.go","line":58},"msg":"room created","room_id":"0cacb399add54f699dece93a095a9f1a","game_id":"snake","seats":2} +{"time":"2026-09-28T00:09:04.945771185+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/hall.(*Matcher).createRoom","file":"/root/zt-online-game-backend/internal/hall/matcher.go","line":190},"msg":"match room created","room_id":"0cacb399add54f699dece93a095a9f1a","game_id":"snake","users":[1,2]} +{"time":"2026-09-28T00:09:06.488192817+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).startGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":373},"msg":"room game started","room_id":"0cacb399add54f699dece93a095a9f1a","game_id":"snake"} +{"time":"2026-09-28T00:09:07.745850606+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).finishGame","file":"/root/zt-online-game-backend/internal/room/room.go","line":383},"msg":"room game finished","room_id":"0cacb399add54f699dece93a095a9f1a","game_id":"snake","winner":0,"reason":"normal"} +{"time":"2026-09-28T00:09:17.746487491+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/room.(*Room).close.func1","file":"/root/zt-online-game-backend/internal/room/room.go","line":453},"msg":"room closed","room_id":"0cacb399add54f699dece93a095a9f1a","reason":"finished"} +{"time":"2026-09-28T00:09:20.711118338+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"97abf1c9-2b4a-4b1a-a429-543a571cbdb6","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"18.586677948s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:20.711118318+08:00","level":"INFO","source":{"function":"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers.NewRouter.GinSlogMiddleware.func5","file":"/root/zt-online-game-backend/internal/middleware/logger.go","line":74},"msg":"GIN_REQUEST","request_id":"53acaaaa-0c99-47a1-8616-f443ff26c0a1","method":"GET","path":"/ws","query":"token=%2A%2A%2A","status":101,"ip":"127.0.0.1","latency":"16.659337259s","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"} +{"time":"2026-09-28T00:09:37.866121744+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":76},"msg":"shutting down server..."} +{"time":"2026-09-28T00:09:37.866288598+08:00","level":"INFO","source":{"function":"main.main","file":"/root/zt-online-game-backend/main.go","line":88},"msg":"server exited"} diff --git a/web/ws-test.html b/web/ws-test.html new file mode 100644 index 0000000..366288e --- /dev/null +++ b/web/ws-test.html @@ -0,0 +1,257 @@ + + + + +WebSocket 联调测试台 + + + +

WebSocket 联调测试台

+

+ 通用流程:① 选玩法 → ② A、B 各「取 token」→ ③ 各「连接」→ ④ 各「匹配」→ ⑤ 各「准备」。
+ 五子棋 / 井字棋:在棋盘上点空格落子。贪吃蛇:玩家 A 用方向键 ↑→↓←,玩家 B 用 W / D / S / A(点一下本页获得键盘焦点)。 +

+ +
+

玩法

+ 选择 + + 等待开局 +
+ +
+

玩家 A

+ 用户ID + + + + + + 未连接 +
+ +
+

玩家 B

+ 用户ID + + + + + + 未连接 +
+ +
+

棋盘

+
+
+
+ +
+

消息日志(贪吃蛇的 tick 事件很密集,默认只记非 tick 消息)

+ + +
+ + + +