引言
弹球台(Pinball)是街机厅中最经典的机械游戏之一。玩家通过控制左右挡板,让小球在倾斜的台面上弹跳、碰撞各种障碍物,最终落入不同区域获得分数。本文将用Java实现一个带AI决策的弹球台模拟器,核心聚焦于反射物理模拟与贪心最优得分路径规划算法。
核心算法概览
弹球台的AI决策面临两个核心挑战:
- 反射物理模拟:精确计算小球与障碍物、挡板、边界的碰撞反射轨迹
- 贪心最优得分路径:在挡板可操作的短暂时间窗口内,选择使小球获得最高期望得分的反射角度
反射物理模拟
弹球台中的碰撞属于弹性碰撞,遵循反射定律:入射角等于反射角。当小球碰撞到倾斜表面时,速度向量关于法线对称翻转。
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决策系统。核心亮点在于:
- 使用向量反射公式精确模拟弹性碰撞
- 采用前向轨迹模拟评估不同挡板操作的期望得分
- 贪心策略在离散化搜索空间中找到最优击打方案
读者可以在此基础上扩展:引入蒙特卡洛模拟评估随机 bumper 碰撞、使用遗传算法优化挡板角度、或增加多球协同控制策略。