每日算法 — 使用java实现弹球台:反射物理模拟与贪心最优得分路径规划

引言

弹球台(Pinball)是街机厅中最经典的机械游戏之一。玩家通过控制左右挡板,让小球在倾斜的台面上弹跳、碰撞各种障碍物,最终落入不同区域获得分数。本文将用Java实现一个带AI决策的弹球台模拟器,核心聚焦于反射物理模拟与贪心最优得分路径规划算法。

核心算法概览

弹球台的AI决策面临两个核心挑战:

  1. 反射物理模拟:精确计算小球与障碍物、挡板、边界的碰撞反射轨迹
  2. 贪心最优得分路径:在挡板可操作的短暂时间窗口内,选择使小球获得最高期望得分的反射角度

反射物理模拟

弹球台中的碰撞属于弹性碰撞,遵循反射定律:入射角等于反射角。当小球碰撞到倾斜表面时,速度向量关于法线对称翻转。

import java.awt.geom.*;

/**
 * 弹球台物理引擎:反射碰撞模拟
 */
public class PinballPhysics {

    // 小球状态
    static class Ball {
        Point2D.Double position;  // 位置
        Point2D.Double velocity;  // 速度向量
        double radius;            // 半径

        Ball(double x, double y, double vx, double vy, double r) {
            position = new Point2D.Double(x, y);
            velocity = new Point2D.Double(vx, vy);
            radius = r;
        }
    }

    // 障碍物/挡板线段
    static class Obstacle {
        Point2D.Double p1, p2;    // 线段端点
        int score;                // 碰撞得分
        double restitution;       // 弹性系数 (0-1)

        Obstacle(double x1, double y1, double x2, double y2, int score, double rest) {
            p1 = new Point2D.Double(x1, y1);
            p2 = new Point2D.Double(x2, y2);
            this.score = score;
            this.restitution = rest;
        }

        // 计算线段法向量(指向台面上方)
        Point2D.Double getNormal() {
            double dx = p2.x - p1.x;
            double dy = p2.y - p1.y;
            double len = Math.hypot(dx, dy);
            // 法向量: 逆时针旋转90度并单位化
            return new Point2D.Double(-dy / len, dx / len);
        }
    }

    /**
     * 计算小球与线段的碰撞反射
     * 核心算法:向量反射公式 v' = v - 2(v·n)n
     * @param ball 小球
     * @param obs 障碍物
     * @return 碰撞后的新速度向量,若未碰撞返回null
     */
    public Point2D.Double reflect(Ball ball, Obstacle obs) {
        // 计算小球到线段的最近距离和投影点
        double dx = obs.p2.x - obs.p1.x;
        double dy = obs.p2.y - obs.p1.y;
        double lenSq = dx * dx + dy * dy;

        // 参数t表示投影点在线段上的比例
        double t = ((ball.position.x - obs.p1.x) * dx + (ball.position.y - obs.p1.y) * dy) / lenSq;
        t = Math.max(0, Math.min(1, t)); // 限制在线段范围内

        // 最近点坐标
        double closestX = obs.p1.x + t * dx;
        double closestY = obs.p1.y + t * dy;

        // 距离检测
        double dist = Math.hypot(ball.position.x - closestX, ball.position.y - closestY);
        if (dist > ball.radius) {
            return null; // 未碰撞
        }

        // 获取法向量
        Point2D.Double normal = obs.getNormal();

        // 确保法向量指向小球(入射方向相反)
        double dot = ball.velocity.x * normal.x + ball.velocity.y * normal.y;
        if (dot > 0) {
            normal.x = -normal.x;
            normal.y = -normal.y;
            dot = -dot;
        }

        // 反射公式: v' = v - 2(v·n)n
        double reflectX = ball.velocity.x - 2 * dot * normal.x;
        double reflectY = ball.velocity.y - 2 * dot * normal.y;

        // 应用弹性系数衰减
        reflectX *= obs.restitution;
        reflectY *= obs.restitution;

        return new Point2D.Double(reflectX, reflectY);
    }
}

贪心最优得分路径规划

