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

引言

弹球台(Pinball)是街机厅中最具代表性的游戏之一。玩家通过控制挡板,让小球在布满障碍物、弹簧和缓冲器的台面上弹跳,尽可能长时间地保持小球不落入底部的出球口。表面上看,弹球台是一个考验反应速度的游戏,但其内核却蕴含着丰富的物理碰撞学与计算几何知识。本文将用Java实现一个带AI决策的弹球台模拟器,深入讲解圆形碰撞检测、反射向量计算与物理运动学模拟三大核心算法,帮助读者理解游戏物理引擎的底层原理。

一、弹球台的核心物理模型

弹球台中的物理现象可以抽象为以下三种核心碰撞场景:

  1. 球与圆形缓冲器(Bumper)的碰撞:圆形与圆形的碰撞检测,需要计算法线方向并反射速度向量
  2. 球与挡板(Flipper)的碰撞:动态碰撞体,挡板的旋转运动会影响碰撞后的球速
  3. 球与倾斜边界的碰撞:线段与圆的碰撞检测,计算点到线段最近距离

物理模拟采用欧拉积分法更新位置和速度,时间步长固定为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)$。

五、扩展与优化方向

  1. 空间加速结构:引入四叉树或均匀网格加速碰撞检测
  2. 连续碰撞检测(CCD):处理高速运动物体的穿透问题
  3. 多球物理:多个球之间的相互碰撞,使用迭代求解器
  4. 更精细的挡板物理:模拟挡板的旋转惯量和角速度对球的冲击效果

六、总结

本文从弹球台这一经典街机游戏出发,完整实现了基于Java的物理碰撞模拟系统。核心算法涵盖了圆形与圆形碰撞检测、点到线段投影、向量反射等计算几何基础,以及欧拉积分物理模拟。AI模块通过简单的射线投射预测球的运动轨迹,实现了挡板的自动决策。希望读者通过本文能够理解游戏物理引擎的基本构建方法,并在自己的项目中灵活运用这些算法。