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

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