Files
nl-game-api/internal/room/hub.go
2026-08-15 09:14:40 +08:00

349 lines
8.9 KiB
Go
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 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)
}
}