每日算法 — 使用java实现切水果:抛物线运动检测与贪心切割策略

引言

切水果是一款风靡全球的休闲游戏,玩家需要在屏幕上滑动手指,将抛出的水果切成两半。游戏的核心挑战在于准确判断水果的运动轨迹,并在最佳时机完成切割。本文将用Java实现一个切水果模拟系统,重点讲解抛物线运动学建模、线段与圆的碰撞检测,以及基于多目标覆盖的贪心切割策略算法。

核心算法原理

1. 抛物线运动学建模

水果被抛出后,在空中仅受重力作用,其运动轨迹为抛物线。设初始位置为 $(x_0, y_0)$,初始速度为 $(vx_0, vy_0)$,则 $t$ 时刻的位置为:

x(t) = x0 + vx0 * t
y(t) = y0 + vy0 * t - 0.5 * g * t^2

其中 $g$ 为重力加速度。通过求解 $y(t) = 0$ 可以得到水果落地时间。

2. 线段与圆的碰撞检测

切割轨迹可抽象为一条线段,水果可抽象为一个圆。判断线段是否与圆相交的算法:

  1. 计算圆心到线段所在直线的最短距离
  2. 如果距离小于圆半径,则存在交点
  3. 进一步判断垂足是否在线段范围内

3. 贪心多目标切割策略

当屏幕上有多个水果时,AI需要规划一条切割路径,使单次滑动切割尽可能多的水果。贪心策略:

  1. 枚举所有可能的切割直线(通过每两个水果的圆心确定一条直线)
  2. 计算每条直线能同时切割的水果数量
  3. 选择能切割最多水果的路径
  4. 若存在多条最优路径,优先选择切割高价值水果的路径

Java完整实现

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
import java.util.List;

/**
 * 切水果模拟 - 抛物线运动检测与贪心切割策略
 * 
 * 核心算法:
 * 1. 抛物线运动学建模(重力加速度)
 * 2. 线段与圆的碰撞检测
 * 3. 贪心多目标切割路径选择
 * 4. 粒子爆炸效果系统
 */
public class FruitNinjaGame extends JPanel implements Runnable {

    // 物理常量
    private static final double GRAVITY = 0.25;       // 重力加速度(像素/帧²)
    private static final double SPAWN_INTERVAL = 60;  // 生成间隔(帧)
    private static final int MAX_FRUITS = 8;          // 最大同时存在水果数
    private static final double SLICE_TOLERANCE = 5.0; // 切割容差(像素)

    // 游戏状态
    private final List<Fruit> fruits = new ArrayList<>();
    private final List<SliceTrail> trails = new ArrayList<>();
    private final List<Particle> particles = new ArrayList<>();
    private volatile boolean running = true;
    private volatile int score = 0;
    private volatile int lives = 3;
    private volatile boolean gameOver = false;
    private int frameCount = 0;
    private boolean aiMode = true;

    // 切割状态
    private Point2D lastMousePos = null;
    private Point2D currentMousePos = null;
    private List<Point2D> currentTrail = new ArrayList<>();

    /**
     * 水果类
     */
    static class Fruit {
        Point2D position;
        Point2D velocity;
        double radius;
        Color color;
        int value;           // 分值
        boolean sliced;      // 是否已被切
        double rotation;     // 旋转角度
        double rotationSpeed;
        String type;         // 水果类型

        Fruit(double x, double y, double vx, double vy, 
              double radius, Color color, int value, String type) {
            this.position = new Point2D.Double(x, y);
            this.velocity = new Point2D.Double(vx, vy);
            this.radius = radius;
            this.color = color;
            this.value = value;
            this.type = type;
            this.sliced = false;
            this.rotation = 0;
            this.rotationSpeed = (Math.random() - 0.5) * 0.15;
        }

        double getX() { return position.getX(); }
        double getY() { return position.getY(); }
        double getVX() { return velocity.getX(); }
        double getVY() { return velocity.getY(); }
    }