挡板操作的关键在于:当小球即将落入底部时,应在极短时间内判断挡板的最佳击打角度,使小球弹向高分区域。

import java.util.*;

/**
 * 贪心最优得分路径规划器
 * 在挡板可操作范围内,枚举所有可能的反射方向,选择期望得分最高的路径
 */
public class GreedyScorePlanner {

    private PinballPhysics physics;
    private List<PinballPhysics.Obstacle> highScoreTargets; // 高分目标区域
    private List<PinballPhysics.Obstacle> bumpers;           // 缓冲器

    // 模拟的时间步长和最大步数
    private static final double DT = 0.016;  // 60FPS
    private static final int MAX_STEPS = 600; // 最多模拟10秒轨迹
    private static final double GRAVITY = 200.0; // 台面倾斜导致的等效重力

    public GreedyScorePlanner() {
        physics = new PinballPhysics();
    }

    /**
     * 贪心策略:评估给定初始速度下的未来轨迹总得分
     * @param ball 当前小球状态
     * @param initialVel 挡板击打后的初始速度
     * @return 该轨迹的预测总得分
     */
    public double simulateTrajectory(PinballPhysics.Ball ball, 
                                      PinballPhysics.Point2D.Double initialVel) {
        // 复制小球状态进行模拟
        PinballPhysics.Ball simBall = new PinballPhysics.Ball(
            ball.position.x, ball.position.y,
            initialVel.x, initialVel.y,
            ball.radius
        );

        double totalScore = 0;
        Set<PinballPhysics.Obstacle> hitSet = new HashSet<>(); // 避免重复计分

        for (int step = 0; step < MAX_STEPS; step++) {
            // 应用等效重力(台面倾斜)
            simBall.velocity.y += GRAVITY * DT;

            // 更新位置
            simBall.position.x += simBall.velocity.x * DT;
            simBall.position.y += simBall.velocity.y * DT;

            // 边界检测(底部漏球则终止)
            if (simBall.position.y > 800) {
                break; // 球已丢失
            }

            // 检测与所有障碍物的碰撞
            for (PinballPhysics.Obstacle obs : getAllObstacles()) {
                PinballPhysics.Point2D.Double newVel = physics.reflect(simBall, obs);
                if (newVel != null) {
                    simBall.velocity = newVel;
                    // 修正位置防止穿透
                    PinballPhysics.Point2D.Double normal = obs.getNormal();
                    simBall.position.x += normal.x * 0.5;
                    simBall.position.y += normal.y * 0.5;

                    if (!hitSet.contains(obs)) {
                        totalScore += obs.score;
                        hitSet.add(obs);
                    }
                }
            }
        }

        return totalScore;
    }

    /**
     * 贪心搜索最优挡板角度
     * 枚举离散化的速度方向,选择期望得分最高的方案
     * @param ball 即将落入挡板区域的小球
     * @return 最优击打速度向量
     */
    public PinballPhysics.Point2D.Double findBestFlip(PinballPhysics.Ball ball) {
        double bestScore = -1;
        PinballPhysics.Point2D.Double bestVel = null;

        // 挡板可提供的速度范围:角度[-60°, +60°],速度大小[300, 800]
        double[] angles = new double[25]; // 离散化25个角度
        double[] speeds = {300, 400, 500, 600, 700, 800};

        for (int i = 0; i < angles.length; i++) {
            angles[i] = Math.toRadians(-60 + i * 5); // -60°到+60°,步长5°
        }

        for (double angle : angles) {
            for (double speed : speeds) {
                double vx = speed * Math.sin(angle); // 水平分量
                double vy = -speed * Math.cos(angle); // 向上为负Y

                PinballPhysics.Point2D.Double vel = 
                    new PinballPhysics.Point2D.Double(vx, vy);
                double score = simulateTrajectory(ball, vel);

                if (score > bestScore) {
                    bestScore = score;
                    bestVel = vel;
                }
            }
        }

        System.out.println("最优预测得分: " + bestScore);
        return bestVel;
    }

