2026-04-20 16:07:22 +08:00

605 lines
20 KiB
Go
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math/rand"
"time"
"wuziqi-server/characters"
"wuziqi-server/config"
"wuziqi-server/core"
"wuziqi-server/items"
"wuziqi-server/logic"
"github.com/heroiclabs/nakama-common/runtime"
)
type MatchLabel struct {
Open bool `json:"open"`
Started bool `json:"started"`
PlayerCount int `json:"player_count"`
MaxPlayers int `json:"max_players"`
Label string `json:"label"`
GameType string `json:"game_type"` // 游戏类型标识(minesweeper/minesweeper_free)
}
type MatchHandler struct{}
func (m *MatchHandler) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
logger.Info("MatchInit (Refactored) called")
// 从 params 中提取 game_type
gameType := "minesweeper" // 默认值
if params != nil {
if gt, ok := params["game_type"]; ok {
if gtStr, ok := gt.(string); ok && gtStr != "" {
gameType = gtStr
}
}
}
logger.Info("MatchInit: GameType = %s", gameType)
// 1. 加载配置
apiConfig := config.GetMinesweeperConfig(logger)
// 默认值
gridSizeSide := 10
bombCount := 30
itemMin := 5
itemMax := 10
hpInit := 4
matchPlayerCount := core.MaxPlayers
enabledItems := make(map[string]bool)
itemWeights := make(map[string]int)
charHPConfig := make(map[string]int)
turnDuration := 15
if apiConfig != nil {
if apiConfig.GridSize > 0 {
gridSizeSide = apiConfig.GridSize
}
if apiConfig.BombCount > 0 {
bombCount = apiConfig.BombCount
}
if apiConfig.ItemMin >= 0 {
itemMin = apiConfig.ItemMin
}
if apiConfig.ItemMax >= itemMin {
itemMax = apiConfig.ItemMax
}
if apiConfig.HPInit > 0 {
hpInit = apiConfig.HPInit
}
if apiConfig.MatchPlayerCount >= 2 {
matchPlayerCount = apiConfig.MatchPlayerCount
}
enabledItems = apiConfig.EnabledItems
itemWeights = apiConfig.ItemWeights
charHPConfig = apiConfig.CharacterHP
if apiConfig.TurnDuration > 0 {
turnDuration = apiConfig.TurnDuration
}
}
// 强制最小值检查 (防止配置为0导致死循环)
if turnDuration < 5 {
logger.Warn("TurnDuration too small (%d), resetting to 15", turnDuration)
turnDuration = 15
}
// 2. 初始化管理器
charMgr := characters.NewCharacterManager(charHPConfig)
itemMgr := items.NewItemManager()
// 3. 生成网格
// 如果池需要回退,我们需要将所有道具类型传递给 GenerateGrid
// 道具已在 ItemManager 中注册但我们需要ID字符串列表
// 我们在这里硬编码列表或从 items 包中公开它。
// 目前,硬编码以匹配以前的逻辑。
allItemTypes := []string{
"medkit", "bomb_timer", "poison", "shield", "skip",
"magnifier", "knife", "revive", "lightning", "chest", "curse",
}
grid := logic.GenerateGrid(gridSizeSide, bombCount, itemMin, itemMax, enabledItems, itemWeights, allItemTypes)
// 4. 创建初始状态
gameState := &core.GameState{
Players: make(map[string]*core.Player),
Grid: grid,
GridSize: gridSizeSide,
TurnOrder: make([]string, 0),
CurrentTurnIndex: 0,
Round: 1,
WinnerID: "",
GameStarted: false,
TurnDuration: turnDuration,
}
logger.Info("MatchInit: Configured TurnDuration: %d (API Config: %v)", turnDuration, apiConfig != nil)
// 5. 创建比赛状态
// 我们需要一个持有引擎的简化 MatchState 吗?
// 或者应该在 MatchLoop 中创建 logic.GameEngine
// Nakama 在调用之间传递 'state' interface{}。
// 我们应该存储 GameState + 引擎依赖项。
// 初始化角色池
charTypes := []string{"elephant", "cat", "dog", "monkey", "chicken", "sloth", "hippo", "tiger"}
shuffledChars := make([]string, len(charTypes))
copy(shuffledChars, charTypes)
rand.Shuffle(len(shuffledChars), func(i, j int) {
shuffledChars[i], shuffledChars[j] = shuffledChars[j], shuffledChars[i]
})
matchState := &MatchState{
State: gameState,
HPInit: hpInit,
MatchPlayerCount: matchPlayerCount,
ValidatedPlayers: make(map[string]*config.GameTokenInfo),
DisconnectedPlayers: make(map[string]*core.Player),
CharacterPool: shuffledChars,
CharacterIndex: 0,
Spectators: make(map[string]bool),
Presences: make(map[string]runtime.Presence),
CharManager: charMgr,
ItemManager: itemMgr,
GameType: gameType, // 存储房间的游戏类型
CreatedAt: time.Now().Unix(), // 记录房间创建时间,用于兜底销毁
}
tickRate := 10
label := "Animal Minesweeper"
// 初始化 Label
initialLabel := MatchLabel{
Open: true,
Started: false,
PlayerCount: 0,
MaxPlayers: matchPlayerCount,
Label: label,
GameType: gameType, // 添加游戏类型到标签
}
labelBytes, _ := json.Marshal(initialLabel)
return matchState, tickRate, string(labelBytes)
}
type MatchState struct {
State *core.GameState
HPInit int
MatchPlayerCount int
ValidatedPlayers map[string]*config.GameTokenInfo
DisconnectedPlayers map[string]*core.Player
CharacterPool []string
CharacterIndex int
Spectators map[string]bool
Presences map[string]runtime.Presence
GameType string // 房间的游戏类型(minesweeper/minesweeper_free)
CreatedAt int64 // 房间创建时间Unix 秒),用于兜底销毁
// 用于构建引擎的管理器
CharManager *characters.CharacterManager
ItemManager *items.ItemManager
}
// ... MatchJoinAttempt, MatchJoin, MatchLoop ...
func (m *MatchHandler) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) {
ms := state.(*MatchState)
// Token 验证逻辑
gameToken, ok := metadata["game_token"]
if !ok || gameToken == "" {
logger.Warn("MatchJoinAttempt: No game_token provided for user %s (SID: %s). Metadata: %v", presence.GetUserId(), presence.GetSessionId(), metadata)
return ms, false, "Game token required"
}
logger.Info("MatchJoinAttempt: Validating token for user %s", presence.GetUserId())
tokenInfo := config.ValidateGameToken(logger, gameToken)
if tokenInfo == nil {
logger.Error("MatchJoinAttempt: Invalid game token for user %s", presence.GetUserId())
return ms, false, "Invalid game token"
}
// 校验玩家的游戏类型是否与房间匹配
if tokenInfo.GameType != ms.GameType {
logger.Error("MatchJoinAttempt: GameType MISMATCH for user %s. Player: %s, Room: %s. This indicates matchmaker query filtering may not be working correctly.",
presence.GetUserId(), tokenInfo.GameType, ms.GameType)
return ms, false, fmt.Sprintf("Game type mismatch (player: %s, room: %s)", tokenInfo.GameType, ms.GameType)
}
ms.ValidatedPlayers[presence.GetUserId()] = tokenInfo
if ms.State.GameStarted {
if _, ok := ms.DisconnectedPlayers[presence.GetUserId()]; ok {
return ms, true, "" // 重连
}
ms.Spectators[presence.GetUserId()] = true
return ms, true, "" // 观众
}
if len(ms.State.Players) >= ms.MatchPlayerCount {
return ms, false, "Match full"
}
return ms, true, ""
}
func (m *MatchHandler) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
ms := state.(*MatchState)
// 仅在需要时构建引擎?还是只使用它的组件?
// 如果需要,我们可以在本地构建它以使用其逻辑,但 Join 逻辑主要是设置。
matchID, _ := ctx.Value(runtime.RUNTIME_CTX_MATCH_ID).(string)
for _, presence := range presences {
userID := presence.GetUserId()
ms.Presences[userID] = presence // 统一追踪 Presence确保消息路由正确
// 记录活跃对局到存储,用于全设备重连查找
nk.StorageWrite(ctx, []*runtime.StorageWrite{
{
Collection: "game_data",
Key: "active_match",
UserID: userID,
Value: fmt.Sprintf(`{"match_id":"%s"}`, matchID),
PermissionRead: 2, // Owner Read
PermissionWrite: 0, // Server Only
},
})
// 观众
if ms.Spectators[userID] {
publicState := core.CreatePublicGameState(ms.State)
data, _ := json.Marshal(publicState)
dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true)
continue
}
// 重连
if p, ok := ms.DisconnectedPlayers[userID]; ok {
p.SessionID = presence.GetSessionId()
ms.State.Players[userID] = p
delete(ms.DisconnectedPlayers, userID)
sanitized := ms.State.SanitizeForUser(userID)
data, err := json.Marshal(sanitized)
if err != nil {
continue
}
dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true)
continue
}
// 新玩家
if _, exists := ms.State.Players[userID]; exists {
continue
}
tokenInfo := ms.ValidatedPlayers[userID]
if tokenInfo == nil {
continue
}
// 分配角色
char := "dog" // 回退
if ms.CharacterIndex < len(ms.CharacterPool) {
char = ms.CharacterPool[ms.CharacterIndex]
ms.CharacterIndex++
}
maxHP := ms.CharManager.GetInitialHP(char, ms.HPInit)
avatar := ms.CharManager.GetAvatar(char)
username := tokenInfo.Username
if username == "" {
username = presence.GetUsername()
}
player := &core.Player{
UserID: userID,
RealUserID: tokenInfo.UserID,
SessionID: presence.GetSessionId(),
Username: username,
Avatar: avatar,
HP: maxHP,
MaxHP: maxHP,
Character: char,
Ticket: tokenInfo.Ticket,
GameType: tokenInfo.GameType, // 从 token 中获取游戏类型(免费/付费)
Status: []string{},
RevealedCells: make(map[int]string),
}
ms.State.Players[userID] = player
ms.State.TurnOrder = append(ms.State.TurnOrder, userID)
ms.Presences[userID] = presence // 追踪 presence
}
// 更新 Label
ms.updateLabel(dispatcher)
// 开始游戏检查
if len(ms.State.Players) >= ms.MatchPlayerCount && !ms.State.GameStarted {
ms.State.GameStarted = true
ms.State.LastMoveTimestamp = time.Now().Unix()
// 打乱回合顺序
rand.Shuffle(len(ms.State.TurnOrder), func(i, j int) {
ms.State.TurnOrder[i], ms.State.TurnOrder[j] = ms.State.TurnOrder[j], ms.State.TurnOrder[i]
})
// 记录并消耗入场券
// ... 入场券消耗逻辑(由于是处理器保持简单)...
for _, p := range ms.State.Players {
go consumeTicket(p.RealUserID, p.Ticket, p.GameType, logger)
}
// 广播开始
broadcastUpdate(dispatcher, ms.State, core.OpCodeGameStart)
ms.updateLabel(dispatcher)
} else {
// 更新大厅
broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState)
}
return ms
}
func (m *MatchHandler) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
ms := state.(*MatchState)
// 兜底销毁:房间存活超过 10 分钟强制销毁,防止异常房间泄漏
const maxRoomLifetimeSecs = 10 * 60
if time.Now().Unix()-ms.CreatedAt > maxRoomLifetimeSecs {
logger.Warn("MatchLoop: Room exceeded max lifetime (%ds), force destroying. CreatedAt=%d", maxRoomLifetimeSecs, ms.CreatedAt)
return nil
}
// 为此循环 tick 创建引擎实例
loopMatchID, _ := ctx.Value(runtime.RUNTIME_CTX_MATCH_ID).(string)
engine := logic.NewGameEngine(logger, dispatcher, ms.CharManager, ms.ItemManager, ms.Presences, ms.DisconnectedPlayers, loopMatchID)
for _, message := range messages {
userID := message.GetUserId()
opCode := message.GetOpCode()
logger.Debug("MatchLoop: Received msg from UserID=%s, OpCode=%d, DataLen=%d", userID, opCode, len(message.GetData()))
if ms.Spectators[userID] {
continue
}
switch opCode {
case core.OpCodeMove:
if !ms.State.GameStarted {
logger.Warn("Move ignored: game not started")
continue
}
var move core.MoveMessage
if err := json.Unmarshal(message.GetData(), &move); err != nil {
logger.Error("Failed to unmarshal move: %v", err)
continue
}
// 委托给引擎
engine.HandleMove(ms.State, userID, move.Index)
case core.OpCodeGetState:
sanitized := ms.State.SanitizeForUser(message.GetUserId())
data, _ := json.Marshal(sanitized)
targetPresence := ms.Presences[message.GetUserId()]
if targetPresence == nil {
// Presence 可能还未更新(重连时序问题),记录警告
logger.Warn("OpCodeGetState: Presence not found for user %s, possible timing issue", message.GetUserId())
continue
}
dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{targetPresence}, nil, true)
}
}
// 超时检查
if ms.State.GameStarted && ms.State.WinnerID == "" {
now := time.Now().Unix()
// Debug logging for timeout logic
if now-ms.State.LastMoveTimestamp >= int64(ms.State.TurnDuration) {
logger.Info("MatchLoop: Turn Timeout Triggered. Now: %d, Last: %d, Diff: %d, Duration: %d",
now, ms.State.LastMoveTimestamp, now-ms.State.LastMoveTimestamp, ms.State.TurnDuration)
currentUID := ms.State.TurnOrder[ms.State.CurrentTurnIndex]
if _, disconnected := ms.DisconnectedPlayers[currentUID]; disconnected {
// 跳过回合
engine.BroadcastEvent(core.GameEvent{Type: "status", Message: "⏰ 玩家断线,跳过回合"})
engine.AdvanceTurn(ms.State)
} else {
// 扣除HP并跳过
player := ms.State.Players[currentUID]
if player != nil {
engine.ApplyDamage(ms.State, player, 1, false) // 1点HP惩罚非道具效果
engine.AdvanceTurn(ms.State)
}
}
ms.State.LastMoveTimestamp = now
// 检查游戏结束
if engine.CheckGameOver(ms.State) {
return ms
}
// 广播更新
broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState)
}
}
// 检查游戏是否应该结束并销毁房间
if !ms.State.GameStarted && ms.State.WinnerID != "" {
logger.Info("Match %s is over (Winner: %s), destroying room", ms.State.WinnerID, ms.State.WinnerID)
// 游戏结束,清理所有参与者的对局存储
for uid := range ms.State.Players {
nk.StorageDelete(ctx, []*runtime.StorageDelete{
{
Collection: "game_data",
Key: "active_match",
UserID: uid,
},
})
}
for uid := range ms.DisconnectedPlayers {
nk.StorageDelete(ctx, []*runtime.StorageDelete{
{
Collection: "game_data",
Key: "active_match",
UserID: uid,
},
})
}
return nil
}
return ms
}
func (m *MatchHandler) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
ms := state.(*MatchState)
for _, presence := range presences {
userID := presence.GetUserId()
if ms.Spectators[userID] {
delete(ms.Spectators, userID)
delete(ms.Presences, userID)
continue
}
if ms.State.GameStarted { // 将玩家标记为断开连接
if p, ok := ms.State.Players[userID]; ok {
ms.DisconnectedPlayers[userID] = p
p.DisconnectTime = time.Now().Unix()
delete(ms.State.Players, userID) // 从活跃玩家中移除
broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState)
}
} else {
delete(ms.State.Players, userID)
// 退出大厅时,清理对局存储
nk.StorageDelete(ctx, []*runtime.StorageDelete{
{
Collection: "game_data",
Key: "active_match",
UserID: userID,
},
})
// 从回合顺序中移除...(如果我们支持离开大厅则需要逻辑)
// 找到并移除 userID
for i, id := range ms.State.TurnOrder {
if id == userID {
ms.State.TurnOrder = append(ms.State.TurnOrder[:i], ms.State.TurnOrder[i+1:]...)
break
}
}
}
delete(ms.Presences, userID) // 移除 presence
}
ms.updateLabel(dispatcher)
return ms
}
func (ms *MatchState) updateLabel(dispatcher runtime.MatchDispatcher) {
label := MatchLabel{
Open: !ms.State.GameStarted && len(ms.State.Players) < ms.MatchPlayerCount,
Started: ms.State.GameStarted,
PlayerCount: len(ms.State.Players),
MaxPlayers: ms.MatchPlayerCount,
Label: "Animal Minesweeper",
GameType: ms.GameType, // 包含游戏类型
}
labelBytes, _ := json.Marshal(label)
_ = dispatcher.MatchLabelUpdate(string(labelBytes))
}
func (m *MatchHandler) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} {
return state
}
func (m *MatchHandler) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) {
return state, data
}
// 辅助函数
func broadcastUpdate(dispatcher runtime.MatchDispatcher, state *core.GameState, opCode int64) {
// 原始逻辑:向每个玩家发送已脱敏的状态
// 我们需要遍历已连接的 presence 吗?
// 或者只是广播已脱敏的通用状态?
// 原始 main.go 循环:
// for _, presence := range presences ... SanitizeForUser ...
// 由于我们在全局函数中无法轻松获取 presence我们要依赖玩家映射的键吗
// 但是 Dispatcher 需要 presence 作为目标。
// BroadcastMessage 使用 nil presence 会发送给所有人。
// 但是我们需要针对每个用户的脱敏来处理隐藏格子。
// Sanitize() 方法(无参数)清除所有已揭示的格子。这对公开广播是安全的。
// 并且它更新所有人。
// 但是正确的扫雷游戏需要用户看到他们自己揭示的格子。
// 所以我们现在应该广播安全的通用版本,除非我们追踪 presence。
// 简化:广播通用的已脱敏状态(如果不是公开的,每个人都看到揭示的格子是隐藏的?)
// 等等Sanitize() 清除了 Player 中的 RevealedCells 映射。
// SanitizeForUser(uid) 保留该用户的格子。
// 如果我们使用 dispatcher.BroadcastMessage(..., nil, ...) 它会发送给所有人。
// 没有 Presence 列表,我们无法在这里实现每用户的视图。
// MatchState 通常不存储 presence 列表Nakama 会管理它。
// 如果我们需要完美的实现,可能需要重新考虑这一点。
// 然而,原始 main.go 的 broadcastStateToAllPlayers 需要传递 presence。
// 在 MatchLoop 中,我们没有在消息中传递所有已连接用户的 presence 列表。
// 原始代码 MatchJoin 使用 dispatcher.BroadcastMessage(..., nil) 广播,这会向所有人发送一条消息。
// 而且 Sanitize() 清除了所有私有信息。
// 逻辑 handleMove 也广播了 Sanitize()。
// 所以原始代码实际上并没有发送私有信息?
// 让我们重读 main.go。
// 第 319 行Sanitize() 清除所有 revealedCells。
// 第 413 行broadcastStateToAllPlayers 循环 presence...
// 但是 MatchLoop 调用 handleMove第 1158 行)调用了 dispatcher.BroadcastMessage(..., state.Sanitize()...)!
// 所以在原始代码中RevealedCells 从未通过广播更新显示给客户端?
// 放大镜事件发送 CellIndex 和 CellType。客户端使用该事件在本地更新地图
// 是的broadcastEvent 发送 CellIndex 和 CellType。
// 所以如果事件携带私有信息,状态更新本身不需要携带它。
// 所以 Sanitize() 就足够了。
data, _ := json.Marshal(state.Sanitize())
dispatcher.BroadcastMessage(opCode, data, nil, nil, true)
}
func consumeTicket(realUserID int64, ticket string, gameType string, logger runtime.Logger) {
if realUserID <= 0 {
return
}
reqBody, _ := json.Marshal(map[string]string{
"user_id": fmt.Sprintf("%d", realUserID),
"game_code": gameType, // 使用实际的游戏类型,免费模式后端会跳过扣减
"ticket": ticket,
})
url := config.BackendBaseURL + "/game/consume-ticket"
resp, err := config.MakeInternalRequest("POST", url, reqBody)
if err != nil {
logger.Error("Failed to call backend consume-ticket API for user %d: %v", realUserID, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
logger.Error("Backend consume-ticket returned status %d for user %d", resp.StatusCode, realUserID)
} else {
logger.Info("Ticket consumed successfully for user %d", realUserID)
}
}