555 lines
15 KiB
Go
555 lines
15 KiB
Go
// Package billiards 2D 八球台球:服务端权威物理模拟 + 简化八球规则
|
||
// 玩家提交击球角度与力度,服务端逐帧模拟碰撞、库边反弹与落袋,
|
||
// 并把关键帧序列返回给前端做回放动画,保证双方看到一致的结果
|
||
package billiards
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"math/rand"
|
||
)
|
||
|
||
// 桌面与物理常量(单位:像素、秒)
|
||
const (
|
||
TableW = 800.0 // 桌面宽
|
||
TableH = 400.0 // 桌面高
|
||
BallR = 10.0 // 球半径
|
||
PocketR = 26.0 // 角袋捕获半径
|
||
PocketRm = 24.0 // 中袋捕获半径
|
||
FricLin = 55.0 // 线性摩擦减速度
|
||
FricProp = 0.30 // 速度比例阻尼系数
|
||
Restitut = 0.92 // 库边反弹恢复系数
|
||
StopSpeed = 4.0 // 低于该速度视为停止
|
||
SimDt = 1.0 / 120.0 // 物理步长
|
||
FrameEach = 6 // 每 6 个物理步记录一帧(20fps 回放)
|
||
MaxSimSec = 20.0 // 单杆模拟时长上限
|
||
CueStartX = 200.0 // 白球初始/重置位置
|
||
CueStartY = 200.0
|
||
)
|
||
|
||
// 球组
|
||
const (
|
||
GroupNone = 0 // 未分组
|
||
GroupSolid = 1 // 全色球(1-7)
|
||
GroupStripe = 2 // 花色球(9-15)
|
||
)
|
||
|
||
// 阶段
|
||
const (
|
||
PhaseAim = "aim" // 等待当前玩家击球
|
||
PhaseOver = "over" // 对局结束
|
||
)
|
||
|
||
// Ball 一颗球
|
||
type Ball struct {
|
||
ID int `json:"id"` // 0=白球 1-7全色 8=黑八 9-15花色
|
||
X float64 `json:"x"`
|
||
Y float64 `json:"y"`
|
||
On bool `json:"on"` // 是否还在桌面上
|
||
vx float64
|
||
vy float64
|
||
// 杆法旋转(仅白球有效):spinX 左右塞 -1~1(右为正),spinY 高低杆 -1~1(高杆为正)
|
||
spinX float64
|
||
spinY float64
|
||
}
|
||
|
||
// Game 一局台球(两座位)
|
||
type Game struct {
|
||
Balls [16]*Ball // 全部球
|
||
Turn int // 当前击球座位(0/1)
|
||
Groups [2]int // 双方球组(未定为 GroupNone)
|
||
Phase string // aim / over
|
||
Winner int // 获胜座位(-1 未定)
|
||
Shots int // 已击球杆数
|
||
}
|
||
|
||
// ShotResult 一杆的模拟结果
|
||
type ShotResult struct {
|
||
Frames [][][3]float64 // 回放关键帧:每帧为 [ [id,x,y], ... ](仅在桌球)
|
||
Potted []int // 本杆落袋的球 ID(含白球)
|
||
Foul bool // 是否犯规(白球落袋或空杆)
|
||
Continue bool // 击球方是否继续击球
|
||
Over bool // 对局是否结束
|
||
Winner int // 结束时的获胜座位
|
||
Desc string // 一句话战报
|
||
}
|
||
|
||
// pockets 六个袋口坐标与捕获半径
|
||
var pockets = [6][3]float64{
|
||
{0, 0, PocketR}, {TableW, 0, PocketR}, {0, TableH, PocketR}, {TableW, TableH, PocketR},
|
||
{TableW / 2, -4, PocketRm}, {TableW / 2, TableH + 4, PocketRm},
|
||
}
|
||
|
||
// NewGame 摆球开局:白球在左侧,15 颗彩球在右侧摆三角(黑八居中)
|
||
func NewGame() *Game {
|
||
g := &Game{Turn: 0, Phase: PhaseAim, Winner: -1}
|
||
g.Balls[0] = &Ball{ID: 0, X: CueStartX, Y: CueStartY, On: true}
|
||
// 三角阵五排:花色与全色交错、黑八在第三排中心
|
||
rack := [][]int{
|
||
{1},
|
||
{9, 2},
|
||
{3, 8, 10},
|
||
{11, 4, 12, 5},
|
||
{6, 13, 7, 14, 15},
|
||
}
|
||
apexX, apexY := 560.0, 200.0
|
||
dx := BallR * 2 * math.Cos(math.Pi/6) // 排间距(略留缝隙)
|
||
for row, ids := range rack {
|
||
for j, id := range ids {
|
||
x := apexX + float64(row)*(dx+0.5)
|
||
y := apexY + (float64(j)-float64(row)/2)*(BallR*2+1)
|
||
g.Balls[id] = &Ball{ID: id, X: x, Y: y, On: true}
|
||
}
|
||
}
|
||
return g
|
||
}
|
||
|
||
// groupOf 球 ID 所属球组
|
||
func groupOf(id int) int {
|
||
if id >= 1 && id <= 7 {
|
||
return GroupSolid
|
||
}
|
||
if id >= 9 && id <= 15 {
|
||
return GroupStripe
|
||
}
|
||
return GroupNone
|
||
}
|
||
|
||
// GroupCleared 某座位的组内球是否已全部落袋(未分组视为未清台)
|
||
func (g *Game) GroupCleared(seat int) bool {
|
||
grp := g.Groups[seat]
|
||
if grp == GroupNone {
|
||
return false
|
||
}
|
||
for _, b := range g.Balls {
|
||
if b.On && groupOf(b.ID) == grp {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Shoot 当前玩家击球(中杆无旋转):angle 弧度、power 1-100,返回模拟结果
|
||
func (g *Game) Shoot(seat int, angle, power float64) (*ShotResult, error) {
|
||
return g.ShootSpin(seat, angle, power, 0, 0)
|
||
}
|
||
|
||
// ShootSpin 带杆法的击球:spinX 左右塞 -1~1(右塞为正),spinY 高低杆 -1~1(高杆为正)
|
||
// 高低杆在白球首次碰到目标球后沿原行进方向跟进/回拉;左右塞让白球碰库时反弹方向偏转
|
||
func (g *Game) ShootSpin(seat int, angle, power, spinX, spinY float64) (*ShotResult, error) {
|
||
if g.Phase != PhaseAim {
|
||
return nil, fmt.Errorf("对局已结束")
|
||
}
|
||
if g.Turn != seat {
|
||
return nil, fmt.Errorf("还没轮到你击球")
|
||
}
|
||
if math.IsNaN(angle) || math.IsInf(angle, 0) {
|
||
return nil, fmt.Errorf("击球角度不合法")
|
||
}
|
||
if math.IsNaN(power) || math.IsInf(power, 0) {
|
||
power = 50
|
||
}
|
||
power = math.Max(5, math.Min(100, power))
|
||
if math.IsNaN(spinX) || math.IsInf(spinX, 0) {
|
||
spinX = 0
|
||
}
|
||
if math.IsNaN(spinY) || math.IsInf(spinY, 0) {
|
||
spinY = 0
|
||
}
|
||
cue := g.Balls[0]
|
||
speed := power * 13 // 最大约 1300 px/s
|
||
cue.vx = math.Cos(angle) * speed
|
||
cue.vy = math.Sin(angle) * speed
|
||
cue.spinX = math.Max(-1, math.Min(1, spinX))
|
||
cue.spinY = math.Max(-1, math.Min(1, spinY))
|
||
g.Shots++
|
||
res := g.simulate()
|
||
g.applyRules(seat, res)
|
||
return res, nil
|
||
}
|
||
|
||
// simulate 逐帧物理模拟直到所有球停止,返回关键帧与落袋列表
|
||
func (g *Game) simulate() *ShotResult {
|
||
res := &ShotResult{Winner: -1}
|
||
res.Frames = append(res.Frames, g.snapshot())
|
||
maxSteps := int(MaxSimSec / SimDt)
|
||
anyCueContact := false
|
||
for step := 0; step < maxSteps; step++ {
|
||
moving := false
|
||
// 位置积分 + 摩擦
|
||
for _, b := range g.Balls {
|
||
if !b.On {
|
||
continue
|
||
}
|
||
sp := math.Hypot(b.vx, b.vy)
|
||
if sp < StopSpeed {
|
||
b.vx, b.vy = 0, 0
|
||
continue
|
||
}
|
||
moving = true
|
||
b.X += b.vx * SimDt
|
||
b.Y += b.vy * SimDt
|
||
// 摩擦:线性减速 + 速度比例阻尼
|
||
dec := (FricLin + FricProp*sp) * SimDt
|
||
ns := sp - dec
|
||
if ns < 0 {
|
||
ns = 0
|
||
}
|
||
b.vx *= ns / sp
|
||
b.vy *= ns / sp
|
||
// 台呢摩擦消耗旋转:约 1 秒后旋转余量降至 40%
|
||
if b.spinX != 0 || b.spinY != 0 {
|
||
b.spinX *= 1 - 0.9*SimDt
|
||
b.spinY *= 1 - 0.9*SimDt
|
||
}
|
||
}
|
||
if !moving {
|
||
break
|
||
}
|
||
// 落袋检测
|
||
for _, b := range g.Balls {
|
||
if !b.On {
|
||
continue
|
||
}
|
||
for _, p := range pockets {
|
||
if math.Hypot(b.X-p[0], b.Y-p[1]) < p[2] {
|
||
b.On = false
|
||
b.vx, b.vy = 0, 0
|
||
res.Potted = append(res.Potted, b.ID)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
// 库边反弹(袋口附近不反弹,让球能滚进袋)
|
||
// 白球带左右塞时反弹方向发生偏转:切向获得与法向来速成正比的分量,每碰库消耗一半塞量
|
||
for _, b := range g.Balls {
|
||
if !b.On || g.nearPocket(b) {
|
||
continue
|
||
}
|
||
if b.X < BallR {
|
||
vn := math.Abs(b.vx)
|
||
b.X = BallR
|
||
b.vx = -b.vx * Restitut
|
||
if b.spinX != 0 {
|
||
b.vy += b.spinX * 0.45 * vn // 左库(法线 +x):右塞往 +y 偏
|
||
b.spinX *= 0.5
|
||
}
|
||
} else if b.X > TableW-BallR {
|
||
vn := math.Abs(b.vx)
|
||
b.X = TableW - BallR
|
||
b.vx = -b.vx * Restitut
|
||
if b.spinX != 0 {
|
||
b.vy -= b.spinX * 0.45 * vn // 右库(法线 -x):右塞往 -y 偏
|
||
b.spinX *= 0.5
|
||
}
|
||
}
|
||
if b.Y < BallR {
|
||
vn := math.Abs(b.vy)
|
||
b.Y = BallR
|
||
b.vy = -b.vy * Restitut
|
||
if b.spinX != 0 {
|
||
b.vx -= b.spinX * 0.45 * vn // 上库(法线 +y):右塞往 -x 偏
|
||
b.spinX *= 0.5
|
||
}
|
||
} else if b.Y > TableH-BallR {
|
||
vn := math.Abs(b.vy)
|
||
b.Y = TableH - BallR
|
||
b.vy = -b.vy * Restitut
|
||
if b.spinX != 0 {
|
||
b.vx += b.spinX * 0.45 * vn // 下库(法线 -y):右塞往 +x 偏
|
||
b.spinX *= 0.5
|
||
}
|
||
}
|
||
}
|
||
// 球间弹性碰撞(等质量:交换法线方向速度分量)
|
||
for i := 0; i < len(g.Balls); i++ {
|
||
bi := g.Balls[i]
|
||
if !bi.On {
|
||
continue
|
||
}
|
||
for j := i + 1; j < len(g.Balls); j++ {
|
||
bj := g.Balls[j]
|
||
if !bj.On {
|
||
continue
|
||
}
|
||
dx, dy := bj.X-bi.X, bj.Y-bi.Y
|
||
dist := math.Hypot(dx, dy)
|
||
if dist >= BallR*2 || dist == 0 {
|
||
continue
|
||
}
|
||
var cueB *Ball
|
||
if bi.ID == 0 {
|
||
cueB = bi
|
||
} else if bj.ID == 0 {
|
||
cueB = bj
|
||
}
|
||
if cueB != nil {
|
||
anyCueContact = true
|
||
}
|
||
nx, ny := dx/dist, dy/dist
|
||
// 先推开重叠,避免粘连
|
||
overlap := (BallR*2 - dist) / 2
|
||
bi.X -= nx * overlap
|
||
bi.Y -= ny * overlap
|
||
bj.X += nx * overlap
|
||
bj.Y += ny * overlap
|
||
// 法线方向速度分量交换(含少量能量损耗)
|
||
vi := bi.vx*nx + bi.vy*ny
|
||
vj := bj.vx*nx + bj.vy*ny
|
||
if vi-vj <= 0 {
|
||
continue
|
||
}
|
||
// 等质量弹性碰撞:双方交换法向速度分量(bi 失去 vi-vj,bj 获得 vi-vj)
|
||
const loss = 0.97
|
||
bi.vx += (vj - vi) * nx * loss
|
||
bi.vy += (vj - vi) * ny * loss
|
||
bj.vx += (vi - vj) * nx * loss
|
||
bj.vy += (vi - vj) * ny * loss
|
||
// 高低杆:碰撞后白球沿连心线跟进(高杆)或回拉(低杆),
|
||
// 冲量与法向撞击速度成正比 → 薄切时效果自然减弱;一次碰撞即消耗旋转
|
||
if cueB != nil && cueB.spinY != 0 {
|
||
k := cueB.spinY * 0.55 * math.Min(vi-vj, 900)
|
||
if cueB == bi {
|
||
cueB.vx += k * nx
|
||
cueB.vy += k * ny
|
||
} else {
|
||
cueB.vx -= k * nx
|
||
cueB.vy -= k * ny
|
||
}
|
||
cueB.spinY = 0
|
||
}
|
||
}
|
||
}
|
||
// 关键帧采样
|
||
if step%FrameEach == 0 {
|
||
res.Frames = append(res.Frames, g.snapshot())
|
||
}
|
||
}
|
||
res.Frames = append(res.Frames, g.snapshot())
|
||
// 空杆(白球没碰到任何球)也算犯规
|
||
res.Foul = !anyCueContact
|
||
return res
|
||
}
|
||
|
||
// nearPocket 球是否在袋口捕获区附近(此时不做库边反弹)
|
||
func (g *Game) nearPocket(b *Ball) bool {
|
||
for _, p := range pockets {
|
||
if math.Hypot(b.X-p[0], b.Y-p[1]) < p[2]+BallR {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// snapshot 当前桌面关键帧(仅在桌球,坐标保留 1 位小数减小体积)
|
||
func (g *Game) snapshot() [][3]float64 {
|
||
frame := make([][3]float64, 0, 16)
|
||
for _, b := range g.Balls {
|
||
if b.On {
|
||
frame = append(frame, [3]float64{
|
||
float64(b.ID),
|
||
math.Round(b.X*10) / 10,
|
||
math.Round(b.Y*10) / 10,
|
||
})
|
||
}
|
||
}
|
||
return frame
|
||
}
|
||
|
||
// applyRules 按简化八球规则处理一杆的结果:分组、犯规、胜负与击球权
|
||
func (g *Game) applyRules(seat int, res *ShotResult) {
|
||
cuePotted := false
|
||
eightPotted := false
|
||
var pottedGroups []int
|
||
for _, id := range res.Potted {
|
||
switch {
|
||
case id == 0:
|
||
cuePotted = true
|
||
case id == 8:
|
||
eightPotted = true
|
||
default:
|
||
pottedGroups = append(pottedGroups, groupOf(id))
|
||
}
|
||
}
|
||
if cuePotted {
|
||
res.Foul = true
|
||
}
|
||
// 黑八落袋:清完自己组打进为胜,否则直接告负(白球同落也告负)
|
||
if eightPotted {
|
||
g.Phase = PhaseOver
|
||
res.Over = true
|
||
if g.GroupCleared(seat) && !cuePotted {
|
||
g.Winner = seat
|
||
res.Desc = "打进黑八,制胜一杆!"
|
||
} else {
|
||
g.Winner = 1 - seat
|
||
res.Desc = "黑八提前落袋,痛失好局"
|
||
}
|
||
res.Winner = g.Winner
|
||
return
|
||
}
|
||
// 首次打进彩球确定分组
|
||
if g.Groups[seat] == GroupNone && len(pottedGroups) > 0 {
|
||
g.Groups[seat] = pottedGroups[0]
|
||
g.Groups[1-seat] = 3 - pottedGroups[0]
|
||
if pottedGroups[0] == GroupSolid {
|
||
res.Desc = "分组确定:你打全色球(1-7)"
|
||
} else {
|
||
res.Desc = "分组确定:你打花色球(9-15)"
|
||
}
|
||
}
|
||
// 白球落袋:重置回开球点(顺移避开占位球)
|
||
if cuePotted {
|
||
cue := g.Balls[0]
|
||
cue.On = true
|
||
cue.X, cue.Y = CueStartX, CueStartY
|
||
cue.vx, cue.vy = 0, 0
|
||
cue.spinX, cue.spinY = 0, 0
|
||
for g.overlapAny(cue) {
|
||
cue.X += BallR * 2.2
|
||
if cue.X > TableW-BallR*2 {
|
||
cue.X = BallR * 2
|
||
cue.Y = math.Mod(cue.Y+BallR*3, TableH-BallR*4) + BallR*2
|
||
}
|
||
}
|
||
}
|
||
// 击球权:无犯规且打进自己组的球(或未分组时打进任意彩球)则继续
|
||
ownPotted := false
|
||
for _, grp := range pottedGroups {
|
||
if g.Groups[seat] == GroupNone || grp == g.Groups[seat] {
|
||
ownPotted = true
|
||
}
|
||
}
|
||
res.Continue = !res.Foul && ownPotted
|
||
if !res.Continue {
|
||
g.Turn = 1 - seat
|
||
}
|
||
}
|
||
|
||
// overlapAny 白球重置时是否与其他在桌球重叠
|
||
func (g *Game) overlapAny(cue *Ball) bool {
|
||
for _, b := range g.Balls {
|
||
if b.ID == 0 || !b.On {
|
||
continue
|
||
}
|
||
if math.Hypot(b.X-cue.X, b.Y-cue.Y) < BallR*2.1 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// AIShot 规则 AI 选杆:遍历「目标球 × 袋口」找角度最顺的进球线路
|
||
// 难度决定瞄准噪声:easy 偏差大,hard 几乎指哪打哪
|
||
func AIShot(g *Game, seat int, difficulty string) (angle, power float64) {
|
||
cue := g.Balls[0]
|
||
targets := g.aiTargets(seat)
|
||
type plan struct {
|
||
angle float64
|
||
power float64
|
||
score float64
|
||
}
|
||
best := plan{score: -1e18}
|
||
for _, t := range targets {
|
||
for _, p := range pockets {
|
||
// 目标球到袋口方向
|
||
tpx, tpy := p[0]-t.X, p[1]-t.Y
|
||
tpd := math.Hypot(tpx, tpy)
|
||
if tpd < 1 {
|
||
continue
|
||
}
|
||
// 幽灵球点:白球需要击中的位置(目标球背向袋口 2R 处)
|
||
gx := t.X - tpx/tpd*BallR*2
|
||
gy := t.Y - tpy/tpd*BallR*2
|
||
cgx, cgy := gx-cue.X, gy-cue.Y
|
||
cgd := math.Hypot(cgx, cgy)
|
||
if cgd < 1 {
|
||
continue
|
||
}
|
||
// 切球角余弦:白球行进方向与目标球进袋方向的夹角,太薄不选
|
||
cosCut := (cgx*tpx + cgy*tpy) / (cgd * tpd)
|
||
if cosCut < 0.2 {
|
||
continue
|
||
}
|
||
score := cosCut*1000 - cgd*0.35 - tpd*0.25 - g.pathBlockPenalty(cue.X, cue.Y, gx, gy, t.ID)
|
||
if score > best.score {
|
||
pw := 28 + cgd*0.075 + tpd*0.06/math.Max(cosCut, 0.35)
|
||
best = plan{angle: math.Atan2(cgy, cgx), power: math.Min(92, pw), score: score}
|
||
}
|
||
}
|
||
}
|
||
if best.score <= -1e17 {
|
||
// 没有好线路:朝最近的目标球直打
|
||
var near *Ball
|
||
nd := 1e18
|
||
for _, t := range targets {
|
||
d := math.Hypot(t.X-cue.X, t.Y-cue.Y)
|
||
if d < nd {
|
||
nd, near = d, t
|
||
}
|
||
}
|
||
if near == nil {
|
||
return rand.Float64() * math.Pi * 2, 50
|
||
}
|
||
best = plan{angle: math.Atan2(near.Y-cue.Y, near.X-cue.X), power: 55}
|
||
}
|
||
// 难度噪声
|
||
sigma := 0.02
|
||
switch difficulty {
|
||
case "easy":
|
||
sigma = 0.05
|
||
case "hard":
|
||
sigma = 0.006
|
||
}
|
||
return best.angle + rand.NormFloat64()*sigma, best.power
|
||
}
|
||
|
||
// aiTargets AI 的合法目标球:已分组打自己组,清台后打黑八,未分组打任意彩球
|
||
func (g *Game) aiTargets(seat int) []*Ball {
|
||
var list []*Ball
|
||
grp := g.Groups[seat]
|
||
if grp != GroupNone && g.GroupCleared(seat) {
|
||
if g.Balls[8].On {
|
||
return []*Ball{g.Balls[8]}
|
||
}
|
||
return nil
|
||
}
|
||
for _, b := range g.Balls {
|
||
if !b.On || b.ID == 0 || b.ID == 8 {
|
||
continue
|
||
}
|
||
if grp == GroupNone || groupOf(b.ID) == grp {
|
||
list = append(list, b)
|
||
}
|
||
}
|
||
if len(list) == 0 && g.Balls[8].On {
|
||
list = append(list, g.Balls[8])
|
||
}
|
||
return list
|
||
}
|
||
|
||
// pathBlockPenalty 白球到幽灵球点的直线路径上有其他球则加罚分
|
||
func (g *Game) pathBlockPenalty(x1, y1, x2, y2 float64, targetID int) float64 {
|
||
dx, dy := x2-x1, y2-y1
|
||
length := math.Hypot(dx, dy)
|
||
if length < 1 {
|
||
return 0
|
||
}
|
||
penalty := 0.0
|
||
for _, b := range g.Balls {
|
||
if !b.On || b.ID == 0 || b.ID == targetID {
|
||
continue
|
||
}
|
||
// 球心到线段的距离
|
||
t := ((b.X-x1)*dx + (b.Y-y1)*dy) / (length * length)
|
||
if t < 0 || t > 1 {
|
||
continue
|
||
}
|
||
px, py := x1+dx*t, y1+dy*t
|
||
if math.Hypot(b.X-px, b.Y-py) < BallR*2.2 {
|
||
penalty += 800
|
||
}
|
||
}
|
||
return penalty
|
||
}
|