    /**
     * 切割轨迹段
     */
    static class SliceTrail {
        Point2D start, end;
        int life;

        SliceTrail(Point2D s, Point2D e) {
            this.start = s;
            this.end = e;
            this.life = 10;
        }
    }

    /**
     * 粒子效果(果汁飞溅)
     */
    static class Particle {
        Point2D position;
        Point2D velocity;
        Color color;
        int life;
        int maxLife;
        double size;

        Particle(double x, double y, double vx, double vy, 
                 Color color, int life, double size) {
            this.position = new Point2D.Double(x, y);
            this.velocity = new Point2D.Double(vx, vy);
            this.color = color;
            this.life = life;
            this.maxLife = life;
            this.size = size;
        }
    }

    /**
     * 贪心切割路径候选
     */
    static class SliceCandidate {
        Point2D start, end;
        List<Fruit> targets;
        double totalValue;
        double efficiency;  // 价值/路径长度

        SliceCandidate(Point2D s, Point2D e, List<Fruit> t, double v, double eff) {
            this.start = s;
            this.end = e;
            this.targets = t;
            this.totalValue = v;
            this.efficiency = eff;
        }
    }

    public FruitNinjaGame() {
        setPreferredSize(new Dimension(800, 600));
        setBackground(new Color(20, 20, 40));

        // 鼠标/触摸监听
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                if (!aiMode && !gameOver) {
                    lastMousePos = new Point2D.Double(e.getX(), e.getY());
                    currentTrail.clear();
                    currentTrail.add(lastMousePos);
                }
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                if (!aiMode && lastMousePos != null) {
                    performSlice();
                    lastMousePos = null;
                    currentTrail.clear();
                }
            }
        });

        addMouseMotionListener(new MouseMotionAdapter() {
            @Override
            public void mouseDragged(MouseEvent e) {
                if (!aiMode && lastMousePos != null) {
                    currentMousePos = new Point2D.Double(e.getX(), e.getY());
                    currentTrail.add(currentMousePos);
                    if (currentTrail.size() > 2) {
                        performSlice();
                    }
                }
            }
        });
    }

    /**
     * 生成新水果
     * 
     * 算法:
     * 1. 从屏幕底部两侧随机生成
     * 2. 初速度向上偏中间方向
     * 3. 随机选择水果类型和大小
     */
    private void spawnFruit() {
        if (fruits.size() >= MAX_FRUITS) return;

        String[] types = {"苹果", "香蕉", "西瓜", "橙子", "炸弹"};
        Color[] colors = {Color.RED, Color.YELLOW, Color.GREEN, Color.ORANGE, Color.BLACK};
        int[] values = {10, 15, 20, 12, -50};
        double[] radii = {25, 30, 40, 22, 28};

        int idx = (int) (Math.random() * types.length);

        // 从底部两侧生成,向中间抛出
        double startX = Math.random() < 0.5 ? 50 + Math.random() * 100 
                                            : getWidth() - 150 + Math.random() * 100;
        double startY = getHeight() + 20;

        // 初速度:向上且偏向屏幕中心
        double targetX = getWidth() / 2 + (Math.random() - 0.5) * 200;
        double vx = (targetX - startX) / 80;
        double vy = -(8 + Math.random() * 5);  // 向上速度

        fruits.add(new Fruit(startX, startY, vx, vy, 
                            radii[idx], colors[idx], values[idx], types[idx]));
    }

    /**
     * 更新物理状态
     * 
     * 每帧执行:
     * 1. 应用重力
     * 2. 更新位置
     * 3. 更新旋转
     * 4. 移除掉出屏幕的水果
     */
    private void updatePhysics() {
        Iterator<Fruit> it = fruits.iterator();
        while (it.hasNext()) {
            Fruit fruit = it.next();

            // 应用重力
            double newVY = fruit.getVY() + GRAVITY;
            double newVX = fruit.getVX() * 0.999;  // 微小空气阻力
            fruit.velocity.setLocation(newVX, newVY);

            // 更新位置
            double newX = fruit.getX() + newVX;
            double newY = fruit.getY() + newVY;
            fruit.position.setLocation(newX, newY);

            // 更新旋转
            fruit.rotation += fruit.rotationSpeed;

            // 移除掉出屏幕且未切的水果(扣生命)
            if (fruit.getY() > getHeight() + 50 && !fruit.sliced) {
                if (!fruit.type.equals("炸弹")) {
                    lives--;
                    if (lives <= 0) gameOver = true;
                }
                it.remove();
            }
        }

        // 更新粒子
        Iterator<Particle> pit = particles.iterator();
        while (pit.hasNext()) {
            Particle p = pit.next();
            p.velocity.setLocation(p.velocity.getX(), p.velocity.getY() + GRAVITY * 0.5);
            p.position.setLocation(p.getX() + p.velocity.getX(), 
                                  p.getY() + p.velocity.getY());
            p.life--;
            if (p.life <= 0) pit.remove();
        }

        // 更新轨迹
        trails.removeIf(t -> --t.life <= 0);
    }

    /**
     * 执行切割检测
     * 
     * 算法步骤:
     * 1. 取切割轨迹的最后两个点形成线段
     * 2. 遍历所有未切水果
     * 3. 计算圆心到线段的最短距离
     * 4. 若距离小于半径+容差,判定为切割成功
     */
    private void performSlice() {
        if (currentTrail.size() < 2) return;

        Point2D p1 = currentTrail.get(currentTrail.size() - 2);
        Point2D p2 = currentTrail.get(currentTrail.size() - 1);

        // 添加视觉轨迹
        trails.add(new SliceTrail(p1, p2));

        for (Fruit fruit : fruits) {
            if (fruit.sliced) continue;

            // 线段与圆的碰撞检测
            if (lineIntersectsCircle(p1, p2, fruit.position, fruit.radius + SLICE_TOLERANCE)) {
                fruit.sliced = true;

                if (fruit.type.equals("炸弹")) {
                    // 炸弹:扣除生命并产生爆炸效果
                    lives--;
                    if (lives <= 0) gameOver = true;
                    createExplosion(fruit.getX(), fruit.getY(), Color.RED);
                } else {
                    // 正常水果:加分并产生果汁效果
                    score += fruit.value;
                    createJuiceEffect(fruit.getX(), fruit.getY(), fruit.color);
                }

                // 水果被切后分成两半飞出
                fruit.velocity.setLocation(
                    fruit.getVX() + (Math.random() - 0.5) * 3,
                    fruit.getVY() - 2
                );
            }
        }
    }

    /**
     * 线段与圆的碰撞检测算法
     * 
     * 数学推导:
     * 1. 线段参数方程:P = P1 + t*(P2-P1), t∈[0,1]
     * 2. 圆心到线段上最近点的参数:t = ((C-P1)·(P2-P1)) / |P2-P1|²
     * 3. 将t限制在[0,1]范围内
     * 4. 计算最近点到圆心的距离
     * 
     * @param p1 线段起点
     * @param p2 线段终点
     * @param center 圆心
     * @param radius 圆半径
     * @return 是否相交
     */
    private boolean lineIntersectsCircle(Point2D p1, Point2D p2, 
                                          Point2D center, double radius) {
        double dx = p2.getX() - p1.getX();
        double dy = p2.getY() - p1.getY();
        double lenSq = dx * dx + dy * dy;

        if (lenSq < 0.0001) {
            // 线段近似一个点
            return center.distance(p1) <= radius;
        }

        // 计算投影参数t
        double t = ((center.getX() - p1.getX()) * dx + 
                    (center.getY() - p1.getY()) * dy) / lenSq;

        // 限制t在[0,1]内
        t = Math.max(0, Math.min(1, t));

        // 最近点坐标
        double closestX = p1.getX() + t * dx;
        double closestY = p1.getY() + t * dy;

        // 距离
        double distSq = (center.getX() - closestX) * (center.getX() - closestX) +
                        (center.getY() - closestY) * (center.getY() - closestY);

        return distSq <= radius * radius;
    }

    /**
     * AI贪心切割策略
     * 
     * 核心算法:
     * 1. 收集所有未切水果的位置
     * 2. 枚举通过每两个水果圆心的直线作为候选切割路径
     * 3. 对每条候选路径,计算能同时切割的所有水果
     * 4. 按切割数量优先、总价值次之排序
     * 5. 选择最优路径执行切割
     */
    private void aiSliceDecision() {
        if (gameOver) return;

        List<Fruit> targets = new ArrayList<>();
        for (Fruit f : fruits) {
            if (!f.sliced && !f.type.equals("炸弹")) {
                targets.add(f);
            }
        }

        if (targets.isEmpty()) return;

        List<SliceCandidate> candidates = new ArrayList<>();

        // 策略1:尝试通过两个水果的直线切割
        for (int i = 0; i < targets.size(); i++) {
            for (int j = i + 1; j < targets.size(); j++) {
                Fruit f1 = targets.get(i);
                Fruit f2 = targets.get(j);

                // 计算通过两圆心的直线,向外延伸
                double dx = f2.getX() - f1.getX();
                double dy = f2.getY() - f1.getY();
                double dist = Math.hypot(dx, dy);

                if (dist < 0.001) continue;

                double nx = dx / dist;
                double ny = dy / dist;

                // 向外延伸形成切割线段
                double extend = 100;
                Point2D start = new Point2D.Double(
                    f1.getX() - nx * extend, f1.getY() - ny * extend);
                Point2D end = new Point2D.Double(
                    f2.getX() + nx * extend, f2.getY() + ny * extend);

                // 评估这条路径能切多少水果
                evaluateCandidate(start, end, candidates);
            }
        }

        // 策略2:单水果垂直切割(当没有好的多目标路径时)
        for (Fruit f : targets) {
            Point2D start = new Point2D.Double(f.getX() - 100, f.getY());
            Point2D end = new Point2D.Double(f.getX() + 100, f.getY());
            evaluateCandidate(start, end, candidates);

            start = new Point2D.Double(f.getX(), f.getY() - 100);
            end = new Point2D.Double(f.getX(), f.getY() + 100);
            evaluateCandidate(start, end, candidates);
        }

        if (candidates.isEmpty()) return;

        // 贪心选择:优先切最多水果,其次总价值最高
        candidates.sort((a, b) -> {
            if (b.targets.size() != a.targets.size()) {
                return b.targets.size() - a.targets.size();
            }
            return Double.compare(b.totalValue, a.totalValue);
        });

        SliceCandidate best = candidates.get(0);
        if (best.targets.size() >= 2 || 
            (best.targets.size() == 1 && best.targets.get(0).getY() < getHeight() * 0.7)) {

            // 执行切割:模拟鼠标轨迹
            currentTrail.clear();
            currentTrail.add(best.start);
            currentTrail.add(best.end);
            performSlice();
        }
    }

    /**
     * 评估候选切割路径
     */
    private void evaluateCandidate(Point2D start, Point2D end, 
                                    List<SliceCandidate> candidates) {
        List<Fruit> hit = new ArrayList<>();
        double totalValue = 0;

        for (Fruit f : fruits) {
            if (f.sliced || f.type.equals("炸弹")) continue;

            if (lineIntersectsCircle(start, end, f.position, f.radius)) {
                hit.add(f);
                totalValue += f.value;
            }
        }

        if (!hit.isEmpty()) {
            double dist = start.distance(end);
            double efficiency = totalValue / Math.max(1, dist);
            candidates.add(new SliceCandidate(start, end, hit, totalValue, efficiency));
        }
    }

    /**
     * 创建果汁粒子效果
     */
    private void createJuiceEffect(double x, double y, Color color) {
        int count = 15 + (int) (Math.random() * 10);
        for (int i = 0; i < count; i++) {
            double angle = Math.random() * Math.PI * 2;
            double speed = 1 + Math.random() * 4;
            double vx = Math.cos(angle) * speed;
            double vy = Math.sin(angle) * speed - 3;
            int life = 20 + (int) (Math.random() * 20);
            particles.add(new Particle(x, y, vx, vy, color, life, 3 + Math.random() * 4));
        }
    }

    /**
     * 创建爆炸效果
     */
    private void createExplosion(double x, double y, Color color) {
        for (int i = 0; i < 30; i++) {
            double angle = Math.random() * Math.PI * 2;
            double speed = 2 + Math.random() * 6;
            double vx = Math.cos(angle) * speed;
            double vy = Math.sin(angle) * speed;
            particles.add(new Particle(x, y, vx, vy, color, 40, 5 + Math.random() * 6));
        }
    }

    /**
     * 游戏主循环
     */
    @Override
    public void run() {
        while (running) {
            frameCount++;

            // 生成新水果
            if (frameCount % (int) SPAWN_INTERVAL == 0 && !gameOver) {
                spawnFruit();
            }

            // AI决策
            if (aiMode && frameCount % 5 == 0 && !gameOver) {
                aiSliceDecision();
            }

            // 更新物理
            updatePhysics();

            // 重绘
            repaint();

            // 帧率控制
            try {
                Thread.sleep(16);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2d = (Graphics2D) g;
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, 
                             RenderingHints.VALUE_ANTIALIAS_ON);

        // 绘制背景网格(装饰)
        g2d.setColor(new Color(30, 30, 50));
        for (int i = 0; i < getWidth(); i += 50) {
            g2d.drawLine(i, 0, i, getHeight());
        }
        for (int i = 0; i < getHeight(); i += 50) {
            g2d.drawLine(0, i, getWidth(), i);
        }

        // 绘制切割轨迹
        g2d.setStroke(new BasicStroke(3, BasicStroke.CAP_ROUND, 
                                     BasicStroke.JOIN_ROUND));
        for (SliceTrail trail : trails) {
            float alpha = trail.life / 10.0f;
            g2d.setColor(new Color(0.8f, 0.9f, 1.0f, alpha));
            g2d.drawLine((int) trail.start.getX(), (int) trail.start.getY(),
                        (int) trail.end.getX(), (int) trail.end.getY());
        }

        // 绘制当前切割轨迹
        if (currentTrail.size() >= 2) {
            g2d.setColor(new Color(150, 220, 255));
            g2d.setStroke(new BasicStroke(4));
            for (int i = 1; i < currentTrail.size(); i++) {
                Point2D a = currentTrail.get(i - 1);
                Point2D b = currentTrail.get(i);
                g2d.drawLine((int) a.getX(), (int) a.getY(),
                            (int) b.getX(), (int) b.getY());
            }
        }

        // 绘制水果
        for (Fruit fruit : fruits) {
            if (fruit.sliced && fruit.getY() > getHeight() + 100) continue;

            Graphics2D fruitG = (Graphics2D) g2d.create();
            fruitG.translate(fruit.getX(), fruit.getY());
            fruitG.rotate(fruit.rotation);

            if (fruit.sliced) {
                // 被切后的两半效果
                fruitG.setColor(fruit.color.darker());
                fruitG.fillArc((int) (-fruit.radius), (int) (-fruit.radius),
                              (int) (fruit.radius * 2), (int) (fruit.radius * 2),
                              0, 180);
                fruitG.setColor(Color.WHITE);
                fruitG.fillArc((int) (-fruit.radius), (int) (-fruit.radius),
                              (int) (fruit.radius * 2), (int) (fruit.radius * 2),
                              180, 180);
            } else {
                // 完整水果
                fruitG.setColor(fruit.color);
                fruitG.fillOval((int) (-fruit.radius), (int) (-fruit.radius),
                               (int) (fruit.radius * 2), (int) (fruit.radius * 2));

                // 高光
                fruitG.setColor(fruit.color.brighter());
                fruitG.fillOval((int) (-fruit.radius * 0.4), 
                               (int) (-fruit.radius * 0.6),
                               (int) (fruit.radius * 0.5), 
                               (int) (fruit.radius * 0.4));
            }

            // 炸弹绘制特殊标记
            if (fruit.type.equals("炸弹") && !fruit.sliced) {
                fruitG.setColor(Color.YELLOW);
                fruitG.setFont(new Font("Arial", Font.BOLD, 16));
                fruitG.drawString("X", -6, 6);
            }

            fruitG.dispose();
        }

        // 绘制粒子
        for (Particle p : particles) {
            float alpha = p.life / (float) p.maxLife;
            g2d.setColor(new Color(
                p.color.getRed() / 255.0f,
                p.color.getGreen() / 255.0f,
                p.color.getBlue() / 255.0f,
                alpha));
            int s = (int) (p.size * alpha);
            g2d.fillOval((int) (p.getX() - s / 2), (int) (p.getY() - s / 2), s, s);
        }

        // 绘制UI
        g2d.setColor(Color.WHITE);
        g2d.setFont(new Font("微软雅黑", Font.BOLD, 24));
        g2d.drawString("分数: " + score, 20, 40);

        // 生命数
        g2d.setColor(Color.RED);
        for (int i = 0; i < lives; i++) {
            g2d.fillOval(getWidth() - 40 - i * 35, 20, 25, 25);
        }

        // AI模式标识
        if (aiMode) {
            g2d.setColor(Color.CYAN);
            g2d.drawString("AI 模式", 20, 70);
        }

        // 游戏结束
        if (gameOver) {
            g2d.setColor(new Color(0, 0, 0, 180));
            g2d.fillRect(0, 0, getWidth(), getHeight());
            g2d.setColor(Color.WHITE);
            g2d.setFont(new Font("微软雅黑", Font.BOLD, 48));
            String msg = "游戏结束 - 得分: " + score;
            FontMetrics fm = g2d.getFontMetrics();
            g2d.drawString(msg, (getWidth() - fm.stringWidth(msg)) / 2, 
                          getHeight() / 2);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("切水果 - 抛物线运动检测与贪心切割策略");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            FruitNinjaGame game = new FruitNinjaGame();
            frame.add(game);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
            new Thread(game).start();
        });
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度 说明
抛物线位置更新 O(N) O(1) N为水果数
切割检测 O(N) O(1) 每条轨迹检测一次
线段与圆碰撞 O(1) O(1) 纯数学计算
AI贪心路径枚举 O(N³) O(N²) 枚举所有两两组合
粒子更新 O(P) O(1) P为粒子数

核心算法优化点

  1. 投影法碰撞检测:将圆心投影到线段上,避免了复杂的联立方程求解,仅需O(1)计算。
  2. 贪心多目标策略:通过枚举所有两水果连线,优先选择切割数量最多的路径,提高AI得分效率。
  3. 分层粒子系统:果汁飞溅和爆炸效果使用独立的粒子池管理,避免频繁创建销毁对象。
  4. 轨迹插值:切割轨迹使用连续线段而非离散点,提升碰撞检测精度。

扩展思考

  1. 贝塞尔曲线切割:将直线切割扩展为曲线切割,通过二次贝塞尔曲线模拟挥刀弧度,增加游戏真实感。
  2. 预测切割:根据水果抛物线轨迹,预测未来位置并提前规划切割路径,实现”预判切”。
  3. 神经网络策略:使用强化学习训练AI,让模型自主学习何时切、如何避开炸弹的最优策略。

总结

本文通过Java实现了一个完整的切水果游戏模拟,涵盖抛物线运动学建模、线段与圆碰撞检测、贪心多目标切割策略三大核心算法。该实现不仅展示了物理模拟与几何碰撞检测的结合,也演示了如何用贪心思想解决实时多目标决策问题。