349 lines
8.9 KiB
Go
349 lines
8.9 KiB
Go
package room
|
||
|
||
import (
|
||
"encoding/json"
|
||
"math/rand"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gorilla/websocket"
|
||
|
||
"nl-game-api-gin/internal/ai"
|
||
"nl-game-api-gin/internal/database"
|
||
"nl-game-api-gin/internal/model"
|
||
"nl-game-api-gin/pkg/jwtutil"
|
||
)
|
||
|
||
// upgrader WebSocket 升级器(开发环境放行所有来源)
|
||
var upgrader = websocket.Upgrader{
|
||
ReadBufferSize: 4096,
|
||
WriteBufferSize: 4096,
|
||
CheckOrigin: func(r *http.Request) bool { return true },
|
||
}
|
||
|
||
// Hub 房间管理器:维护全部活跃房间、用户所在房间以及全部在线连接的映射
|
||
type Hub struct {
|
||
mu sync.Mutex // 保护 rooms 与 userRoom
|
||
rooms map[string]*Room // 邀请码 → 房间
|
||
userRoom map[int]*Room // 用户ID → 所在房间(断线重连用)
|
||
connMu sync.Mutex // 保护 userClients
|
||
userClients map[int]map[*Client]bool // 用户ID → 该用户的全部在线连接(私聊推送/在线状态用)
|
||
}
|
||
|
||
// defaultHub 进程内唯一的 Hub 实例(供 handler 包做私聊推送与在线查询)
|
||
var defaultHub *Hub
|
||
|
||
// NewHub 创建房间管理器并启动过期房间清理协程
|
||
func NewHub() *Hub {
|
||
h := &Hub{
|
||
rooms: map[string]*Room{},
|
||
userRoom: map[int]*Room{},
|
||
userClients: map[int]map[*Client]bool{},
|
||
}
|
||
defaultHub = h
|
||
go h.janitor()
|
||
return h
|
||
}
|
||
|
||
// registerClient 连接建立后登记到在线表
|
||
func (h *Hub) registerClient(c *Client) {
|
||
h.connMu.Lock()
|
||
defer h.connMu.Unlock()
|
||
if h.userClients[c.userID] == nil {
|
||
h.userClients[c.userID] = map[*Client]bool{}
|
||
}
|
||
h.userClients[c.userID][c] = true
|
||
}
|
||
|
||
// unregisterClient 连接断开后从在线表移除
|
||
func (h *Hub) unregisterClient(c *Client) {
|
||
h.connMu.Lock()
|
||
defer h.connMu.Unlock()
|
||
if set, ok := h.userClients[c.userID]; ok {
|
||
delete(set, c)
|
||
if len(set) == 0 {
|
||
delete(h.userClients, c.userID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// PushToUser 向指定用户的全部在线连接推送一条消息(离线返回 false,调用方靠数据库兜底)
|
||
func PushToUser(userID int, msgType string, data any) bool {
|
||
if defaultHub == nil {
|
||
return false
|
||
}
|
||
defaultHub.connMu.Lock()
|
||
defer defaultHub.connMu.Unlock()
|
||
set, ok := defaultHub.userClients[userID]
|
||
if !ok || len(set) == 0 {
|
||
return false
|
||
}
|
||
for c := range set {
|
||
c.push(msgType, data)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// OnlineCount 当前在线用户数(后台数据概览用)
|
||
func OnlineCount() int {
|
||
if defaultHub == nil {
|
||
return 0
|
||
}
|
||
defaultHub.connMu.Lock()
|
||
defer defaultHub.connMu.Unlock()
|
||
return len(defaultHub.userClients)
|
||
}
|
||
|
||
// OnlineMap 批量查询用户在线状态(好友列表/会话列表展示用)
|
||
func OnlineMap(userIDs []int) map[int]bool {
|
||
out := map[int]bool{}
|
||
if defaultHub == nil {
|
||
return out
|
||
}
|
||
defaultHub.connMu.Lock()
|
||
defer defaultHub.connMu.Unlock()
|
||
for _, id := range userIDs {
|
||
out[id] = len(defaultHub.userClients[id]) > 0
|
||
}
|
||
return out
|
||
}
|
||
|
||
// janitor 定时清理:每分钟检查一次,移除已解散或长时间无人在线的房间
|
||
func (h *Hub) janitor() {
|
||
for range time.Tick(time.Minute) {
|
||
h.mu.Lock()
|
||
for code, r := range h.rooms {
|
||
if r.expired() {
|
||
r.close("房间长时间无人活动,已解散")
|
||
for _, uid := range r.humanUserIDs() {
|
||
delete(h.userRoom, uid)
|
||
}
|
||
delete(h.rooms, code)
|
||
}
|
||
}
|
||
h.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// HandleWS WebSocket 入口:?token= 鉴权成功后启动读写泵
|
||
// 若用户已在房间中(断线重连),自动重新挂载并推送最新状态
|
||
func (h *Hub) HandleWS(c *gin.Context) {
|
||
claims, err := jwtutil.Parse(c.Query("token"))
|
||
if err != nil {
|
||
c.JSON(401, gin.H{"code": 401, "msg": "凭证无效"})
|
||
return
|
||
}
|
||
var user model.User
|
||
if err := database.DB.First(&user, claims.UserID).Error; err != nil || user.Status != model.UserStatusNormal {
|
||
c.JSON(401, gin.H{"code": 401, "msg": "用户状态异常"})
|
||
return
|
||
}
|
||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
client := &Client{
|
||
hub: h, conn: conn, send: make(chan []byte, 64),
|
||
userID: user.ID, name: user.Nickname, avatar: user.Avatar,
|
||
}
|
||
go client.writePump()
|
||
// 登记在线连接(私聊推送/好友在线状态)
|
||
h.registerClient(client)
|
||
// 连接成功先下发身份确认
|
||
client.push("hello", gin.H{"user_id": user.ID})
|
||
// 断线重连:若用户已在某房间,重新绑定连接并同步状态
|
||
h.mu.Lock()
|
||
if r, ok := h.userRoom[user.ID]; ok {
|
||
h.mu.Unlock()
|
||
r.reattach(client)
|
||
} else {
|
||
h.mu.Unlock()
|
||
}
|
||
client.readPump()
|
||
}
|
||
|
||
// onDisconnect 连接断开:注销在线登记,标记座位离线(等待重连),等待中的房间直接移除该玩家
|
||
func (h *Hub) onDisconnect(c *Client) {
|
||
h.unregisterClient(c)
|
||
h.mu.Lock()
|
||
r, ok := h.userRoom[c.userID]
|
||
h.mu.Unlock()
|
||
if !ok {
|
||
return
|
||
}
|
||
if left := r.onOffline(c); left {
|
||
// 等待中离开即退出房间
|
||
h.mu.Lock()
|
||
delete(h.userRoom, c.userID)
|
||
if r.empty() {
|
||
delete(h.rooms, r.Code)
|
||
}
|
||
h.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// dispatch 消息路由:按类型分发到建房/加入/游戏动作等处理函数
|
||
func (h *Hub) dispatch(c *Client, msgType string, raw json.RawMessage) {
|
||
switch msgType {
|
||
case "create_room":
|
||
h.createRoom(c, raw)
|
||
case "join_room":
|
||
h.joinRoom(c, raw)
|
||
case "leave_room":
|
||
h.leaveRoom(c)
|
||
default:
|
||
// 其余消息都要求已在房间内
|
||
h.mu.Lock()
|
||
r, ok := h.userRoom[c.userID]
|
||
h.mu.Unlock()
|
||
if !ok {
|
||
c.pushError("你不在任何房间中")
|
||
return
|
||
}
|
||
r.handleMessage(c, msgType, raw)
|
||
}
|
||
}
|
||
|
||
// roomCodeChars 邀请码字符集(去掉易混淆的 0/O/1/I)
|
||
const roomCodeChars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||
|
||
// genRoomCode 生成 6 位不重复邀请码(调用方需持有 h.mu)
|
||
func (h *Hub) genRoomCode() string {
|
||
for {
|
||
code := make([]byte, 6)
|
||
for i := range code {
|
||
code[i] = roomCodeChars[rand.Intn(len(roomCodeChars))]
|
||
}
|
||
if _, exists := h.rooms[string(code)]; !exists {
|
||
return string(code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// createRoomReq 建房请求参数(AI 提供方不由前台指定,取后台全站统一配置)
|
||
type createRoomReq struct {
|
||
Game string `json:"game"` // doudizhu / chess / monopoly / ludo / billiards
|
||
Mode string `json:"mode"` // pvp=好友联机 ai=人机对战
|
||
AIDifficulty string `json:"ai_difficulty"` // easy / medium / hard
|
||
}
|
||
|
||
// validGames 支持联机对战的游戏编码(starve=饥荒组队联机,主机权威模拟)
|
||
var validGames = map[string]bool{
|
||
"doudizhu": true, "chess": true,
|
||
"monopoly": true, "ludo": true, "billiards": true,
|
||
"starve": true,
|
||
}
|
||
|
||
// createRoom 创建房间:AI 模式立即用 AI 填满空位
|
||
func (h *Hub) createRoom(c *Client, raw json.RawMessage) {
|
||
var req createRoomReq
|
||
json.Unmarshal(raw, &req)
|
||
if !validGames[req.Game] {
|
||
c.pushError("未知的游戏类型")
|
||
return
|
||
}
|
||
if req.Mode != "pvp" && req.Mode != "ai" {
|
||
req.Mode = "pvp"
|
||
}
|
||
h.mu.Lock()
|
||
// 已在房间中不允许重复建房
|
||
if _, ok := h.userRoom[c.userID]; ok {
|
||
h.mu.Unlock()
|
||
c.pushError("你已在房间中,请先退出")
|
||
return
|
||
}
|
||
code := h.genRoomCode()
|
||
// AI 模型全站统一:由后台「AI 设置」配置,房间创建时读取当前值
|
||
r := newRoom(code, req.Game, req.Mode, ai.GlobalProvider(), req.AIDifficulty)
|
||
h.rooms[code] = r
|
||
h.userRoom[c.userID] = r
|
||
h.mu.Unlock()
|
||
r.join(c, true)
|
||
}
|
||
|
||
// joinRoomReq 加入房间请求参数
|
||
type joinRoomReq struct {
|
||
Code string `json:"code"` // 6位邀请码
|
||
}
|
||
|
||
// joinRoom 凭邀请码加入房间
|
||
func (h *Hub) joinRoom(c *Client, raw json.RawMessage) {
|
||
var req joinRoomReq
|
||
json.Unmarshal(raw, &req)
|
||
h.mu.Lock()
|
||
if _, ok := h.userRoom[c.userID]; ok {
|
||
h.mu.Unlock()
|
||
c.pushError("你已在房间中,请先退出")
|
||
return
|
||
}
|
||
r, ok := h.rooms[req.Code]
|
||
if !ok {
|
||
h.mu.Unlock()
|
||
c.pushError("房间不存在或已解散")
|
||
return
|
||
}
|
||
h.userRoom[c.userID] = r
|
||
h.mu.Unlock()
|
||
if err := r.join(c, false); err != nil {
|
||
h.mu.Lock()
|
||
delete(h.userRoom, c.userID)
|
||
h.mu.Unlock()
|
||
c.pushError(err.Error())
|
||
}
|
||
}
|
||
|
||
// leaveRoom 主动退出房间:对局中退出按认输处理
|
||
func (h *Hub) leaveRoom(c *Client) {
|
||
h.mu.Lock()
|
||
r, ok := h.userRoom[c.userID]
|
||
if ok {
|
||
delete(h.userRoom, c.userID)
|
||
}
|
||
h.mu.Unlock()
|
||
if !ok {
|
||
return
|
||
}
|
||
r.leave(c)
|
||
h.mu.Lock()
|
||
if r.empty() {
|
||
delete(h.rooms, r.Code)
|
||
}
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
// removeUserRoom 房间内部结算/解散时同步清理映射(房间锁外调用)
|
||
func (h *Hub) removeUserRoom(uid int) {
|
||
h.mu.Lock()
|
||
delete(h.userRoom, uid)
|
||
h.mu.Unlock()
|
||
}
|
||
|
||
// onStarveOfflineTimeout 饥荒等待中断线保护超时:30秒内未重连才清座;若房间无真人则解散
|
||
func (h *Hub) onStarveOfflineTimeout(r *Room, userID, seatIndex int) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
seat := r.seatByUser(userID)
|
||
if seat == nil || seat.Index != seatIndex || seat.Online || seat.client != nil || r.closed {
|
||
return
|
||
}
|
||
r.clearSeatLocked(seat)
|
||
r.broadcastStateLocked()
|
||
delete(h.userRoom, userID)
|
||
|
||
empty := true
|
||
for _, s := range r.seats {
|
||
if s.UserID > 0 {
|
||
empty = false
|
||
break
|
||
}
|
||
}
|
||
if empty {
|
||
delete(h.rooms, r.Code)
|
||
}
|
||
}
|