弹珠台(Pinball)是街机厅最具代表性的游戏之一。一颗银色弹球在倾斜的台面中高速弹跳,碰撞挡板、缓冲器和障碍物,最终落入底部出口。本文将用 Java 从零构建一个弹珠台物理引擎,核心聚焦在圆形碰撞检测、向量反射公式与弹球运动轨迹模拟三大算法模块。
一、物理模型抽象
弹珠台可抽象为二维平面上的刚体运动系统。弹球视为质点(或等效圆),台面元素分为三类:
- 静态障碍物:圆形缓冲器(bumper)、三角形斜面、弧形轨道
- 动态挡板:底部左右两个可旋转挡板(flipper)
- 边界墙:台面四周边框
弹球运动遵循牛顿力学:受重力沿台面方向的分量加速,与障碍物碰撞时发生动量交换。由于弹球质量极小,我们采用完全弹性碰撞模型并引入阻尼系数模拟能量损失。
二、核心数据结构
import java.awt.geom.Point2D;
/**
* 二维向量工具类,封装向量运算
*/
public class Vector2D {
public double x, y;
public Vector2D(double x, double y) {
this.x = x;
this.y = y;
}
/** 向量加法 */
public Vector2D add(Vector2D v) {
return new Vector2D(this.x + v.x, this.y + v.y);
}
/** 向量减法 */
public Vector2D sub(Vector2D v) {
return new Vector2D(this.x - v.x, this.y - v.y);
}
/** 数乘 */
public Vector2D scale(double s) {
return new Vector2D(this.x * s, this.y * s);
}
/** 点积 */
public double dot(Vector2D v) {
return this.x * v.x + this.y * v.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) {
// r = v - 2 * (v·n) * n,n 需为单位向量
double dot = this.dot(normal);
return this.sub(normal.scale(2 * dot));
}
}
/**
* 弹球实体
*/
public class Ball {
public Vector2D position; // 位置
public Vector2D velocity; // 速度(像素/帧)
public double radius; // 半径
public static final double GRAVITY = 0.15; // 沿台面下滑的重力加速度
public static final double DAMPING = 0.985; // 空气阻尼
public Ball(double x, double y, double r) {
this.position = new Vector2D(x, y);
this.velocity = new Vector2D(0, 0);
this.radius = r;
}
/** 每帧更新位置与速度 */
public void update() {
// 重力沿 Y 轴正向(台面倾斜向下)
velocity.y += GRAVITY;
// 空气阻力
velocity = velocity.scale(DAMPING);
// 更新位置
position = position.add(velocity);
}
}
三、圆形碰撞检测算法
弹珠台中最频繁的碰撞是弹球与圆形缓冲器之间的碰撞。检测逻辑基于两圆心距离与半径和的比较,时间复杂度 O(1)。
/**
* 圆形障碍物(缓冲器)
*/
public class CircleBumper {
public Vector2D center;
public double radius;
public double bounceFactor; // 弹性系数 >1 表示加速(主动弹射)
public CircleBumper(double x, double y, double r, double bf) {
this.center = new Vector2D(x, y);
this.radius = r;
this.bounceFactor = bf;
}
/**
* 检测与弹球的碰撞并处理反弹
* @return 是否发生碰撞
*/
public boolean checkCollision(Ball ball) {
Vector2D diff = ball.position.sub(center);
double dist = diff.length();
double minDist = ball.radius + radius;
if (dist < minDist) {
// 计算碰撞法线(从缓冲器中心指向弹球中心)
Vector2D normal = diff.normalize();
// 防止弹球卡进缓冲器内部:沿法线方向推出
double overlap = minDist - dist;
ball.position = ball.position.add(normal.scale(overlap));
// 反射速度向量
ball.velocity = ball.velocity.reflect(normal);
// 乘以弹性系数(缓冲器可能提供额外动能)
ball.velocity = ball.velocity.scale(bounceFactor);
return true;
}
return false;
}
}
四、线段(挡板/墙壁)碰撞与反射
台面边框和斜面可抽象为线段。检测点(弹球中心)到线段的距离是否小于弹球半径,若是则计算反弹。
/**
* 线段障碍物(墙壁、斜面、挡板)
*/
public class LineSegment {
public Vector2D start, end;
public LineSegment(Vector2D s, Vector2D e) {
this.start = s;
this.end = e;
}
/**
* 计算点到线段的最近距离及最近点坐标
* 采用向量投影法,时间复杂度 O(1)
*/
public double distanceToPoint(Vector2D p, Vector2D[] closestPoint) {
Vector2D seg = end.sub(start);
Vector2D toP = p.sub(start);
double segLenSq = seg.dot(seg);
if (segLenSq < 1e-12) {
closestPoint[0] = start;
return toP.length();
}
// 投影参数 t = ((P - S) · (E - S)) / |E - S|^2
double t = Math.max(0, Math.min(1, toP.dot(seg) / segLenSq));
closestPoint[0] = start.add(seg.scale(t));
return p.sub(closestPoint[0]).length();
}
/**
* 检测与弹球的碰撞
*/
public boolean checkCollision(Ball ball) {
Vector2D[] cp = new Vector2D[1];
double dist = distanceToPoint(ball.position, cp);
if (dist < ball.radius) {
// 法线方向:从最近点指向球心
Vector2D normal = ball.position.sub(cp[0]).normalize();
// 推出重叠部分
double overlap = ball.radius - dist;
ball.position = ball.position.add(normal.scale(overlap));
// 反射
ball.velocity = ball.velocity.reflect(normal);
return true;
}
return false;
}
}
五、挡板(Flipper)的动态碰撞
挡板绕固定轴旋转,碰撞检测需考虑旋转坐标系下的速度合成。当弹球击中正在上挥的挡板时,除了反射外还要叠加挡板端点的线速度。
/**
* 可旋转挡板
*/
public class Flipper {
public Vector2D pivot; // 旋转轴心
public double length; // 挡板长度
public double angle; // 当前角度(弧度)
public double angularVel; // 角速度(弧度/帧)
public Flipper(Vector2D pivot, double length) {
this.pivot = pivot;
this.length = length;
this.angle = 0;
this.angularVel = 0;
}
/** 激活挡板(按键触发) */
public void activate() {
angularVel = 0.25; // 快速上挥
}
/** 每帧更新角度 */
public void update() {
angle += angularVel;
angularVel *= 0.85; // 阻尼衰减
// 限制角度范围
if (angle > 0.6) {
angle = 0.6;
angularVel = 0;
}
if (angle < 0) {
angle = 0;
angularVel = 0;
}
}
/** 获取挡板端点坐标 */
public Vector2D getEndPoint() {
return new Vector2D(
pivot.x + length * Math.cos(angle),
pivot.y - length * Math.sin(angle)
);
}
/**
* 检测与弹球碰撞,若碰撞则叠加挡板线速度
*/
public boolean checkCollision(Ball ball) {
Vector2D end = getEndPoint();
LineSegment seg = new LineSegment(pivot, end);
Vector2D[] cp = new Vector2D[1];
double dist = seg.distanceToPoint(ball.position, cp);
if (dist < ball.radius) {
Vector2D normal = ball.position.sub(cp[0]).normalize();
double overlap = ball.radius - dist;
ball.position = ball.position.add(normal.scale(overlap));
// 计算挡板端点线速度 v = ω × r
double tangentX = -Math.sin(angle);
double tangentY = -Math.cos(angle);
Vector2D flipperVel = new Vector2D(
tangentX * angularVel * length,
tangentY * angularVel * length
);
// 反射后叠加挡板速度
ball.velocity = ball.velocity.reflect(normal).add(flipperVel);
return true;
}
return false;
}
}
六、游戏主循环与综合演示
import java.util.ArrayList;
import java.util.List;
public class PinballEngine {
private Ball ball;
private List<CircleBumper> bumpers;
private List<LineSegment> walls;
private Flipper leftFlipper, rightFlipper;
public PinballEngine() {
ball = new Ball(200, 50, 8);
bumpers = new ArrayList<>();
walls = new ArrayList<>();
// 添加三个圆形缓冲器
bumpers.add(new CircleBumper(150, 200, 20, 1.2));
bumpers.add(new CircleBumper(250, 150, 15, 1.3));
bumpers.add(new CircleBumper(200, 300, 25, 1.1));
// 添加边框墙壁
walls.add(new LineSegment(new Vector2D(50, 50), new Vector2D(350, 50)));
walls.add(new LineSegment(new Vector2D(50, 50), new Vector2D(50, 450)));
walls.add(new LineSegment(new Vector2D(350, 50), new Vector2D(350, 450)));
// 挡板
leftFlipper = new Flipper(new Vector2D(120, 420), 60);
rightFlipper = new Flipper(new Vector2D(280, 420), 60);
}
/** 主循环每帧调用 */
public void tick() {
ball.update();
// 检测缓冲器碰撞
for (CircleBumper b : bumpers) {
b.checkCollision(ball);
}
// 检测墙壁碰撞
for (LineSegment w : walls) {
w.checkCollision(ball);
}
// 检测挡板碰撞
leftFlipper.update();
rightFlipper.update();
leftFlipper.checkCollision(ball);
rightFlipper.checkCollision(ball);
// 简单边界:球落到底部以下则重置
if (ball.position.y > 480) {
ball.position = new Vector2D(200, 50);
ball.velocity = new Vector2D(0, 0);
}
}
public void activateLeftFlipper() { leftFlipper.activate(); }
public void activateRightFlipper() { rightFlipper.activate(); }
}
七、算法复杂度分析
| 模块 | 单次检测复杂度 | 说明 |
|---|---|---|
| 圆形-圆形碰撞 | O(1) | 距离公式,常数运算 |
| 点-线段距离 | O(1) | 向量投影,常数运算 |
| 单帧全部碰撞 | O(n+m) | n 为缓冲器数,m 为墙壁数 |
| 反射计算 | O(1) | 向量点积与数乘 |
在典型的弹珠台场景中,每帧碰撞检测总数不超过 50 次,现代 CPU 可轻松达到 1000+ FPS。
八、扩展方向
- 多球碰撞:引入球与球之间的碰撞,需处理质心连线方向的速度交换。
- 弧形轨道:用参数方程描述圆弧,求解弹球与圆弧的最短距离。
- 物理更真实:引入摩擦力、旋转(spin)导致的马格努斯效应。
弹珠台物理引擎的核心在于将连续运动离散化为每帧更新,并通过向量反射精确计算反弹方向。掌握了这套碰撞检测体系,你就能为任何 2D 物理游戏搭建可靠的底层框架。