每日算法 — 使用java实现冰壶游戏:物理摩擦减速与碰撞路径规划

引言

冰壶(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) 使用空间哈希可优化至近线性

扩展思考

  1. 刷冰摩擦模型:实际刷冰会在局部区域临时降低摩擦系数,可通过在路径上叠加可变摩擦场来模拟。
  2. 旋转效应:真实冰壶带有自旋,会产生偏向力(curl),可通过在速度更新中加入科里奥利-like项模拟。
  3. 蒙特卡洛树搜索:在更复杂的策略场景中,可使用MCTS评估不同投掷顺序的期望得分。

总结

本文实现了一个完整的冰壶物理模拟系统,涵盖了摩擦减速、弹性碰撞和最优路径规划三大核心算法。通过网格搜索评估不同投掷参数,系统能够自动找到兼顾精度与避障的最优解。该实现可作为物理引擎教学、游戏AI开发的参考案例。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注