Files
2026-08-14 13:17:03 +08:00

555 lines
15 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 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-vjbj 获得 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
}