Files
zt-online-game-backend/internal/game/snake/snake.go
T

390 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
}