支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册
This commit is contained in:
@@ -73,3 +73,5 @@ WS:
|
||||
MatchTimeoutSec: 60 # 匹配排队超过该时长(秒)自动取消
|
||||
QueueTickMs: 1000 # 匹配队列的扫描间隔(毫秒)
|
||||
MaxRooms: 1000 # 同时存在的房间数上限,防止内存失控
|
||||
AIEnabled: true # 匹配超时后用 AI 补齐空位(对外不可感知)
|
||||
AIWaitSec: 30 # 排队超过该秒数仍未凑满则补 AI;0 表示不启用
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"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"
|
||||
@@ -24,9 +23,9 @@ var (
|
||||
// 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 回合制(示例)
|
||||
// 玩法自己通过 Meta 声明人数范围与推进周期(TickMs),匹配器据此分队列。
|
||||
game.Register("gobang", func() game.Game { return gobang.New() }) // 五子棋:2 人,回合制
|
||||
game.Register("snake", func() game.Game { return snake.New() }) // 贪吃蛇:2~4 人,实时(200ms 一步)
|
||||
|
||||
Hub = wsgateway.DefaultHub // 使用网关默认 Hub
|
||||
Rooms = room.NewManager(Hub) // 创建房间管理器
|
||||
|
||||
+31
-17
@@ -1,12 +1,23 @@
|
||||
package dto // 包名声明:与 user.go 同属 dto 包,集中放置对外数据结构
|
||||
package dto // 包名声明:与 user.go 同属 dto 包
|
||||
|
||||
import "encoding/json" // 标准库:玩法动作的自定义参数保持原始 JSON,由玩法插件自行解析
|
||||
|
||||
// PlayerBrief 是玩家在房间里的展示信息。
|
||||
// 客户端只拿到昵称与头像,不含任何 AI 标记,因此无法区分真人与 AI。
|
||||
type PlayerBrief struct {
|
||||
UserID int64 `json:"user_id"` // 用户 ID(AI 也使用同构 ID,号段隔离但对外无差别)
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Avatar string `json:"avatar"` // 头像(可访问 URL;为空时客户端用默认头像)
|
||||
Seat int `json:"seat"` // 座位下标
|
||||
Online bool `json:"online"` // 是否在线
|
||||
}
|
||||
|
||||
// ===== 大厅上行 =====
|
||||
|
||||
// WSHallMatchRequest 是 hall.match 的上行体:请求进入匹配队列。
|
||||
type WSHallMatchRequest struct {
|
||||
GameID string `json:"game_id"` // 要玩的游戏 ID,必须是已注册的玩法
|
||||
Seats int `json:"seats"` // 要开的桌人数(如贪吃蛇 2/3/4);0 或空表示按玩法最少人数
|
||||
}
|
||||
|
||||
// WSHallCancelRequest 是 hall.cancel 的上行体:取消当前排队。
|
||||
@@ -28,7 +39,7 @@ type WSRoomReadyRequest struct {
|
||||
|
||||
// WSRoomActionRequest 是 room.action 的上行体:一次玩法动作,透传给玩法插件。
|
||||
type WSRoomActionRequest struct {
|
||||
Action string `json:"action"` // 动作名,由玩法约定(如 "place")
|
||||
Action string `json:"action"` // 动作名,由玩法约定(如 "place" / "turn")
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // 动作参数:原样透传,网关与房间都不解析
|
||||
}
|
||||
|
||||
@@ -44,10 +55,11 @@ type WSRoomReconnectRequest struct {
|
||||
|
||||
// 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,顺序即座位顺序
|
||||
RoomID string `json:"room_id"` // 房间 ID
|
||||
GameID string `json:"game_id"` // 游戏 ID
|
||||
Seats int `json:"seats"` // 总座位数
|
||||
Users []int64 `json:"users"` // 同局全部用户 ID(顺序即入座顺序)
|
||||
Players []PlayerBrief `json:"players"` // 同局展示信息:客户端应优先用它渲染头像与昵称
|
||||
}
|
||||
|
||||
// WSHallCancelledPayload 是 hall.cancelled 的下行体:告知排队已取消。
|
||||
@@ -60,12 +72,13 @@ type WSHallCancelledPayload struct {
|
||||
|
||||
// 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 生成,可做信息隐藏)
|
||||
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
|
||||
Players []PlayerBrief `json:"players"` // 玩家展示信息(含 AI,但无任何标识)
|
||||
State any `json:"state"` // 玩法对该用户可见的状态
|
||||
}
|
||||
|
||||
// WSRoomEventPayload 是 room.event 的下行体:玩法产生的增量事件。
|
||||
@@ -77,11 +90,12 @@ type WSRoomEventPayload struct {
|
||||
|
||||
// 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
|
||||
RoomID string `json:"room_id"` // 房间 ID
|
||||
GameID string `json:"game_id"` // 游戏 ID
|
||||
Winner int64 `json:"winner"` // 获胜用户 ID,0 表示平局
|
||||
Players []PlayerBrief `json:"players"` // 结算时的展示信息(便于直接显示「谁赢了」)
|
||||
Scores map[int64]int `json:"scores"` // 各用户得分,可为空
|
||||
Reason string `json:"reason"` // 结束原因:normal/opponent_leave/timeout
|
||||
}
|
||||
|
||||
// WSRoomClosedPayload 是 room.closed 的下行体:房间已关闭。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 本文件提供 AI 玩家的身份与展示信息:
|
||||
// AI 使用独立号段(与真实自增 ID 完全隔离),并拥有稳定的拟人昵称,
|
||||
// 房间与匹配器据此补齐空位,客户端只看到昵称与头像,无法区分真人还是 AI。
|
||||
package game
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// aiUIDBase 是 AI 用户号段的起点:真实用户 ID 从 1 开始自增,永远不会到达该号段。
|
||||
const aiUIDBase = 900000000
|
||||
|
||||
// aiSeq 是进程内的 AI 序号分配器(原子自增,避免并发重复)。
|
||||
var aiSeq atomic.Int64
|
||||
|
||||
// NewAIUID 分配一个新的 AI 用户 ID。
|
||||
func NewAIUID() int64 {
|
||||
return aiUIDBase + aiSeq.Add(1)
|
||||
}
|
||||
|
||||
// IsAIUID 判断某个用户 ID 是否属于 AI 号段。
|
||||
func IsAIUID(uid int64) bool {
|
||||
return uid >= aiUIDBase
|
||||
}
|
||||
|
||||
// aiNames 是 AI 昵称池:看起来就是普通玩家的昵称,不含任何 bot/robot/AI 字样。
|
||||
var aiNames = []string{
|
||||
"路过的风", "小柚子", "阿呆", "一只猫", "晚安月亮", "橘子汽水",
|
||||
"慢慢来", "老张", "南风知我意", "会飞的鱼", "山野", "木木",
|
||||
"夏天的风", "不打伞", "小满", "青柠", "路人甲", "深海",
|
||||
}
|
||||
|
||||
// aiAvatars 是 AI 头像池:留空表示由客户端使用默认头像兜底。
|
||||
// 若你有默认头像资源,可在此填入可访问 URL,客户端将直接展示。
|
||||
var aiAvatars = []string{
|
||||
"", "", "", "", "", "",
|
||||
}
|
||||
|
||||
// AIProfile 返回指定 AI 的展示信息(昵称、头像)。
|
||||
// 同一 uid 永远映射到同一组合,保证一局之内昵称不会跳变。
|
||||
func AIProfile(uid int64) (nickname string, avatar string) {
|
||||
idx := int(uid-aiUIDBase) % len(aiNames)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
avatar = ""
|
||||
if idx < len(aiAvatars) {
|
||||
avatar = aiAvatars[idx]
|
||||
}
|
||||
return aiNames[idx], avatar
|
||||
}
|
||||
+22
-3
@@ -26,7 +26,7 @@ type Action struct {
|
||||
|
||||
// Event 描述「发给谁、发什么」:投递由房间完成,玩法不接触连接。
|
||||
type Event struct {
|
||||
To []int64 // 收件人;nil 或空表示房间内全体广播
|
||||
To []int64 // 收件人;nil 或空表示全体广播
|
||||
Name string // 事件名,客户端据此分发
|
||||
Payload any // 事件内容
|
||||
}
|
||||
@@ -35,7 +35,7 @@ type Event struct {
|
||||
type Finish struct {
|
||||
Winner int64 // 获胜用户 ID,0 表示平局
|
||||
Scores map[int64]int // 各用户得分,可为空
|
||||
Reason string // 结束原因:normal/opponent_leave/timeout
|
||||
Reason string // 结束原因:normal / opponent_leave / timeout
|
||||
}
|
||||
|
||||
// State 是玩法私有状态:房间只做透传,具体类型由玩法在 Init 中创建。
|
||||
@@ -52,6 +52,19 @@ type Game interface {
|
||||
Snapshot(uid int64, s State) any // 按观众裁剪的可见状态(重连/观战用)
|
||||
}
|
||||
|
||||
// AIAction 是 AI 玩家要执行的一次动作:带执行者,便于房间代它调用 OnAction。
|
||||
type AIAction struct {
|
||||
UID int64 // 执行者(AI 的 uid)
|
||||
Action Action // 要执行的动作
|
||||
}
|
||||
|
||||
// AIBrain 是可选接口:玩法实现它即可支持「AI 补位」。
|
||||
// 房间在每次推进前调用一次,把返回的动作代 AI 执行;不实现该接口的玩法不会被补 AI。
|
||||
type AIBrain interface {
|
||||
// TickAI 返回本回合所有 AI 玩家要执行的动作(含思考延迟由玩法内部决定)。
|
||||
TickAI(now int64, s State) []AIAction
|
||||
}
|
||||
|
||||
// Factory 创建玩法实例:每局一个新实例,保证状态隔离。
|
||||
type Factory func() Game
|
||||
|
||||
@@ -78,7 +91,7 @@ func New(gameID string) (Game, error) {
|
||||
return f(), nil // 返回新实例
|
||||
}
|
||||
|
||||
// LookupMeta 取已注册玩法的元信息:匹配器用它决定队列人数。
|
||||
// LookupMeta 取已注册玩法的元信息(匹配器用它决定队列人数)。
|
||||
func LookupMeta(gameID string) (Meta, error) {
|
||||
g, err := New(gameID) // 先建实例
|
||||
if err != nil { // 未注册
|
||||
@@ -86,3 +99,9 @@ func LookupMeta(gameID string) (Meta, error) {
|
||||
}
|
||||
return g.Meta(), nil // 返回元信息
|
||||
}
|
||||
|
||||
// HasAIBrain 判断玩法是否支持 AI 补位(实现了 AIBrain 接口)。
|
||||
func HasAIBrain(g Game) bool {
|
||||
_, ok := g.(AIBrain)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ type state struct {
|
||||
}
|
||||
|
||||
// Game 实现 game.Game 接口。
|
||||
type Game struct{}
|
||||
type Game struct {
|
||||
aiPlanIdx int // AI 计划落点(配合 aiHasPlan 使用)
|
||||
aiHasPlan bool // AI 是否已有落子计划
|
||||
aiThinkAt int64 // AI 落子时刻,模拟思考耗时
|
||||
}
|
||||
|
||||
// New 创建玩法实例:每局一个,状态互不影响。
|
||||
func New() *Game { return &Game{} }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package gobang // 与 gobang.go 同包,可直接访问 state 与 checkWin
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:生成 place 动作的 payload
|
||||
"math/rand" // 标准库:思考时长与落点随机,避免每局一模一样
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// TickAI 为 AI 玩家生成落子动作(实现 game.AIBrain)。
|
||||
//
|
||||
// 拟人要点:先「思考」0.6~1.5 秒再落子,绝不秒下;落点策略按
|
||||
// 「能赢就赢 → 对手要赢就堵 → 贴着已有棋子下 → 中心兜底」递减,
|
||||
// 既有棋力又不会强到反常。
|
||||
func (g *Game) TickAI(now int64, s game.State) []game.AIAction {
|
||||
st, ok := s.(*state) // 断言为本玩法状态
|
||||
if !ok || st.Winner != 0 || len(st.Players) != 2 { // 未开局或已分胜负
|
||||
return nil
|
||||
}
|
||||
|
||||
turnUID := st.Players[st.Turn] // 当前该走的人
|
||||
if !game.IsAIUID(turnUID) { // 不是 AI 的回合:丢弃未落子的计划
|
||||
g.aiPlanIdx = 0
|
||||
g.aiHasPlan = false
|
||||
return nil
|
||||
}
|
||||
|
||||
if !g.aiHasPlan { // 还没想好:选点并开始思考
|
||||
idx := g.pickMove(st, turnUID)
|
||||
if idx < 0 { // 无点可下(棋盘满)
|
||||
return nil
|
||||
}
|
||||
g.aiPlanIdx = idx
|
||||
g.aiHasPlan = true
|
||||
// 注意 now 是秒级时间戳,房间 tick 粒度也是 1 秒,因此思考时长只能按「秒」计:
|
||||
// 思考 1~2 秒再落子,既像人在思考,又不会让玩家等太久。
|
||||
g.aiThinkAt = now + int64(1+rand.Intn(2))
|
||||
return nil
|
||||
}
|
||||
if now < g.aiThinkAt { // 思考时间未到:继续等
|
||||
return nil
|
||||
}
|
||||
|
||||
idx := g.aiPlanIdx // 到点落子
|
||||
g.aiHasPlan = false
|
||||
g.aiPlanIdx = 0
|
||||
|
||||
payload, err := json.Marshal(placePayload{Row: idx / boardSize, Col: idx % boardSize})
|
||||
if err != nil { // 序列化失败:本回合不下
|
||||
return nil
|
||||
}
|
||||
return []game.AIAction{{UID: turnUID, Action: game.Action{Name: "place", Payload: payload}}}
|
||||
}
|
||||
|
||||
// pickMove 选落点:赢 → 堵 → 贴子 → 中心。
|
||||
func (g *Game) pickMove(st *state, aiUID int64) int {
|
||||
aiMark := markOf(st, aiUID) // AI 执黑(1) 还是执白(2)
|
||||
foeMark := 3 - aiMark // 对手的棋子
|
||||
|
||||
// 1) 能直接连五获胜:立刻下
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 { // 只考虑空点
|
||||
continue
|
||||
}
|
||||
st.Board[i] = aiMark // 试落
|
||||
win := checkWin(st.Board, i/boardSize, i%boardSize, aiMark) // 判断是否成五
|
||||
st.Board[i] = 0 // 还原
|
||||
if win {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 对手下一手就要成五:必须堵
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 {
|
||||
continue
|
||||
}
|
||||
st.Board[i] = foeMark
|
||||
win := checkWin(st.Board, i/boardSize, i%boardSize, foeMark)
|
||||
st.Board[i] = 0
|
||||
if win {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 贴着已有棋子下(像人一样连片),从候选里随机取一个
|
||||
cands := make([]int, 0, 64)
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 { // 空点
|
||||
continue
|
||||
}
|
||||
if !hasNeighbor(st.Board, i, 2) { // 两格内没有棋子则跳过
|
||||
continue
|
||||
}
|
||||
cands = append(cands, i)
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
return cands[rand.Intn(len(cands))]
|
||||
}
|
||||
|
||||
// 4) 兜底:天元(棋盘中心)
|
||||
mid := boardSize / 2
|
||||
if st.Board[mid*boardSize+mid] == 0 {
|
||||
return mid*boardSize + mid
|
||||
}
|
||||
for i := 0; i < len(st.Board); i++ { // 再兜底:第一个空点
|
||||
if st.Board[i] == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1 // 棋盘已满
|
||||
}
|
||||
|
||||
// markOf 返回该玩家的棋子颜色(先手=1 黑,后手=2 白)。
|
||||
func markOf(st *state, uid int64) int8 {
|
||||
for i, p := range st.Players {
|
||||
if p == uid {
|
||||
return int8(i + 1)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// hasNeighbor 判断 radius 格范围内是否存在棋子(用于「贴着下」的选点)。
|
||||
func hasNeighbor(board []int8, idx, radius int) bool {
|
||||
r0, c0 := idx/boardSize, idx%boardSize
|
||||
for dr := -radius; dr <= radius; dr++ {
|
||||
for dc := -radius; dc <= radius; dc++ {
|
||||
r, c := r0+dr, c0+dc
|
||||
if r < 0 || r >= boardSize || c < 0 || c >= boardSize { // 越界
|
||||
continue
|
||||
}
|
||||
if board[r*boardSize+c] != 0 { // 有棋子
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package snake 是双人贪吃蛇玩法:两人同场竞技的实时玩法。
|
||||
// Package snake 是多人贪吃蛇玩法:2~4 人同场竞技的实时玩法。
|
||||
// 与回合制玩法(gobang / tictactoe)不同,本玩法由房间的 tick 循环持续推进:
|
||||
// 玩家只发「转向」指令,位置推进、吃食物、碰撞判定都在 OnTick 中完成。
|
||||
package snake
|
||||
@@ -6,6 +6,7 @@ package snake
|
||||
import (
|
||||
"encoding/json" // 标准库:解析 turn 动作参数
|
||||
"errors" // 标准库:返回玩法内校验错误
|
||||
"fmt" // 标准库:人数不合法时返回明确错误
|
||||
"math/rand" // 标准库:随机生成食物位置
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
@@ -17,6 +18,10 @@ const (
|
||||
tickMs = 200 // 推进周期(毫秒):每秒 5 步,通过 Meta.TickMs 交给房间
|
||||
initLen = 3 // 初始蛇长
|
||||
targetScore = 20 // 先吃满该分数直接获胜;0 表示只比谁活得久
|
||||
|
||||
// 人数范围:支持双人场、三人场、四人场(客户端匹配时指定具体人数)。
|
||||
minSeats = 2
|
||||
maxSeats = 4
|
||||
)
|
||||
|
||||
// 方向:上右下左(顺时针);反向为 (dir+2)%4。
|
||||
@@ -30,6 +35,34 @@ const (
|
||||
// dirOffsets 是四个方向对应的「行、列」增量。
|
||||
var dirOffsets = [4][2]int{{-1, 0}, {0, 1}, {1, 0}, {0, -1}}
|
||||
|
||||
// spawnPoint 是一个出生点:头位置 + 初始方向。
|
||||
type spawnPoint struct{ row, col, dir int }
|
||||
|
||||
// spawnPlans 按人数给出出生点,核心要求是「开局不正面对撞」——
|
||||
// 否则两条蛇相向而行会在几秒内头对头同归于尽,体验极差。
|
||||
// - 2 人:左右相对(保留经典对冲的对抗性,两人场玩家有心理预期)
|
||||
// - 3 人:三个方向且行列错开,避免两两正对
|
||||
// - 4 人:沿棋盘四边顺时针环绕,谁也不会正面撞上谁
|
||||
//
|
||||
// 每个出生点的头到边界距离都不小于 initLen-1,保证初始身体完整落在棋盘内。
|
||||
var spawnPlans = map[int][]spawnPoint{
|
||||
2: {
|
||||
{boardSize / 2, 2, dirRight}, // 左中 → 向右(身体向左侧延伸)
|
||||
{boardSize / 2, boardSize - 3, dirLeft}, // 右中 → 向左(身体向右侧延伸)
|
||||
},
|
||||
3: {
|
||||
{2, 7, dirDown}, // 上偏左 → 向下
|
||||
{14, boardSize - 3, dirLeft}, // 右偏下 → 向左
|
||||
{7, 2, dirRight}, // 左偏上 → 向右
|
||||
},
|
||||
4: {
|
||||
{2, boardSize / 2, dirRight}, // 上中 → 向右(顺时针环绕)
|
||||
{boardSize / 2, boardSize - 3, dirDown}, // 右中 → 向下
|
||||
{boardSize - 3, boardSize / 2, dirLeft}, // 下中 → 向左
|
||||
{boardSize / 2, 2, dirUp}, // 左中 → 向上
|
||||
},
|
||||
}
|
||||
|
||||
// turnPayload 是 turn 动作的参数。
|
||||
type turnPayload struct {
|
||||
Dir string `json:"dir"` // up / down / left / right
|
||||
@@ -47,7 +80,7 @@ type snakeState struct {
|
||||
// state 是本玩法私有状态,房间只透传不解析。
|
||||
type state struct {
|
||||
Size int `json:"size"` // 棋盘边长
|
||||
Snakes []*snakeState `json:"snakes"` // 两条蛇
|
||||
Snakes []*snakeState `json:"snakes"` // 全部蛇(2~4 条)
|
||||
Foods []int `json:"foods"` // 食物坐标(一维下标)
|
||||
Over bool `json:"over"` // 是否已结束
|
||||
}
|
||||
@@ -58,28 +91,35 @@ type Game struct{}
|
||||
// New 创建玩法实例:每局一个,状态互不影响。
|
||||
func New() *Game { return &Game{} }
|
||||
|
||||
// Meta 声明本玩法为 2 人实时玩法,并指定 200ms 的推进周期。
|
||||
// Meta 声明本玩法支持 2~4 人实时对局,并指定 200ms 的推进周期。
|
||||
func (g *Game) Meta() game.Meta {
|
||||
return game.Meta{
|
||||
GameID: gameID, MinSeats: 2, MaxSeats: 2,
|
||||
GameID: gameID, MinSeats: minSeats, MaxSeats: maxSeats,
|
||||
Realtime: true, AllowJoin: false, TickMs: tickMs,
|
||||
}
|
||||
}
|
||||
|
||||
// Init 初始化棋盘:两条蛇分别从左右两侧中间出发,方向相对。
|
||||
// Init 按人数初始化棋盘:每条蛇占一个出生点,食物数量与人数相同。
|
||||
func (g *Game) Init(seatCount int) (game.State, error) {
|
||||
if seatCount != 2 { // 固定两人
|
||||
return nil, errors.New("snake requires 2 seats")
|
||||
if seatCount < minSeats || seatCount > maxSeats { // 人数越界
|
||||
return nil, fmt.Errorf("snake requires %d~%d seats, got %d", minSeats, maxSeats, seatCount)
|
||||
}
|
||||
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 := &state{Size: boardSize}
|
||||
plan := spawnPlans[seatCount] // 取该人数对应的出生方案
|
||||
for i := 0; i < seatCount; i++ { // 按出生点依次建蛇
|
||||
sp := plan[i]
|
||||
st.Snakes = append(st.Snakes, &snakeState{
|
||||
Body: bodyFrom(sp.row, sp.col, sp.dir),
|
||||
Dir: sp.dir,
|
||||
Alive: true,
|
||||
})
|
||||
}
|
||||
for i := 0; i < seatCount; i++ { // 每条蛇配一个初始食物
|
||||
if f := st.spawnFood(); f >= 0 {
|
||||
st.Foods = append(st.Foods, f)
|
||||
}
|
||||
}
|
||||
st.Foods = []int{st.spawnFood()} // 初始一个食物
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -141,7 +181,7 @@ func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) {
|
||||
return nil, errors.New("room is full")
|
||||
}
|
||||
|
||||
// OnLeave 玩家离座:直接判对手胜。
|
||||
// OnLeave 玩家离座:该蛇直接死亡;若只剩一条存活则对手获胜。
|
||||
func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) {
|
||||
st, ok := s.(*state) // 断言状态
|
||||
if !ok {
|
||||
@@ -149,16 +189,19 @@ func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, err
|
||||
}
|
||||
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
|
||||
}
|
||||
sn.Alive = false // 标记死亡,交由后续判定收尾
|
||||
events := []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}}
|
||||
|
||||
alive := aliveSnakes(st) // 剩余存活者
|
||||
if len(alive) == 1 { // 只剩一条:直接分出胜负
|
||||
st.Over = true
|
||||
return events, &game.Finish{Winner: alive[0].UserID, Scores: scores(st), Reason: "opponent_leave"}, nil
|
||||
}
|
||||
return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}},
|
||||
&game.Finish{Winner: winner, Scores: scores(st), Reason: "opponent_leave"}, nil
|
||||
if len(alive) == 0 { // 全部离场:平局
|
||||
st.Over = true
|
||||
return events, &game.Finish{Winner: 0, Scores: scores(st), Reason: "opponent_leave"}, nil
|
||||
}
|
||||
return events, nil, nil // 多人场还剩多条:对局继续
|
||||
}
|
||||
}
|
||||
return nil, nil, nil // 非本局成员:无事件
|
||||
@@ -204,6 +247,7 @@ func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *
|
||||
}
|
||||
|
||||
// OnTick 推进一步:计算新头位置 → 判定碰撞 → 移动与吃食物 → 判定胜负。
|
||||
// 逻辑与人数无关:所有蛇都在同一批里先算后改,保证「同时发生」的判定公平。
|
||||
func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) {
|
||||
st, ok := s.(*state) // 断言状态
|
||||
if !ok || st.Over { // 类型错误或已结束
|
||||
@@ -260,7 +304,7 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
|
||||
dead[i] = true
|
||||
}
|
||||
}
|
||||
for oi, other := range st.Snakes { // 撞到蛇身
|
||||
for oi, other := range st.Snakes { // 撞到任意蛇身
|
||||
for k, b := range other.Body {
|
||||
// 对方本回合不吃食物时,尾节点会让位,不算撞
|
||||
if k == len(other.Body)-1 && !grow[oi] {
|
||||
@@ -299,7 +343,7 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 组装事件:每步一次 tick(含全量可渲染数据),死亡额外发 died
|
||||
// 5. 组装事件:每步一次 tick(含全量可渲染数据),本回合死亡额外发 died
|
||||
events := []game.Event{{Name: "tick", Payload: map[string]any{
|
||||
"size": st.Size, "snakes": st.Snakes, "foods": st.Foods,
|
||||
}}}
|
||||
@@ -316,22 +360,17 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
|
||||
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)
|
||||
}
|
||||
}
|
||||
alive := aliveSnakes(st)
|
||||
if len(alive) <= 1 { // 分出结果
|
||||
st.Over = true
|
||||
var winner int64 // 零值表示平局(同时死亡)
|
||||
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 // 对局继续
|
||||
return events, nil, nil // 对局继续(多人场剩多条蛇)
|
||||
}
|
||||
|
||||
// Snapshot 生成对指定玩家可见的状态(贪吃蛇无隐藏信息,返回全量)。
|
||||
@@ -349,6 +388,17 @@ func (g *Game) Snapshot(uid int64, s game.State) any {
|
||||
}
|
||||
}
|
||||
|
||||
// aliveSnakes 返回仍存活的蛇。
|
||||
func aliveSnakes(st *state) []*snakeState {
|
||||
out := make([]*snakeState, 0, len(st.Snakes))
|
||||
for _, sn := range st.Snakes {
|
||||
if sn.Alive {
|
||||
out = append(out, sn)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scores 汇总各玩家得分。
|
||||
func scores(st *state) map[int64]int {
|
||||
out := make(map[int64]int, len(st.Snakes))
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package snake // 与 snake.go 同包,可直接访问 state 与方向工具
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:生成 turn 动作的 payload
|
||||
"math/rand" // 标准库:随机化候选方向,制造拟人失误
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// TickAI 为 AI 蛇生成转向指令(实现 game.AIBrain)。
|
||||
//
|
||||
// 拟人要点:
|
||||
// - 只在方向需要改变时才发指令(真人也不会每帧狂按方向键)
|
||||
// - 候选方向里混入随机顺序,偶尔会走"不是最优"的一步,反应不会过于机械
|
||||
// - 绝不使用全局最优解,只做「朝最近食物 + 保证这一步不死」的贪心判断
|
||||
func (g *Game) TickAI(now int64, s game.State) []game.AIAction {
|
||||
st, ok := s.(*state) // 断言为本玩法状态
|
||||
if !ok || st.Over { // 类型错误或已结束
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]game.AIAction, 0, len(st.Snakes))
|
||||
for _, sn := range st.Snakes {
|
||||
if !sn.Alive || !game.IsAIUID(sn.UserID) { // 只操作存活的 AI 蛇
|
||||
continue
|
||||
}
|
||||
dir := pickDir(st, sn) // 选方向
|
||||
if dir == sn.Dir { // 与当前方向一致:无需发指令
|
||||
continue
|
||||
}
|
||||
if len(sn.Body) > 1 && (sn.Dir+2)%4 == dir { // 禁止 180 度掉头
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(turnPayload{Dir: dirName(dir)}) // 组装动作
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, game.AIAction{UID: sn.UserID, Action: game.Action{Name: "turn", Payload: payload}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pickDir 选一个尽量朝向最近食物、且下一步不会立刻死掉的方向。
|
||||
func pickDir(st *state, sn *snakeState) int {
|
||||
hr, hc := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0]) // 当前蛇头
|
||||
|
||||
// 1) 找最近的食物,派生期望方向(先缩小行差,再缩小列差)
|
||||
want := make([]int, 0, 2)
|
||||
best, bestDist := -1, int(1<<30)
|
||||
for _, f := range st.Foods {
|
||||
fr, fc := st.rowOf(f), st.colOf(f)
|
||||
if d := absInt(fr-hr) + absInt(fc-hc); d < bestDist {
|
||||
bestDist, best = d, f
|
||||
}
|
||||
}
|
||||
if best >= 0 {
|
||||
fr, fc := st.rowOf(best), st.colOf(best)
|
||||
if fr > hr {
|
||||
want = append(want, dirDown)
|
||||
} else if fr < hr {
|
||||
want = append(want, dirUp)
|
||||
}
|
||||
if fc > hc {
|
||||
want = append(want, dirRight)
|
||||
} else if fc < hc {
|
||||
want = append(want, dirLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 保持当前方向次之
|
||||
want = append(want, sn.Dir)
|
||||
|
||||
// 3) 其余方向随机排序:制造拟人失误,避免四条 AI 走出完全相同的路线
|
||||
rest := make([]int, 0, 4)
|
||||
for d := 0; d < 4; d++ {
|
||||
if d == sn.Dir { // 已在 want 中
|
||||
continue
|
||||
}
|
||||
dup := false
|
||||
for _, w := range want {
|
||||
if w == d {
|
||||
dup = true
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
rand.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] })
|
||||
|
||||
// 4) 按顺序取第一个安全方向;都不安全就维持原方向(坦然赴死,符合真实对局)
|
||||
for _, d := range append(want, rest...) {
|
||||
if isSafe(st, sn, d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return sn.Dir
|
||||
}
|
||||
|
||||
// isSafe 判断朝该方向走一步是否安全:不撞墙、不撞到任意蛇身。
|
||||
func isSafe(st *state, sn *snakeState, dir int) bool {
|
||||
hr, hc := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0])
|
||||
nr, nc := hr+dirOffsets[dir][0], hc+dirOffsets[dir][1]
|
||||
if nr < 0 || nr >= st.Size || nc < 0 || nc >= st.Size { // 越界即撞墙
|
||||
return false
|
||||
}
|
||||
next := st.idx(nr, nc)
|
||||
for _, other := range st.Snakes { // 检查所有蛇身
|
||||
for k, b := range other.Body {
|
||||
if k == len(other.Body)-1 { // 尾节点本回合会让位,忽略
|
||||
continue
|
||||
}
|
||||
if b == next { // 撞身
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// absInt 返回整数绝对值。
|
||||
func absInt(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// 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 // 未分出胜负
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func RegisterHandlers(hub *wsgateway.Hub, mt *Matcher, mgr *room.Manager) {
|
||||
c.SendError(env.Seq, 0, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := mt.Enqueue(c.UserID(), req.GameID); err != nil { // 入队
|
||||
if err := mt.Enqueue(c.UserID(), req.GameID, req.Seats); err != nil { // 入队(人数由客户端指定)
|
||||
c.SendError(env.Seq, 0, err.Error()) // 回错误(未注册 / 已在房间 / 已在排队)
|
||||
return
|
||||
}
|
||||
|
||||
+92
-32
@@ -1,10 +1,11 @@
|
||||
// Package hall 提供大厅能力:按游戏分队列匹配,凑满人数后建房间并通知玩家。
|
||||
// 本包同样玩法无关:人数来自玩法 Meta,成局后交给 room.Manager。
|
||||
// Package hall 提供大厅能力:按「游戏 + 桌人数」分队列匹配,凑满人数后建房间并通知玩家。
|
||||
// 排队过久仍未凑满时,自动用 AI 补齐空位,且对外完全不可感知。
|
||||
package hall
|
||||
|
||||
import (
|
||||
"context" // 标准库:Run 的生命周期控制
|
||||
"errors" // 标准库:重复排队、已在房间等错误
|
||||
"fmt" // 标准库:队列键拼接与人数越界提示
|
||||
"log/slog" // 标准库:记录入队、成局、超时
|
||||
"sync" // 标准库:保护队列表
|
||||
"time" // 标准库:排队时刻、tick 周期、超时判定
|
||||
@@ -20,15 +21,15 @@ import (
|
||||
type item struct {
|
||||
uid int64 // 用户 ID
|
||||
gameID string // 游戏 ID(超时通知时要用)
|
||||
enqueueAt time.Time // 入队时刻,用于排队超时
|
||||
seats int // 该排队者要开的桌人数(决定补几个 AI)
|
||||
enqueueAt time.Time // 入队时刻,用于排队超时与补 AI 判定
|
||||
}
|
||||
|
||||
// queue 是某个游戏的一条匹配队列。
|
||||
// TODO(扩展):若将来要区分排位/娱乐等模式,可把队列键改为「gameID|mode」并在上行体中携带 mode。
|
||||
// queue 是「某个游戏 + 某个桌人数」的一条匹配队列(如 snake|4 表示四人场)。
|
||||
type queue struct {
|
||||
key string // 队列键:当前即 gameID
|
||||
key string // 队列键:gameID|seats
|
||||
gameID string // 游戏 ID
|
||||
seats int // 凑满几人开局,取自玩法 Meta.MinSeats
|
||||
seats int // 凑满几人开局(客户端指定,已校验在玩法人数范围内)
|
||||
items []*item // 排队者,先进先出
|
||||
}
|
||||
|
||||
@@ -51,32 +52,39 @@ func NewMatcher(hub *wsgateway.Hub, manager *room.Manager) *Matcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue 把用户放入对应游戏的队列。
|
||||
func (m *Matcher) Enqueue(uid int64, gameID string) error {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数
|
||||
// Enqueue 把用户放入「游戏 + 人数」对应的队列(如贪吃蛇四人场)。
|
||||
// seats 为 0 时按玩法最少人数处理;超出玩法人数范围直接拒绝。
|
||||
func (m *Matcher) Enqueue(uid int64, gameID string, seats int) error {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法已注册,并取人数范围
|
||||
if err != nil { // 未注册的游戏
|
||||
return err
|
||||
}
|
||||
if seats <= 0 { // 未指定人数:按最少人数
|
||||
seats = meta.MinSeats
|
||||
}
|
||||
if seats < meta.MinSeats || (meta.MaxSeats > 0 && seats > meta.MaxSeats) { // 人数越界
|
||||
return fmt.Errorf("该玩法只支持 %d~%d 人,收到 %d", meta.MinSeats, meta.MaxSeats, seats)
|
||||
}
|
||||
if _, ok := m.manager.RoomOf(uid); ok { // 已在房间内:不允许同时排队
|
||||
return errors.New("已在房间内,请先离开")
|
||||
}
|
||||
|
||||
key := gameID // 队列键:按游戏分队列
|
||||
m.mu.Lock() // 加锁
|
||||
defer m.mu.Unlock() // 返回时解锁
|
||||
key := fmt.Sprintf("%s|%d", gameID, seats) // 队列键:游戏 + 人数
|
||||
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}
|
||||
q = &queue{key: key, gameID: gameID, seats: seats}
|
||||
m.queues[key] = q
|
||||
}
|
||||
q.items = append(q.items, &item{uid: uid, gameID: gameID, enqueueAt: time.Now()}) // 入队
|
||||
m.waiting[uid] = key // 记录索引
|
||||
q.items = append(q.items, &item{uid: uid, gameID: gameID, seats: seats, enqueueAt: time.Now()}) // 入队
|
||||
m.waiting[uid] = key // 记录索引
|
||||
|
||||
slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "size", len(q.items))
|
||||
slog.Info("match enqueue", "user_id", uid, "game_id", gameID, "seats", seats, "size", len(q.items))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,15 +129,16 @@ func (m *Matcher) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// scan 扫描全部队列:先清超时,再凑人成局。
|
||||
// scan 扫描全部队列:清超时 → 凑人成局 → 等待过久的用 AI 补齐。
|
||||
func (m *Matcher) scan(now time.Time) {
|
||||
m.mu.Lock() // 加锁
|
||||
|
||||
var timeout []*item // 超时者
|
||||
// 1) 清理排队超时(未开启补 AI 时才会真的发生)
|
||||
var timeout []*item
|
||||
for key, q := range m.queues { // 遍历队列
|
||||
kept := q.items[:0] // 原地过滤,复用底层数组
|
||||
for _, it := range q.items { // 逐个判断
|
||||
if now.Sub(it.enqueueAt) > matchTimeout() { // 排队超时
|
||||
if now.Sub(it.enqueueAt) > matchTimeout() && !aiEnabled() { // 超时且未开启补 AI
|
||||
delete(m.waiting, it.uid) // 清除索引
|
||||
timeout = append(timeout, it)
|
||||
continue
|
||||
@@ -142,7 +151,8 @@ func (m *Matcher) scan(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
var groups [][]*item // 本次成局的分组
|
||||
// 2) 凑满人的分组:真人成局
|
||||
var groups [][]*item
|
||||
for _, q := range m.queues { // 遍历队列凑人
|
||||
for len(q.items) >= q.seats { // 人数足够
|
||||
group := append([]*item(nil), q.items[:q.seats]...) // 取前 seats 个
|
||||
@@ -153,41 +163,78 @@ func (m *Matcher) scan(now time.Time) {
|
||||
groups = append(groups, group) // 记录分组
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 等待过久仍未凑满:用 AI 补齐空位(对外不可感知)
|
||||
var aiGroups [][]*item
|
||||
if aiEnabled() {
|
||||
for key, q := range m.queues { // 遍历未满队列
|
||||
if len(q.items) == 0 { // 空队列跳过
|
||||
continue
|
||||
}
|
||||
if now.Sub(q.items[0].enqueueAt) < aiWait() { // 队首等待时长还不够
|
||||
continue
|
||||
}
|
||||
take := len(q.items) // 取出当前全部排队者(最多一桌)
|
||||
if take > q.seats {
|
||||
take = q.seats
|
||||
}
|
||||
group := append([]*item(nil), q.items[:take]...) // 取走
|
||||
q.items = q.items[take:] // 剩余继续排队
|
||||
for _, it := range group { // 清除索引
|
||||
delete(m.waiting, it.uid)
|
||||
}
|
||||
if len(q.items) == 0 { // 队列已空
|
||||
delete(m.queues, key) // 删除
|
||||
}
|
||||
aiGroups = append(aiGroups, group) // 记录需补 AI 的分组
|
||||
}
|
||||
}
|
||||
m.mu.Unlock() // 解锁
|
||||
|
||||
for _, it := range timeout { // 通知排队超时(锁外)
|
||||
m.notifyCancelled(it.uid, it.gameID, "timeout")
|
||||
}
|
||||
for _, g := range groups { // 逐个分组成局(锁外,建房间较慢)
|
||||
m.createRoom(g)
|
||||
for _, g := range groups { // 真人成局(锁外,建房间较慢)
|
||||
m.createRoom(g, 0)
|
||||
}
|
||||
for _, g := range aiGroups { // AI 补位成局(锁外)
|
||||
m.createRoom(g, g[0].seats-len(g))
|
||||
}
|
||||
}
|
||||
|
||||
// createRoom 为一组成局玩家创建房间并通知。
|
||||
func (m *Matcher) createRoom(g []*item) {
|
||||
// createRoom 为一组成员创建房间并通知;aiCount 为需要补位的 AI 数量。
|
||||
func (m *Matcher) createRoom(g []*item, aiCount int) {
|
||||
if len(g) == 0 { // 空分组
|
||||
return
|
||||
}
|
||||
uids := make([]int64, 0, len(g)) // 收集用户 ID
|
||||
uids := make([]int64, 0, len(g)) // 收集真人用户 ID
|
||||
for _, it := range g {
|
||||
uids = append(uids, it.uid)
|
||||
}
|
||||
if aiCount < 0 { // 防御:人数不会为负
|
||||
aiCount = 0
|
||||
}
|
||||
|
||||
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)
|
||||
r, err := m.manager.Create(g[0].gameID, uids, aiCount) // 创建房间(真人 + AI 一起入座)
|
||||
if err != nil { // 创建失败(如玩法不支持 AI、房间数超限)
|
||||
slog.Error("match create room failed", "game_id", g[0].gameID, "ai", aiCount, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := wsgateway.Encode(wsgateway.TypeHallMatched, 0, dto.WSHallMatchedPayload{ // 编码匹配成功通知
|
||||
RoomID: r.ID(), GameID: r.GameID(), Seats: len(uids), Users: uids,
|
||||
RoomID: r.ID(),
|
||||
GameID: r.GameID(),
|
||||
Seats: len(uids) + aiCount,
|
||||
Users: r.UserIDs(),
|
||||
Players: r.Roster(),
|
||||
})
|
||||
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)
|
||||
m.hub.Broadcast(uids, data) // 只广播给真人(AI 无连接,Hub 会自然跳过)
|
||||
slog.Info("match room created", "room_id", r.ID(), "game_id", g[0].gameID,
|
||||
"users", uids, "ai", aiCount)
|
||||
}
|
||||
|
||||
// notifyCancelled 下发取消通知。
|
||||
@@ -216,3 +263,16 @@ func matchTimeout() time.Duration {
|
||||
}
|
||||
return 60 * time.Second // 兜底 60 秒
|
||||
}
|
||||
|
||||
// aiEnabled 是否开启「匹配超时补 AI」。
|
||||
func aiEnabled() bool {
|
||||
return global.WSSetting != nil && global.WSSetting.AIEnabled
|
||||
}
|
||||
|
||||
// aiWait 排队超过多久开始补 AI。
|
||||
func aiWait() time.Duration {
|
||||
if global.WSSetting != nil && global.WSSetting.AIWaitSec > 0 { // 配置了
|
||||
return time.Duration(global.WSSetting.AIWaitSec) * time.Second
|
||||
}
|
||||
return 30 * time.Second // 兜底 30 秒
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package room // 与 room.go 同包
|
||||
|
||||
import (
|
||||
"log/slog" // 标准库:记录 AI 驱动异常
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// driveAI 让房间里的 AI 玩家先行动,再交给玩法推进世界。
|
||||
//
|
||||
// AI 由玩法自己决定怎么走(玩法实现可选的 game.AIBrain 接口):
|
||||
// 房间只是把玩法返回的动作代 AI 执行一遍,因此:
|
||||
// - 房间不知道谁是 AI,也不需要知道(对外完全无差别)
|
||||
// - 未实现 AIBrain 的玩法不会被补 AI(匹配器在建房前会校验)
|
||||
func (r *Room) driveAI(now int64) {
|
||||
if r.status != StatusPlaying { // 仅对局中才行动
|
||||
return
|
||||
}
|
||||
brain, ok := r.g.(game.AIBrain) // 玩法是否支持 AI
|
||||
if !ok { // 不支持:直接跳过
|
||||
return
|
||||
}
|
||||
|
||||
actions := brain.TickAI(now, r.state) // 问玩法:AI 本回合要做什么
|
||||
if len(actions) == 0 { // AI 本回合无动作(如还在思考)
|
||||
return
|
||||
}
|
||||
|
||||
for _, a := range actions { // 逐个代 AI 执行
|
||||
events, finish, err := r.g.OnAction(a.UID, a.Action, r.state)
|
||||
if err != nil { // 玩法拒绝(如不是该 AI 的回合):忽略即可,下一回合再试
|
||||
slog.Debug("room ai action rejected", "room_id", r.id,
|
||||
"user_id", a.UID, "error", err)
|
||||
continue
|
||||
}
|
||||
r.deliver(events) // 投递 AI 行为产生的事件
|
||||
if finish != nil { // AI 走完直接分出胜负
|
||||
r.finishGame(finish)
|
||||
return
|
||||
}
|
||||
r.broadcastState() // 让所有人看到 AI 的落子/转向结果
|
||||
}
|
||||
}
|
||||
|
||||
// Roster 返回本房间的玩家名册(昵称、头像、座位),供匹配通知与结算展示使用。
|
||||
// 名册里包含 AI,但没有任何 AI 标识,客户端无法区分。
|
||||
func (r *Room) Roster() []dto.PlayerBrief {
|
||||
return r.roster
|
||||
}
|
||||
+93
-26
@@ -1,22 +1,27 @@
|
||||
package room // 包名声明:房间管理器与房间同包
|
||||
|
||||
import (
|
||||
"errors" // 标准库:房间数超限、游戏未注册等错误
|
||||
"context" // 标准库:查询用户信息时的超时控制
|
||||
"errors" // 标准库:房间数超限、人数不合法等错误
|
||||
"fmt" // 标准库:人数不在玩法允许范围时的提示
|
||||
"log/slog" // 标准库:记录创建与回收日志
|
||||
// 标准库:生成房间 ID 时去除 UUID 连字符
|
||||
"sync" // 标准库:保护房间表与用户绑定表
|
||||
"sync" // 标准库:保护房间表与用户绑定表
|
||||
"time" // 标准库:查询用户信息的超时
|
||||
|
||||
"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/mysqldao"
|
||||
"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 // 下发通道,透传给房间
|
||||
mu sync.RWMutex // 保护下面两张表
|
||||
rooms map[string]*Room // roomID -> 房间
|
||||
userRoom map[int64]string // userID -> roomID(一个用户同时只在一个房间)
|
||||
hub *wsgateway.Hub // 下发通道,透传给房间
|
||||
userDao *mysqldao.UserDao // 查询真实用户昵称与头像(失败时降级为默认昵称)
|
||||
}
|
||||
|
||||
// NewManager 创建房间管理器。
|
||||
@@ -25,37 +30,80 @@ func NewManager(hub *wsgateway.Hub) *Manager {
|
||||
rooms: make(map[string]*Room), // 初始化房间表
|
||||
userRoom: make(map[int64]string), // 初始化绑定表
|
||||
hub: hub, // 保存下发通道
|
||||
userDao: mysqldao.NewUserDao(), // 用于构造玩家名册
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建一个房间并让指定用户入座(匹配成功后由匹配器调用)。
|
||||
func (m *Manager) Create(gameID string, uids []int64) (*Room, error) {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数
|
||||
// Create 创建一个房间并让真人、AI 依次入座(匹配成功后由匹配器调用)。
|
||||
//
|
||||
// aiCount 为需要补位的 AI 数量:AI 只入座不绑定连接,客户端从 roster 里看到的是
|
||||
// 与真人同构的昵称与头像,无法区分。
|
||||
func (m *Manager) Create(gameID string, uids []int64, aiCount int) (*Room, error) {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数范围
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seats := len(uids) + aiCount // 本局总人数 = 真人 + AI
|
||||
if seats < meta.MinSeats || (meta.MaxSeats > 0 && seats > meta.MaxSeats) {
|
||||
return nil, fmt.Errorf("game %s requires %d~%d players, got %d", gameID, meta.MinSeats, meta.MaxSeats, seats)
|
||||
}
|
||||
if aiCount > 0 { // 需要补 AI:玩法必须实现 AIBrain
|
||||
probe, perr := game.New(gameID)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
if !game.HasAIBrain(probe) {
|
||||
return nil, errors.New("该玩法不支持 AI 补位")
|
||||
}
|
||||
}
|
||||
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 { // 创建失败
|
||||
// 构造名册:先真人后 AI,座位顺序即入座顺序。
|
||||
roster := make([]dto.PlayerBrief, 0, seats)
|
||||
seat := 0
|
||||
for _, uid := range uids { // 真人:查库取昵称与头像
|
||||
nickname, avatar := m.lookProfile(uid)
|
||||
roster = append(roster, dto.PlayerBrief{
|
||||
UserID: uid, Nickname: nickname, Avatar: avatar, Seat: seat, Online: true,
|
||||
})
|
||||
seat++
|
||||
}
|
||||
aiUIDs := make([]int64, 0, aiCount)
|
||||
for i := 0; i < aiCount; i++ { // AI:用拟人昵称,与其它玩家同构
|
||||
uid := game.NewAIUID()
|
||||
nickname, avatar := game.AIProfile(uid)
|
||||
aiUIDs = append(aiUIDs, uid)
|
||||
roster = append(roster, dto.PlayerBrief{
|
||||
UserID: uid, Nickname: nickname, Avatar: avatar, Seat: seat, Online: true,
|
||||
})
|
||||
seat++
|
||||
}
|
||||
|
||||
m.mu.Lock() // 加写锁
|
||||
r, err := newRoom(newRoomID(), gameID, seats, roster, 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 { // 逐个用户入座
|
||||
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()) // 绑定用户与房间
|
||||
}
|
||||
for _, uid := range aiUIDs { // AI 入座:不绑定(AI 没有连接,Hub 下发时会自然跳过)
|
||||
if err := r.Join(uid); err != nil {
|
||||
slog.Warn("room ai join failed on create", "room_id", r.ID(), "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", meta.MinSeats)
|
||||
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", seats, "ai", aiCount)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -99,17 +147,7 @@ func (m *Manager) unbind(uid int64) {
|
||||
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 处理连接断开:通知所在房间把该用户标记为掉线(进入重连宽限)。
|
||||
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线。
|
||||
// 由 boot 注册到 Hub 的断连回调上。
|
||||
func (m *Manager) OnUserDisconnected(uid int64) {
|
||||
r, ok := m.RoomOf(uid) // 查用户所在房间
|
||||
@@ -121,6 +159,35 @@ func (m *Manager) OnUserDisconnected(uid int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// onRoomClosed 是房间关闭回调:从表中移除并解绑全部成员。
|
||||
func (m *Manager) onRoomClosed(roomID string, uids []int64) {
|
||||
m.mu.Lock() // 加写锁
|
||||
delete(m.rooms, roomID) // 移除房间
|
||||
m.mu.Unlock() // 解锁
|
||||
for _, uid := range uids { // 解绑成员(AI 未绑定,删除空键无副作用)
|
||||
m.unbind(uid)
|
||||
}
|
||||
}
|
||||
|
||||
// lookProfile 取真实用户的昵称与头像;数据库不可用时降级为「玩家+ID」,绝不建房失败。
|
||||
func (m *Manager) lookProfile(uid int64) (nickname string, avatar string) {
|
||||
nickname, avatar = fmt.Sprintf("玩家%d", uid), "" // 默认降级值
|
||||
if m.userDao == nil || global.MysqlEngine == nil { // 未初始化数据库
|
||||
return nickname, avatar
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // 建房路径不能被 DB 拖慢
|
||||
defer cancel()
|
||||
u, err := m.userDao.UserGetByID(ctx, uid) // 查询用户
|
||||
if err != nil || u == nil { // 查不到就用默认值
|
||||
return nickname, avatar
|
||||
}
|
||||
if u.Username != "" { // 以库里昵称为准
|
||||
nickname = u.Username
|
||||
}
|
||||
avatar = u.Avatar // 头像为空时客户端用默认头像
|
||||
return nickname, avatar
|
||||
}
|
||||
|
||||
// maxRooms 返回房间总数上限。
|
||||
func maxRooms() int {
|
||||
if global.WSSetting != nil && global.WSSetting.MaxRooms > 0 { // 配置了
|
||||
|
||||
+27
-21
@@ -68,16 +68,17 @@ type Room struct {
|
||||
lastActive time.Time // 最近活跃时间,用于空闲回收
|
||||
finish *game.Finish // 对局结果
|
||||
tickInterval time.Duration // 事件循环驱动周期:实时玩法按 Meta.TickMs 加速
|
||||
roster []dto.PlayerBrief // 玩家名册(昵称/头像/座位):客户端据此渲染,不含任何 AI 标记
|
||||
}
|
||||
|
||||
// newRoom 创建房间并启动事件循环。
|
||||
func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
||||
func newRoom(id, gameID string, seats int, roster []dto.PlayerBrief, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
||||
g, err := game.New(gameID) // 按 ID 建玩法实例
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := g.Meta() // 取玩法元信息
|
||||
st, err := g.Init(meta.MinSeats) // 按最少人数初始化状态
|
||||
meta := g.Meta() // 取玩法元信息
|
||||
st, err := g.Init(seats) // 按本局实际人数初始化状态
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,19 +90,20 @@ func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int6
|
||||
}
|
||||
|
||||
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, // 驱动周期(实时玩法更快)
|
||||
id: id, // 房间 ID
|
||||
gameID: gameID, // 游戏 ID
|
||||
g: g, // 玩法实例
|
||||
state: st, // 玩法状态
|
||||
seats: make([]int64, seats), // 按本局人数建座位表
|
||||
members: make(map[int64]*member), // 成员表
|
||||
status: StatusWaiting, // 初始为等待
|
||||
reqCh: make(chan message, 64), // 请求缓冲队列
|
||||
closed: make(chan struct{}), // 关闭信号
|
||||
hub: hub, // 下发通道
|
||||
onClosed: onClosed, // 关闭回调
|
||||
lastActive: time.Now(), // 初始活跃时间
|
||||
roster: roster, // 玩家名册(含 AI,对外无标识)
|
||||
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
|
||||
}
|
||||
go r.run() // 启动单 goroutine 事件循环
|
||||
return r, nil
|
||||
@@ -247,9 +249,9 @@ func (r *Room) handleJoin(uid int64) error {
|
||||
return errors.New("room is full")
|
||||
}
|
||||
|
||||
r.seats[seat] = uid // 占座
|
||||
r.members[uid] = &member{uid: uid, seat: seat, online: true} // 登记成员
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
r.seats[seat] = uid // 占座
|
||||
r.members[uid] = &member{uid: uid, seat: seat, online: true, ready: game.IsAIUID(uid)} // 登记成员(AI 入座即自动准备)
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
|
||||
events, err := r.g.OnJoin(uid, r.state) // 通知玩法
|
||||
if err != nil { // 玩法拒绝(如人数已满)
|
||||
@@ -384,7 +386,7 @@ func (r *Room) finishGame(f *game.Finish) {
|
||||
"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,
|
||||
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Players: r.roster, Scores: f.Scores, Reason: f.Reason,
|
||||
})
|
||||
|
||||
go func() { // 延时关闭,给客户端留出查看结算的时间
|
||||
@@ -428,6 +430,10 @@ func (r *Room) tick(now time.Time) {
|
||||
|
||||
// 实时玩法驱动:每秒把时间交给玩法(回合制玩法可实现为空)。
|
||||
if r.status == StatusPlaying {
|
||||
r.driveAI(now.Unix()) // 先让 AI 玩家行动(对外不可感知)
|
||||
if r.status != StatusPlaying { // AI 行动后可能已分出胜负
|
||||
return
|
||||
}
|
||||
events, finish, err := r.g.OnTick(now.Unix(), r.state) // 调用玩法 tick
|
||||
if err != nil { // 玩法内部错误
|
||||
slog.Error("room game tick error", "room_id", r.id, "error", err)
|
||||
@@ -483,7 +489,7 @@ func (r *Room) broadcastState() {
|
||||
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,
|
||||
Ready: r.readyIDs(), State: snap, Players: r.roster,
|
||||
})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("room encode state failed", "room_id", r.id, "error", err)
|
||||
|
||||
@@ -89,11 +89,13 @@ type WSSettingS struct {
|
||||
OriginPatterns []string // 允许的 Origin 域名列表:浏览器调试用,小游戏连接通常不带 Origin
|
||||
|
||||
// ===== 以下为房间 / 匹配预留(第二步填充) =====
|
||||
ReconnectGraceSec int // 掉线后保留座位的时长(秒):超时未重连则判负或平局
|
||||
RoomIdleTimeoutSec int // 房间空闲自动关闭的时长(秒)
|
||||
MatchTimeoutSec int // 匹配排队超过该时长(秒)自动取消
|
||||
QueueTickMs int // 匹配队列的扫描间隔(毫秒)
|
||||
MaxRooms int // 同时存在的房间数上限,防止内存失控
|
||||
ReconnectGraceSec int // 掉线后保留座位的时长(秒):超时未重连则判负或平局
|
||||
RoomIdleTimeoutSec int // 房间空闲自动关闭的时长(秒)
|
||||
MatchTimeoutSec int // 匹配排队超过该时长(秒)自动取消
|
||||
QueueTickMs int // 匹配队列的扫描间隔(毫秒)
|
||||
MaxRooms int // 同时存在的房间数上限,防止内存失控
|
||||
AIEnabled bool // 匹配超时后是否用 AI 补齐空位(对外不可感知)
|
||||
AIWaitSec int // 排队超过该秒数仍未凑满则补 AI;0 表示不启用
|
||||
}
|
||||
|
||||
func (s *Setting) ReadSection(k string, v any) error {
|
||||
|
||||
+121
-67
@@ -8,59 +8,91 @@
|
||||
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; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.row > .panel { flex: 1 1 260px; }
|
||||
button { margin-right: 6px; padding: 4px 10px; cursor: pointer; }
|
||||
input { width: 70px; }
|
||||
input { width: 60px; }
|
||||
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.snake2 { background: #ff9800; }
|
||||
.cell.snake3 { background: #9c27b0; }
|
||||
.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; }
|
||||
.cell.head2 { background: #e65100; }
|
||||
.cell.head3 { background: #6a1b9a; }
|
||||
.cell.food { background: #f44336; border-radius: 50%; }
|
||||
.cell.stone1 { color: #333; }
|
||||
.cell.stone2 { color: #888; }
|
||||
#log { width: 100%; height: 240px; 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>(点一下本页获得键盘焦点)。
|
||||
通用流程:选玩法(贪吃蛇再选人数)→ 各玩家「取 token」→「连接」→「匹配」→「准备」。<br>
|
||||
<b>五子棋</b>:点棋盘空格落子。<b>贪吃蛇</b>:玩家1 方向键 ↑→↓←,玩家2 W/D/S/A,玩家3 I/L/K/J,玩家4 T/G/H/F(先点一下本页获取键盘焦点)。
|
||||
</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>
|
||||
<h2>玩法与人数</h2>
|
||||
玩法
|
||||
<select id="game" onchange="onGameChange()">
|
||||
<option value="gobang">五子棋(15×15,2 人,回合制)</option>
|
||||
<option value="snake">贪吃蛇(21×21,实时 200ms/步)</option>
|
||||
</select>
|
||||
<span id="seatsWrap" style="display:none">
|
||||
人数
|
||||
<select id="seats">
|
||||
<option value="2">2 人场</option>
|
||||
<option value="3">3 人场</option>
|
||||
<option value="4">4 人场</option>
|
||||
</select>
|
||||
</span>
|
||||
<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 class="row">
|
||||
<div class="panel"><h2>玩家 1</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>
|
||||
<div class="status" id="stA">未连接</div>
|
||||
</div>
|
||||
<div class="panel"><h2>玩家 2</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>
|
||||
<div class="status" id="stB">未连接</div>
|
||||
</div>
|
||||
<div class="panel" id="panelC" style="display:none"><h2>玩家 3</h2>
|
||||
用户ID <input id="uidC" value="3">
|
||||
<button onclick="getToken('C')">取 token</button>
|
||||
<button onclick="connect('C')">连接</button>
|
||||
<button onclick="match('C')">匹配</button>
|
||||
<button onclick="ready('C')">准备</button>
|
||||
<button onclick="leave('C')">离开</button>
|
||||
<div class="status" id="stC">未连接</div>
|
||||
</div>
|
||||
<div class="panel" id="panelD" style="display:none"><h2>玩家 4</h2>
|
||||
用户ID <input id="uidD" value="4">
|
||||
<button onclick="getToken('D')">取 token</button>
|
||||
<button onclick="connect('D')">连接</button>
|
||||
<button onclick="match('D')">匹配</button>
|
||||
<button onclick="ready('D')">准备</button>
|
||||
<button onclick="leave('D')">离开</button>
|
||||
<div class="status" id="stD">未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@@ -70,25 +102,36 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>消息日志(贪吃蛇的 tick 事件很密集,默认只记非 tick 消息)</h2>
|
||||
<h2>消息日志(贪吃蛇的 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; // 贪吃蛇最新画面数据
|
||||
const KEYS = ['A', 'B', 'C', 'D'];
|
||||
const S = {};
|
||||
KEYS.forEach(k => { S[k] = { uid: 0, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] }; });
|
||||
let snakeData = null;
|
||||
|
||||
function gameId() { return document.getElementById('game').value; }
|
||||
function seats() { return parseInt(document.getElementById('seats').value, 10); }
|
||||
|
||||
function log(msg) {
|
||||
const el = document.getElementById('log');
|
||||
el.value += new Date().toLocaleTimeString() + ' ' + msg + '\n';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function onGameChange() {
|
||||
const isSnake = gameId() === 'snake';
|
||||
document.getElementById('seatsWrap').style.display = isSnake ? 'inline' : 'none';
|
||||
const n = isSnake ? seats() : 2;
|
||||
document.getElementById('panelC').style.display = n >= 3 ? '' : 'none';
|
||||
document.getElementById('panelD').style.display = n >= 4 ? '' : 'none';
|
||||
resetBoard();
|
||||
}
|
||||
document.getElementById('seats').addEventListener('change', onGameChange);
|
||||
|
||||
async function getToken(k) {
|
||||
const uid = document.getElementById('uid' + k).value;
|
||||
const res = await fetch('/api/v1/dev/token?user_id=' + uid);
|
||||
@@ -117,7 +160,11 @@ function send(k, type, payload) {
|
||||
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 match(k) {
|
||||
const p = gameId() === 'snake' ? { game_id: gameId(), seats: seats() } : { game_id: gameId() };
|
||||
send(k, 'hall.match', p);
|
||||
}
|
||||
function ready(k) { send(k, 'room.ready', { ready: true }); }
|
||||
function leave(k) { send(k, 'room.leave', {}); }
|
||||
|
||||
@@ -127,6 +174,10 @@ function handle(k, env) {
|
||||
log(k + ' ← ' + env.type + ' ' + (env.payload ? JSON.stringify(env.payload) : ''));
|
||||
}
|
||||
|
||||
if (env.type === 'hall.matched') {
|
||||
log(k + ' 匹配成功:' + JSON.stringify(env.payload));
|
||||
}
|
||||
|
||||
if (env.type === 'room.state') {
|
||||
const p = env.payload || {};
|
||||
S[k].room = p.room_id || S[k].room;
|
||||
@@ -145,7 +196,7 @@ function handle(k, env) {
|
||||
}
|
||||
}
|
||||
|
||||
if (env.type === 'room.event' && env.payload && env.payload.event === 'tick' && gameId() === 'snake') {
|
||||
if (isTick && gameId() === 'snake') {
|
||||
const d = env.payload.payload || {};
|
||||
snakeData = { size: d.size, snakes: d.snakes, foods: d.foods || [] };
|
||||
renderSnake();
|
||||
@@ -159,7 +210,8 @@ function handle(k, env) {
|
||||
}
|
||||
|
||||
function resetBoard() {
|
||||
S.A.board = []; S.B.board = []; snakeData = null;
|
||||
KEYS.forEach(k => { S[k].board = []; });
|
||||
snakeData = null;
|
||||
document.getElementById('scores').textContent = '';
|
||||
render();
|
||||
}
|
||||
@@ -169,18 +221,17 @@ function render() {
|
||||
grid.innerHTML = '';
|
||||
if (gameId() === 'snake') { renderSnake(); return; }
|
||||
|
||||
const ref = S.A.board.length ? S.A : S.B;
|
||||
const ref = KEYS.map(k => S[k]).find(s => s.board.length) || S.A;
|
||||
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;
|
||||
const cell = 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.className = 'cell' + (v === 1 ? ' stone1' : v === 2 ? ' stone2' : '');
|
||||
el.style.width = cell + 'px';
|
||||
el.style.height = cell + 'px';
|
||||
el.style.lineHeight = cell + 'px';
|
||||
@@ -188,7 +239,6 @@ function render() {
|
||||
el.onclick = () => place(i);
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
const turnUid = (ref.players || [])[ref.turn || 0];
|
||||
document.getElementById('turn').textContent = turnUid ? '轮到 uid=' + turnUid : '等待开局';
|
||||
}
|
||||
@@ -202,7 +252,7 @@ function renderSnake() {
|
||||
const cell = 20;
|
||||
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
|
||||
|
||||
const owner = {}; // 坐标 -> 蛇序号
|
||||
const owner = {};
|
||||
snakeData.snakes.forEach((sn, si) => {
|
||||
sn.body.forEach((b, bi) => { owner[b] = { si: si, head: bi === 0 }; });
|
||||
});
|
||||
@@ -220,38 +270,42 @@ function renderSnake() {
|
||||
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';
|
||||
document.getElementById('scores').textContent = snakeData.snakes
|
||||
.map(sn => 'uid=' + sn.user_id + ' 分' + sn.score + (sn.alive ? '' : '(阵亡)')).join(' | ');
|
||||
document.getElementById('turn').textContent = '贪吃蛇进行中:1=方向键 2=WASD 3=IJKL 4=TFGH';
|
||||
}
|
||||
|
||||
function place(index) {
|
||||
if (gameId() === 'snake') return;
|
||||
const ref = S.A.board.length ? S.A : S.B;
|
||||
const ref = KEYS.map(k => S[k]).find(s => s.board.length) || S.A;
|
||||
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 });
|
||||
const who = KEYS.find(k => S[k].uid === turnUid);
|
||||
if (!who) { log('现在不是已连接玩家的回合,或尚未开局'); return; }
|
||||
send(who, 'room.action', { action: 'place', payload: { row: Math.floor(index / n), col: index % n } });
|
||||
}
|
||||
|
||||
function turn(k, dir) {
|
||||
if (gameId() !== 'snake') return;
|
||||
send(k, 'room.action', { action: 'turn', payload: { dir: dir } });
|
||||
}
|
||||
// 四个玩家各自的键位
|
||||
const KEYMAP = {
|
||||
A: { ArrowUp: 'up', ArrowRight: 'right', ArrowDown: 'down', ArrowLeft: 'left' },
|
||||
B: { w: 'up', d: 'right', s: 'down', a: 'left' },
|
||||
C: { i: 'up', l: 'right', k: 'down', j: 'left' },
|
||||
D: { t: 'up', g: 'right', h: 'down', f: 'left' },
|
||||
};
|
||||
|
||||
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(); }
|
||||
if (gameId() !== 'snake') return;
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
for (const k of KEYS) {
|
||||
if (KEYMAP[k][key]) {
|
||||
send(k, 'room.action', { action: 'turn', payload: { dir: KEYMAP[k][key] } });
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onGameChange();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user