引言
冰壶(Curling)是一项起源于苏格兰的冬季运动,被誉为”冰上国际象棋”。比赛中两队轮流将冰壶石滑向靶心,同时通过刷冰控制摩擦来微调路径。本文将用Java实现一个简化版冰壶游戏,核心算法包括:基于物理学的摩擦减速模型、弹性碰撞检测、以及最优投掷路径规划。
核心算法概述
1. 摩擦减速模型
冰壶在冰面上滑行时,受到冰面摩擦力的影响逐渐减速。摩擦力与速度方向相反,大小与正压力和摩擦系数成正比。我们使用欧拉积分法模拟运动状态:
v(t+Δt) = v(t) - μ·g·Δt·v̂(t)
p(t+Δt) = p(t) + v(t)·Δt
其中 μ 为动摩擦系数,g 为重力加速度,v̂ 为速度单位向量。
2. 弹性碰撞模型
当两枚冰壶石碰撞时,根据动量守恒和能量守恒(近似弹性碰撞),可推导出碰撞后的速度:
v1' = v1 - [(v1-v2)·n]·n
v2' = v2 + [(v1-v2)·n]·n
其中 n 为碰撞法向量。由于冰壶质量相同且近似刚体,简化为一维弹性碰撞在法线方向上的投影计算。
3. 最优路径规划
使用射线投射(Ray Casting)结合摩擦衰减模型,模拟从投掷点到靶心的多条候选路径,评估每条路径的落点精度,选择得分最高的投掷方案。
Java实现
物理引擎核心类
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.List;
/**
* 冰壶物理引擎:模拟冰壶在冰面上的运动、摩擦减速与碰撞
*/
public class CurlingPhysicsEngine {
// 物理常量
private static final double FRICTION_COEFF = 0.015; // 冰面摩擦系数
private static final double GRAVITY = 9.8; // 重力加速度 (m/s²)
private static final double STONE_RADIUS = 0.15; // 冰壶半径 (m)
private static final double STONE_MASS = 19.96; // 冰壶质量 (kg)
private static final double TIME_STEP = 0.01; // 模拟时间步长 (s)
private static final double STOP_THRESHOLD = 0.05; // 停止速度阈值 (m/s)
private List<Stone> stones = new ArrayList<>();
/**
* 冰壶石实体类
*/
public static class Stone {
public double x, y; // 位置 (m)
public double vx, vy; // 速度 (m/s)
public boolean active; // 是否仍在运动
public int team; // 队伍编号 (0或1)
public Stone(double x, double y, double vx, double vy, int team) {
this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.active = true;
this.team = team;
}
public double getSpeed() {
return Math.sqrt(vx * vx + vy * vy);
}
}
/**
* 添加一枚冰壶石到模拟场景
*/
public void addStone(double x, double y, double vx, double vy, int team) {
stones.add(new Stone(x, y, vx, vy, team));
}
/**
* 单步物理模拟:更新所有冰壶的位置与速度
*/
public void step() {
// 1. 更新位置并应用摩擦
for (Stone s : stones) {
if (!s.active) continue;
double speed = s.getSpeed();
if (speed < STOP_THRESHOLD) {
s.active = false;
s.vx = 0; s.vy = 0;
continue;
}
// 应用摩擦减速:a = -μ·g,方向与速度相反
double frictionDecel = FRICTION_COEFF * GRAVITY * TIME_STEP;
double nx = s.vx / speed;
double ny = s.vy / speed;
s.vx -= frictionDecel * nx;
s.vy -= frictionDecel * ny;
// 更新位置
s.x += s.vx * TIME_STEP;
s.y += s.vy * TIME_STEP;
// 速度校正:若摩擦使速度反向,则归零
if (s.vx * nx < 0 || s.vy * ny < 0) {
s.active = false;
s.vx = 0; s.vy = 0;
}
}
// 2. 检测并处理碰撞
for (int i = 0; i < stones.size(); i++) {
for (int j = i + 1; j < stones.size(); j++) {
resolveCollision(stones.get(i), stones.get(j));
}
}
}
/**
* 两枚冰壶的弹性碰撞处理
*/
private void resolveCollision(Stone a, Stone b) {
double dx = b.x - a.x;
double dy = b.y - a.y;
double distSq = dx * dx + dy * dy;
double minDist = 2 * STONE_RADIUS;
if (distSq > minDist * minDist || distSq < 1e-9) return;
double dist = Math.sqrt(distSq);
// 碰撞法向量
double nx = dx / dist;
double ny = dy / dist;
// 相对速度在法线方向的分量
double dvx = a.vx - b.vx;
double dvy = a.vy - b.vy;
double velAlongNormal = dvx * nx + dvy * ny;
// 若相对速度方向使两球分离,则不处理
if (velAlongNormal > 0) return;
// 弹性碰撞:交换法线方向速度分量(等质量近似)
double impulse = velAlongNormal;
a.vx -= impulse * nx;
a.vy -= impulse * ny;
b.vx += impulse * nx;
b.vy += impulse * ny;
// 位置校正:防止重叠
double overlap = minDist - dist;
double correction = overlap / 2.0;
a.x -= correction * nx;
a.y -= correction * ny;
b.x += correction * nx;
b.y += correction * ny;
}
/**
* 模拟直到所有冰壶停止,返回最终位置列表
*/
public List<Point2D.Double> simulateToRest() {
boolean anyActive;
do {
anyActive = false;
step();
for (Stone s : stones) {
if (s.active) { anyActive = true; break; }
}
} while (anyActive);
List<Point2D.Double> result = new ArrayList<>();
for (Stone s : stones) {
result.add(new Point2D.Double(s.x, s.y));
}
return result;
}
public List<Stone> getStones() { return stones; }
}
最优投掷路径规划器
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.List;
/**
* 最优投掷规划器:使用射线投射与摩擦模型寻找最佳投掷参数
*/
public class ThrowPlanner {
// 场地参数 (单位:米)
private static final double HOUSE_X = 38.0; // 靶心X坐标
private static final double HOUSE_Y = 0.0; // 靶心Y坐标
private static final double HOUSE_RADIUS = 1.83; // 靶心外圈半径
private static final double START_X = 0.0; // 投掷起点
private static final double START_Y = 0.0;
private CurlingPhysicsEngine engine;
public ThrowPlanner() {
this.engine = new CurlingPhysicsEngine();
}
/**
* 搜索最优投掷参数
* @param obstacles 场上已有冰壶的位置列表
* @return 最优投掷速度向量 [vx, vy]
*/
public double[] findOptimalThrow(List<Point2D.Double> obstacles) {
double bestScore = Double.NEGATIVE_INFINITY;
double[] bestVelocity = new double[2];
// 参数搜索空间:速度和角度网格搜索
double minSpeed = 2.0, maxSpeed = 6.0; // m/s
double angleRange = Math.toRadians(30); // ±30度
int speedSteps = 40;
int angleSteps = 60;
for (int si = 0; si < speedSteps; si++) {
double speed = minSpeed + (maxSpeed - minSpeed) * si / (speedSteps - 1);
for (int ai = 0; ai < angleSteps; ai++) {
double baseAngle = Math.atan2(HOUSE_Y - START_Y, HOUSE_X - START_X);
double angle = baseAngle - angleRange / 2
+ angleRange * ai / (angleSteps - 1);
double vx = speed * Math.cos(angle);
double vy = speed * Math.sin(angle);
double score = evaluateThrow(vx, vy, obstacles);
if (score > bestScore) {
bestScore = score;
bestVelocity[0] = vx;
bestVelocity[1] = vy;
}
}
}
return bestVelocity;
}
/**
* 评估一次投掷的得分:综合落点精度与碰撞风险
*/
private double evaluateThrow(double vx, double vy, List<Point2D.Double> obstacles) {
engine = new CurlingPhysicsEngine();
// 添加障碍物(已有冰壶)
for (Point2D.Double p : obstacles) {
engine.addStone(p.x, p.y, 0, 0, 1);
}
// 添加待投掷冰壶
engine.addStone(START_X, START_Y, vx, vy, 0);
List<Point2D.Double> finalPositions = engine.simulateToRest();
Point2D.Double myFinal = finalPositions.get(finalPositions.size() - 1);
// 落点与靶心的距离
double distToHouse = Math.hypot(myFinal.x - HOUSE_X, myFinal.y - HOUSE_Y);
// 得分:越靠近靶心得分越高,落在靶圈内额外加分
double score = -distToHouse;
if (distToHouse < HOUSE_RADIUS) {
score += 10 * (1 - distToHouse / HOUSE_RADIUS);
}
// 碰撞惩罚:若因碰撞导致大幅偏离,扣分
double intendedDist = Math.hypot(HOUSE_X - START_X, HOUSE_Y - START_Y);
double actualDist = Math.hypot(myFinal.x - START_X, myFinal.y - START_Y);
if (Math.abs(actualDist - intendedDist) > 2.0) {
score -= 5; // 碰撞偏离惩罚
}
return score;
}
/**
* 计算特定投掷路径上的擦冰建议
* 擦冰可降低局部摩擦系数,微调路径
*/
public List<Double> computeSweepingZones(double vx, double vy) {
List<Double> zones = new ArrayList<>();
// 简化的擦冰区域计算:在路径中点前后寻找需要微调的区域
double midX = START_X + (HOUSE_X - START_X) * 0.5;
double midY = START_Y + (HOUSE_Y - START_Y) * 0.5;
double sweepWidth = 1.0; // 擦冰有效宽度 (m)
// 若初始角度有偏差,在路径前半段擦冰以修正方向
double idealAngle = Math.atan2(HOUSE_Y - START_Y, HOUSE_X - START_X);
double actualAngle = Math.atan2(vy, vx);
if (Math.abs(actualAngle - idealAngle) > Math.toRadians(2)) {
zones.add(midX - sweepWidth);
zones.add(midX + sweepWidth);
}
return zones;
}
}
主程序与可视化
import javax.swing.*;
import java.awt.*;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.List;
/**
* 冰壶游戏主程序:集成物理引擎、路径规划与可视化
*/
public class CurlingGame extends JPanel {
private static final double SCALE = 20; // 像素/米
private CurlingPhysicsEngine engine;
private List<List<Point2D.Double>> trajectories = new ArrayList<>();
public CurlingGame() {
this.engine = new CurlingPhysicsEngine();
setPreferredSize(new Dimension(800, 400));
setBackground(new Color(220, 240, 255)); // 冰面颜色
}
/**
* 执行一局投掷并记录轨迹
*/
public void playThrow(double vx, double vy) {
List<Point2D.Double> traj = new ArrayList<>();
engine.addStone(0, 0, vx, vy, 0);
// 记录轨迹点
for (int i = 0; i < 5000 && engine.getStones().get(engine.getStones().size()-1).active; i++) {
Stone s = engine.getStones().get(engine.getStones().size() - 1);
traj.add(new Point2D.Double(s.x, s.y));
engine.step();
}
trajectories.add(traj);
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
int offsetX = 50, offsetY = 200;
// 绘制靶心 (House)
g2d.setColor(Color.RED);
g2d.drawOval(
offsetX + (int)(38 * SCALE) - (int)(1.83 * SCALE),
offsetY - (int)(1.83 * SCALE),
(int)(3.66 * SCALE), (int)(3.66 * SCALE)
);
// 绘制冰壶
for (CurlingPhysicsEngine.Stone s : engine.getStones()) {
int px = offsetX + (int)(s.x * SCALE);
int py = offsetY + (int)(s.y * SCALE);
g2d.setColor(s.team == 0 ? Color.BLUE : Color.YELLOW);
g2d.fillOval(px - 6, py - 6, 12, 12);
}
// 绘制轨迹
g2d.setColor(new Color(0, 0, 0, 80));
for (List<Point2D.Double> traj : trajectories) {
for (int i = 1; i < traj.size(); i++) {
int x1 = offsetX + (int)(traj.get(i-1).x * SCALE);
int y1 = offsetY + (int)(traj.get(i-1).y * SCALE);
int x2 = offsetX + (int)(traj.get(i).x * SCALE);
int y2 = offsetY + (int)(traj.get(i).y * SCALE);
g2d.drawLine(x1, y1, x2, y2);
}
}
}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("冰壶游戏 — 物理摩擦与碰撞模拟");
CurlingGame game = new CurlingGame();
frame.add(game);
frame.pack();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
// 演示:最优路径规划后投掷
ThrowPlanner planner = new ThrowPlanner();
List<Point2D.Double> obstacles = new ArrayList<>();
obstacles.add(new Point2D.Double(30, 0.3)); // 障碍物
double[] bestV = planner.findOptimalThrow(obstacles);
System.out.printf("最优投掷: vx=%.3f m/s, vy=%.3f m/s%n", bestV[0], bestV[1]);
game.playThrow(bestV[0], bestV[1]);
});
}
}
算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 物理模拟单步 | O(n²) | O(n) | n为冰壶数量,碰撞检测为两两比较 |
| 最优路径搜索 | O(s·a·(n²·t)) | O(n) | s为速度网格数,a为角度网格数,t为模拟步数 |
| 碰撞检测优化 | O(n) 平均 | O(n) | 使用空间哈希可优化至近线性 |
扩展思考
- 刷冰摩擦模型:实际刷冰会在局部区域临时降低摩擦系数,可通过在路径上叠加可变摩擦场来模拟。
- 旋转效应:真实冰壶带有自旋,会产生偏向力(curl),可通过在速度更新中加入科里奥利-like项模拟。
- 蒙特卡洛树搜索:在更复杂的策略场景中,可使用MCTS评估不同投掷顺序的期望得分。
总结
本文实现了一个完整的冰壶物理模拟系统,涵盖了摩擦减速、弹性碰撞和最优路径规划三大核心算法。通过网格搜索评估不同投掷参数,系统能够自动找到兼顾精度与避障的最优解。该实现可作为物理引擎教学、游戏AI开发的参考案例。