支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package room // 与 room.go 同包
|
||||
|
||||
import (
|
||||
"log/slog" // 标准库:记录 AI 驱动异常
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
||||
)
|
||||
|
||||
// driveAI 让房间里的 AI 玩家先行动,再交给玩法推进世界。
|
||||
//
|
||||
// AI 由玩法自己决定怎么走(玩法实现可选的 game.AIBrain 接口):
|
||||
// 房间只是把玩法返回的动作代 AI 执行一遍,因此:
|
||||
// - 房间不知道谁是 AI,也不需要知道(对外完全无差别)
|
||||
// - 未实现 AIBrain 的玩法不会被补 AI(匹配器在建房前会校验)
|
||||
func (r *Room) driveAI(now int64) {
|
||||
if r.status != StatusPlaying { // 仅对局中才行动
|
||||
return
|
||||
}
|
||||
brain, ok := r.g.(game.AIBrain) // 玩法是否支持 AI
|
||||
if !ok { // 不支持:直接跳过
|
||||
return
|
||||
}
|
||||
|
||||
actions := brain.TickAI(now, r.state) // 问玩法:AI 本回合要做什么
|
||||
if len(actions) == 0 { // AI 本回合无动作(如还在思考)
|
||||
return
|
||||
}
|
||||
|
||||
for _, a := range actions { // 逐个代 AI 执行
|
||||
events, finish, err := r.g.OnAction(a.UID, a.Action, r.state)
|
||||
if err != nil { // 玩法拒绝(如不是该 AI 的回合):忽略即可,下一回合再试
|
||||
slog.Debug("room ai action rejected", "room_id", r.id,
|
||||
"user_id", a.UID, "error", err)
|
||||
continue
|
||||
}
|
||||
r.deliver(events) // 投递 AI 行为产生的事件
|
||||
if finish != nil { // AI 走完直接分出胜负
|
||||
r.finishGame(finish)
|
||||
return
|
||||
}
|
||||
r.broadcastState() // 让所有人看到 AI 的落子/转向结果
|
||||
}
|
||||
}
|
||||
|
||||
// Roster 返回本房间的玩家名册(昵称、头像、座位),供匹配通知与结算展示使用。
|
||||
// 名册里包含 AI,但没有任何 AI 标识,客户端无法区分。
|
||||
func (r *Room) Roster() []dto.PlayerBrief {
|
||||
return r.roster
|
||||
}
|
||||
+93
-26
@@ -1,22 +1,27 @@
|
||||
package room // 包名声明:房间管理器与房间同包
|
||||
|
||||
import (
|
||||
"errors" // 标准库:房间数超限、游戏未注册等错误
|
||||
"context" // 标准库:查询用户信息时的超时控制
|
||||
"errors" // 标准库:房间数超限、人数不合法等错误
|
||||
"fmt" // 标准库:人数不在玩法允许范围时的提示
|
||||
"log/slog" // 标准库:记录创建与回收日志
|
||||
// 标准库:生成房间 ID 时去除 UUID 连字符
|
||||
"sync" // 标准库:保护房间表与用户绑定表
|
||||
"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 // 下发通道,透传给房间
|
||||
mu sync.RWMutex // 保护下面两张表
|
||||
rooms map[string]*Room // roomID -> 房间
|
||||
userRoom map[int64]string // userID -> roomID(一个用户同时只在一个房间)
|
||||
hub *wsgateway.Hub // 下发通道,透传给房间
|
||||
userDao *mysqldao.UserDao // 查询真实用户昵称与头像(失败时降级为默认昵称)
|
||||
}
|
||||
|
||||
// NewManager 创建房间管理器。
|
||||
@@ -25,37 +30,80 @@ func NewManager(hub *wsgateway.Hub) *Manager {
|
||||
rooms: make(map[string]*Room), // 初始化房间表
|
||||
userRoom: make(map[int64]string), // 初始化绑定表
|
||||
hub: hub, // 保存下发通道
|
||||
userDao: mysqldao.NewUserDao(), // 用于构造玩家名册
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建一个房间并让指定用户入座(匹配成功后由匹配器调用)。
|
||||
func (m *Manager) Create(gameID string, uids []int64) (*Room, error) {
|
||||
meta, err := game.LookupMeta(gameID) // 校验玩法是否已注册,并取人数
|
||||
// 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")
|
||||
}
|
||||
|
||||
m.mu.Lock() // 加写锁
|
||||
r, err := newRoom(newRoomID(), gameID, m.hub, m.onRoomClosed) // 建房间并启动事件循环
|
||||
if err != nil { // 创建失败
|
||||
// 构造名册:先真人后 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 { // 逐个用户入座
|
||||
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", meta.MinSeats)
|
||||
slog.Info("room created", "room_id", r.ID(), "game_id", gameID, "seats", seats, "ai", aiCount)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -99,17 +147,7 @@ func (m *Manager) unbind(uid int64) {
|
||||
delete(m.userRoom, uid) // 删除绑定
|
||||
}
|
||||
|
||||
// onRoomClosed 是房间关闭回调:从表中移除并解绑全部成员。
|
||||
func (m *Manager) onRoomClosed(roomID string, uids []int64) {
|
||||
m.mu.Lock() // 加写锁
|
||||
delete(m.rooms, roomID) // 移除房间
|
||||
m.mu.Unlock() // 解锁
|
||||
for _, uid := range uids { // 解绑成员
|
||||
m.unbind(uid)
|
||||
}
|
||||
}
|
||||
|
||||
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线(进入重连宽限)。
|
||||
// OnUserDisconnected 处理连接断开:通知所在房间把该用户标记为掉线。
|
||||
// 由 boot 注册到 Hub 的断连回调上。
|
||||
func (m *Manager) OnUserDisconnected(uid int64) {
|
||||
r, ok := m.RoomOf(uid) // 查用户所在房间
|
||||
@@ -121,6 +159,35 @@ func (m *Manager) OnUserDisconnected(uid int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 { // 配置了
|
||||
|
||||
+27
-21
@@ -68,16 +68,17 @@ type Room struct {
|
||||
lastActive time.Time // 最近活跃时间,用于空闲回收
|
||||
finish *game.Finish // 对局结果
|
||||
tickInterval time.Duration // 事件循环驱动周期:实时玩法按 Meta.TickMs 加速
|
||||
roster []dto.PlayerBrief // 玩家名册(昵称/头像/座位):客户端据此渲染,不含任何 AI 标记
|
||||
}
|
||||
|
||||
// newRoom 创建房间并启动事件循环。
|
||||
func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
||||
func newRoom(id, gameID string, seats int, roster []dto.PlayerBrief, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
||||
g, err := game.New(gameID) // 按 ID 建玩法实例
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := g.Meta() // 取玩法元信息
|
||||
st, err := g.Init(meta.MinSeats) // 按最少人数初始化状态
|
||||
meta := g.Meta() // 取玩法元信息
|
||||
st, err := g.Init(seats) // 按本局实际人数初始化状态
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,19 +90,20 @@ func newRoom(id, gameID string, hub *wsgateway.Hub, onClosed func(string, []int6
|
||||
}
|
||||
|
||||
r := &Room{
|
||||
id: id, // 房间 ID
|
||||
gameID: gameID, // 游戏 ID
|
||||
g: g, // 玩法实例
|
||||
state: st, // 玩法状态
|
||||
seats: make([]int64, meta.MinSeats), // 按人数建座位表
|
||||
members: make(map[int64]*member), // 成员表
|
||||
status: StatusWaiting, // 初始为等待
|
||||
reqCh: make(chan message, 64), // 请求缓冲队列
|
||||
closed: make(chan struct{}), // 关闭信号
|
||||
hub: hub, // 下发通道
|
||||
onClosed: onClosed, // 关闭回调
|
||||
lastActive: time.Now(), // 初始活跃时间
|
||||
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
|
||||
id: id, // 房间 ID
|
||||
gameID: gameID, // 游戏 ID
|
||||
g: g, // 玩法实例
|
||||
state: st, // 玩法状态
|
||||
seats: make([]int64, seats), // 按本局人数建座位表
|
||||
members: make(map[int64]*member), // 成员表
|
||||
status: StatusWaiting, // 初始为等待
|
||||
reqCh: make(chan message, 64), // 请求缓冲队列
|
||||
closed: make(chan struct{}), // 关闭信号
|
||||
hub: hub, // 下发通道
|
||||
onClosed: onClosed, // 关闭回调
|
||||
lastActive: time.Now(), // 初始活跃时间
|
||||
roster: roster, // 玩家名册(含 AI,对外无标识)
|
||||
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
|
||||
}
|
||||
go r.run() // 启动单 goroutine 事件循环
|
||||
return r, nil
|
||||
@@ -247,9 +249,9 @@ func (r *Room) handleJoin(uid int64) error {
|
||||
return errors.New("room is full")
|
||||
}
|
||||
|
||||
r.seats[seat] = uid // 占座
|
||||
r.members[uid] = &member{uid: uid, seat: seat, online: true} // 登记成员
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
r.seats[seat] = uid // 占座
|
||||
r.members[uid] = &member{uid: uid, seat: seat, online: true, ready: game.IsAIUID(uid)} // 登记成员(AI 入座即自动准备)
|
||||
r.lastActive = time.Now() // 刷新活跃时间
|
||||
|
||||
events, err := r.g.OnJoin(uid, r.state) // 通知玩法
|
||||
if err != nil { // 玩法拒绝(如人数已满)
|
||||
@@ -384,7 +386,7 @@ func (r *Room) finishGame(f *game.Finish) {
|
||||
"winner", f.Winner, "reason", f.Reason)
|
||||
|
||||
r.broadcast(wsgateway.TypeRoomFinished, dto.WSRoomFinishedPayload{ // 广播结算
|
||||
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Scores: f.Scores, Reason: f.Reason,
|
||||
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Players: r.roster, Scores: f.Scores, Reason: f.Reason,
|
||||
})
|
||||
|
||||
go func() { // 延时关闭,给客户端留出查看结算的时间
|
||||
@@ -428,6 +430,10 @@ func (r *Room) tick(now time.Time) {
|
||||
|
||||
// 实时玩法驱动:每秒把时间交给玩法(回合制玩法可实现为空)。
|
||||
if r.status == StatusPlaying {
|
||||
r.driveAI(now.Unix()) // 先让 AI 玩家行动(对外不可感知)
|
||||
if r.status != StatusPlaying { // AI 行动后可能已分出胜负
|
||||
return
|
||||
}
|
||||
events, finish, err := r.g.OnTick(now.Unix(), r.state) // 调用玩法 tick
|
||||
if err != nil { // 玩法内部错误
|
||||
slog.Error("room game tick error", "room_id", r.id, "error", err)
|
||||
@@ -483,7 +489,7 @@ func (r *Room) broadcastState() {
|
||||
snap := r.g.Snapshot(uid, r.state) // 玩法按观众裁剪
|
||||
data, err := wsgateway.Encode(wsgateway.TypeRoomState, 0, dto.WSRoomStatePayload{
|
||||
RoomID: r.id, GameID: r.gameID, Status: string(r.status), Seats: r.seats,
|
||||
Ready: r.readyIDs(), State: snap,
|
||||
Ready: r.readyIDs(), State: snap, Players: r.roster,
|
||||
})
|
||||
if err != nil { // 编码失败
|
||||
slog.Error("room encode state failed", "room_id", r.id, "error", err)
|
||||
|
||||
Reference in New Issue
Block a user