弹球(Pinball)是街机游戏中的经典类型,核心乐趣在于小球在挡板、弹簧和障碍物之间的弹跳。本文将用Java实现一个完整的弹球物理引擎,重点讲解圆形碰撞检测、反射向量计算以及重力加速度模拟三大核心算法。
一、游戏机制与算法目标
弹球游戏的核心物理规则:
– 小球受重力影响持续下落
– 与圆形挡板、边界、障碍物发生弹性碰撞
– 碰撞后速度方向按反射定律改变
– 挡板可通过按键控制左右移动,击打来球
算法层面需要解决三个问题:
1. 如何判断小球与圆形障碍物的碰撞时刻
2. 如何根据入射角和法向量计算反射速度
3. 如何模拟重力加速度对运动轨迹的影响
二、核心算法原理
2.1 圆形碰撞检测
两个圆发生碰撞的充要条件是:圆心距离小于等于半径之和。
设小球圆心为 $(x_1, y_1)$,半径 $r_1$;障碍物圆心为 $(x_2, y_2)$,半径 $r_2$。碰撞条件为:
$$
\sqrt{(x_1-x_2)^2 + (y_1-y_2)^2} \leq r_1 + r_2
$$
为减少开方运算,实际代码中比较距离的平方:
$$
(x_1-x_2)^2 + (y_1-y_2)^2 \leq (r_1+r_2)^2
$$
2.2 反射向量计算
碰撞发生后,小球速度需要沿法线方向反转。设入射速度向量为 $\vec{v}$,碰撞点处的法向量(从障碍物指向小球)为 $\vec{n}$,则反射速度 $\vec{v’}$ 的计算公式为:
$$
\vec{v’} = \vec{v} – 2(\vec{v} \cdot \vec{n})\vec{n}
$$
其中 $\vec{n}$ 需要单位化。该公式基于向量投影,将速度在法线方向的分量反向,切线方向分量保持不变,从而实现完美的弹性碰撞。
2.3 重力模拟与运动学更新
每帧更新时,先对垂直方向速度叠加重力加速度:
$$
v_y = v_y + g \cdot \Delta t
$$
再根据速度更新位置:
$$
x = x + v_x \cdot \Delta t \
y = y + v_y \cdot \Delta t
$$
其中 $g$ 为重力常数,$\Delta t$ 为帧时间间隔。
三、完整Java实现
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.ArrayList;
import java.util.List;
/**
* 弹球游戏主类:实现圆形碰撞检测、反射向量计算与重力物理模拟
*/
public class PinballGame extends JPanel implements ActionListener, KeyListener {
// 游戏参数常量
private static final int WIDTH = 600;
private static final int HEIGHT = 800;
private static final double GRAVITY = 0.15; // 重力加速度(像素/帧²)
private static final double FRICTION = 0.998; // 空气阻力系数
private static final double BOUNCE_DAMPING = 0.92; // 碰撞能量损耗
private static final int BALL_RADIUS = 10;
private static final int PADDLE_WIDTH = 100;
private static final int PADDLE_HEIGHT = 15;
private static final int OBSTACLE_RADIUS = 20;
// 小球状态
private double ballX = WIDTH / 2.0;
private double ballY = 100;
private double ballVx = 3.5;
private double ballVy = 0;
// 挡板状态
private double paddleX = WIDTH / 2.0 - PADDLE_WIDTH / 2.0;
private double paddleY = HEIGHT - 80;
private boolean moveLeft = false;
private boolean moveRight = false;
private static final double PADDLE_SPEED = 8.0;
// 障碍物列表(圆形缓冲器)
private List<Obstacle> obstacles = new ArrayList<>();
// 计时器驱动游戏循环
private Timer timer;
private int score = 0;
/**
* 圆形障碍物类,包含位置、半径和碰撞动画状态
*/
static class Obstacle {
double x, y;
double radius;
int flashTimer = 0; // 碰撞后闪烁帧数
Obstacle(double x, double y, double radius) {
this.x = x;
this.y = y;
this.radius = radius;
}
/**
* 检测与该障碍物的碰撞,若碰撞则返回法向量,否则返回null
*/
double[] checkCollision(double bx, double by, double br) {
double dx = bx - x;
double dy = by - y;
double distSq = dx * dx + dy * dy;
double minDist = br + radius;
if (distSq <= minDist * minDist && distSq > 0) {
double dist = Math.sqrt(distSq);
// 返回单位法向量(从障碍物指向小球)
return new double[]{dx / dist, dy / dist};
}
return null;
}
}
public PinballGame() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(new Color(20, 20, 40));
setFocusable(true);
addKeyListener(this);
// 初始化障碍物布局(经典弹球台布局)
obstacles.add(new Obstacle(WIDTH / 2.0, HEIGHT / 3.0, OBSTACLE_RADIUS));
obstacles.add(new Obstacle(WIDTH / 3.0, HEIGHT / 2.0, OBSTACLE_RADIUS * 0.8));
obstacles.add(new Obstacle(WIDTH * 2.0 / 3.0, HEIGHT / 2.0, OBSTACLE_RADIUS * 0.8));
obstacles.add(new Obstacle(WIDTH / 2.0, HEIGHT * 2.0 / 3.0, OBSTACLE_RADIUS * 1.2));
// 16ms一帧,约60FPS
timer = new Timer(16, this);
timer.start();
}
/**
* 核心物理更新:重力、运动学、碰撞检测与反射
*/
@Override
public void actionPerformed(ActionEvent e) {
// 1. 挡板移动更新
if (moveLeft && paddleX > 0) paddleX -= PADDLE_SPEED;
if (moveRight && paddleX < WIDTH - PADDLE_WIDTH) paddleX += PADDLE_SPEED;
// 2. 重力加速度(垂直方向)
ballVy += GRAVITY;
// 3. 空气阻力(微量减速)
ballVx *= FRICTION;
ballVy *= FRICTION;
// 4. 根据速度更新位置
ballX += ballVx;
ballY += ballVy;
// 5. 边界碰撞检测(左右墙壁和顶部)
if (ballX - BALL_RADIUS < 0) {
ballX = BALL_RADIUS;
ballVx = -ballVx * BOUNCE_DAMPING;
} else if (ballX + BALL_RADIUS > WIDTH) {
ballX = WIDTH - BALL_RADIUS;
ballVx = -ballVx * BOUNCE_DAMPING;
}
if (ballY - BALL_RADIUS < 0) {
ballY = BALL_RADIUS;
ballVy = -ballVy * BOUNCE_DAMPING;
}
// 6. 挡板碰撞检测(简化为矩形碰撞)
if (ballY + BALL_RADIUS >= paddleY &&
ballY - BALL_RADIUS <= paddleY + PADDLE_HEIGHT &&
ballX + BALL_RADIUS >= paddleX &&
ballX - BALL_RADIUS <= paddleX + PADDLE_WIDTH) {
// 根据击中挡板的位置调整水平速度(增加操控感)
double hitOffset = (ballX - (paddleX + PADDLE_WIDTH / 2.0)) / (PADDLE_WIDTH / 2.0);
ballVx = hitOffset * 6.0; // 边缘击打给予更大水平速度
ballVy = -Math.abs(ballVy) * 1.05; // 向上反弹并略微加速
ballY = paddleY - BALL_RADIUS;
score += 10;
}
// 7. 圆形障碍物碰撞检测与反射向量计算
for (Obstacle obs : obstacles) {
double[] normal = obs.checkCollision(ballX, ballY, BALL_RADIUS);
if (normal != null) {
// 反射公式: v' = v - 2(v·n)n
double dotProduct = ballVx * normal[0] + ballVy * normal[1];
ballVx = (ballVx - 2 * dotProduct * normal[0]) * BOUNCE_DAMPING;
ballVy = (ballVy - 2 * dotProduct * normal[1]) * BOUNCE_DAMPING;
// 防止小球陷入障碍物内部:沿法线方向推出
double overlap = (BALL_RADIUS + obs.radius) -
Math.hypot(ballX - obs.x, ballY - obs.y);
ballX += normal[0] * overlap;
ballY += normal[1] * overlap;
obs.flashTimer = 10; // 触发闪烁动画
score += 50;
}
if (obs.flashTimer > 0) obs.flashTimer--;
}
// 8. 落底检测(游戏重置)
if (ballY > HEIGHT + BALL_RADIUS) {
resetBall();
}
repaint();
}
/**
* 重置小球到初始位置,保留部分水平速度
*/
private void resetBall() {
ballX = WIDTH / 2.0;
ballY = 100;
ballVx = (Math.random() - 0.5) * 6;
ballVy = 0;
score = Math.max(0, score - 100);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 绘制障碍物
for (Obstacle obs : obstacles) {
if (obs.flashTimer > 0) {
g2d.setColor(new Color(255, 255, 100));
} else {
g2d.setColor(new Color(100, 200, 255));
}
g2d.fillOval((int)(obs.x - obs.radius), (int)(obs.y - obs.radius),
(int)(obs.radius * 2), (int)(obs.radius * 2));
g2d.setColor(Color.WHITE);
g2d.drawOval((int)(obs.x - obs.radius), (int)(obs.y - obs.radius),
(int)(obs.radius * 2), (int)(obs.radius * 2));
}
// 绘制挡板
g2d.setColor(new Color(255, 100, 100));
g2d.fillRoundRect((int)paddleX, (int)paddleY, PADDLE_WIDTH, PADDLE_HEIGHT, 10, 10);
// 绘制小球及速度向量指示
g2d.setColor(new Color(255, 220, 100));
g2d.fillOval((int)(ballX - BALL_RADIUS), (int)(ballY - BALL_RADIUS),
BALL_RADIUS * 2, BALL_RADIUS * 2);
// 绘制速度方向线
g2d.setColor(new Color(255, 255, 255, 150));
g2d.drawLine((int)ballX, (int)ballY,
(int)(ballX + ballVx * 5), (int)(ballY + ballVy * 5));
// 绘制分数
g2d.setColor(Color.WHITE);
g2d.setFont(new Font("Arial", Font.BOLD, 20));
g2d.drawString("Score: " + score, 20, 30);
g2d.drawString("Controls: ← → Move Paddle", 20, 55);
}
@Override public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_LEFT) moveLeft = true;
if (e.getKeyCode() == KeyEvent.VK_RIGHT) moveRight = true;
}
@Override public void keyReleased(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_LEFT) moveLeft = false;
if (e.getKeyCode() == KeyEvent.VK_RIGHT) moveRight = false;
}
@Override public void keyTyped(KeyEvent e) {}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("弹球游戏 - 圆形碰撞与反射向量");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new PinballGame());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
四、关键算法详解
4.1 反射向量的几何推导
反射的核心是将速度向量分解为法线分量和切线分量,仅反转法线分量。
设单位法向量 $\vec{n}$,入射速度 $\vec{v}$:
– 法线方向投影长度:$v_n = \vec{v} \cdot \vec{n}$
– 法线分量:$\vec{v_n} = v_n \cdot \vec{n}$
– 切线分量:$\vec{v_t} = \vec{v} – \vec{v_n}$
反射后:切线分量不变,法线分量反向:
$$
\vec{v’} = \vec{v_t} – \vec{v_n} = (\vec{v} – \vec{v_n}) – \vec{v_n} = \vec{v} – 2\vec{v_n} = \vec{v} – 2(\vec{v}\cdot\vec{n})\vec{n}
$$
4.2 碰撞穿透处理
由于离散时间步进,小球可能在某一帧”穿透”障碍物。检测到碰撞后,需要沿法线方向将小球推出重叠区域:
double overlap = (r1 + r2) - distance;
ballX += normalX * overlap;
ballY += normalY * overlap;
这保证了碰撞后的物理正确性,避免小球”卡”在障碍物内部。
五、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 单帧物理更新 | $O(N)$,$N$ 为障碍物数量 | $O(1)$ |
| 圆形碰撞检测 | $O(1)$ 单次检测 | $O(1)$ |
| 反射向量计算 | $O(1)$ | $O(1)$ |
每帧需要遍历所有障碍物进行碰撞检测,当障碍物数量很大时,可考虑使用空间哈希或四叉树进行加速,将时间复杂度降至 $O(\log N)$ 或 $O(1)$ 平均。
六、扩展思路
- 非完全弹性碰撞:引入恢复系数,让不同障碍物有不同的弹性
- 旋转挡板:挡板带有角度,碰撞时根据挡板倾斜角计算新的法向量
- 多球同屏:使用空间分割数据结构管理大量小球的碰撞检测
- 轨迹预测:基于当前速度和重力,用抛物线方程预测小球落点,辅助AI操控
弹球游戏的物理引擎虽然简单,却完整覆盖了碰撞检测、向量运算和运动学模拟三大基础物理算法,是理解游戏物理的绝佳入门案例。