// Package snake 是多人贪吃蛇玩法:2~4 人同场竞技的实时玩法。 // 与回合制玩法(gobang / tictactoe)不同,本玩法由房间的 tick 循环持续推进: // 玩家只发「转向」指令,位置推进、吃食物、碰撞判定都在 OnTick 中完成。 package snake import ( "encoding/json" // 标准库:解析 turn 动作参数 "errors" // 标准库:返回玩法内校验错误 "fmt" // 标准库:人数不合法时返回明确错误 "math/rand" // 标准库:随机生成食物位置 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game" ) const ( gameID = "snake" // 玩法 ID:匹配队列与房间都以它索引 boardSize = 21 // 棋盘边长(21×21) tickMs = 200 // 推进周期(毫秒):每秒 5 步,通过 Meta.TickMs 交给房间 initLen = 3 // 初始蛇长 targetScore = 20 // 先吃满该分数直接获胜;0 表示只比谁活得久 // 人数范围:支持双人场、三人场、四人场(客户端匹配时指定具体人数)。 minSeats = 2 maxSeats = 4 ) // 方向:上右下左(顺时针);反向为 (dir+2)%4。 const ( dirUp = iota dirRight dirDown dirLeft ) // 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 } // snakeState 是单条蛇的状态。 type snakeState struct { UserID int64 `json:"user_id"` // 操控该蛇的用户 Body []int `json:"body"` // 身体坐标(一维下标),头在 [0] Dir int `json:"dir"` // 当前方向 Alive bool `json:"alive"` // 是否存活 Score int `json:"score"` // 吃到食物数 } // state 是本玩法私有状态,房间只透传不解析。 type state struct { Size int `json:"size"` // 棋盘边长 Snakes []*snakeState `json:"snakes"` // 全部蛇(2~4 条) Foods []int `json:"foods"` // 食物坐标(一维下标) Over bool `json:"over"` // 是否已结束 } // Game 实现 game.Game 接口。 type Game struct{} // New 创建玩法实例:每局一个,状态互不影响。 func New() *Game { return &Game{} } // Meta 声明本玩法支持 2~4 人实时对局,并指定 200ms 的推进周期。 func (g *Game) Meta() game.Meta { return game.Meta{ GameID: gameID, MinSeats: minSeats, MaxSeats: maxSeats, Realtime: true, AllowJoin: false, TickMs: tickMs, } } // Init 按人数初始化棋盘:每条蛇占一个出生点,食物数量与人数相同。 func (g *Game) Init(seatCount int) (game.State, error) { if seatCount < minSeats || seatCount > maxSeats { // 人数越界 return nil, fmt.Errorf("snake requires %d~%d seats, got %d", minSeats, maxSeats, seatCount) } 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) } } return st, nil } // bodyFrom 按「头位置 + 方向」生成初始长度为 initLen 的身体(头在 [0])。 func bodyFrom(row, col, dir int) []int { body := make([]int, 0, initLen) for i := 0; i < initLen; i++ { r := row - dirOffsets[dir][0]*i // 从头部向反方向延伸 c := col - dirOffsets[dir][1]*i body = append(body, r*boardSize+c) } return body } // idx 把行列换算成一维下标。 func (s *state) idx(row, col int) int { return row*s.Size + col } // rowOf / colOf 把一维下标还原为行列。 func (s *state) rowOf(i int) int { return i / s.Size } func (s *state) colOf(i int) int { return i % s.Size } // spawnFood 在空格中随机生成食物;无空格返回 -1。 func (s *state) spawnFood() int { occupied := make(map[int]bool, s.Size*s.Size) for _, sn := range s.Snakes { // 蛇身占位 for _, b := range sn.Body { occupied[b] = true } } for _, f := range s.Foods { // 已有食物占位 occupied[f] = true } free := make([]int, 0, s.Size*s.Size) for i := 0; i < s.Size*s.Size; i++ { // 收集空格 if !occupied[i] { free = append(free, i) } } if len(free) == 0 { // 棋盘已满 return -1 } return free[rand.Intn(len(free))] // 随机取一个 } // OnJoin 玩家入座:按顺序绑定到某条蛇。 func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) { st, ok := s.(*state) // 断言为本玩法状态 if !ok { return nil, errors.New("invalid state type") } for i, sn := range st.Snakes { // 找未绑定的蛇 if sn.UserID == 0 { sn.UserID = uid return []game.Event{{Name: "seat", Payload: map[string]any{ "user_id": uid, "seat": i, "dir": dirName(sn.Dir), }}}, nil } } return nil, errors.New("room is full") } // OnLeave 玩家离座:该蛇直接死亡;若只剩一条存活则对手获胜。 func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) { st, ok := s.(*state) // 断言状态 if !ok { return nil, nil, errors.New("invalid state type") } for _, sn := range st.Snakes { // 找到离座者 if sn.UserID == uid { sn.Alive = false // 标记死亡,交由后续判定收尾 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 } 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 // 非本局成员:无事件 } // OnAction 处理转向指令:只允许改变方向,位置推进交给 OnTick。 func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *game.Finish, error) { st, ok := s.(*state) // 断言状态 if !ok { return nil, nil, errors.New("invalid state type") } if st.Over { // 已结束 return nil, nil, errors.New("game over") } if a.Name != "turn" { // 未知动作 return nil, nil, errors.New("unsupported action: " + a.Name) } var p turnPayload // 解析动作参数 if err := json.Unmarshal(a.Payload, &p); err != nil { return nil, nil, errors.New("invalid turn payload") } dir, ok := parseDir(p.Dir) // 方向名转枚举 if !ok { return nil, nil, errors.New("invalid direction") } for _, sn := range st.Snakes { // 找到该玩家操控的蛇 if sn.UserID != uid { continue } if !sn.Alive { // 已死亡 return nil, nil, errors.New("snake is dead") } // 禁止 180 度掉头(长度为 1 时允许) if len(sn.Body) > 1 && (sn.Dir+2)%4 == dir { return nil, nil, errors.New("cannot reverse") } sn.Dir = dir // 更新方向 return []game.Event{{Name: "turn", Payload: map[string]any{"user_id": uid, "dir": p.Dir}}}, nil, nil } return nil, nil, errors.New("not in game") } // OnTick 推进一步:计算新头位置 → 判定碰撞 → 移动与吃食物 → 判定胜负。 // 逻辑与人数无关:所有蛇都在同一批里先算后改,保证「同时发生」的判定公平。 func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) { st, ok := s.(*state) // 断言状态 if !ok || st.Over { // 类型错误或已结束 return nil, nil, nil } for _, sn := range st.Snakes { // 人数未齐(有人未入座)不推进 if sn.UserID == 0 { return nil, nil, nil } } // 1. 计算每条蛇的新头位置(-1 表示撞墙) newHead := make([]int, len(st.Snakes)) for i, sn := range st.Snakes { if !sn.Alive { // 已死亡不参与 newHead[i] = -1 continue } r, c := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0]) nr, nc := r+dirOffsets[sn.Dir][0], c+dirOffsets[sn.Dir][1] if nr < 0 || nr >= st.Size || nc < 0 || nc >= st.Size { // 越界即撞墙 newHead[i] = -1 continue } newHead[i] = st.idx(nr, nc) } // 2. 标记本回合是否吃到食物(决定是否增长) grow := make([]bool, len(st.Snakes)) for i, sn := range st.Snakes { if !sn.Alive || newHead[i] < 0 { continue } for _, f := range st.Foods { if f == newHead[i] { grow[i] = true } } } // 3. 死亡判定:撞墙、头对头、撞到任意蛇身 dead := make([]bool, len(st.Snakes)) for i, sn := range st.Snakes { if !sn.Alive { // 之前已死 dead[i] = true continue } if newHead[i] < 0 { // 撞墙 dead[i] = true continue } for j := range st.Snakes { // 头对头相撞:双方都死 if j != i && newHead[j] == newHead[i] { dead[i] = true } } for oi, other := range st.Snakes { // 撞到任意蛇身 for k, b := range other.Body { // 对方本回合不吃食物时,尾节点会让位,不算撞 if k == len(other.Body)-1 && !grow[oi] { continue } if b == newHead[i] { dead[i] = true } } } } // 4. 移动:吃到食物则增长并补充食物,否则去掉尾节点 for i, sn := range st.Snakes { if !sn.Alive { // 已死不动 continue } if dead[i] { // 本回合死亡 sn.Alive = false continue } sn.Body = append([]int{newHead[i]}, sn.Body...) // 新头插到最前 if grow[i] { // 吃到食物:保留尾巴即增长 sn.Score++ for fi, f := range st.Foods { // 移除被吃掉的食物 if f == newHead[i] { st.Foods = append(st.Foods[:fi], st.Foods[fi+1:]...) break } } if nf := st.spawnFood(); nf >= 0 { // 补充新食物 st.Foods = append(st.Foods, nf) } } else { // 未吃到:去掉尾节点 sn.Body = sn.Body[:len(sn.Body)-1] } } // 5. 组装事件:每步一次 tick(含全量可渲染数据),本回合死亡额外发 died events := []game.Event{{Name: "tick", Payload: map[string]any{ "size": st.Size, "snakes": st.Snakes, "foods": st.Foods, }}} for i, sn := range st.Snakes { if dead[i] { events = append(events, game.Event{Name: "died", Payload: map[string]any{"user_id": sn.UserID}}) } } // 6. 胜负判定:先达标者胜;只剩一条存活则该蛇胜;全灭为平局 for _, sn := range st.Snakes { if targetScore > 0 && sn.Alive && sn.Score >= targetScore { st.Over = true return events, &game.Finish{Winner: sn.UserID, Scores: scores(st), Reason: "normal"}, nil } } alive := aliveSnakes(st) if len(alive) <= 1 { // 分出结果 st.Over = true var winner int64 // 零值表示平局(同时全灭) if len(alive) == 1 { winner = alive[0].UserID } return events, &game.Finish{Winner: winner, Scores: scores(st), Reason: "normal"}, nil } return events, nil, nil // 对局继续(多人场剩多条蛇) } // Snapshot 生成对指定玩家可见的状态(贪吃蛇无隐藏信息,返回全量)。 func (g *Game) Snapshot(uid int64, s game.State) any { st, ok := s.(*state) // 断言状态 if !ok { return nil } return map[string]any{ "size": st.Size, "snakes": st.Snakes, "foods": st.Foods, "over": st.Over, "tick": tickMs, // 客户端据此设定渲染节奏 } } // 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)) for _, sn := range st.Snakes { out[sn.UserID] = sn.Score } return out } // parseDir 把方向名解析为枚举。 func parseDir(name string) (int, bool) { switch name { case "up": return dirUp, true case "right": return dirRight, true case "down": return dirDown, true case "left": return dirLeft, true } return 0, false } // dirName 把方向枚举转回名称(事件与快照里可读)。 func dirName(dir int) string { switch dir { case dirUp: return "up" case dirRight: return "right" case dirDown: return "down" case dirLeft: return "left" } return "right" }