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