支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册

This commit is contained in:
2026-09-28 00:41:28 +08:00
parent fb5a730ef3
commit adb31eec68
17 changed files with 854 additions and 367 deletions
+2
View File
@@ -73,3 +73,5 @@ WS:
MatchTimeoutSec: 60 # 匹配排队超过该时长(秒)自动取消
QueueTickMs: 1000 # 匹配队列的扫描间隔(毫秒)
MaxRooms: 1000 # 同时存在的房间数上限,防止内存失控
AIEnabled: true # 匹配超时后用 AI 补齐空位(对外不可感知)
AIWaitSec: 30 # 排队超过该秒数仍未凑满则补 AI;0 表示不启用
+3 -4
View File
@@ -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
View File
@@ -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 的下行体:房间已关闭。
+49
View File
@@ -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
View File
@@ -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
}
+5 -1
View File
@@ -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{} }
+139
View File
@@ -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
}
+85 -35
View File
@@ -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))
+127
View File
@@ -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
}
-155
View File
@@ -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 // 未分出胜负
}
+1 -1
View File
@@ -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
View File
@@ -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 秒
}
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -5
View File
@@ -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
View File
@@ -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>