198 lines
7.3 KiB
Go
198 lines
7.3 KiB
Go
package room // 包名声明:房间管理器与房间同包
|
|
|
|
import (
|
|
"context" // 标准库:查询用户信息时的超时控制
|
|
"errors" // 标准库:房间数超限、人数不合法等错误
|
|
"fmt" // 标准库:人数不在玩法允许范围时的提示
|
|
"log/slog" // 标准库:记录创建与回收日志
|
|
"sync" // 标准库:保护房间表与用户绑定表
|
|
"time" // 标准库:查询用户信息的超时
|
|
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqldao"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
|
)
|
|
|
|
// Manager 管理全部房间:创建、查询、用户绑定与回收。
|
|
type Manager struct {
|
|
mu sync.RWMutex // 保护下面两张表
|
|
rooms map[string]*Room // roomID -> 房间
|
|
userRoom map[int64]string // userID -> roomID(一个用户同时只在一个房间)
|
|
hub *wsgateway.Hub // 下发通道,透传给房间
|
|
userDao *mysqldao.UserDao // 查询真实用户昵称与头像(失败时降级为默认昵称)
|
|
}
|
|
|
|
// NewManager 创建房间管理器。
|
|
func NewManager(hub *wsgateway.Hub) *Manager {
|
|
return &Manager{
|
|
rooms: make(map[string]*Room), // 初始化房间表
|
|
userRoom: make(map[int64]string), // 初始化绑定表
|
|
hub: hub, // 保存下发通道
|
|
userDao: mysqldao.NewUserDao(), // 用于构造玩家名册
|
|
}
|
|
}
|
|
|
|
// Create 创建一个房间并让真人、AI 依次入座(匹配成功后由匹配器调用)。
|
|
//
|
|
// aiCount 为需要补位的 AI 数量:AI 只入座不绑定连接,客户端从 roster 里看到的是
|
|
// 与真人同构的昵称与头像,无法区分。
|
|
func (m *Manager) Create(gameID string, uids []int64, aiCount int) (*Room, error) {
|
|
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数范围
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seats := len(uids) + aiCount // 本局总人数 = 真人 + AI
|
|
if seats < meta.MinSeats || (meta.MaxSeats > 0 && seats > meta.MaxSeats) {
|
|
return nil, fmt.Errorf("game %s requires %d~%d players, got %d", gameID, meta.MinSeats, meta.MaxSeats, seats)
|
|
}
|
|
if aiCount > 0 { // 需要补 AI:玩法必须实现 AIBrain
|
|
probe, perr := game.New(gameID)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if !game.HasAIBrain(probe) {
|
|
return nil, errors.New("该玩法不支持 AI 补位")
|
|
}
|
|
}
|
|
if m.Count() >= maxRooms() { // 房间数达到上限
|
|
return nil, errors.New("too many rooms")
|
|
}
|
|
|
|
// 构造名册:先真人后 AI,座位顺序即入座顺序。
|
|
roster := make([]dto.PlayerBrief, 0, seats)
|
|
seat := 0
|
|
for _, uid := range uids { // 真人:查库取昵称与头像
|
|
nickname, avatar := m.lookProfile(uid)
|
|
roster = append(roster, dto.PlayerBrief{
|
|
UserID: uid, Nickname: nickname, Avatar: avatar, Seat: seat, Online: true,
|
|
})
|
|
seat++
|
|
}
|
|
aiUIDs := make([]int64, 0, aiCount)
|
|
for i := 0; i < aiCount; i++ { // AI:用拟人昵称,与其它玩家同构
|
|
uid := game.NewAIUID()
|
|
nickname, avatar := game.AIProfile(uid)
|
|
aiUIDs = append(aiUIDs, uid)
|
|
roster = append(roster, dto.PlayerBrief{
|
|
UserID: uid, Nickname: nickname, Avatar: avatar, Seat: seat, Online: true,
|
|
})
|
|
seat++
|
|
}
|
|
|
|
m.mu.Lock() // 加写锁
|
|
r, err := newRoom(newRoomID(), gameID, seats, roster, m.hub, m.onRoomClosed) // 建房间并启动事件循环
|
|
if err != nil { // 创建失败
|
|
m.mu.Unlock() // 解锁
|
|
return nil, err
|
|
}
|
|
m.rooms[r.ID()] = r // 登记房间
|
|
m.mu.Unlock() // 解锁
|
|
|
|
for _, uid := range uids { // 真人入座并绑定房间
|
|
if err := r.Join(uid); err != nil { // 入座失败(如人数超出座位)
|
|
slog.Warn("room join failed on create", "room_id", r.ID(), "user_id", uid, "error", err)
|
|
continue // 跳过该用户,房间继续可用
|
|
}
|
|
m.bind(uid, r.ID()) // 绑定用户与房间
|
|
}
|
|
for _, uid := range aiUIDs { // AI 入座:不绑定(AI 没有连接,Hub 下发时会自然跳过)
|
|
if err := r.Join(uid); err != nil {
|
|
slog.Warn("room ai join failed on create", "room_id", r.ID(), "user_id", uid, "error", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", seats, "ai", aiCount)
|
|
return r, nil
|
|
}
|
|
|
|
// Get 按 ID 取房间。
|
|
func (m *Manager) Get(id string) (*Room, bool) {
|
|
m.mu.RLock() // 加读锁
|
|
defer m.mu.RUnlock() // 返回时解锁
|
|
r, ok := m.rooms[id] // 查表
|
|
return r, ok
|
|
}
|
|
|
|
// RoomOf 取用户当前所在房间(用于重连与防止重复匹配)。
|
|
func (m *Manager) RoomOf(uid int64) (*Room, bool) {
|
|
m.mu.RLock() // 加读锁
|
|
id, ok := m.userRoom[uid] // 查绑定表
|
|
m.mu.RUnlock() // 解锁
|
|
if !ok { // 未绑定
|
|
return nil, false
|
|
}
|
|
return m.Get(id) // 取房间
|
|
}
|
|
|
|
// Count 返回当前房间数。
|
|
func (m *Manager) Count() int {
|
|
m.mu.RLock() // 加读锁
|
|
defer m.mu.RUnlock() // 返回时解锁
|
|
return len(m.rooms) // 表长度
|
|
}
|
|
|
|
// bind 绑定用户与房间。
|
|
func (m *Manager) bind(uid int64, roomID string) {
|
|
m.mu.Lock() // 加写锁
|
|
defer m.mu.Unlock() // 返回时解锁
|
|
m.userRoom[uid] = roomID // 记录绑定关系
|
|
}
|
|
|
|
// unbind 解除用户绑定。
|
|
func (m *Manager) unbind(uid int64) {
|
|
m.mu.Lock() // 加写锁
|
|
defer m.mu.Unlock() // 返回时解锁
|
|
delete(m.userRoom, uid) // 删除绑定
|
|
}
|
|
|
|
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线。
|
|
// 由 boot 注册到 Hub 的断连回调上。
|
|
func (m *Manager) OnUserDisconnected(uid int64) {
|
|
r, ok := m.RoomOf(uid) // 查用户所在房间
|
|
if !ok { // 不在任何房间(可能只是普通断开)
|
|
return
|
|
}
|
|
if err := r.MarkOffline(uid); err != nil { // 通知房间标记掉线
|
|
slog.Debug("mark offline failed", "user_id", uid, "error", err)
|
|
}
|
|
}
|
|
|
|
// onRoomClosed 是房间关闭回调:从表中移除并解绑全部成员。
|
|
func (m *Manager) onRoomClosed(roomID string, uids []int64) {
|
|
m.mu.Lock() // 加写锁
|
|
delete(m.rooms, roomID) // 移除房间
|
|
m.mu.Unlock() // 解锁
|
|
for _, uid := range uids { // 解绑成员(AI 未绑定,删除空键无副作用)
|
|
m.unbind(uid)
|
|
}
|
|
}
|
|
|
|
// lookProfile 取真实用户的昵称与头像;数据库不可用时降级为「玩家+ID」,绝不建房失败。
|
|
func (m *Manager) lookProfile(uid int64) (nickname string, avatar string) {
|
|
nickname, avatar = fmt.Sprintf("玩家%d", uid), "" // 默认降级值
|
|
if m.userDao == nil || global.MysqlEngine == nil { // 未初始化数据库
|
|
return nickname, avatar
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // 建房路径不能被 DB 拖慢
|
|
defer cancel()
|
|
u, err := m.userDao.UserGetByID(ctx, uid) // 查询用户
|
|
if err != nil || u == nil { // 查不到就用默认值
|
|
return nickname, avatar
|
|
}
|
|
if u.Username != "" { // 以库里昵称为准
|
|
nickname = u.Username
|
|
}
|
|
avatar = u.Avatar // 头像为空时客户端用默认头像
|
|
return nickname, avatar
|
|
}
|
|
|
|
// maxRooms 返回房间总数上限。
|
|
func maxRooms() int {
|
|
if global.WSSetting != nil && global.WSSetting.MaxRooms > 0 { // 配置了
|
|
return global.WSSetting.MaxRooms
|
|
}
|
|
return 1000 // 兜底
|
|
}
|