实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理

This commit is contained in:
2026-09-28 00:10:11 +08:00
parent 99d1827262
commit fb5a730ef3
19 changed files with 2492 additions and 18 deletions
+202
View File
@@ -0,0 +1,202 @@
// Package gobang 是五子棋玩法:1v1 回合制,15×15 棋盘,横竖斜任一方向先连成五子者胜。
// 结构与 tictactoe 一致:实现 game.Game 接口,由房间驱动,不接触连接与数据库。
package gobang
import (
"encoding/json" // 标准库:解析 place 动作参数
"errors" // 标准库:返回玩法内校验错误
"time" // 标准库:记录上一手落子时刻,用于可选的回合超时
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
)
const (
gameID = "gobang" // 玩法 ID:匹配队列与房间都以它索引
boardSize = 15 // 棋盘边长
winCount = 5 // 连成几子获胜
// turnTimeoutSec 单步思考时长上限(秒):0 表示不限制。
// 需要「超时判负」时改成 90 之类的正数,OnTick 会在超时后判对手胜。
turnTimeoutSec = 0
)
// placePayload 是 place 动作的参数。
type placePayload struct {
Row int `json:"row"` // 行下标 0-14
Col int `json:"col"` // 列下标 0-14
}
// state 是本玩法私有状态,房间只透传不解析。
type state struct {
Board []int8 `json:"board"` // 棋盘:长度 225,0=空,1=黑(先手),2=白(后手)
Players []int64 `json:"players"` // 座位顺序:下标 0 执黑先手
Turn int `json:"turn"` // 当前应落子方下标
Winner int64 `json:"winner"` // 获胜用户 ID,0 表示未分出
Moves int `json:"moves"` // 已落子数
LastMove int64 `json:"last_move"` // 上一手落子时刻(秒),回合超时判定用
}
// 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("gobang requires 2 seats")
}
return &state{
Board: make([]int8, boardSize*boardSize), // 225 格全空
LastMove: time.Now().Unix(), // 起手时刻:回合超时从开局算起
}, nil
}
// OnJoin 玩家入座:先到者执黑(1),后到者执白(2)。
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) // 记录座位顺序
color := len(st.Players) // 1=黑,2=白
return []game.Event{{Name: "seat", Payload: map[string]any{
"user_id": uid, "seat": color - 1, "color": color,
}}}, 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 // 默认无人获胜
if len(st.Players) == 2 { // 双人对局:判对手胜
winner = st.Players[1-i]
}
st.Winner = winner
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.Row < 0 || p.Row >= boardSize || p.Col < 0 || p.Col >= boardSize { // 越界
return nil, nil, errors.New("position out of range")
}
idx := p.Row*boardSize + p.Col // 一维下标
if st.Board[idx] != 0 { // 该点已有子
return nil, nil, errors.New("cell occupied")
}
mark := int8(st.Turn + 1) // 执黑=1,执白=2
st.Board[idx] = mark // 落子
st.Moves++ // 步数加一
st.LastMove = time.Now().Unix() // 刷新最后落子时刻
events := []game.Event{{Name: "placed", Payload: map[string]any{
"user_id": uid, "row": p.Row, "col": p.Col, "color": mark,
}}}
if checkWin(st.Board, p.Row, p.Col, mark) { // 连成五子
st.Winner = uid
return events, &game.Finish{Winner: uid, Reason: "normal"}, nil
}
if st.Moves == boardSize*boardSize { // 棋盘填满:平局
return events, &game.Finish{Winner: 0, Reason: "normal"}, nil
}
st.Turn = 1 - st.Turn // 轮转到对手
return events, nil, nil
}
// OnTick 每秒驱动:回合制默认不做事;打开 turnTimeoutSec 后用于超时判负。
func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) {
if turnTimeoutSec <= 0 { // 未开启超时判负
return nil, nil, nil
}
st, ok := s.(*state) // 断言状态
if !ok || len(st.Players) != 2 || st.Winner != 0 { // 未开局或已分胜负
return nil, nil, nil
}
if now-st.LastMove < int64(turnTimeoutSec) { // 未超时
return nil, nil, nil
}
loser := st.Turn // 超时方
winner := st.Players[1-loser] // 判对手胜
st.Winner = winner // 记录结果
return []game.Event{{Name: "timeout", Payload: map[string]any{"user_id": st.Players[loser]}}},
&game.Finish{Winner: winner, Reason: "timeout"}, 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, // 棋盘(225)
"players": st.Players, // 座位:0=黑,1=白
"turn": st.Turn, // 当前轮次
"winner": st.Winner, // 获胜者
"moves": st.Moves, // 步数
"size": boardSize, // 棋盘边长,便于客户端渲染
}
}
// dirs 是四个判定方向:横、竖、右下、右上。
var dirs = [4][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}}
// checkWin 以落点为中心,统计四个方向的同色连续长度是否达到 winCount。
func checkWin(board []int8, row, col int, mark int8) bool {
for _, d := range dirs { // 遍历四个方向
count := 1 // 含落点自身
for _, sign := range [2]int{1, -1} { // 向两侧延伸
r, c := row+d[0]*sign, col+d[1]*sign
for r >= 0 && r < boardSize && c >= 0 && c < boardSize && board[r*boardSize+c] == mark {
count++ // 连续同色
r += d[0] * sign // 继续延伸
c += d[1] * sign
}
}
if count >= winCount { // 达到五连
return true
}
}
return false // 未连成
}