实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Package game 定义玩法插件的统一接口与注册表。
|
||||
// 房间只通过该接口驱动玩法:新增一个游戏 = 新增实现 + 注册,网关/大厅/房间零改动。
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:动作参数以原始 JSON 透传给玩法
|
||||
"fmt" // 标准库:未注册玩法时返回明确错误
|
||||
"sync" // 标准库:保护注册表并发读写
|
||||
)
|
||||
|
||||
// Meta 描述玩法的匹配与房间形态:人数与节奏差异全部由它声明。
|
||||
type Meta struct {
|
||||
GameID string // 游戏唯一 ID,匹配队列与房间都用它索引
|
||||
MinSeats int // 最少人数:双人=2,多人=4/6...
|
||||
MaxSeats int // 最多人数
|
||||
Realtime bool // true=实时玩法(依赖 OnTick 驱动),false=回合制
|
||||
AllowJoin bool // 是否允许开局后中途加入
|
||||
TickMs int // 房间驱动周期(毫秒):0 表示用房间默认 1 秒;实时玩法设小值(如贪吃蛇 200)
|
||||
}
|
||||
|
||||
// Action 是一次玩家动作的通用载体:房间只透传,不解析内容。
|
||||
type Action struct {
|
||||
Name string // 动作名,玩法内部约定(如 "place")
|
||||
Payload json.RawMessage // 动作参数原样 JSON,由玩法自行解析
|
||||
}
|
||||
|
||||
// Event 描述「发给谁、发什么」:投递由房间完成,玩法不接触连接。
|
||||
type Event struct {
|
||||
To []int64 // 收件人;nil 或空表示房间内全体广播
|
||||
Name string // 事件名,客户端据此分发
|
||||
Payload any // 事件内容
|
||||
}
|
||||
|
||||
// Finish 是对局结果:由玩法返回,房间负责广播与结算。
|
||||
type Finish struct {
|
||||
Winner int64 // 获胜用户 ID,0 表示平局
|
||||
Scores map[int64]int // 各用户得分,可为空
|
||||
Reason string // 结束原因:normal/opponent_leave/timeout
|
||||
}
|
||||
|
||||
// State 是玩法私有状态:房间只做透传,具体类型由玩法在 Init 中创建。
|
||||
type State any
|
||||
|
||||
// Game 是所有玩法必须实现的接口,房间按序调用这些方法驱动一局游戏。
|
||||
type Game interface {
|
||||
Meta() Meta // 元信息:人数、是否实时等
|
||||
Init(seatCount int) (State, error) // 开局初始化私有状态
|
||||
OnJoin(uid int64, s State) ([]Event, error) // 玩家入座
|
||||
OnLeave(uid int64, s State) ([]Event, *Finish, error) // 玩家离座;可返回 Finish 判负
|
||||
OnAction(uid int64, a Action, s State) ([]Event, *Finish, error) // 玩家动作;返回 nil Finish 表示未结束
|
||||
OnTick(now int64, s State) ([]Event, *Finish, error) // 每秒驱动:实时玩法与超时判定
|
||||
Snapshot(uid int64, s State) any // 按观众裁剪的可见状态(重连/观战用)
|
||||
}
|
||||
|
||||
// Factory 创建玩法实例:每局一个新实例,保证状态隔离。
|
||||
type Factory func() Game
|
||||
|
||||
var (
|
||||
mu sync.RWMutex // 保护注册表
|
||||
registry = map[string]Factory{} // gameID -> 构造函数
|
||||
)
|
||||
|
||||
// Register 注册一个玩法;同名 ID 重复注册会覆盖(便于测试替换)。
|
||||
func Register(gameID string, f Factory) {
|
||||
mu.Lock() // 加写锁
|
||||
defer mu.Unlock() // 返回时解锁
|
||||
registry[gameID] = f
|
||||
}
|
||||
|
||||
// New 按 ID 创建玩法实例。
|
||||
func New(gameID string) (Game, error) {
|
||||
mu.RLock() // 加读锁
|
||||
f, ok := registry[gameID] // 查注册表
|
||||
mu.RUnlock() // 解锁
|
||||
if !ok { // 未注册
|
||||
return nil, fmt.Errorf("game %q not registered", gameID)
|
||||
}
|
||||
return f(), nil // 返回新实例
|
||||
}
|
||||
|
||||
// LookupMeta 取已注册玩法的元信息:匹配器用它决定队列人数。
|
||||
func LookupMeta(gameID string) (Meta, error) {
|
||||
g, err := New(gameID) // 先建实例
|
||||
if err != nil { // 未注册
|
||||
return Meta{}, err
|
||||
}
|
||||
return g.Meta(), nil // 返回元信息
|
||||
}
|
||||
@@ -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 // 未连成
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Package snake 是双人贪吃蛇玩法:两人同场竞技的实时玩法。
|
||||
// 与回合制玩法(gobang / tictactoe)不同,本玩法由房间的 tick 循环持续推进:
|
||||
// 玩家只发「转向」指令,位置推进、吃食物、碰撞判定都在 OnTick 中完成。
|
||||
package snake
|
||||
|
||||
import (
|
||||
"encoding/json" // 标准库:解析 turn 动作参数
|
||||
"errors" // 标准库:返回玩法内校验错误
|
||||
"math/rand" // 标准库:随机生成食物位置
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
const (
|
||||
gameID = "snake" // 玩法 ID:匹配队列与房间都以它索引
|
||||
boardSize = 21 // 棋盘边长(21×21)
|
||||
tickMs = 200 // 推进周期(毫秒):每秒 5 步,通过 Meta.TickMs 交给房间
|
||||
initLen = 3 // 初始蛇长
|
||||
targetScore = 20 // 先吃满该分数直接获胜;0 表示只比谁活得久
|
||||
)
|
||||
|
||||
// 方向:上右下左(顺时针);反向为 (dir+2)%4。
|
||||
const (
|
||||
dirUp = iota
|
||||
dirRight
|
||||
dirDown
|
||||
dirLeft
|
||||
)
|
||||
|
||||
// dirOffsets 是四个方向对应的「行、列」增量。
|
||||
var dirOffsets = [4][2]int{{-1, 0}, {0, 1}, {1, 0}, {0, -1}}
|
||||
|
||||
// turnPayload 是 turn 动作的参数。
|
||||
type turnPayload struct {
|
||||
Dir string `json:"dir"` // up / down / left / right
|
||||
}
|
||||
|
||||
// snakeState 是单条蛇的状态。
|
||||
type snakeState struct {
|
||||
UserID int64 `json:"user_id"` // 操控该蛇的用户
|
||||
Body []int `json:"body"` // 身体坐标(一维下标),头在 [0]
|
||||
Dir int `json:"dir"` // 当前方向
|
||||
Alive bool `json:"alive"` // 是否存活
|
||||
Score int `json:"score"` // 吃到食物数
|
||||
}
|
||||
|
||||
// state 是本玩法私有状态,房间只透传不解析。
|
||||
type state struct {
|
||||
Size int `json:"size"` // 棋盘边长
|
||||
Snakes []*snakeState `json:"snakes"` // 两条蛇
|
||||
Foods []int `json:"foods"` // 食物坐标(一维下标)
|
||||
Over bool `json:"over"` // 是否已结束
|
||||
}
|
||||
|
||||
// Game 实现 game.Game 接口。
|
||||
type Game struct{}
|
||||
|
||||
// New 创建玩法实例:每局一个,状态互不影响。
|
||||
func New() *Game { return &Game{} }
|
||||
|
||||
// Meta 声明本玩法为 2 人实时玩法,并指定 200ms 的推进周期。
|
||||
func (g *Game) Meta() game.Meta {
|
||||
return game.Meta{
|
||||
GameID: gameID, MinSeats: 2, MaxSeats: 2,
|
||||
Realtime: true, AllowJoin: false, TickMs: tickMs,
|
||||
}
|
||||
}
|
||||
|
||||
// Init 初始化棋盘:两条蛇分别从左右两侧中间出发,方向相对。
|
||||
func (g *Game) Init(seatCount int) (game.State, error) {
|
||||
if seatCount != 2 { // 固定两人
|
||||
return nil, errors.New("snake requires 2 seats")
|
||||
}
|
||||
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.Foods = []int{st.spawnFood()} // 初始一个食物
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// bodyFrom 按「头位置 + 方向」生成初始长度为 initLen 的身体(头在 [0])。
|
||||
func bodyFrom(row, col, dir int) []int {
|
||||
body := make([]int, 0, initLen)
|
||||
for i := 0; i < initLen; i++ {
|
||||
r := row - dirOffsets[dir][0]*i // 从头部向反方向延伸
|
||||
c := col - dirOffsets[dir][1]*i
|
||||
body = append(body, r*boardSize+c)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// idx 把行列换算成一维下标。
|
||||
func (s *state) idx(row, col int) int { return row*s.Size + col }
|
||||
|
||||
// rowOf / colOf 把一维下标还原为行列。
|
||||
func (s *state) rowOf(i int) int { return i / s.Size }
|
||||
func (s *state) colOf(i int) int { return i % s.Size }
|
||||
|
||||
// spawnFood 在空格中随机生成食物;无空格返回 -1。
|
||||
func (s *state) spawnFood() int {
|
||||
occupied := make(map[int]bool, s.Size*s.Size)
|
||||
for _, sn := range s.Snakes { // 蛇身占位
|
||||
for _, b := range sn.Body {
|
||||
occupied[b] = true
|
||||
}
|
||||
}
|
||||
for _, f := range s.Foods { // 已有食物占位
|
||||
occupied[f] = true
|
||||
}
|
||||
free := make([]int, 0, s.Size*s.Size)
|
||||
for i := 0; i < s.Size*s.Size; i++ { // 收集空格
|
||||
if !occupied[i] {
|
||||
free = append(free, i)
|
||||
}
|
||||
}
|
||||
if len(free) == 0 { // 棋盘已满
|
||||
return -1
|
||||
}
|
||||
return free[rand.Intn(len(free))] // 随机取一个
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
for i, sn := range st.Snakes { // 找未绑定的蛇
|
||||
if sn.UserID == 0 {
|
||||
sn.UserID = uid
|
||||
return []game.Event{{Name: "seat", Payload: map[string]any{
|
||||
"user_id": uid, "seat": i, "dir": dirName(sn.Dir),
|
||||
}}}, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("room is full")
|
||||
}
|
||||
|
||||
// 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 _, 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
|
||||
}
|
||||
}
|
||||
return []game.Event{{Name: "leave", Payload: map[string]any{"user_id": uid}}},
|
||||
&game.Finish{Winner: winner, Scores: scores(st), Reason: "opponent_leave"}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, nil // 非本局成员:无事件
|
||||
}
|
||||
|
||||
// OnAction 处理转向指令:只允许改变方向,位置推进交给 OnTick。
|
||||
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 st.Over { // 已结束
|
||||
return nil, nil, errors.New("game over")
|
||||
}
|
||||
if a.Name != "turn" { // 未知动作
|
||||
return nil, nil, errors.New("unsupported action: " + a.Name)
|
||||
}
|
||||
|
||||
var p turnPayload // 解析动作参数
|
||||
if err := json.Unmarshal(a.Payload, &p); err != nil {
|
||||
return nil, nil, errors.New("invalid turn payload")
|
||||
}
|
||||
dir, ok := parseDir(p.Dir) // 方向名转枚举
|
||||
if !ok {
|
||||
return nil, nil, errors.New("invalid direction")
|
||||
}
|
||||
|
||||
for _, sn := range st.Snakes { // 找到该玩家操控的蛇
|
||||
if sn.UserID != uid {
|
||||
continue
|
||||
}
|
||||
if !sn.Alive { // 已死亡
|
||||
return nil, nil, errors.New("snake is dead")
|
||||
}
|
||||
// 禁止 180 度掉头(长度为 1 时允许)
|
||||
if len(sn.Body) > 1 && (sn.Dir+2)%4 == dir {
|
||||
return nil, nil, errors.New("cannot reverse")
|
||||
}
|
||||
sn.Dir = dir // 更新方向
|
||||
return []game.Event{{Name: "turn", Payload: map[string]any{"user_id": uid, "dir": p.Dir}}}, nil, nil
|
||||
}
|
||||
return nil, nil, errors.New("not in game")
|
||||
}
|
||||
|
||||
// OnTick 推进一步:计算新头位置 → 判定碰撞 → 移动与吃食物 → 判定胜负。
|
||||
func (g *Game) OnTick(now int64, s game.State) ([]game.Event, *game.Finish, error) {
|
||||
st, ok := s.(*state) // 断言状态
|
||||
if !ok || st.Over { // 类型错误或已结束
|
||||
return nil, nil, nil
|
||||
}
|
||||
for _, sn := range st.Snakes { // 人数未齐(有人未入座)不推进
|
||||
if sn.UserID == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 计算每条蛇的新头位置(-1 表示撞墙)
|
||||
newHead := make([]int, len(st.Snakes))
|
||||
for i, sn := range st.Snakes {
|
||||
if !sn.Alive { // 已死亡不参与
|
||||
newHead[i] = -1
|
||||
continue
|
||||
}
|
||||
r, c := st.rowOf(sn.Body[0]), st.colOf(sn.Body[0])
|
||||
nr, nc := r+dirOffsets[sn.Dir][0], c+dirOffsets[sn.Dir][1]
|
||||
if nr < 0 || nr >= st.Size || nc < 0 || nc >= st.Size { // 越界即撞墙
|
||||
newHead[i] = -1
|
||||
continue
|
||||
}
|
||||
newHead[i] = st.idx(nr, nc)
|
||||
}
|
||||
|
||||
// 2. 标记本回合是否吃到食物(决定是否增长)
|
||||
grow := make([]bool, len(st.Snakes))
|
||||
for i, sn := range st.Snakes {
|
||||
if !sn.Alive || newHead[i] < 0 {
|
||||
continue
|
||||
}
|
||||
for _, f := range st.Foods {
|
||||
if f == newHead[i] {
|
||||
grow[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 死亡判定:撞墙、头对头、撞到任意蛇身
|
||||
dead := make([]bool, len(st.Snakes))
|
||||
for i, sn := range st.Snakes {
|
||||
if !sn.Alive { // 之前已死
|
||||
dead[i] = true
|
||||
continue
|
||||
}
|
||||
if newHead[i] < 0 { // 撞墙
|
||||
dead[i] = true
|
||||
continue
|
||||
}
|
||||
for j := range st.Snakes { // 头对头相撞:双方都死
|
||||
if j != i && newHead[j] == newHead[i] {
|
||||
dead[i] = true
|
||||
}
|
||||
}
|
||||
for oi, other := range st.Snakes { // 撞到蛇身
|
||||
for k, b := range other.Body {
|
||||
// 对方本回合不吃食物时,尾节点会让位,不算撞
|
||||
if k == len(other.Body)-1 && !grow[oi] {
|
||||
continue
|
||||
}
|
||||
if b == newHead[i] {
|
||||
dead[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 移动:吃到食物则增长并补充食物,否则去掉尾节点
|
||||
for i, sn := range st.Snakes {
|
||||
if !sn.Alive { // 已死不动
|
||||
continue
|
||||
}
|
||||
if dead[i] { // 本回合死亡
|
||||
sn.Alive = false
|
||||
continue
|
||||
}
|
||||
sn.Body = append([]int{newHead[i]}, sn.Body...) // 新头插到最前
|
||||
if grow[i] { // 吃到食物:保留尾巴即增长
|
||||
sn.Score++
|
||||
for fi, f := range st.Foods { // 移除被吃掉的食物
|
||||
if f == newHead[i] {
|
||||
st.Foods = append(st.Foods[:fi], st.Foods[fi+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if nf := st.spawnFood(); nf >= 0 { // 补充新食物
|
||||
st.Foods = append(st.Foods, nf)
|
||||
}
|
||||
} else { // 未吃到:去掉尾节点
|
||||
sn.Body = sn.Body[:len(sn.Body)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 组装事件:每步一次 tick(含全量可渲染数据),死亡额外发 died
|
||||
events := []game.Event{{Name: "tick", Payload: map[string]any{
|
||||
"size": st.Size, "snakes": st.Snakes, "foods": st.Foods,
|
||||
}}}
|
||||
for i, sn := range st.Snakes {
|
||||
if dead[i] {
|
||||
events = append(events, game.Event{Name: "died", Payload: map[string]any{"user_id": sn.UserID}})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 胜负判定:先达标者胜;只剩一条存活则该蛇胜;全灭为平局
|
||||
for _, sn := range st.Snakes {
|
||||
if targetScore > 0 && sn.Alive && sn.Score >= targetScore {
|
||||
st.Over = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
if len(alive) <= 1 { // 分出结果
|
||||
st.Over = true
|
||||
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 // 对局继续
|
||||
}
|
||||
|
||||
// Snapshot 生成对指定玩家可见的状态(贪吃蛇无隐藏信息,返回全量)。
|
||||
func (g *Game) Snapshot(uid int64, s game.State) any {
|
||||
st, ok := s.(*state) // 断言状态
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"size": st.Size,
|
||||
"snakes": st.Snakes,
|
||||
"foods": st.Foods,
|
||||
"over": st.Over,
|
||||
"tick": tickMs, // 客户端据此设定渲染节奏
|
||||
}
|
||||
}
|
||||
|
||||
// scores 汇总各玩家得分。
|
||||
func scores(st *state) map[int64]int {
|
||||
out := make(map[int64]int, len(st.Snakes))
|
||||
for _, sn := range st.Snakes {
|
||||
out[sn.UserID] = sn.Score
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseDir 把方向名解析为枚举。
|
||||
func parseDir(name string) (int, bool) {
|
||||
switch name {
|
||||
case "up":
|
||||
return dirUp, true
|
||||
case "right":
|
||||
return dirRight, true
|
||||
case "down":
|
||||
return dirDown, true
|
||||
case "left":
|
||||
return dirLeft, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// dirName 把方向枚举转回名称(事件与快照里可读)。
|
||||
func dirName(dir int) string {
|
||||
switch dir {
|
||||
case dirUp:
|
||||
return "up"
|
||||
case dirRight:
|
||||
return "right"
|
||||
case dirDown:
|
||||
return "down"
|
||||
case dirLeft:
|
||||
return "left"
|
||||
}
|
||||
return "right"
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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