From adb31eec68fff312e67b08f298773dac3093c81f Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Mon, 28 Sep 2026 00:41:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E4=BA=BA=E6=95=B0?= =?UTF-8?q?=E5=AF=B9=E5=B1=80=E4=B8=8E=20AI=20=E8=A1=A5=E4=BD=8D=EF=BC=9A?= =?UTF-8?q?=E7=8E=A9=E6=B3=95=E5=A3=B0=E6=98=8E=E4=BA=BA=E6=95=B0=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=81=E5=8C=B9=E9=85=8D=E5=88=86=E9=98=9F=E5=88=97?= =?UTF-8?q?=E4=B8=8E=E8=B6=85=E6=97=B6=E8=A1=A5=20AI=E3=80=81=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E7=8E=A9=E5=AE=B6=E5=90=8D=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.example.yaml | 2 + internal/boot/boot.go | 7 +- internal/dto/ws.go | 48 ++++--- internal/game/ai.go | 49 +++++++ internal/game/engine.go | 25 +++- internal/game/gobang/gobang.go | 6 +- internal/game/gobang/gobang_ai.go | 139 ++++++++++++++++++++ internal/game/snake/snake.go | 120 ++++++++++++----- internal/game/snake/snake_ai.go | 127 ++++++++++++++++++ internal/game/tictactoe/tictactoe.go | 155 ---------------------- internal/hall/handlers.go | 2 +- internal/hall/matcher.go | 124 +++++++++++++----- internal/room/ai.go | 50 +++++++ internal/room/manager.go | 119 +++++++++++++---- internal/room/room.go | 48 ++++--- pkg/setting/section.go | 12 +- web/ws-test.html | 188 +++++++++++++++++---------- 17 files changed, 854 insertions(+), 367 deletions(-) create mode 100644 internal/game/ai.go create mode 100644 internal/game/gobang/gobang_ai.go create mode 100644 internal/game/snake/snake_ai.go delete mode 100644 internal/game/tictactoe/tictactoe.go create mode 100644 internal/room/ai.go diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 1ba7da9..eae4497 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -73,3 +73,5 @@ WS: MatchTimeoutSec: 60 # 匹配排队超过该时长(秒)自动取消 QueueTickMs: 1000 # 匹配队列的扫描间隔(毫秒) MaxRooms: 1000 # 同时存在的房间数上限,防止内存失控 + AIEnabled: true # 匹配超时后用 AI 补齐空位(对外不可感知) + AIWaitSec: 30 # 排队超过该秒数仍未凑满则补 AI;0 表示不启用 diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 27e9899..0abf6ec 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -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) // 创建房间管理器 diff --git a/internal/dto/ws.go b/internal/dto/ws.go index 21a9e6a..e0eb286 100644 --- a/internal/dto/ws.go +++ b/internal/dto/ws.go @@ -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 的下行体:房间已关闭。 diff --git a/internal/game/ai.go b/internal/game/ai.go new file mode 100644 index 0000000..3fe5135 --- /dev/null +++ b/internal/game/ai.go @@ -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 +} diff --git a/internal/game/engine.go b/internal/game/engine.go index 4df4d63..a22e1b0 100644 --- a/internal/game/engine.go +++ b/internal/game/engine.go @@ -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 +} diff --git a/internal/game/gobang/gobang.go b/internal/game/gobang/gobang.go index 3561779..35c0f86 100644 --- a/internal/game/gobang/gobang.go +++ b/internal/game/gobang/gobang.go @@ -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{} } diff --git a/internal/game/gobang/gobang_ai.go b/internal/game/gobang/gobang_ai.go new file mode 100644 index 0000000..d29d0d8 --- /dev/null +++ b/internal/game/gobang/gobang_ai.go @@ -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 +} diff --git a/internal/game/snake/snake.go b/internal/game/snake/snake.go index b6e4d18..a7feeba 100644 --- a/internal/game/snake/snake.go +++ b/internal/game/snake/snake.go @@ -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)) diff --git a/internal/game/snake/snake_ai.go b/internal/game/snake/snake_ai.go new file mode 100644 index 0000000..2790d3c --- /dev/null +++ b/internal/game/snake/snake_ai.go @@ -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 +} diff --git a/internal/game/tictactoe/tictactoe.go b/internal/game/tictactoe/tictactoe.go deleted file mode 100644 index 05f0580..0000000 --- a/internal/game/tictactoe/tictactoe.go +++ /dev/null @@ -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 // 未分出胜负 -} diff --git a/internal/hall/handlers.go b/internal/hall/handlers.go index 64236c3..3f1cb7b 100644 --- a/internal/hall/handlers.go +++ b/internal/hall/handlers.go @@ -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 } diff --git a/internal/hall/matcher.go b/internal/hall/matcher.go index 12e8244..0f67a30 100644 --- a/internal/hall/matcher.go +++ b/internal/hall/matcher.go @@ -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 秒 +} diff --git a/internal/room/ai.go b/internal/room/ai.go new file mode 100644 index 0000000..58eba7d --- /dev/null +++ b/internal/room/ai.go @@ -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 +} diff --git a/internal/room/manager.go b/internal/room/manager.go index f108a69..051e788 100644 --- a/internal/room/manager.go +++ b/internal/room/manager.go @@ -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 { // 配置了 diff --git a/internal/room/room.go b/internal/room/room.go index e8ee226..310669e 100644 --- a/internal/room/room.go +++ b/internal/room/room.go @@ -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) diff --git a/pkg/setting/section.go b/pkg/setting/section.go index 4beabea..e8be5ef 100644 --- a/pkg/setting/section.go +++ b/pkg/setting/section.go @@ -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 { diff --git a/web/ws-test.html b/web/ws-test.html index 366288e..a7085c4 100644 --- a/web/ws-test.html +++ b/web/ws-test.html @@ -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; }
- 通用流程:① 选玩法 → ② A、B 各「取 token」→ ③ 各「连接」→ ④ 各「匹配」→ ⑤ 各「准备」。
- 五子棋 / 井字棋:在棋盘上点空格落子。贪吃蛇:玩家 A 用方向键 ↑→↓←,玩家 B 用 W / D / S / A(点一下本页获得键盘焦点)。
+ 通用流程:选玩法(贪吃蛇再选人数)→ 各玩家「取 token」→「连接」→「匹配」→「准备」。
+ 五子棋:点棋盘空格落子。贪吃蛇:玩家1 方向键 ↑→↓←,玩家2 W/D/S/A,玩家3 I/L/K/J,玩家4 T/G/H/F(先点一下本页获取键盘焦点)。