Files
nl-game-api/internal/room/room_billiards.go
2026-08-14 13:17:03 +08:00

173 lines
4.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.
// 2D 台球在房间内的动作处理、AI/托管击球与状态快照
// 每一杆由服务端权威模拟,回放关键帧通过 bill_anim 事件推给双方
package room
import (
"fmt"
"strings"
"time"
"nl-game-api-gin/internal/gamecore/billiards"
)
// handleBillShotLocked 真人击球(需持锁):支持杆法(高低杆/左右塞)
func (r *Room) handleBillShotLocked(seatIdx int, angle, power, spinX, spinY float64, c *Client) {
if r.status != "playing" || r.billGame == nil {
c.pushError("对局未开始")
return
}
res, err := r.billGame.ShootSpin(seatIdx, angle, power, spinX, spinY)
if err != nil {
c.pushError(err.Error())
return
}
r.afterBillShotLocked(seatIdx, res)
}
// aiBillActLocked AI/托管击球:规则 AI 选线路后带噪声出杆(需持锁)
// escrow=true 为超时托管:使用低难度(大噪声)出杆
func (r *Room) aiBillActLocked(seatIdx int, escrow bool) {
g := r.billGame
if g == nil || r.status != "playing" || g.Turn != seatIdx {
return
}
diff := r.AIDifficulty
if escrow {
diff = "easy"
}
angle, power := billiards.AIShot(g, seatIdx, diff)
res, err := g.Shoot(seatIdx, angle, power)
if err != nil {
return
}
r.afterBillShotLocked(seatIdx, res)
}
// afterBillShotLocked 一杆结束后的统一收尾:推送回放动画、播报、结算或调度下一杆(需持锁)
func (r *Room) afterBillShotLocked(seatIdx int, res *billiards.ShotResult) {
// 回放动画推给所有真人20fps 关键帧,前端插值播放)
// 附带犯规标记与原因:前端回放结束后先弹犯规提示,再播换人横幅
animMs := len(res.Frames) * 50
foulReason := ""
if res.Foul {
foulReason = "空杆,未碰到任何球"
for _, id := range res.Potted {
if id == 0 {
foulReason = "白球落袋"
break
}
}
}
anim := map[string]any{
"shooter": seatIdx, "frames": res.Frames,
"potted": res.Potted, "interval_ms": 50,
"foul": res.Foul && !res.Over, "foul_reason": foulReason,
}
for _, s := range r.seats {
if s.client != nil {
s.client.push("bill_anim", anim)
}
}
r.animMs = animMs
// 组装战报
name := r.seats[seatIdx].Name
var lines []string
if names := pottedNames(res.Potted); names != "" {
lines = append(lines, fmt.Sprintf("%s 打进了 %s", name, names))
}
if res.Desc != "" {
lines = append(lines, res.Desc)
}
// 打进对方目标球的说明(简化规则:送球不判犯规,只影响是否续杆)
if !res.Over && oppBallPotted(r.billGame.Groups[seatIdx], res.Potted) {
lines = append(lines, fmt.Sprintf("%s 把对方的球送进袋(不判犯规)", name))
}
if res.Foul && !res.Over {
lines = append(lines, fmt.Sprintf("%s 犯规,击球权交给对方", name))
} else if res.Continue && !res.Over {
lines = append(lines, fmt.Sprintf("%s 打进己方目标球,继续击球", name))
}
for _, l := range lines {
r.broadcastChatLocked(-1, "系统", "", l, false)
}
if res.Over {
// 黑八落袋的胜负结算延迟到回放播完再广播,避免提前剧透结果
r.turnSeq++ // 使遗留的回合超时定时器失效
if r.turnTimer != nil {
r.turnTimer.Stop()
}
winners := []int{res.Winner}
desc := fmt.Sprintf("%s 获胜!", r.seats[res.Winner].Name)
time.AfterFunc(time.Duration(animMs+600)*time.Millisecond, func() {
r.mu.Lock()
defer r.mu.Unlock()
// 等待期间房间可能已被解散,不再结算与广播
if r.closed {
return
}
r.settleLocked(winners, desc)
r.broadcastStateLocked()
})
} else {
r.scheduleTurnLocked()
}
r.broadcastStateLocked()
}
// oppBallPotted 本杆是否把对方组的球打进袋(分组未定时不判断)
func oppBallPotted(myGroup int, potted []int) bool {
if myGroup == billiards.GroupNone {
return false
}
for _, id := range potted {
var g int
switch {
case id >= 1 && id <= 7:
g = billiards.GroupSolid
case id >= 9 && id <= 15:
g = billiards.GroupStripe
default:
continue
}
if g != myGroup {
return true
}
}
return false
}
// pottedNames 落袋球的可读描述(白球单独说明)
func pottedNames(potted []int) string {
var parts []string
for _, id := range potted {
if id == 0 {
parts = append(parts, "白球(犯规)")
} else if id == 8 {
parts = append(parts, "黑八")
} else {
parts = append(parts, fmt.Sprintf("%d 号球", id))
}
}
return strings.Join(parts, "、")
}
// billStateLocked 台球状态快照(需持锁)
func (r *Room) billStateLocked(viewSeat int) map[string]any {
g := r.billGame
balls := make([]map[string]any, 0, 16)
for _, b := range g.Balls {
balls = append(balls, map[string]any{
"id": b.ID, "x": b.X, "y": b.Y, "on": b.On,
})
}
myGroup := 0
if viewSeat >= 0 && viewSeat < 2 {
myGroup = g.Groups[viewSeat]
}
return map[string]any{
"phase": g.Phase, "turn": g.Turn, "groups": g.Groups,
"my_group": myGroup, "balls": balls, "shots": g.Shots,
"cleared": []bool{g.GroupCleared(0), g.GroupCleared(1)},
}
}