支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// 本文件提供 AI 玩家的身份与展示信息:
|
||||
// AI 使用独立号段(与真实自增 ID 完全隔离),并拥有稳定的拟人昵称,
|
||||
// 房间与匹配器据此补齐空位,客户端只看到昵称与头像,无法区分真人还是 AI。
|
||||
package game
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// aiUIDBase 是 AI 用户号段的起点:真实用户 ID 从 1 开始自增,永远不会到达该号段。
|
||||
const aiUIDBase = 900000000
|
||||
|
||||
// aiSeq 是进程内的 AI 序号分配器(原子自增,避免并发重复)。
|
||||
var aiSeq atomic.Int64
|
||||
|
||||
// NewAIUID 分配一个新的 AI 用户 ID。
|
||||
func NewAIUID() int64 {
|
||||
return aiUIDBase + aiSeq.Add(1)
|
||||
}
|
||||
|
||||
// IsAIUID 判断某个用户 ID 是否属于 AI 号段。
|
||||
func IsAIUID(uid int64) bool {
|
||||
return uid >= aiUIDBase
|
||||
}
|
||||
|
||||
// aiNames 是 AI 昵称池:看起来就是普通玩家的昵称,不含任何 bot/robot/AI 字样。
|
||||
var aiNames = []string{
|
||||
"路过的风", "小柚子", "阿呆", "一只猫", "晚安月亮", "橘子汽水",
|
||||
"慢慢来", "老张", "南风知我意", "会飞的鱼", "山野", "木木",
|
||||
"夏天的风", "不打伞", "小满", "青柠", "路人甲", "深海",
|
||||
}
|
||||
|
||||
// aiAvatars 是 AI 头像池:留空表示由客户端使用默认头像兜底。
|
||||
// 若你有默认头像资源,可在此填入可访问 URL,客户端将直接展示。
|
||||
var aiAvatars = []string{
|
||||
"", "", "", "", "", "",
|
||||
}
|
||||
|
||||
// AIProfile 返回指定 AI 的展示信息(昵称、头像)。
|
||||
// 同一 uid 永远映射到同一组合,保证一局之内昵称不会跳变。
|
||||
func AIProfile(uid int64) (nickname string, avatar string) {
|
||||
idx := int(uid-aiUIDBase) % len(aiNames)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
avatar = ""
|
||||
if idx < len(aiAvatars) {
|
||||
avatar = aiAvatars[idx]
|
||||
}
|
||||
return aiNames[idx], avatar
|
||||
}
|
||||
+22
-3
@@ -26,7 +26,7 @@ type Action struct {
|
||||
|
||||
// Event 描述「发给谁、发什么」:投递由房间完成,玩法不接触连接。
|
||||
type Event struct {
|
||||
To []int64 // 收件人;nil 或空表示房间内全体广播
|
||||
To []int64 // 收件人;nil 或空表示全体广播
|
||||
Name string // 事件名,客户端据此分发
|
||||
Payload any // 事件内容
|
||||
}
|
||||
@@ -35,7 +35,7 @@ type Event struct {
|
||||
type Finish struct {
|
||||
Winner int64 // 获胜用户 ID,0 表示平局
|
||||
Scores map[int64]int // 各用户得分,可为空
|
||||
Reason string // 结束原因:normal/opponent_leave/timeout
|
||||
Reason string // 结束原因:normal / opponent_leave / timeout
|
||||
}
|
||||
|
||||
// State 是玩法私有状态:房间只做透传,具体类型由玩法在 Init 中创建。
|
||||
@@ -52,6 +52,19 @@ type Game interface {
|
||||
Snapshot(uid int64, s State) any // 按观众裁剪的可见状态(重连/观战用)
|
||||
}
|
||||
|
||||
// AIAction 是 AI 玩家要执行的一次动作:带执行者,便于房间代它调用 OnAction。
|
||||
type AIAction struct {
|
||||
UID int64 // 执行者(AI 的 uid)
|
||||
Action Action // 要执行的动作
|
||||
}
|
||||
|
||||
// AIBrain 是可选接口:玩法实现它即可支持「AI 补位」。
|
||||
// 房间在每次推进前调用一次,把返回的动作代 AI 执行;不实现该接口的玩法不会被补 AI。
|
||||
type AIBrain interface {
|
||||
// TickAI 返回本回合所有 AI 玩家要执行的动作(含思考延迟由玩法内部决定)。
|
||||
TickAI(now int64, s State) []AIAction
|
||||
}
|
||||
|
||||
// Factory 创建玩法实例:每局一个新实例,保证状态隔离。
|
||||
type Factory func() Game
|
||||
|
||||
@@ -78,7 +91,7 @@ func New(gameID string) (Game, error) {
|
||||
return f(), nil // 返回新实例
|
||||
}
|
||||
|
||||
// LookupMeta 取已注册玩法的元信息:匹配器用它决定队列人数。
|
||||
// LookupMeta 取已注册玩法的元信息(匹配器用它决定队列人数)。
|
||||
func LookupMeta(gameID string) (Meta, error) {
|
||||
g, err := New(gameID) // 先建实例
|
||||
if err != nil { // 未注册
|
||||
@@ -86,3 +99,9 @@ func LookupMeta(gameID string) (Meta, error) {
|
||||
}
|
||||
return g.Meta(), nil // 返回元信息
|
||||
}
|
||||
|
||||
// HasAIBrain 判断玩法是否支持 AI 补位(实现了 AIBrain 接口)。
|
||||
func HasAIBrain(g Game) bool {
|
||||
_, ok := g.(AIBrain)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ type state struct {
|
||||
}
|
||||
|
||||
// Game 实现 game.Game 接口。
|
||||
type Game struct{}
|
||||
type Game struct {
|
||||
aiPlanIdx int // AI 计划落点(配合 aiHasPlan 使用)
|
||||
aiHasPlan bool // AI 是否已有落子计划
|
||||
aiThinkAt int64 // AI 落子时刻,模拟思考耗时
|
||||
}
|
||||
|
||||
// New 创建玩法实例:每局一个,状态互不影响。
|
||||
func New() *Game { return &Game{} }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package gobang // 与 gobang.go 同包,可直接访问 state 与 checkWin
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:生成 place 动作的 payload
|
||||
"math/rand" // 标准库:思考时长与落点随机,避免每局一模一样
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// TickAI 为 AI 玩家生成落子动作(实现 game.AIBrain)。
|
||||
//
|
||||
// 拟人要点:先「思考」0.6~1.5 秒再落子,绝不秒下;落点策略按
|
||||
// 「能赢就赢 → 对手要赢就堵 → 贴着已有棋子下 → 中心兜底」递减,
|
||||
// 既有棋力又不会强到反常。
|
||||
func (g *Game) TickAI(now int64, s game.State) []game.AIAction {
|
||||
st, ok := s.(*state) // 断言为本玩法状态
|
||||
if !ok || st.Winner != 0 || len(st.Players) != 2 { // 未开局或已分胜负
|
||||
return nil
|
||||
}
|
||||
|
||||
turnUID := st.Players[st.Turn] // 当前该走的人
|
||||
if !game.IsAIUID(turnUID) { // 不是 AI 的回合:丢弃未落子的计划
|
||||
g.aiPlanIdx = 0
|
||||
g.aiHasPlan = false
|
||||
return nil
|
||||
}
|
||||
|
||||
if !g.aiHasPlan { // 还没想好:选点并开始思考
|
||||
idx := g.pickMove(st, turnUID)
|
||||
if idx < 0 { // 无点可下(棋盘满)
|
||||
return nil
|
||||
}
|
||||
g.aiPlanIdx = idx
|
||||
g.aiHasPlan = true
|
||||
// 注意 now 是秒级时间戳,房间 tick 粒度也是 1 秒,因此思考时长只能按「秒」计:
|
||||
// 思考 1~2 秒再落子,既像人在思考,又不会让玩家等太久。
|
||||
g.aiThinkAt = now + int64(1+rand.Intn(2))
|
||||
return nil
|
||||
}
|
||||
if now < g.aiThinkAt { // 思考时间未到:继续等
|
||||
return nil
|
||||
}
|
||||
|
||||
idx := g.aiPlanIdx // 到点落子
|
||||
g.aiHasPlan = false
|
||||
g.aiPlanIdx = 0
|
||||
|
||||
payload, err := json.Marshal(placePayload{Row: idx / boardSize, Col: idx % boardSize})
|
||||
if err != nil { // 序列化失败:本回合不下
|
||||
return nil
|
||||
}
|
||||
return []game.AIAction{{UID: turnUID, Action: game.Action{Name: "place", Payload: payload}}}
|
||||
}
|
||||
|
||||
// pickMove 选落点:赢 → 堵 → 贴子 → 中心。
|
||||
func (g *Game) pickMove(st *state, aiUID int64) int {
|
||||
aiMark := markOf(st, aiUID) // AI 执黑(1) 还是执白(2)
|
||||
foeMark := 3 - aiMark // 对手的棋子
|
||||
|
||||
// 1) 能直接连五获胜:立刻下
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 { // 只考虑空点
|
||||
continue
|
||||
}
|
||||
st.Board[i] = aiMark // 试落
|
||||
win := checkWin(st.Board, i/boardSize, i%boardSize, aiMark) // 判断是否成五
|
||||
st.Board[i] = 0 // 还原
|
||||
if win {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 对手下一手就要成五:必须堵
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 {
|
||||
continue
|
||||
}
|
||||
st.Board[i] = foeMark
|
||||
win := checkWin(st.Board, i/boardSize, i%boardSize, foeMark)
|
||||
st.Board[i] = 0
|
||||
if win {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 贴着已有棋子下(像人一样连片),从候选里随机取一个
|
||||
cands := make([]int, 0, 64)
|
||||
for i := 0; i < len(st.Board); i++ {
|
||||
if st.Board[i] != 0 { // 空点
|
||||
continue
|
||||
}
|
||||
if !hasNeighbor(st.Board, i, 2) { // 两格内没有棋子则跳过
|
||||
continue
|
||||
}
|
||||
cands = append(cands, i)
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
return cands[rand.Intn(len(cands))]
|
||||
}
|
||||
|
||||
// 4) 兜底:天元(棋盘中心)
|
||||
mid := boardSize / 2
|
||||
if st.Board[mid*boardSize+mid] == 0 {
|
||||
return mid*boardSize + mid
|
||||
}
|
||||
for i := 0; i < len(st.Board); i++ { // 再兜底:第一个空点
|
||||
if st.Board[i] == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1 // 棋盘已满
|
||||
}
|
||||
|
||||
// markOf 返回该玩家的棋子颜色(先手=1 黑,后手=2 白)。
|
||||
func markOf(st *state, uid int64) int8 {
|
||||
for i, p := range st.Players {
|
||||
if p == uid {
|
||||
return int8(i + 1)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// hasNeighbor 判断 radius 格范围内是否存在棋子(用于「贴着下」的选点)。
|
||||
func hasNeighbor(board []int8, idx, radius int) bool {
|
||||
r0, c0 := idx/boardSize, idx%boardSize
|
||||
for dr := -radius; dr <= radius; dr++ {
|
||||
for dc := -radius; dc <= radius; dc++ {
|
||||
r, c := r0+dr, c0+dc
|
||||
if r < 0 || r >= boardSize || c < 0 || c >= boardSize { // 越界
|
||||
continue
|
||||
}
|
||||
if board[r*boardSize+c] != 0 { // 有棋子
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Package tictactoe 是井字棋示例玩法:1v1 回合制。
|
||||
// 新玩法照此结构新增子包并实现 game.Game,然后在 boot.InitWS 中注册即可。
|
||||
package tictactoe
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:解析 place 动作参数
|
||||
"errors" // 标准库:返回玩法内校验错误
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
const gameID = "tictactoe" // 玩法 ID:匹配队列与房间都以它索引
|
||||
|
||||
// placePayload 是 place 动作的参数。
|
||||
type placePayload struct {
|
||||
Index int `json:"index"` // 落子位置 0-8
|
||||
}
|
||||
|
||||
// state 是本玩法私有状态,房间只透传不解析。
|
||||
type state struct {
|
||||
Board [9]int8 `json:"board"` // 棋盘:0=空,1=先手,2=后手
|
||||
Players []int64 `json:"players"` // 座位顺序:下标 0 为先手
|
||||
Turn int `json:"turn"` // 当前应落子的玩家下标
|
||||
Winner int64 `json:"winner"` // 已产生的获胜者,0 表示未分出
|
||||
Moves int `json:"moves"` // 已落子数,用于判平局
|
||||
}
|
||||
|
||||
// Game 实现 game.Game 接口。
|
||||
type Game struct{}
|
||||
|
||||
// New 创建玩法实例:每局一个,状态互不影响。
|
||||
func New() *Game { return &Game{} }
|
||||
|
||||
// Meta 声明本玩法为 2 人回合制。
|
||||
func (g *Game) Meta() game.Meta {
|
||||
return game.Meta{GameID: gameID, MinSeats: 2, MaxSeats: 2, Realtime: false, AllowJoin: false}
|
||||
}
|
||||
|
||||
// Init 按座位数初始化私有状态。
|
||||
func (g *Game) Init(seatCount int) (game.State, error) {
|
||||
if seatCount != 2 { // 井字棋固定两人
|
||||
return nil, errors.New("tictactoe requires 2 seats")
|
||||
}
|
||||
return &state{}, nil // 返回空棋盘
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
if len(st.Players) >= 2 { // 已满员
|
||||
return nil, errors.New("room is full")
|
||||
}
|
||||
st.Players = append(st.Players, uid) // 记录座位顺序
|
||||
return []game.Event{{Name: "seat", Payload: map[string]any{"user_id": uid, "seat": len(st.Players) - 1}}}, nil
|
||||
}
|
||||
|
||||
// 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 i, p := range st.Players { // 找到离座者
|
||||
if p == uid {
|
||||
var winner int64 // 默认 0(无人获胜)
|
||||
if len(st.Players) == 2 { // 双人对局:判对手胜
|
||||
winner = st.Players[1-i]
|
||||
}
|
||||
return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}},
|
||||
&game.Finish{Winner: winner, Reason: "opponent_leave"}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, nil // 非本局成员:无事件
|
||||
}
|
||||
|
||||
// OnAction 处理一次落子:校验轮次与位置,判定胜负或平局。
|
||||
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 len(st.Players) != 2 { // 人数不足
|
||||
return nil, nil, errors.New("game not started")
|
||||
}
|
||||
if uid != st.Players[st.Turn] { // 不是该玩家的回合
|
||||
return nil, nil, errors.New("not your turn")
|
||||
}
|
||||
if a.Name != "place" { // 未知动作
|
||||
return nil, nil, errors.New("unsupported action: " + a.Name)
|
||||
}
|
||||
|
||||
var p placePayload // 解析动作参数
|
||||
if err := json.Unmarshal(a.Payload, &p); err != nil {
|
||||
return nil, nil, errors.New("invalid place payload")
|
||||
}
|
||||
if p.Index < 0 || p.Index > 8 { // 越界
|
||||
return nil, nil, errors.New("index out of range")
|
||||
}
|
||||
if st.Board[p.Index] != 0 { // 该格已有子
|
||||
return nil, nil, errors.New("cell occupied")
|
||||
}
|
||||
|
||||
mark := int8(st.Turn + 1) // 先手=1,后手=2
|
||||
st.Board[p.Index] = mark // 落子
|
||||
st.Moves++ // 步数加一
|
||||
|
||||
events := []game.Event{{Name: "placed", Payload: map[string]any{"user_id": uid, "index": p.Index, "mark": mark}}}
|
||||
|
||||
if w := checkWin(st.Board); w != 0 { // 出现连线
|
||||
st.Winner = st.Players[w-1] // 换算为用户 ID
|
||||
return events, &game.Finish{Winner: st.Winner, Reason: "normal"}, nil
|
||||
}
|
||||
if st.Moves == 9 { // 棋盘下满:平局
|
||||
return events, &game.Finish{Winner: 0, Reason: "normal"}, nil
|
||||
}
|
||||
|
||||
st.Turn = 1 - st.Turn // 轮转到对手
|
||||
return events, nil, nil
|
||||
}
|
||||
|
||||
// OnTick 每秒驱动:回合制无需驱动,直接返回空。
|
||||
func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) {
|
||||
return nil, 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{
|
||||
"board": st.Board, // 棋盘
|
||||
"players": st.Players, // 座位
|
||||
"turn": st.Turn, // 当前轮次
|
||||
"winner": st.Winner, // 获胜者
|
||||
"moves": st.Moves, // 步数
|
||||
}
|
||||
}
|
||||
|
||||
// lines 是全部八条连线(三横、三竖、两斜)。
|
||||
var lines = [8][3]int{{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, {0, 3, 6}, {1, 4, 7}, {2, 5, 8}, {0, 4, 8}, {2, 4, 6}}
|
||||
|
||||
// checkWin 返回连成一线的标记(1/2),无则返回 0。
|
||||
func checkWin(b [9]int8) int8 {
|
||||
for _, l := range lines { // 遍历八条线
|
||||
if b[l[0]] != 0 && b[l[0]] == b[l[1]] && b[l[1]] == b[l[2]] { // 三格相同且非空
|
||||
return b[l[0]] // 返回获胜标记
|
||||
}
|
||||
}
|
||||
return 0 // 未分出胜负
|
||||
}
|
||||
Reference in New Issue
Block a user