支持多人数对局与 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
+49
View File
@@ -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
View File
@@ -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
}
+5 -1
View File
@@ -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{} }
+139
View File
@@ -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
}
+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))
+127
View File
@@ -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
}
-155
View File
@@ -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 // 未分出胜负
}