Files

349 lines
8.9 KiB
Go
Raw Permalink Normal View History

2026-08-14 13:17:03 +08:00
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)
}
}