实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理
This commit is contained in:
@@ -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 取消即退出
|
||||
}
|
||||
@@ -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 秒时间戳)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 // 返回元信息
|
||||
}
|
||||
@@ -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 // 未连成
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 // 未分出胜负
|
||||
}
|
||||
@@ -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"}) // 回执
|
||||
})
|
||||
}
|
||||
@@ -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 秒
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package room // 包名声明:房间消息处理,与房间同包
|
||||
|
||||
import (
|
||||
"context" // 标准库:handler 入参上下文
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
||||
)
|
||||
|
||||
// RegisterHandlers 向 Hub 注册房间相关消息的处理函数。
|
||||
func RegisterHandlers(hub *wsgateway.Hub, m *Manager) {
|
||||
hub.RegisterHandler(wsgateway.TypeRoomJoin, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
|
||||
var req dto.WSRoomJoinRequest // 解析上行体
|
||||
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
|
||||
c.SendError(env.Seq, 0, "参数错误: "+err.Error()) // 回错误
|
||||
return
|
||||
}
|
||||
r, ok := m.Get(req.RoomID) // 查房间
|
||||
if !ok { // 房间不存在
|
||||
c.SendError(env.Seq, 0, "房间不存在")
|
||||
return
|
||||
}
|
||||
if err := r.Join(c.UserID()); err != nil { // 入座
|
||||
c.SendError(env.Seq, 0, err.Error()) // 回错误(如房间已满)
|
||||
return
|
||||
}
|
||||
m.bind(c.UserID(), r.ID()) // 绑定用户与房间
|
||||
})
|
||||
|
||||
hub.RegisterHandler(wsgateway.TypeRoomReady, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
|
||||
var req dto.WSRoomReadyRequest // 解析上行体
|
||||
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
|
||||
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
|
||||
if !ok { // 不在任何房间
|
||||
c.SendError(env.Seq, 0, "不在房间内")
|
||||
return
|
||||
}
|
||||
if err := r.Ready(c.UserID(), req.Ready); err != nil { // 设置准备
|
||||
c.SendError(env.Seq, 0, err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
hub.RegisterHandler(wsgateway.TypeRoomAction, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
|
||||
var req dto.WSRoomActionRequest // 解析上行体
|
||||
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
|
||||
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
|
||||
if !ok { // 不在任何房间
|
||||
c.SendError(env.Seq, 0, "不在房间内")
|
||||
return
|
||||
}
|
||||
action := game.Action{Name: req.Action, Payload: req.Payload} // 封装为玩法动作
|
||||
if err := r.Submit(c.UserID(), action); err != nil { // 提交给玩法
|
||||
c.SendError(env.Seq, 0, err.Error()) // 回错误(如不是你的回合)
|
||||
}
|
||||
})
|
||||
|
||||
hub.RegisterHandler(wsgateway.TypeRoomLeave, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
|
||||
r, ok := m.RoomOf(c.UserID()) // 取用户所在房间
|
||||
if !ok { // 不在任何房间
|
||||
c.SendError(env.Seq, 0, "不在房间内")
|
||||
return
|
||||
}
|
||||
if err := r.Leave(c.UserID()); err != nil { // 离座
|
||||
c.SendError(env.Seq, 0, err.Error())
|
||||
return
|
||||
}
|
||||
m.unbind(c.UserID()) // 解绑
|
||||
})
|
||||
|
||||
hub.RegisterHandler(wsgateway.TypeRoomReconnect, func(ctx context.Context, c *wsgateway.Connection, env *wsgateway.Envelope) {
|
||||
var req dto.WSRoomReconnectRequest // 解析上行体
|
||||
if err := wsgateway.BindPayload(env, &req); err != nil { // 参数错误
|
||||
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
r, ok := m.Get(req.RoomID) // 按 ID 取房间
|
||||
if !ok { // 房间已不存在(可能已关闭)
|
||||
c.SendError(env.Seq, 0, "房间不存在")
|
||||
return
|
||||
}
|
||||
if err := r.Reconnect(c.UserID()); err != nil { // 恢复在线并下发快照
|
||||
c.SendError(env.Seq, 0, err.Error())
|
||||
return
|
||||
}
|
||||
m.bind(c.UserID(), r.ID()) // 重新绑定
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package room // 包名声明:房间管理器与房间同包
|
||||
|
||||
import (
|
||||
"errors" // 标准库:房间数超限、游戏未注册等错误
|
||||
"log/slog" // 标准库:记录创建与回收日志
|
||||
// 标准库:生成房间 ID 时去除 UUID 连字符
|
||||
"sync" // 标准库:保护房间表与用户绑定表
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
||||
)
|
||||
|
||||
// Manager 管理全部房间:创建、查询、用户绑定与回收。
|
||||
type Manager struct {
|
||||
mu sync.RWMutex // 保护下面两张表
|
||||
rooms map[string]*Room // roomID -> 房间
|
||||
userRoom map[int64]string // userID -> roomID(一个用户同时只在一个房间)
|
||||
hub *wsgateway.Hub // 下发通道,透传给房间
|
||||
}
|
||||
|
||||
// NewManager 创建房间管理器。
|
||||
func NewManager(hub *wsgateway.Hub) *Manager {
|
||||
return &Manager{
|
||||
rooms: make(map[string]*Room), // 初始化房间表
|
||||
userRoom: make(map[int64]string), // 初始化绑定表
|
||||
hub: hub, // 保存下发通道
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建一个房间并让指定用户入座(匹配成功后由匹配器调用)。
|
||||
func (m *Manager) Create(gameID string, uids []int64) (*Room, error) {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Count() >= maxRooms() { // 房间数达到上限
|
||||
return nil, errors.New("too many rooms")
|
||||
}
|
||||
|
||||
m.mu.Lock() // 加写锁
|
||||
r, err := newRoom(newRoomID(), gameID, m.hub, m.onRoomClosed) // 建房间并启动事件循环
|
||||
if err != nil { // 创建失败
|
||||
m.mu.Unlock() // 解锁
|
||||
return nil, err
|
||||
}
|
||||
m.rooms[r.ID()] = r // 登记房间
|
||||
m.mu.Unlock() // 解锁
|
||||
|
||||
for _, uid := range uids { // 逐个用户入座
|
||||
if err := r.Join(uid); err != nil { // 入座失败(如人数超出座位)
|
||||
slog.Warn("room join failed on create", "room_id", r.ID(), "user_id", uid, "error", err)
|
||||
continue // 跳过该用户,房间继续可用
|
||||
}
|
||||
m.bind(uid, r.ID()) // 绑定用户与房间
|
||||
}
|
||||
|
||||
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", meta.MinSeats)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Get 按 ID 取房间。
|
||||
func (m *Manager) Get(id string) (*Room, bool) {
|
||||
m.mu.RLock() // 加读锁
|
||||
defer m.mu.RUnlock() // 返回时解锁
|
||||
r, ok := m.rooms[id] // 查表
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// RoomOf 取用户当前所在房间(用于重连与防止重复匹配)。
|
||||
func (m *Manager) RoomOf(uid int64) (*Room, bool) {
|
||||
m.mu.RLock() // 加读锁
|
||||
id, ok := m.userRoom[uid] // 查绑定表
|
||||
m.mu.RUnlock() // 解锁
|
||||
if !ok { // 未绑定
|
||||
return nil, false
|
||||
}
|
||||
return m.Get(id) // 取房间
|
||||
}
|
||||
|
||||
// Count 返回当前房间数。
|
||||
func (m *Manager) Count() int {
|
||||
m.mu.RLock() // 加读锁
|
||||
defer m.mu.RUnlock() // 返回时解锁
|
||||
return len(m.rooms) // 表长度
|
||||
}
|
||||
|
||||
// bind 绑定用户与房间。
|
||||
func (m *Manager) bind(uid int64, roomID string) {
|
||||
m.mu.Lock() // 加写锁
|
||||
defer m.mu.Unlock() // 返回时解锁
|
||||
m.userRoom[uid] = roomID // 记录绑定关系
|
||||
}
|
||||
|
||||
// unbind 解除用户绑定。
|
||||
func (m *Manager) unbind(uid int64) {
|
||||
m.mu.Lock() // 加写锁
|
||||
defer m.mu.Unlock() // 返回时解锁
|
||||
delete(m.userRoom, uid) // 删除绑定
|
||||
}
|
||||
|
||||
// onRoomClosed 是房间关闭回调:从表中移除并解绑全部成员。
|
||||
func (m *Manager) onRoomClosed(roomID string, uids []int64) {
|
||||
m.mu.Lock() // 加写锁
|
||||
delete(m.rooms, roomID) // 移除房间
|
||||
m.mu.Unlock() // 解锁
|
||||
for _, uid := range uids { // 解绑成员
|
||||
m.unbind(uid)
|
||||
}
|
||||
}
|
||||
|
||||
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线(进入重连宽限)。
|
||||
// 由 boot 注册到 Hub 的断连回调上。
|
||||
func (m *Manager) OnUserDisconnected(uid int64) {
|
||||
r, ok := m.RoomOf(uid) // 查用户所在房间
|
||||
if !ok { // 不在任何房间(可能只是普通断开)
|
||||
return
|
||||
}
|
||||
if err := r.MarkOffline(uid); err != nil { // 通知房间标记掉线
|
||||
slog.Debug("mark offline failed", "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maxRooms 返回房间总数上限。
|
||||
func maxRooms() int {
|
||||
if global.WSSetting != nil && global.WSSetting.MaxRooms > 0 { // 配置了
|
||||
return global.WSSetting.MaxRooms
|
||||
}
|
||||
return 1000 // 兜底
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
// Package room 提供玩法无关的房间容器:成员与座位、状态机、事件串行化、下发与超时回收。
|
||||
// 房间只调用 game.Game 接口,不知道任何具体玩法规则。
|
||||
package room
|
||||
|
||||
import (
|
||||
"context" // 标准库:房间事件循环的生命周期上下文
|
||||
"errors" // 标准库:房间不可加入、动作非法等错误
|
||||
"log/slog" // 标准库:记录结算与关闭日志
|
||||
"strings"
|
||||
"sync" // 标准库:保护成员表与关闭幂等
|
||||
"time" // 标准库:空闲超时、掉线宽限、tick 周期
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Status 是房间状态。
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusWaiting Status = "waiting" // 等待玩家到齐/准备
|
||||
StatusReady Status = "ready" // 人数达标且全员准备
|
||||
StatusPlaying Status = "playing" // 对局进行中
|
||||
StatusPaused Status = "paused" // 有人掉线,重连宽限中
|
||||
StatusFinished Status = "finished" // 已分出结果
|
||||
StatusClosed Status = "closed" // 已关闭,不再可用
|
||||
)
|
||||
|
||||
// roomTick 是房间事件循环的 tick 周期:驱动实时玩法与各类超时检查。
|
||||
const roomTick = time.Second
|
||||
|
||||
// member 是房间内一名成员。
|
||||
type member struct {
|
||||
uid int64 // 用户 ID
|
||||
seat int // 座位下标
|
||||
ready bool // 是否已准备
|
||||
online bool // 是否在线
|
||||
offlineAt time.Time // 掉线时刻,用于宽限倒计时
|
||||
}
|
||||
|
||||
// message 是投递到房间事件循环的一次请求。
|
||||
type message struct {
|
||||
kind string // 请求类型:join/leave/ready/action/reconnect/offline/close
|
||||
uid int64 // 发起用户
|
||||
action *game.Action // 玩法动作(仅 action 用)
|
||||
ready bool // 准备状态(仅 ready 用)
|
||||
reason string // 关闭原因(仅 close 用)
|
||||
result chan error // 同步回传结果,便于 handler 直接回错误
|
||||
}
|
||||
|
||||
// Room 是一局游戏的容器。所有状态变更都经由 reqCh 串行处理,玩法实现因此无需加锁。
|
||||
type Room struct {
|
||||
id string // 房间 ID
|
||||
gameID string // 游戏 ID
|
||||
g game.Game // 玩法实例
|
||||
state game.State // 玩法私有状态(透传)
|
||||
seats []int64 // 座位表:下标即座位号,0 表示空位
|
||||
members map[int64]*member // userID -> 成员
|
||||
status Status // 当前状态
|
||||
reqCh chan message // 请求队列
|
||||
closed chan struct{} // 关闭信号
|
||||
closeOnce sync.Once // 关闭幂等
|
||||
hub *wsgateway.Hub // 下发通道
|
||||
onClosed func(string, []int64) // 关闭回调:通知 Manager 解绑
|
||||
lastActive time.Time // 最近活跃时间,用于空闲回收
|
||||
finish *game.Finish // 对局结果
|
||||
tickInterval time.Duration // 事件循环驱动周期:实时玩法按 Meta.TickMs 加速
|
||||
}
|
||||
|
||||
// newRoom 创建房间并启动事件循环。
|
||||
func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
||||
g, err := game.New(gameID) // 按 ID 建玩法实例
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := g.Meta() // 取玩法元信息
|
||||
st, err := g.Init(meta.MinSeats) // 按最少人数初始化状态
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 驱动周期:实时玩法通过 Meta.TickMs 指定(如贪吃蛇 200ms),回合制沿用默认 1 秒。
|
||||
tickInterval := roomTick
|
||||
if meta.TickMs > 0 {
|
||||
tickInterval = time.Duration(meta.TickMs) * time.Millisecond
|
||||
}
|
||||
|
||||
r := &Room{
|
||||
id: id, // 房间 ID
|
||||
gameID: gameID, // 游戏 ID
|
||||
g: g, // 玩法实例
|
||||
state: st, // 玩法状态
|
||||
seats: make([]int64, meta.MinSeats), // 按人数建座位表
|
||||
members: make(map[int64]*member), // 成员表
|
||||
status: StatusWaiting, // 初始为等待
|
||||
reqCh: make(chan message, 64), // 请求缓冲队列
|
||||
closed: make(chan struct{}), // 关闭信号
|
||||
hub: hub, // 下发通道
|
||||
onClosed: onClosed, // 关闭回调
|
||||
lastActive: time.Now(), // 初始活跃时间
|
||||
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
|
||||
}
|
||||
go r.run() // 启动单 goroutine 事件循环
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ID 返回房间 ID。
|
||||
func (r *Room) ID() string { return r.id }
|
||||
|
||||
// GameID 返回游戏 ID。
|
||||
func (r *Room) GameID() string { return r.gameID }
|
||||
|
||||
// Status 返回房间状态。
|
||||
func (r *Room) Status() Status { return r.status }
|
||||
|
||||
// UserIDs 返回房间内全部用户 ID。
|
||||
func (r *Room) UserIDs() []int64 {
|
||||
uids := make([]int64, 0, len(r.members)) // 预分配
|
||||
for uid := range r.members { // 遍历成员表
|
||||
uids = append(uids, uid) // 收集
|
||||
}
|
||||
return uids
|
||||
}
|
||||
|
||||
// Has 判断用户是否在房间内。
|
||||
func (r *Room) Has(uid int64) bool {
|
||||
_, ok := r.members[uid] // 查成员表
|
||||
return ok
|
||||
}
|
||||
|
||||
// Join 入座(同步等待事件循环处理结果)。
|
||||
func (r *Room) Join(uid int64) error {
|
||||
return r.submit(message{kind: "join", uid: uid}) // 提交入座请求
|
||||
}
|
||||
|
||||
// Leave 离座。
|
||||
func (r *Room) Leave(uid int64) error {
|
||||
return r.submit(message{kind: "leave", uid: uid}) // 提交离座请求
|
||||
}
|
||||
|
||||
// Ready 设置准备状态。
|
||||
func (r *Room) Ready(uid int64, ready bool) error {
|
||||
return r.submit(message{kind: "ready", uid: uid, ready: ready}) // 提交准备请求
|
||||
}
|
||||
|
||||
// Submit 提交一次玩法动作。
|
||||
func (r *Room) Submit(uid int64, a game.Action) error {
|
||||
act := a // 拷贝动作,避免调用方后续修改
|
||||
return r.submit(message{kind: "action", uid: uid, action: &act}) // 提交动作请求
|
||||
}
|
||||
|
||||
// Reconnect 标记用户重新在线。
|
||||
func (r *Room) Reconnect(uid int64) error {
|
||||
return r.submit(message{kind: "reconnect", uid: uid}) // 提交重连请求
|
||||
}
|
||||
|
||||
// MarkOffline 标记用户掉线:由 Manager 在收到 Hub 断连回调时调用。
|
||||
func (r *Room) MarkOffline(uid int64) error {
|
||||
return r.submit(message{kind: "offline", uid: uid}) // 提交掉线请求
|
||||
}
|
||||
|
||||
// Close 关闭房间:走事件循环串行化,避免在别的 goroutine 里直接改状态。
|
||||
func (r *Room) Close(reason string) {
|
||||
_ = r.submit(message{kind: "close", reason: reason}) // 提交关闭请求
|
||||
}
|
||||
|
||||
// submit 把请求投递到事件循环并等待结果。
|
||||
func (r *Room) submit(m message) error {
|
||||
m.result = make(chan error, 1) // 建结果通道
|
||||
select {
|
||||
case <-r.closed: // 房间已关闭
|
||||
return errors.New("room closed")
|
||||
case r.reqCh <- m: // 投递成功
|
||||
}
|
||||
select {
|
||||
case err := <-m.result: // 等待处理结果
|
||||
return err
|
||||
case <-time.After(3 * time.Second): // 事件循环繁忙
|
||||
return errors.New("room busy")
|
||||
}
|
||||
}
|
||||
|
||||
// run 是房间事件循环:串行处理请求与 tick,保证玩法调用无并发。
|
||||
func (r *Room) run() {
|
||||
ticker := time.NewTicker(r.tickInterval) // 按玩法规定的周期驱动(回合制 1 秒,实时玩法更快)
|
||||
defer ticker.Stop() // 退出时停止定时器
|
||||
|
||||
for { // 持续处理
|
||||
select {
|
||||
case <-r.closed: // 房间已关闭
|
||||
return
|
||||
case m := <-r.reqCh: // 收到请求
|
||||
r.handle(m)
|
||||
case <-ticker.C: // 每秒驱动
|
||||
r.tick(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle 按类型分发请求,并把结果写回 result 通道。
|
||||
func (r *Room) handle(m message) {
|
||||
var err error // 处理结果
|
||||
switch m.kind { // 按请求类型分发
|
||||
case "join":
|
||||
err = r.handleJoin(m.uid)
|
||||
case "leave":
|
||||
err = r.handleLeave(m.uid)
|
||||
case "ready":
|
||||
err = r.handleReady(m.uid, m.ready)
|
||||
case "action":
|
||||
err = r.handleAction(m.uid, m.action)
|
||||
case "reconnect":
|
||||
err = r.handleReconnect(m.uid)
|
||||
case "offline":
|
||||
err = r.handleOffline(m.uid)
|
||||
case "close":
|
||||
r.close(m.reason) // 关闭在循环内执行,状态修改保持串行
|
||||
}
|
||||
if m.result != nil { // 有结果通道
|
||||
m.result <- err // 回传结果(带缓冲,不会阻塞)
|
||||
}
|
||||
}
|
||||
|
||||
// handleJoin 处理入座:分配座位并通知玩法。
|
||||
func (r *Room) handleJoin(uid int64) error {
|
||||
if r.status == StatusClosed || r.status == StatusFinished { // 房间不可用
|
||||
return errors.New("room not joinable")
|
||||
}
|
||||
if m, ok := r.members[uid]; ok { // 已在房间内
|
||||
m.online = true // 标记为在线
|
||||
m.offlineAt = time.Time{} // 清空掉线时间
|
||||
r.broadcastState() // 刷新快照
|
||||
return nil
|
||||
}
|
||||
|
||||
seat := -1 // 找空位
|
||||
for i, s := range r.seats { // 遍历座位表
|
||||
if s == 0 { // 空位
|
||||
seat = i // 记录下标
|
||||
break
|
||||
}
|
||||
}
|
||||
if seat < 0 { // 无空位
|
||||
return errors.New("room is full")
|
||||
}
|
||||
|
||||
r.seats[seat] = uid // 占座
|
||||
r.members[uid] = &member{uid: uid, seat: seat, online: true} // 登记成员
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
|
||||
events, err := r.g.OnJoin(uid, r.state) // 通知玩法
|
||||
if err != nil { // 玩法拒绝(如人数已满)
|
||||
delete(r.members, uid) // 回滚成员
|
||||
r.seats[seat] = 0 // 回滚座位
|
||||
return err
|
||||
}
|
||||
r.deliver(events) // 投递玩法事件
|
||||
r.broadcastState() // 广播最新快照
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLeave 处理离座:通知玩法,玩法可据此判负。
|
||||
func (r *Room) handleLeave(uid int64) error {
|
||||
m, ok := r.members[uid] // 查成员
|
||||
if !ok { // 不在房间
|
||||
return errors.New("not in room")
|
||||
}
|
||||
delete(r.members, uid) // 移除成员
|
||||
if m.seat < len(r.seats) && r.seats[m.seat] == uid { // 释放座位
|
||||
r.seats[m.seat] = 0
|
||||
}
|
||||
|
||||
events, finish, err := r.g.OnLeave(uid, r.state) // 通知玩法
|
||||
if err == nil { // 玩法处理成功
|
||||
r.deliver(events) // 投递事件
|
||||
if finish != nil && r.status == StatusPlaying { // 玩法判定结束(如判对手胜)
|
||||
r.finishGame(finish) // 结算
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r.broadcastState() // 广播快照
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReady 处理准备:全员准备且人数达标即开局。
|
||||
func (r *Room) handleReady(uid int64, ready bool) error {
|
||||
if r.status != StatusWaiting && r.status != StatusReady { // 仅等待阶段可准备
|
||||
return errors.New("room not in waiting status")
|
||||
}
|
||||
m, ok := r.members[uid] // 查成员
|
||||
if !ok { // 不在房间
|
||||
return errors.New("not in room")
|
||||
}
|
||||
m.ready = ready // 设置准备状态
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
|
||||
if r.allReady() && len(r.members) == len(r.seats) { // 全员准备且坐满
|
||||
r.status = StatusReady // 进入就绪
|
||||
r.startGame() // 立即开局
|
||||
} else {
|
||||
r.status = StatusWaiting // 仍在等待
|
||||
r.broadcastState() // 广播快照
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAction 处理玩法动作:透传给玩法插件。
|
||||
func (r *Room) handleAction(uid int64, a *game.Action) error {
|
||||
if r.status != StatusPlaying { // 未开局
|
||||
return errors.New("game not playing")
|
||||
}
|
||||
if _, ok := r.members[uid]; !ok { // 非本局成员
|
||||
return errors.New("not in room")
|
||||
}
|
||||
if a == nil { // 缺动作
|
||||
return errors.New("empty action")
|
||||
}
|
||||
|
||||
events, finish, err := r.g.OnAction(uid, *a, r.state) // 交给玩法处理
|
||||
if err != nil { // 玩法拒绝(如不是你的回合)
|
||||
return err
|
||||
}
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
r.deliver(events) // 投递增量事件
|
||||
|
||||
if finish != nil { // 对局结束
|
||||
r.finishGame(finish)
|
||||
} else { // 未结束
|
||||
r.broadcastState() // 广播快照
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReconnect 处理重连:恢复在线并回到暂停中的对局。
|
||||
func (r *Room) handleReconnect(uid int64) error {
|
||||
m, ok := r.members[uid] // 查成员
|
||||
if !ok { // 不在房间(可能已被清理)
|
||||
return errors.New("not in room")
|
||||
}
|
||||
m.online = true // 标记在线
|
||||
m.offlineAt = time.Time{} // 清空掉线时间
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
|
||||
if r.status == StatusPaused { // 从暂停恢复
|
||||
r.status = StatusPlaying
|
||||
}
|
||||
r.broadcastState() // 下发全量快照(客户端据此恢复画面)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOffline 处理掉线:标记离线,对局中则暂停等待重连(宽限超时由 tick 判负)。
|
||||
func (r *Room) handleOffline(uid int64) error {
|
||||
m, ok := r.members[uid] // 查成员
|
||||
if !ok { // 不在房间
|
||||
return errors.New("not in room")
|
||||
}
|
||||
m.online = false // 标记为离线
|
||||
m.offlineAt = time.Now() // 记录掉线时刻,宽限倒计时起点
|
||||
|
||||
if r.status == StatusPlaying { // 对局进行中有人掉线
|
||||
r.status = StatusPaused // 进入暂停,等待重连或超时判负
|
||||
}
|
||||
r.broadcastState() // 广播,让其他人看到掉线状态
|
||||
return nil
|
||||
}
|
||||
|
||||
// startGame 开局:切换状态并广播。
|
||||
func (r *Room) startGame() {
|
||||
r.status = StatusPlaying // 进入对局中
|
||||
slog.Info("room game started", "room_id", r.id, "game_id", r.gameID)
|
||||
r.broadcastState() // 广播开局快照
|
||||
}
|
||||
|
||||
// finishGame 结算:记录结果、广播结算、延时关闭房间。
|
||||
func (r *Room) finishGame(f *game.Finish) {
|
||||
r.status = StatusFinished // 进入已结束
|
||||
r.finish = f // 保存结果
|
||||
|
||||
// TODO(后续):结算落库(对局记录表由你自行准备,可在 internal/mysqldao 新增 room_record DAO)。
|
||||
slog.Info("room game finished", "room_id", r.id, "game_id", r.gameID,
|
||||
"winner", f.Winner, "reason", f.Reason)
|
||||
|
||||
r.broadcast(wsgateway.TypeRoomFinished, dto.WSRoomFinishedPayload{ // 广播结算
|
||||
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Scores: f.Scores, Reason: f.Reason,
|
||||
})
|
||||
|
||||
go func() { // 延时关闭,给客户端留出查看结算的时间
|
||||
time.Sleep(10 * time.Second) // 等待 10 秒
|
||||
r.Close("finished") // 经事件循环关闭,避免跨 goroutine 改状态
|
||||
}()
|
||||
}
|
||||
|
||||
// tick 每秒驱动:空闲回收、掉线宽限判负、实时玩法 tick。
|
||||
func (r *Room) tick(now time.Time) {
|
||||
if r.status == StatusClosed { // 已关闭
|
||||
return
|
||||
}
|
||||
|
||||
// 空闲回收:等待/就绪阶段长时间无操作则关闭,避免僵尸房间占用内存。
|
||||
if r.status == StatusWaiting || r.status == StatusReady {
|
||||
if now.Sub(r.lastActive) > idleTimeout() { // 超过空闲上限
|
||||
slog.Info("room idle timeout", "room_id", r.id)
|
||||
r.close("idle") // 关闭
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 掉线宽限:暂停期间有人未在宽限期内重连,判超时结束。
|
||||
if r.status == StatusPaused {
|
||||
for _, m := range r.members { // 检查每个成员
|
||||
if !m.online && now.Sub(m.offlineAt) > reconnectGrace() { // 超时未重连
|
||||
var winner int64 // 默认平局
|
||||
for _, other := range r.members { // 找仍在线的对手
|
||||
if other.online { // 在线者判胜
|
||||
winner = other.uid
|
||||
break
|
||||
}
|
||||
}
|
||||
slog.Info("room reconnect timeout", "room_id", r.id, "user_id", m.uid, "winner", winner)
|
||||
r.finishGame(&game.Finish{Winner: winner, Reason: "timeout"}) // 判超时结束
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实时玩法驱动:每秒把时间交给玩法(回合制玩法可实现为空)。
|
||||
if r.status == StatusPlaying {
|
||||
events, finish, err := r.g.OnTick(now.Unix(), r.state) // 调用玩法 tick
|
||||
if err != nil { // 玩法内部错误
|
||||
slog.Error("room game tick error", "room_id", r.id, "error", err)
|
||||
return
|
||||
}
|
||||
r.deliver(events) // 投递事件
|
||||
if finish != nil { // 玩法判定结束
|
||||
r.finishGame(finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close 关闭房间:广播关闭并回调 Manager 解绑。
|
||||
func (r *Room) close(reason string) {
|
||||
r.closeOnce.Do(func() { // 幂等:只执行一次
|
||||
r.status = StatusClosed // 置为已关闭
|
||||
close(r.closed) // 通知事件循环退出
|
||||
uids := r.UserIDs() // 先取成员列表
|
||||
r.broadcast(wsgateway.TypeRoomClosed, dto.WSRoomClosedPayload{RoomID: r.id, Reason: reason})
|
||||
if r.onClosed != nil { // 有回调
|
||||
r.onClosed(r.id, uids) // 通知 Manager 清理
|
||||
}
|
||||
slog.Info("room closed", "room_id", r.id, "reason", reason)
|
||||
})
|
||||
}
|
||||
|
||||
// allReady 判断是否全员已准备。
|
||||
func (r *Room) allReady() bool {
|
||||
if len(r.members) != len(r.seats) { // 人数未达标
|
||||
return false
|
||||
}
|
||||
for _, m := range r.members { // 遍历成员
|
||||
if !m.ready { // 有人未准备
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true // 全员准备
|
||||
}
|
||||
|
||||
// broadcast 向房间全体下发一条消息。
|
||||
func (r *Room) broadcast(typ string, payload any) {
|
||||
data, err := wsgateway.Encode(typ, 0, payload) // 编码信封
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("room encode failed", "room_id", r.id, "error", err)
|
||||
return
|
||||
}
|
||||
r.hub.Broadcast(r.UserIDs(), data) // 经 Hub 下发
|
||||
}
|
||||
|
||||
// broadcastState 给每个成员单独下发「对其可见」的房间快照。
|
||||
func (r *Room) broadcastState() {
|
||||
for uid := range r.members { // 逐个成员生成快照
|
||||
snap := r.g.Snapshot(uid, r.state) // 玩法按观众裁剪
|
||||
data, err := wsgateway.Encode(wsgateway.TypeRoomState, 0, dto.WSRoomStatePayload{
|
||||
RoomID: r.id, GameID: r.gameID, Status: string(r.status), Seats: r.seats,
|
||||
Ready: r.readyIDs(), State: snap,
|
||||
})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("room encode state failed", "room_id", r.id, "error", err)
|
||||
continue
|
||||
}
|
||||
r.hub.SendTo(uid, data) // 单独下发
|
||||
}
|
||||
}
|
||||
|
||||
// deliver 投递玩法产生的事件:未指定收件人则全体广播。
|
||||
func (r *Room) deliver(events []game.Event) {
|
||||
if len(events) == 0 { // 无事件
|
||||
return
|
||||
}
|
||||
for _, e := range events { // 逐个事件下发
|
||||
data, err := wsgateway.Encode(wsgateway.TypeRoomEvent, 0, dto.WSRoomEventPayload{
|
||||
RoomID: r.id, Event: e.Name, Payload: e.Payload,
|
||||
})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("room encode event failed", "room_id", r.id, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(e.To) == 0 { // 未指定收件人
|
||||
r.hub.Broadcast(r.UserIDs(), data) // 房间内全体
|
||||
} else { // 指定收件人
|
||||
r.hub.Broadcast(e.To, data) // 定向下发(私有信息)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyIDs 返回已准备的用户 ID。
|
||||
func (r *Room) readyIDs() []int64 {
|
||||
ids := make([]int64, 0, len(r.members)) // 预分配
|
||||
for uid, m := range r.members { // 遍历成员
|
||||
if m.ready { // 已准备
|
||||
ids = append(ids, uid) // 收集
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// idleTimeout 返回房间空闲回收时长。
|
||||
func idleTimeout() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.RoomIdleTimeoutSec > 0 { // 配置了
|
||||
return time.Duration(global.WSSetting.RoomIdleTimeoutSec) * time.Second
|
||||
}
|
||||
return 300 * time.Second // 兜底 5 分钟
|
||||
}
|
||||
|
||||
// reconnectGrace 返回掉线重连宽限时长。
|
||||
func reconnectGrace() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.ReconnectGraceSec > 0 { // 配置了
|
||||
return time.Duration(global.WSSetting.ReconnectGraceSec) * time.Second
|
||||
}
|
||||
return 30 * time.Second // 兜底 30 秒
|
||||
}
|
||||
|
||||
// newRoomID 生成房间 ID(32 位十六进制,去连字符)。
|
||||
func newRoomID() string {
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "") // 去连字符后共 32 字符
|
||||
}
|
||||
|
||||
// 保证 context 包被使用(房间事件循环后续可接入统一 ctx 控制)。
|
||||
var _ = context.Background
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)) // 预分配切片,容量等于目标数
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,257 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WebSocket 联调测试台</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 20px; color: #222; }
|
||||
h1 { font-size: 20px; }
|
||||
.panel { border: 1px solid #ddd; border-radius: 6px; padding: 12px; margin-bottom: 12px; }
|
||||
.panel h2 { font-size: 15px; margin: 0 0 8px; }
|
||||
button { margin-right: 6px; padding: 4px 10px; cursor: pointer; }
|
||||
input { width: 70px; }
|
||||
select { padding: 3px; }
|
||||
.status { font-weight: bold; margin-left: 8px; }
|
||||
.grid { display: grid; gap: 1px; margin: 8px 0; width: max-content; background: #eee; padding: 1px; }
|
||||
.cell { background: #fff; text-align: center; cursor: pointer; user-select: none; }
|
||||
.cell.snake0 { background: #4caf50; }
|
||||
.cell.snake1 { background: #2196f3; }
|
||||
.cell.head0 { background: #2e7d32; }
|
||||
.cell.head1 { background: #1565c0; }
|
||||
.cell.food { background: #ff5722; border-radius: 50%; }
|
||||
#log { width: 100%; height: 260px; font-family: Consolas, monospace; font-size: 12px; }
|
||||
.hint { color: #888; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket 联调测试台</h1>
|
||||
<p class="hint">
|
||||
通用流程:① 选玩法 → ② A、B 各「取 token」→ ③ 各「连接」→ ④ 各「匹配」→ ⑤ 各「准备」。<br>
|
||||
五子棋 / 井字棋:在棋盘上点空格落子。贪吃蛇:<b>玩家 A 用方向键 ↑→↓←,玩家 B 用 W / D / S / A</b>(点一下本页获得键盘焦点)。
|
||||
</p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>玩法</h2>
|
||||
选择
|
||||
<select id="game" onchange="resetBoard()">
|
||||
<option value="gobang">五子棋(15×15,连五胜)</option>
|
||||
<option value="snake">双人贪吃蛇(21×21,实时推进)</option>
|
||||
<option value="tictactoe">井字棋(3×3,示例玩法)</option>
|
||||
</select>
|
||||
<span class="status" id="turn">等待开局</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>玩家 A</h2>
|
||||
用户ID <input id="uidA" value="1">
|
||||
<button onclick="getToken('A')">取 token</button>
|
||||
<button onclick="connect('A')">连接</button>
|
||||
<button onclick="match('A')">匹配</button>
|
||||
<button onclick="ready('A')">准备</button>
|
||||
<button onclick="leave('A')">离开</button>
|
||||
<span class="status" id="stA">未连接</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>玩家 B</h2>
|
||||
用户ID <input id="uidB" value="2">
|
||||
<button onclick="getToken('B')">取 token</button>
|
||||
<button onclick="connect('B')">连接</button>
|
||||
<button onclick="match('B')">匹配</button>
|
||||
<button onclick="ready('B')">准备</button>
|
||||
<button onclick="leave('B')">离开</button>
|
||||
<span class="status" id="stB">未连接</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>棋盘</h2>
|
||||
<div class="grid" id="board"></div>
|
||||
<div id="scores" class="hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>消息日志(贪吃蛇的 tick 事件很密集,默认只记非 tick 消息)</h2>
|
||||
<label class="hint"><input type="checkbox" id="logTick"> 记录 tick 事件</label>
|
||||
<textarea id="log" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const S = {
|
||||
A: { uid: 1, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] },
|
||||
B: { uid: 2, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] },
|
||||
};
|
||||
let snakeData = null; // 贪吃蛇最新画面数据
|
||||
|
||||
function gameId() { return document.getElementById('game').value; }
|
||||
function log(msg) {
|
||||
const el = document.getElementById('log');
|
||||
el.value += new Date().toLocaleTimeString() + ' ' + msg + '\n';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function getToken(k) {
|
||||
const uid = document.getElementById('uid' + k).value;
|
||||
const res = await fetch('/api/v1/dev/token?user_id=' + uid);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) { log(k + ' 取 token 失败: ' + JSON.stringify(json)); return; }
|
||||
S[k].uid = parseInt(uid, 10);
|
||||
S[k].token = json.data.access_token;
|
||||
log(k + ' 已获取 token(uid=' + S[k].uid + ')');
|
||||
}
|
||||
|
||||
function connect(k) {
|
||||
if (!S[k].token) { log(k + ' 请先取 token'); return; }
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(scheme + '://' + location.host + '/ws?token=' + S[k].token);
|
||||
S[k].ws = ws;
|
||||
ws.onopen = () => { setStatus(k, '已连接'); log(k + ' ws 已连接'); };
|
||||
ws.onclose = (e) => { setStatus(k, '已断开(' + e.code + ')'); log(k + ' ws 断开: ' + e.code); };
|
||||
ws.onerror = () => log(k + ' ws 出错(见服务端日志)');
|
||||
ws.onmessage = (e) => { handle(k, JSON.parse(e.data)); };
|
||||
}
|
||||
|
||||
function setStatus(k, text) { document.getElementById('st' + k).textContent = text; }
|
||||
|
||||
function send(k, type, payload) {
|
||||
if (!S[k].ws || S[k].ws.readyState !== 1) { log(k + ' 未连接'); return; }
|
||||
S[k].ws.send(JSON.stringify({ type: type, seq: S[k].seq++, payload: payload }));
|
||||
}
|
||||
|
||||
function match(k) { send(k, 'hall.match', { game_id: gameId() }); }
|
||||
function ready(k) { send(k, 'room.ready', { ready: true }); }
|
||||
function leave(k) { send(k, 'room.leave', {}); }
|
||||
|
||||
function handle(k, env) {
|
||||
const isTick = env.type === 'room.event' && env.payload && env.payload.event === 'tick';
|
||||
if (!isTick || document.getElementById('logTick').checked) {
|
||||
log(k + ' ← ' + env.type + ' ' + (env.payload ? JSON.stringify(env.payload) : ''));
|
||||
}
|
||||
|
||||
if (env.type === 'room.state') {
|
||||
const p = env.payload || {};
|
||||
S[k].room = p.room_id || S[k].room;
|
||||
const st = p.state || {};
|
||||
if (gameId() === 'snake' && st.snakes) {
|
||||
snakeData = { size: st.size, snakes: st.snakes, foods: st.foods || [] };
|
||||
renderSnake();
|
||||
return;
|
||||
}
|
||||
if (st.board) {
|
||||
S[k].size = st.size || Math.round(Math.sqrt(st.board.length));
|
||||
S[k].players = st.players || S[k].players;
|
||||
S[k].turn = st.turn || 0;
|
||||
S[k].board = st.board;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
if (env.type === 'room.event' && env.payload && env.payload.event === 'tick' && gameId() === 'snake') {
|
||||
const d = env.payload.payload || {};
|
||||
snakeData = { size: d.size, snakes: d.snakes, foods: d.foods || [] };
|
||||
renderSnake();
|
||||
}
|
||||
|
||||
if (env.type === 'room.finished') {
|
||||
const w = (env.payload || {}).winner;
|
||||
document.getElementById('turn').textContent = w ? '本局结束,获胜者 uid=' + w : '本局结束:平局';
|
||||
}
|
||||
if (env.type === 'room.closed') { log(k + ' 房间已关闭: ' + (env.payload || {}).reason); }
|
||||
}
|
||||
|
||||
function resetBoard() {
|
||||
S.A.board = []; S.B.board = []; snakeData = null;
|
||||
document.getElementById('scores').textContent = '';
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const grid = document.getElementById('board');
|
||||
grid.innerHTML = '';
|
||||
if (gameId() === 'snake') { renderSnake(); return; }
|
||||
|
||||
const ref = S.A.board.length ? S.A : S.B;
|
||||
const board = ref.board || [];
|
||||
if (!board.length) { document.getElementById('turn').textContent = '等待开局'; return; }
|
||||
|
||||
const n = ref.size || Math.round(Math.sqrt(board.length));
|
||||
const cell = n === 3 ? 60 : 26;
|
||||
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
|
||||
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
const el = document.createElement('div');
|
||||
const v = board[i] || 0;
|
||||
el.className = 'cell';
|
||||
el.style.width = cell + 'px';
|
||||
el.style.height = cell + 'px';
|
||||
el.style.lineHeight = cell + 'px';
|
||||
el.textContent = v === 1 ? '●' : (v === 2 ? '○' : '');
|
||||
el.onclick = () => place(i);
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
const turnUid = (ref.players || [])[ref.turn || 0];
|
||||
document.getElementById('turn').textContent = turnUid ? '轮到 uid=' + turnUid : '等待开局';
|
||||
}
|
||||
|
||||
function renderSnake() {
|
||||
const grid = document.getElementById('board');
|
||||
grid.innerHTML = '';
|
||||
if (!snakeData) { document.getElementById('turn').textContent = '等待开局'; return; }
|
||||
|
||||
const n = snakeData.size;
|
||||
const cell = 20;
|
||||
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
|
||||
|
||||
const owner = {}; // 坐标 -> 蛇序号
|
||||
snakeData.snakes.forEach((sn, si) => {
|
||||
sn.body.forEach((b, bi) => { owner[b] = { si: si, head: bi === 0 }; });
|
||||
});
|
||||
const foods = {};
|
||||
(snakeData.foods || []).forEach(f => { foods[f] = true; });
|
||||
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const el = document.createElement('div');
|
||||
el.style.width = cell + 'px';
|
||||
el.style.height = cell + 'px';
|
||||
let cls = 'cell';
|
||||
if (owner[i]) { cls += owner[i].head ? ' head' + owner[i].si : ' snake' + owner[i].si; }
|
||||
else if (foods[i]) { cls += ' food'; }
|
||||
el.className = cls;
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
const parts = snakeData.snakes.map(sn => 'uid=' + sn.user_id + ' 分' + sn.score + (sn.alive ? '' : '(已阵亡)'));
|
||||
document.getElementById('scores').textContent = parts.join(' | ');
|
||||
document.getElementById('turn').textContent = '贪吃蛇进行中:A 用方向键,B 用 WASD';
|
||||
}
|
||||
|
||||
function place(index) {
|
||||
if (gameId() === 'snake') return;
|
||||
const ref = S.A.board.length ? S.A : S.B;
|
||||
const n = ref.size || Math.round(Math.sqrt(ref.board.length));
|
||||
const row = Math.floor(index / n);
|
||||
const col = index % n;
|
||||
const turnUid = (ref.players || [])[ref.turn || 0];
|
||||
let who = null;
|
||||
if (turnUid && S.A.uid === turnUid) who = 'A';
|
||||
else if (turnUid && S.B.uid === turnUid) who = 'B';
|
||||
if (!who) { log('现在不是 A/B 的回合,或尚未开局'); return; }
|
||||
const payload = n === 3 ? { index: index } : { row: row, col: col };
|
||||
send(who, 'room.action', { action: 'place', payload: payload });
|
||||
}
|
||||
|
||||
function turn(k, dir) {
|
||||
if (gameId() !== 'snake') return;
|
||||
send(k, 'room.action', { action: 'turn', payload: { dir: dir } });
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const arrow = { ArrowUp: 'up', ArrowRight: 'right', ArrowDown: 'down', ArrowLeft: 'left' };
|
||||
const wasd = { w: 'up', d: 'right', s: 'down', a: 'left' };
|
||||
if (arrow[e.key]) { turn('A', arrow[e.key]); e.preventDefault(); return; }
|
||||
const k = (e.key || '').toLowerCase();
|
||||
if (wasd[k]) { turn('B', wasd[k]); e.preventDefault(); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user