每日算法 — 使用java实现弹珠台:物理碰撞检测与弹球轨迹模拟

弹珠台(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。

八、扩展方向

  1. 多球碰撞:引入球与球之间的碰撞,需处理质心连线方向的速度交换。
  2. 弧形轨道:用参数方程描述圆弧,求解弹球与圆弧的最短距离。
  3. 物理更真实:引入摩擦力、旋转(spin)导致的马格努斯效应。

弹珠台物理引擎的核心在于将连续运动离散化为每帧更新,并通过向量反射精确计算反弹方向。掌握了这套碰撞检测体系,你就能为任何 2D 物理游戏搭建可靠的底层框架。