支持多人数对局与 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
+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
}