弹球(Pinball)是一款经典的街机游戏,玩家通过控制左右挡板,让金属小球在倾斜的桌面上弹跳,撞击各种障碍物、缓冲器和目标以获取分数。本文用Java实现一个简化版弹球物理引擎,核心讲解二维空间中的AABB碰撞检测、圆形与线段的几何碰撞、以及向量反射计算。
一、物理模型与坐标系
1.1 游戏对象定义
弹球桌面中的核心对象包括:
– 球(Ball):圆形刚体,具有位置、速度、半径
– 挡板(Flipper):线段,可绕一端旋转,提供弹射力
– 缓冲器(Bumper):圆形静止障碍物,碰撞时给予球额外速度
– 墙壁(Wall):线段边界,球碰撞后发生反射
– 斜坡/重力(Gravity):沿桌面倾斜方向的恒定加速度
1.2 物理参数
重力加速度 g = 300 px/s²(沿桌面倾斜方向)
弹性系数 e = 0.7(碰撞后速度保留比例)
摩擦系数 f = 0.995(每帧速度衰减)
时间步长 dt = 1/60 s
二、核心算法:碰撞检测与响应
2.1 圆形与线段的碰撞检测
球与墙壁/挡板的碰撞可抽象为点(圆心)到线段的最短距离问题:
给定点P和线段AB,求P到AB的最短距离:
1. 计算向量 AP = P - A, AB = B - A
2. 投影参数 t = (AP · AB) / |AB|²
3. 若 t < 0,最近点为A;若 t > 1,最近点为B;否则最近点为 A + t * AB
4. 若 |P - 最近点| < 球半径,则发生碰撞
2.2 碰撞响应:向量反射
当球碰撞到法向量为 n(单位向量)的表面时,反射速度 v' 计算公式为:
v' = v - 2 * (v · n) * n
考虑弹性系数 e 后:
v' = v - (1 + e) * (v · n) * n
2.3 圆形与圆形的碰撞
球与缓冲器(均为圆形)的碰撞:
1. 检测:若两圆心距离 < r1 + r2,则碰撞
2. 法向量:n = (P2 - P1) / |P2 - P1|
3. 对球应用反射公式
4. 缓冲器额外给予球速度加成:v' += n * boostSpeed
2.4 时间步进与连续碰撞
为避免高速球穿透薄墙,采用子步进(Sub-stepping):每帧将dt拆分为4~8个子步长,在每个子步内更新位置和检测碰撞。
三、Java完整实现
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;
/**
* 弹球物理引擎 — AABB碰撞检测与向量反射计算
*/
public class PinballPhysics extends JPanel implements ActionListener, KeyListener {
private static final int WIDTH = 400;
private static final int HEIGHT = 600;
private static final double DT = 1.0 / 60.0;
private static final int SUB_STEPS = 6; // 子步进次数
private static final double GRAVITY = 400.0;
private static final double FRICTION = 0.998;
private static final double ELASTICITY = 0.75;
// 弹球
private Ball ball = new Ball(200, 100, 8);
// 游戏对象
private ArrayList<Wall> walls = new ArrayList<>();
private ArrayList<Bumper> bumpers = new ArrayList<>();
private Flipper leftFlipper, rightFlipper;
private Timer timer;
private Random rand = new Random();
public PinballPhysics() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(Color.BLACK);
setFocusable(true);
addKeyListener(this);
initLayout();
timer = new Timer(16, this); // ~60 FPS
timer.start();
}
/**
* 初始化弹球桌面布局
*/
private void initLayout() {
// 外边界墙壁
walls.add(new Wall(20, 50, 20, 500)); // 左墙
walls.add(new Wall(380, 50, 380, 500)); // 右墙
walls.add(new Wall(20, 50, 380, 50)); // 顶墙
// 三角形障碍物
walls.add(new Wall(100, 200, 150, 250));
walls.add(new Wall(150, 250, 100, 300));
walls.add(new Wall(250, 200, 300, 250));
walls.add(new Wall(300, 250, 250, 300));
// 圆形缓冲器
bumpers.add(new Bumper(200, 150, 15, 250)); // 中心缓冲器
bumpers.add(new Bumper(120, 400, 12, 200)); // 左下
bumpers.add(new Bumper(280, 400, 12, 200)); // 右下
// 挡板(底部左右)
leftFlipper = new Flipper(80, 520, 150, 520, false);
rightFlipper = new Flipper(250, 520, 320, 520, true);
}
/**
* 每帧更新物理状态
*/
@Override
public void actionPerformed(ActionEvent e) {
double subDt = DT / SUB_STEPS;
for (int step = 0; step < SUB_STEPS; step++) {
updatePhysics(subDt);
}
repaint();
}
private void updatePhysics(double dt) {
// 1. 施加重力(沿y轴向下)
ball.vy += GRAVITY * dt;
// 2. 应用摩擦
ball.vx *= FRICTION;
ball.vy *= FRICTION;
// 3. 更新位置
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
// 4. 检测碰撞
for (Wall wall : walls) {
resolveWallCollision(wall);
}
for (Bumper bumper : bumpers) {
resolveBumperCollision(bumper);
}
resolveFlipperCollision(leftFlipper);
resolveFlipperCollision(rightFlipper);
// 5. 更新挡板动画
leftFlipper.update(dt);
rightFlipper.update(dt);
// 6. 出界重置
if (ball.y > HEIGHT + 50) {
resetBall();
}
}
/**
* 圆形与线段碰撞检测与响应
*/
private void resolveWallCollision(Wall wall) {
double[] closest = closestPointOnSegment(ball.x, ball.y, wall.x1, wall.y1, wall.x2, wall.y2);
double dx = ball.x - closest[0];
double dy = ball.y - closest[1];
double dist = Math.sqrt(dx * dx + dy * dy);
if (dist < ball.radius && dist > 0) {
// 法向量(从墙指向球)
double nx = dx / dist;
double ny = dy / dist;
// 将球推出墙
double overlap = ball.radius - dist;
ball.x += nx * overlap;
ball.y += ny * overlap;
// 反射速度
double dot = ball.vx * nx + ball.vy * ny;
ball.vx -= (1 + ELASTICITY) * dot * nx;
ball.vy -= (1 + ELASTICITY) * dot * ny;
}
}
/**
* 圆形与圆形(缓冲器)碰撞
*/
private void resolveBumperCollision(Bumper bumper) {
double dx = ball.x - bumper.x;
double dy = ball.y - bumper.y;
double dist = Math.sqrt(dx * dx + dy * dy);
if (dist < ball.radius + bumper.radius && dist > 0) {
double nx = dx / dist;
double ny = dy / dist;
// 推出
double overlap = (ball.radius + bumper.radius) - dist;
ball.x += nx * overlap;
ball.y += ny * overlap;
// 反射 + 额外加速
double dot = ball.vx * nx + ball.vy * ny;
ball.vx -= (1 + ELASTICITY) * dot * nx + nx * bumper.boost;
ball.vy -= (1 + ELASTICITY) * dot * ny + ny * bumper.boost;
}
}
/**
* 挡板碰撞(动态线段)
*/
private void resolveFlipperCollision(Flipper flipper) {
double[] end = flipper.getCurrentEnd();
resolveWallCollision(new Wall(flipper.pivotX, flipper.pivotY, end[0], end[1]));
}
/**
* 计算点到线段的最短距离点
*/
private double[] closestPointOnSegment(double px, double py,
double ax, double ay, double bx, double by) {
double abx = bx - ax;
double aby = by - ay;
double apx = px - ax;
double apy = py - ay;
double abLenSq = abx * abx + aby * aby;
double t = (abLenSq > 0) ? (apx * abx + apy * aby) / abLenSq : 0;
t = Math.max(0, Math.min(1, t));
return new double[]{ax + t * abx, ay + t * aby};
}
private void resetBall() {
ball.x = 200;
ball.y = 100;
ball.vx = (rand.nextDouble() - 0.5) * 200;
ball.vy = 0;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 绘制墙壁
g2d.setColor(Color.GRAY);
for (Wall wall : walls) {
g2d.drawLine((int)wall.x1, (int)wall.y1, (int)wall.x2, (int)wall.y2);
}
// 绘制缓冲器
g2d.setColor(Color.RED);
for (Bumper b : bumpers) {
g2d.fillOval((int)(b.x - b.radius), (int)(b.y - b.radius),
(int)(b.radius * 2), (int)(b.radius * 2));
}
// 绘制挡板
g2d.setColor(Color.GREEN);
drawFlipper(g2d, leftFlipper);
drawFlipper(g2d, rightFlipper);
// 绘制球
g2d.setColor(Color.YELLOW);
g2d.fillOval((int)(ball.x - ball.radius), (int)(ball.y - ball.radius),
(int)(ball.radius * 2), (int)(ball.radius * 2));
}
private void drawFlipper(Graphics2D g2d, Flipper f) {
double[] end = f.getCurrentEnd();
g2d.drawLine((int)f.pivotX, (int)f.pivotY, (int)end[0], (int)end[1]);
}
// ====== 键盘控制 ======
@Override public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_LEFT) leftFlipper.activate();
if (e.getKeyCode() == KeyEvent.VK_RIGHT) rightFlipper.activate();
}
@Override public void keyReleased(KeyEvent e) {}
@Override public void keyTyped(KeyEvent e) {}
// ====== 数据类 ======
static class Ball {
double x, y, vx, vy, radius;
Ball(double x, double y, double r) {
this.x = x; this.y = y; this.radius = r;
}
}
static class Wall {
double x1, y1, x2, y2;
Wall(double x1, double y1, double x2, double y2) {
this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2;
}
}
static class Bumper {
double x, y, radius, boost;
Bumper(double x, double y, double r, double b) {
this.x = x; this.y = y; this.radius = r; this.boost = b;
}
}
static class Flipper {
double pivotX, pivotY, restX, restY;
boolean isRight;
double angle = 0; // 当前角度(0=静止)
double targetAngle = 0;
double angularVelocity = 0;
static final double MAX_ANGLE = Math.toRadians(45);
static final double ANGULAR_SPEED = 15;
static final double RESTORE_SPEED = 8;
Flipper(double px, double py, double rx, double ry, boolean right) {
pivotX = px; pivotY = py; restX = rx; restY = ry; isRight = right;
}
void activate() {
targetAngle = isRight ? -MAX_ANGLE : MAX_ANGLE;
}
void update(double dt) {
if (Math.abs(angle - targetAngle) > 0.01) {
double dir = targetAngle > angle ? 1 : -1;
angle += dir * ANGULAR_SPEED * dt;
if ((dir > 0 && angle > targetAngle) || (dir < 0 && angle < targetAngle)) {
angle = targetAngle;
}
} else {
targetAngle = 0;
if (Math.abs(angle) > 0.01) {
double dir = angle > 0 ? -1 : 1;
angle += dir * RESTORE_SPEED * dt;
if ((dir > 0 && angle > 0) || (dir < 0 && angle < 0)) angle = 0;
}
}
}
double[] getCurrentEnd() {
double len = Math.hypot(restX - pivotX, restY - pivotY);
double baseAngle = Math.atan2(restY - pivotY, restX - pivotX);
double curAngle = baseAngle + angle;
return new double[]{pivotX + len * Math.cos(curAngle),
pivotY + len * Math.sin(curAngle)};
}
}
// ====== 主程序 ======
public static void main(String[] args) {
JFrame frame = new JFrame("Pinball Physics");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new PinballPhysics());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}
四、算法核心:向量反射的数学推导
为什么反射公式是 v' = v - 2(v·n)n?这源于向量在法线方向上的分解:
任意速度向量 v 可分解为法向分量 v_n = (v·n)n 和切向分量 v_t = v - v_n。
碰撞时切向分量不变,法向分量反向:
v' = v_t - v_n = (v - v_n) - v_n = v - 2*v_n = v - 2(v·n)n
引入弹性系数 e 后,法向分量仅保留 e 倍:
v' = v_t - e*v_n = v - v_n - e*v_n = v - (1+e)(v·n)n
五、复杂度分析
- 碰撞检测:每帧对
N个墙壁、M个缓冲器、K个挡板进行检测,时间复杂度O(N + M + K)。 - 子步进:将每帧分为
S个子步,保证碰撞检测精度,总复杂度O(S × (N + M + K))。 - 空间复杂度:
O(1),仅存储对象状态。
六、扩展方向
- 多球系统:扩展为同时追踪多个球,引入球与球之间的碰撞(动量守恒)。
- 曲线路径:使用贝塞尔曲线定义 ramps 和 curves,碰撞检测升级为曲线距离计算。
- 触发器区域(Trigger Zone):检测球进入特定区域以触发加分、多球等事件。
- 旋转体碰撞:挡板不仅是线段,而是具有厚度的旋转矩形,需要更复杂的OBB碰撞检测。
弹球游戏的物理引擎是二维刚体碰撞的经典入门案例,其核心向量运算广泛应用于游戏开发、机器人路径规划和物理仿真领域。