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

440 lines
14 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 是多人贪吃蛇玩法: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"
}