440 lines
14 KiB
Go
440 lines
14 KiB
Go
// 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"
|
||
}
|