支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册

This commit is contained in:
2026-09-28 00:41:28 +08:00
parent fb5a730ef3
commit adb31eec68
17 changed files with 854 additions and 367 deletions
+85 -35
View File
@@ -1,4 +1,4 @@
// Package snake 是双人贪吃蛇玩法:两人同场竞技的实时玩法。
// Package snake 是多人贪吃蛇玩法:2~4 人同场竞技的实时玩法。
// 与回合制玩法(gobang / tictactoe)不同,本玩法由房间的 tick 循环持续推进:
// 玩家只发「转向」指令,位置推进、吃食物、碰撞判定都在 OnTick 中完成。
package snake
@@ -6,6 +6,7 @@ package snake
import (
"encoding/json" // 标准库:解析 turn 动作参数
"errors" // 标准库:返回玩法内校验错误
"fmt" // 标准库:人数不合法时返回明确错误
"math/rand" // 标准库:随机生成食物位置
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
@@ -17,6 +18,10 @@ const (
tickMs = 200 // 推进周期(毫秒):每秒 5 步,通过 Meta.TickMs 交给房间
initLen = 3 // 初始蛇长
targetScore = 20 // 先吃满该分数直接获胜;0 表示只比谁活得久
// 人数范围:支持双人场、三人场、四人场(客户端匹配时指定具体人数)。
minSeats = 2
maxSeats = 4
)
// 方向:上右下左(顺时针);反向为 (dir+2)%4。
@@ -30,6 +35,34 @@ const (
// 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
@@ -47,7 +80,7 @@ type snakeState struct {
// state 是本玩法私有状态,房间只透传不解析。
type state struct {
Size int `json:"size"` // 棋盘边长
Snakes []*snakeState `json:"snakes"` // 两条蛇
Snakes []*snakeState `json:"snakes"` // 全部蛇(2~4 条)
Foods []int `json:"foods"` // 食物坐标(一维下标)
Over bool `json:"over"` // 是否已结束
}
@@ -58,28 +91,35 @@ type Game struct{}
// New 创建玩法实例:每局一个,状态互不影响。
func New() *Game { return &Game{} }
// Meta 声明本玩法为 2 人实时玩法,并指定 200ms 的推进周期。
// Meta 声明本玩法支持 2~4 人实时对局,并指定 200ms 的推进周期。
func (g *Game) Meta() game.Meta {
return game.Meta{
GameID: gameID, MinSeats: 2, MaxSeats: 2,
GameID: gameID, MinSeats: minSeats, MaxSeats: maxSeats,
Realtime: true, AllowJoin: false, TickMs: tickMs,
}
}
// Init 初始化棋盘:两条蛇分别从左右两侧中间出发,方向相对。
// Init 按人数初始化棋盘:每条蛇占一个出生点,食物数量与人数相同。
func (g *Game) Init(seatCount int) (game.State, error) {
if seatCount != 2 { // 固定两人
return nil, errors.New("snake requires 2 seats")
if seatCount < minSeats || seatCount > maxSeats { // 人数越界
return nil, fmt.Errorf("snake requires %d~%d seats, got %d", minSeats, maxSeats, seatCount)
}
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 := &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)
}
}
st.Foods = []int{st.spawnFood()} // 初始一个食物
return st, nil
}
@@ -141,7 +181,7 @@ func (g *Game) OnJoin(uid int64, s game.State) ([]game.Event, error) {
return nil, errors.New("room is full")
}
// OnLeave 玩家离座:直接判对手胜。
// OnLeave 玩家离座:该蛇直接死亡;若只剩一条存活则对手获胜。
func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, error) {
st, ok := s.(*state) // 断言状态
if !ok {
@@ -149,16 +189,19 @@ func (g *Game) OnLeave(uid int64, s game.State) ([]game.Event, *game.Finish, err
}
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
}
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
}
return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}},
&game.Finish{Winner: winner, 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 // 非本局成员:无事件
@@ -204,6 +247,7 @@ func (g *Game) OnAction(uid int64, a game.Action, s game.State) ([]game.Event, *
}
// OnTick 推进一步:计算新头位置 → 判定碰撞 → 移动与吃食物 → 判定胜负。
// 逻辑与人数无关:所有蛇都在同一批里先算后改,保证「同时发生」的判定公平。
func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) {
st, ok := s.(*state) // 断言状态
if !ok || st.Over { // 类型错误或已结束
@@ -260,7 +304,7 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
dead[i] = true
}
}
for oi, other := range st.Snakes { // 撞到蛇身
for oi, other := range st.Snakes { // 撞到任意蛇身
for k, b := range other.Body {
// 对方本回合不吃食物时,尾节点会让位,不算撞
if k == len(other.Body)-1 && !grow[oi] {
@@ -299,7 +343,7 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
}
}
// 5. 组装事件:每步一次 tick(含全量可渲染数据),死亡额外发 died
// 5. 组装事件:每步一次 tick(含全量可渲染数据),本回合死亡额外发 died
events := []game.Event{{Name: "tick", Payload: map[string]any{
"size": st.Size, "snakes": st.Snakes, "foods": st.Foods,
}}}
@@ -316,22 +360,17 @@ func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, erro
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)
}
}
alive := aliveSnakes(st)
if len(alive) <= 1 { // 分出结果
st.Over = true
var winner int64 // 零值表示平局(同时死亡)
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 // 对局继续
return events, nil, nil // 对局继续(多人场剩多条蛇)
}
// Snapshot 生成对指定玩家可见的状态(贪吃蛇无隐藏信息,返回全量)。
@@ -349,6 +388,17 @@ func (g *Game) Snapshot(uid int64, s game.State) any {
}
}
// 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))