    private List<PinballPhysics.Obstacle> getAllObstacles() {
        List<PinballPhysics.Obstacle> all = new ArrayList<>();
        if (highScoreTargets != null) all.addAll(highScoreTargets);
        if (bumpers != null) all.addAll(bumpers);
        return all;
    }

    public void setTargets(List<PinballPhysics.Obstacle> targets) {
        this.highScoreTargets = targets;
    }

    public void setBumpers(List<PinballPhysics.Obstacle> bumpers) {
        this.bumpers = bumpers;
    }
}

完整模拟主程序

/**
 * 弹球台AI模拟器主程序
 */
public class PinballAI {

    public static void main(String[] args) {
        // 初始化小球:从发射器射出
        PinballPhysics.Ball ball = new PinballPhysics.Ball(
            400, 100,  // 初始位置
            50, 200,   // 初始速度(向下)
            10          // 半径
        );

        // 构建弹球台障碍物
        List<PinballPhysics.Obstacle> targets = Arrays.asList(
            new PinballPhysics.Obstacle(200, 300, 300, 250, 1000, 0.9),
            new PinballPhysics.Obstacle(500, 350, 600, 300, 500, 0.85),
            new PinballPhysics.Obstacle(350, 200, 450, 180, 2000, 0.95)
        );

        List<PinballPhysics.Obstacle> bumpers = Arrays.asList(
            new PinballPhysics.Obstacle(100, 400, 150, 450, 100, 1.1),
            new PinballPhysics.Obstacle(650, 400, 700, 450, 100, 1.1)
        );

        GreedyScorePlanner planner = new GreedyScorePlanner();
        planner.setTargets(targets);
        planner.setBumpers(bumpers);

        // 模拟游戏循环
        double dt = 0.016;
        for (int frame = 0; frame < 1000; frame++) {
            // 更新物理
            ball.velocity.y += 200 * dt; // 重力
            ball.position.x += ball.velocity.x * dt;
            ball.position.y += ball.velocity.y * dt;

            // 检测是否进入挡板区域(底部y=700附近)
            if (ball.position.y > 700 && ball.position.y < 750) {
                // AI决策:计算最优击打
                PinballPhysics.Point2D.Double bestVel = planner.findBestFlip(ball);
                if (bestVel != null) {
                    ball.velocity = bestVel;
                    System.out.println("挡板击打! 新速度: (" + 
                        String.format("%.1f", bestVel.x) + ", " + 
                        String.format("%.1f", bestVel.y) + ")");
                }
            }

            // 检测碰撞
            PinballPhysics physics = new PinballPhysics();
            for (PinballPhysics.Obstacle obs : targets) {
                PinballPhysics.Point2D.Double newVel = physics.reflect(ball, obs);
                if (newVel != null) {
                    ball.velocity = newVel;
                    System.out.println("击中高分目标! 得分: " + obs.score);
                }
            }

            // 球丢失检测
            if (ball.position.y > 800) {
                System.out.println("球已丢失,游戏结束");
                break;
            }
        }
    }
}

复杂度分析

  • 反射计算:单次碰撞检测为 $O(1)$,每帧需遍历所有障碍物,设障碍物数量为 $n$,则每帧 $O(n)$
  • 贪心路径规划:枚举 $A$ 个角度和 $S$ 个速度,每次模拟 $T$ 个时间步,每步 $O(n)$,总复杂度 $O(A \cdot S \cdot T \cdot n)$。在本实现中取 $A=25, S=6, T=600$,可在毫秒级完成决策
  • 空间复杂度:$O(n)$,仅需存储障碍物和模拟状态

总结

本文通过Java实现了弹球台的反射物理引擎与贪心AI决策系统。核心亮点在于:

  1. 使用向量反射公式精确模拟弹性碰撞
  2. 采用前向轨迹模拟评估不同挡板操作的期望得分
  3. 贪心策略在离散化搜索空间中找到最优击打方案

读者可以在此基础上扩展:引入蒙特卡洛模拟评估随机 bumper 碰撞、使用遗传算法优化挡板角度、或增加多球协同控制策略。