支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package snake // 与 snake.go 同包,可直接访问 state 与方向工具
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:生成 turn 动作的 payload
|
||||
"math/rand" // 标准库:随机化候选方向,制造拟人失误
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// TickAI 为 AI 蛇生成转向指令(实现 game.AIBrain)。
|
||||
//
|
||||
// 拟人要点:
|
||||
// - 只在方向需要改变时才发指令(真人也不会每帧狂按方向键)
|
||||
// - 候选方向里混入随机顺序,偶尔会走"不是最优"的一步,反应不会过于机械
|
||||
// - 绝不使用全局最优解,只做「朝最近食物 + 保证这一步不死」的贪心判断
|
||||
func (g *Game) TickAI(now int64, s game.State) []game.AIAction {
|
||||
st, ok := s.(*state) // 断言为本玩法状态
|
||||
if !ok || st.Over { // 类型错误或已结束
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]game.AIAction, 0, len(st.Snakes))
|
||||
for _, sn := range st.Snakes {
|
||||
if !sn.Alive || !game.IsAIUID(sn.UserID) { // 只操作存活的 AI 蛇
|
||||
continue
|
||||
}
|
||||
dir := pickDir(st, sn) // 选方向
|
||||
if dir == sn.Dir { // 与当前方向一致:无需发指令
|
||||
continue
|
||||
}
|
||||
if len(sn.Body) > 1 && (sn.Dir+2)%4 == dir { // 禁止 180 度掉头
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(turnPayload{Dir: dirName(dir)}) // 组装动作
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, game.AIAction{UID: sn.UserID, Action: game.Action{Name: "turn", Payload: payload}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pickDir 选一个尽量朝向最近食物、且下一步不会立刻死掉的方向。
|
||||
func pickDir(st *state, sn *snakeState) int {
|
||||
hr, hc := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0]) // 当前蛇头
|
||||
|
||||
// 1) 找最近的食物,派生期望方向(先缩小行差,再缩小列差)
|
||||
want := make([]int, 0, 2)
|
||||
best, bestDist := -1, int(1<<30)
|
||||
for _, f := range st.Foods {
|
||||
fr, fc := st.rowOf(f), st.colOf(f)
|
||||
if d := absInt(fr-hr) + absInt(fc-hc); d < bestDist {
|
||||
bestDist, best = d, f
|
||||
}
|
||||
}
|
||||
if best >= 0 {
|
||||
fr, fc := st.rowOf(best), st.colOf(best)
|
||||
if fr > hr {
|
||||
want = append(want, dirDown)
|
||||
} else if fr < hr {
|
||||
want = append(want, dirUp)
|
||||
}
|
||||
if fc > hc {
|
||||
want = append(want, dirRight)
|
||||
} else if fc < hc {
|
||||
want = append(want, dirLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 保持当前方向次之
|
||||
want = append(want, sn.Dir)
|
||||
|
||||
// 3) 其余方向随机排序:制造拟人失误,避免四条 AI 走出完全相同的路线
|
||||
rest := make([]int, 0, 4)
|
||||
for d := 0; d < 4; d++ {
|
||||
if d == sn.Dir { // 已在 want 中
|
||||
continue
|
||||
}
|
||||
dup := false
|
||||
for _, w := range want {
|
||||
if w == d {
|
||||
dup = true
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
rand.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] })
|
||||
|
||||
// 4) 按顺序取第一个安全方向;都不安全就维持原方向(坦然赴死,符合真实对局)
|
||||
for _, d := range append(want, rest...) {
|
||||
if isSafe(st, sn, d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return sn.Dir
|
||||
}
|
||||
|
||||
// isSafe 判断朝该方向走一步是否安全:不撞墙、不撞到任意蛇身。
|
||||
func isSafe(st *state, sn *snakeState, dir int) bool {
|
||||
hr, hc := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0])
|
||||
nr, nc := hr+dirOffsets[dir][0], hc+dirOffsets[dir][1]
|
||||
if nr < 0 || nr >= st.Size || nc < 0 || nc >= st.Size { // 越界即撞墙
|
||||
return false
|
||||
}
|
||||
next := st.idx(nr, nc)
|
||||
for _, other := range st.Snakes { // 检查所有蛇身
|
||||
for k, b := range other.Body {
|
||||
if k == len(other.Body)-1 { // 尾节点本回合会让位,忽略
|
||||
continue
|
||||
}
|
||||
if b == next { // 撞身
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// absInt 返回整数绝对值。
|
||||
func absInt(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user