引言
弹球台(Pinball)是街机厅中最具代表性的游戏之一。玩家通过控制挡板,让小球在布满障碍物、弹簧和缓冲器的台面上弹跳,尽可能长时间地保持小球不落入底部的出球口。表面上看,弹球台是一个考验反应速度的游戏,但其内核却蕴含着丰富的物理碰撞学与计算几何知识。本文将用Java实现一个带AI决策的弹球台模拟器,深入讲解圆形碰撞检测、反射向量计算与物理运动学模拟三大核心算法,帮助读者理解游戏物理引擎的底层原理。
一、弹球台的核心物理模型
弹球台中的物理现象可以抽象为以下三种核心碰撞场景:
- 球与圆形缓冲器(Bumper)的碰撞:圆形与圆形的碰撞检测,需要计算法线方向并反射速度向量
- 球与挡板(Flipper)的碰撞:动态碰撞体,挡板的旋转运动会影响碰撞后的球速
- 球与倾斜边界的碰撞:线段与圆的碰撞检测,计算点到线段最近距离
物理模拟采用欧拉积分法更新位置和速度,时间步长固定为16.67ms(对应60FPS)。
二、核心算法详解
2.1 圆形碰撞检测与反射
当球与圆形缓冲器碰撞时,碰撞法线方向即为两圆心连线方向。设球的半径为 $r$,中心位置为 $\vec{p}$,速度为 $\vec{v}$;缓冲器半径为 $R$,中心为 $\vec{c}$。
碰撞条件为两圆心距离小于半径之和:
$$|\vec{p} – \vec{c}| < r + R$$
碰撞后,速度在法线方向的分量反向,切线方向分量保持不变(假设为弹性碰撞):
$$\vec{n} = \frac{\vec{p} – \vec{c}}{|\vec{p} – \vec{c}|}$$
$$\vec{v}’ = \vec{v} – 2(\vec{v} \cdot \vec{n})\vec{n}$$
2.2 点到线段的最近距离检测
弹球台边界由多条线段组成。检测球是否与边界碰撞,需要计算球心到每条线段的最短距离:
设线段端点为 $\vec{a}$ 和 $\vec{b}$,球心为 $\vec{p}$。投影参数 $t$ 为:
$$t = \frac{(\vec{p} – \vec{a}) \cdot (\vec{b} – \vec{a})}{(\vec{b} – \vec{a}) \cdot (\vec{b} – \vec{a})}$$
将 $t$ 限制在 $[0, 1]$ 范围内,最近点 $\vec{q} = \vec{a} + t(\vec{b} – \vec{a})$。当 $|\vec{p} – \vec{q}| < r$ 时发生碰撞。
2.3 碰撞响应与能量衰减
真实弹球台的碰撞并非完全弹性,存在一定的能量损失。引入恢复系数 $e \in (0, 1)$ 来模拟:
$$\vec{v}’ = \vec{v} – (1 + e)(\vec{v} \cdot \vec{n})\vec{n}$$
此外,球在滚动过程中会受到重力和摩擦力的影响,速度逐渐衰减:
$$\vec{v}_{new} = \vec{v} \cdot (1 – \mu \cdot \Delta t)$$
其中 $\mu$ 为摩擦系数,$\Delta t$ 为时间步长。
三、Java完整实现
import java.awt.*;
import java.awt.geom.*;
import java.util.ArrayList;
import java.util.List;
/**
* 弹球台物理模拟器
* 核心算法:圆形碰撞检测、反射向量计算、欧拉积分物理模拟
*/
public class PinballSimulator {
// 物理常量
private static final double GRAVITY = 500.0; // 重力加速度 (像素/秒²)
private static final double FRICTION = 0.995; // 每帧速度衰减系数
private static final double RESTITUTION = 0.85; // 恢复系数 (弹性碰撞)
private static final double BALL_RADIUS = 8.0; // 球的半径
private static final double DT = 1.0 / 60.0; // 时间步长 (秒)
/**
* 二维向量类,封装向量运算
*/
public static class Vector2D {
public double x, y;
public Vector2D(double x, double y) {
this.x = x;
this.y = y;
}
public Vector2D add(Vector2D other) {
return new Vector2D(x + other.x, y + other.y);
}
public Vector2D sub(Vector2D other) {
return new Vector2D(x - other.x, y - other.y);
}
public Vector2D mul(double scalar) {
return new Vector2D(x * scalar, y * scalar);
}
public double dot(Vector2D other) {
return x * other.x + y * other.y;
}
public double length() {
return Math.sqrt(x * x + y * y);
}
public Vector2D normalize() {
double len = length();
if (len < 1e-9) return new Vector2D(0, 0);
return new Vector2D(x / len, y / len);
}
public Vector2D reflect(Vector2D normal) {
// 反射公式:v' = v - 2(v·n)n
double dot = this.dot(normal);
return this.sub(normal.mul(2 * dot));
}
}
/**
* 球体,弹球台中的核心运动物体
*/
public static class Ball {
public Vector2D position; // 位置
public Vector2D velocity; // 速度
public double radius; // 半径
public Ball(Vector2D pos, Vector2D vel, double r) {
this.position = pos;
this.velocity = vel;
this.radius = r;
}
/**
* 使用欧拉积分更新球的位置和速度
*/
public void update() {
// 应用重力(弹球台台面有倾斜角度,重力在水平方向有分量)
Vector2D gravity = new Vector2D(GRAVITY * 0.3 * DT, GRAVITY * DT);
velocity = velocity.add(gravity);
// 应用速度衰减(摩擦力)
velocity = velocity.mul(FRICTION);
// 更新位置
position = position.add(velocity.mul(DT));
}
}
/**
* 圆形缓冲器(Bumper)
* 碰撞时给予球额外的反弹速度奖励
*/
public static class Bumper {
public Vector2D center;
public double radius;
public double bounceForce; // 碰撞后的额外速度加成
public Bumper(Vector2D c, double r, double force) {
this.center = c;
this.radius = r;
this.bounceForce = force;
}
/**
* 检测并处理球与缓冲器的碰撞
* @return true 如果发生了碰撞
*/
public boolean checkCollision(Ball ball) {
Vector2D diff = ball.position.sub(center);
double dist = diff.length();
double minDist = ball.radius + radius;
if (dist < minDist && dist > 1e-9) {
// 计算法线方向(从缓冲器中心指向球心)
Vector2D normal = diff.normalize();
// 将球推离重叠区域,避免穿透
double overlap = minDist - dist;
ball.position = ball.position.add(normal.mul(overlap));
// 计算反射速度
Vector2D reflected = ball.velocity.reflect(normal);
// 应用恢复系数和额外反弹力
double speed = reflected.length();
Vector2D direction = reflected.normalize();
double newSpeed = speed * RESTITUTION + bounceForce;
ball.velocity = direction.mul(newSpeed);
return true;
}
return false;
}
}
/**
* 边界线段,构成弹球台的外框和障碍物
*/
public static class Boundary {
public Vector2D start; // 线段起点
public Vector2D end; // 线段终点
public boolean isFlipper; // 是否为挡板(动态碰撞体)
public Boundary(Vector2D s, Vector2D e, boolean flipper) {
this.start = s;
this.end = e;
this.isFlipper = flipper;
}
/**
* 计算点到线段的最近距离和最近点
* 使用投影法求解
*/
public PointProjection projectPoint(Vector2D p) {
Vector2D ab = end.sub(start);
Vector2D ap = p.sub(start);
double abLenSq = ab.dot(ab);
// 计算投影参数 t = (ap·ab) / (ab·ab)
double t = abLenSq < 1e-9 ? 0 : Math.max(0, Math.min(1, ap.dot(ab) / abLenSq));
// 最近点 = start + t * ab
Vector2D closest = start.add(ab.mul(t));
Vector2D diff = p.sub(closest);
double dist = diff.length();
// 法线方向(指向线段外侧)
Vector2D normal = new Vector2D(-ab.y, ab.x).normalize();
// 确保法线指向球的方向
if (normal.dot(diff) < 0) {
normal = normal.mul(-1);
}
return new PointProjection(closest, dist, normal, t);
}
public boolean checkCollision(Ball ball) {
PointProjection proj = projectPoint(ball.position);
if (proj.distance < ball.radius) {
// 将球推离边界
double overlap = ball.radius - proj.distance;
ball.position = ball.position.add(proj.normal.mul(overlap));
// 反射速度
Vector2D reflected = ball.velocity.reflect(proj.normal);
ball.velocity = reflected.mul(RESTITUTION);
// 如果是挡板,额外增加反弹速度
if (isFlipper) {
ball.velocity = ball.velocity.mul(1.5);
}
return true;
}
return false;
}
}
public static class PointProjection {
public Vector2D closestPoint;
public double distance;
public Vector2D normal;
public double paramT;
public PointProjection(Vector2D cp, double d, Vector2D n, double t) {
this.closestPoint = cp;
this.distance = d;
this.normal = n;
this.paramT = t;
}
}
/**
* AI决策模块:预测球的落点并决定挡板动作
* 使用射线投射法预测球的运动轨迹
*/
public static class FlipperAI {
/**
* 预测球在未来若干帧后的位置(简化预测,忽略碰撞)
*/
public Vector2D predictPosition(Ball ball, int frames) {
Vector2D pos = ball.position;
Vector2D vel = ball.velocity;
for (int i = 0; i < frames; i++) {
Vector2D gravity = new Vector2D(GRAVITY * 0.3 * DT, GRAVITY * DT);
vel = vel.add(gravity).mul(FRICTION);
pos = pos.add(vel.mul(DT));
}
return pos;
}
/**
* 判断是否应该激活挡板
* 当预测落点在挡板有效范围内时返回true
*/
public boolean shouldActivate(Ball ball, Boundary leftFlipper, Boundary rightFlipper) {
Vector2D predicted = predictPosition(ball, 30); // 预测30帧后的位置
// 检查是否接近底部挡板区域
double leftX = Math.min(leftFlipper.start.x, leftFlipper.end.x);
double rightX = Math.max(rightFlipper.start.x, rightFlipper.end.x);
// 如果球在下降且接近挡板高度
if (ball.velocity.y > 0 && predicted.y > 350) {
return predicted.x > leftX && predicted.x < rightX;
}
return false;
}
}
// 弹球台游戏场景
private Ball ball;
private List<Bumper> bumpers;
private List<Boundary> boundaries;
private FlipperAI ai;
public PinballSimulator() {
// 初始化球(从发射器位置射出)
ball = new Ball(
new Vector2D(50, 50),
new Vector2D(300, -200),
BALL_RADIUS
);
// 初始化缓冲器
bumpers = new ArrayList<>();
bumpers.add(new Bumper(new Vector2D(200, 150), 20, 150));
bumpers.add(new Bumper(new Vector2D(300, 100), 15, 120));
bumpers.add(new Bumper(new Vector2D(150, 250), 18, 130));
// 初始化边界(简化版弹球台形状)
boundaries = new ArrayList<>();
// 左侧外框
boundaries.add(new Boundary(new Vector2D(0, 0), new Vector2D(0, 400), false));
// 右侧外框
boundaries.add(new Boundary(new Vector2D(400, 0), new Vector2D(400, 400), false));
// 顶部外框
boundaries.add(new Boundary(new Vector2D(0, 0), new Vector2D(400, 0), false));
// 底部出球口两侧
boundaries.add(new Boundary(new Vector2D(0, 400), new Vector2D(150, 400), false));
boundaries.add(new Boundary(new Vector2D(250, 400), new Vector2D(400, 400), false));
// 左侧挡板
boundaries.add(new Boundary(new Vector2D(100, 380), new Vector2D(190, 350), true));
// 右侧挡板
boundaries.add(new Boundary(new Vector2D(210, 350), new Vector2D(300, 380), true));
ai = new FlipperAI();
}
/**
* 主物理更新循环,每帧调用一次
*/
public void update() {
// 更新球的位置和速度
ball.update();
// 检测与缓冲器的碰撞
for (Bumper bumper : bumpers) {
bumper.checkCollision(ball);
}
// 检测与边界的碰撞(多轮迭代处理解决穿透)
for (int i = 0; i < 3; i++) {
for (Boundary boundary : boundaries) {
boundary.checkCollision(ball);
}
}
// AI决策
Boundary leftFlipper = boundaries.get(boundaries.size() - 2);
Boundary rightFlipper = boundaries.get(boundaries.size() - 1);
if (ai.shouldActivate(ball, leftFlipper, rightFlipper)) {
System.out.println("AI激活挡板!预测落点: (" +
String.format("%.1f", ai.predictPosition(ball, 30).x) + ", " +
String.format("%.1f", ai.predictPosition(ball, 30).y) + ")");
}
}
public void runSimulation(int frames) {
System.out.println("=== 弹球台物理模拟开始 ===");
for (int i = 0; i < frames; i++) {
update();
if (i % 10 == 0) {
System.out.printf("Frame %d: pos=(%.1f, %.1f), vel=(%.1f, %.1f)%n",
i, ball.position.x, ball.position.y,
ball.velocity.x, ball.velocity.y);
}
// 检测出球
if (ball.position.y > 420) {
System.out.println("球已出界,游戏结束!");
break;
}
}
}
public static void main(String[] args) {
PinballSimulator simulator = new PinballSimulator();
simulator.runSimulation(600);
}
}
四、算法复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 圆形碰撞检测 | $O(n)$ | $O(1)$ | $n$ 为缓冲器数量,每帧线性扫描 |
| 线段碰撞检测 | $O(m)$ | $O(1)$ | $m$ 为边界线段数量 |
| 射线投射预测 | $O(k)$ | $O(1)$ | $k$ 为预测帧数 |
| 整体物理更新 | $O(n + m)$ | $O(1)$ | 每帧固定开销 |
为了优化碰撞检测性能,实际游戏中通常会使用空间划分技术(如四叉树或均匀网格),将时间复杂度从 $O(n)$ 降低到 $O(\log n)$ 甚至 $O(1)$。
五、扩展与优化方向
- 空间加速结构:引入四叉树或均匀网格加速碰撞检测
- 连续碰撞检测(CCD):处理高速运动物体的穿透问题
- 多球物理:多个球之间的相互碰撞,使用迭代求解器
- 更精细的挡板物理:模拟挡板的旋转惯量和角速度对球的冲击效果
六、总结
本文从弹球台这一经典街机游戏出发,完整实现了基于Java的物理碰撞模拟系统。核心算法涵盖了圆形与圆形碰撞检测、点到线段投影、向量反射等计算几何基础,以及欧拉积分物理模拟。AI模块通过简单的射线投射预测球的运动轨迹,实现了挡板的自动决策。希望读者通过本文能够理解游戏物理引擎的基本构建方法,并在自己的项目中灵活运用这些算法。