乒乓(Pong)是电子游戏史上最早的经典作品之一,两个玩家各控制一块挡板,通过反弹小球使对方无法接住来得分。虽然规则极简,但其中蕴含的物理碰撞反射、运动学预测以及 AI 追踪策略都是游戏开发中的基础且重要的算法。本文将用 Java 实现一个完整的乒乓游戏,重点讲解小球与挡板的碰撞反射计算、挡板 AI 的贪婪追踪策略以及基于物理预测的高级拦截算法。
一、物理模型与坐标系
1.1 游戏场景定义
使用二维笛卡尔坐标系,屏幕左上角为原点 (0, 0),向右为 X 正方向,向下为 Y 正方向。
– 球场:宽 WIDTH、高 HEIGHT 的矩形区域
– 小球:圆形,半径 BALL_RADIUS,位置 (ballX, ballY),速度 (vx, vy)
– 挡板:矩形,宽 PADDLE_WIDTH、高 PADDLE_HEIGHT
– 左挡板位置:(PADDLE_MARGIN, leftY)
– 右挡板位置:(WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, rightY)
1.2 碰撞检测
小球与墙壁的碰撞:当球心到墙壁的距离小于半径时发生碰撞,对应速度分量取反。
小球与挡板的碰撞检测使用 AABB(轴对齐包围盒)与圆的距离检测:
1. 找到挡板上距离球心最近的点
2. 计算该点到球心的距离
3. 若距离小于半径,则发生碰撞
1.3 反射向量计算
当小球击中挡板时,反射方向由入射角度和挡板的击中位置决定。经典 Pong 的” English “效果:击中挡板中心时对称反射,击中边缘时增大反射角度。
relativeIntersectY = (paddleY + PADDLE_HEIGHT/2) - ballY
normalizedRelativeY = relativeIntersectY / (PADDLE_HEIGHT/2)
bounceAngle = normalizedRelativeY * MAX_BOUNCE_ANGLE
newVx = speed * cos(bounceAngle) * direction
newVy = speed * -sin(bounceAngle)
二、AI 策略设计
2.1 贪婪追踪 AI(Greedy Tracking)
最简单的 AI 策略:每帧将球拍中心向小球当前 Y 坐标移动。这种 AI 反应迅速但缺乏预测能力,容易被快速变向的球突破。
2.2 预测拦截 AI(Predictive Interception)
更高级的 AI 通过物理计算预测小球到达球拍侧时的 Y 坐标:
1. 根据当前速度计算小球到达挡板 X 坐标所需时间 t = (paddleX - ballX) / vx
2. 预测 Y 坐标 predictedY = ballY + vy * t
3. 考虑上下墙壁的多次反射
4. 将挡板中心移动到预测位置
2.3 带误差的拟人化 AI
为了使 AI 不至于无敌,可以引入人为误差:
– 反应延迟:每隔若干帧才更新一次目标位置
– 精度误差:目标位置加上随机偏移
– 速度限制:挡板移动速度有上限
三、Java 实现
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
/**
* 乒乓(Pong)游戏实现
* 核心算法:物理反射向量、AABB碰撞检测、预测拦截AI
*/
public class PongGame extends JPanel implements ActionListener, KeyListener {
// 游戏常量
private static final int WIDTH = 800;
private static final int HEIGHT = 600;
private static final int BALL_RADIUS = 10;
private static final int PADDLE_WIDTH = 15;
private static final int PADDLE_HEIGHT = 100;
private static final int PADDLE_MARGIN = 30;
private static final double BALL_SPEED = 6.0;
private static final double PADDLE_SPEED = 5.0;
private static final double MAX_BOUNCE_ANGLE = Math.PI / 3; // 60度
private static final double AI_REACTION_DELAY = 0.15; // AI反应延迟比例
// 游戏状态
private double ballX, ballY;
private double ballVx, ballVy;
private double leftPaddleY, rightPaddleY;
private int leftScore, rightScore;
private boolean upPressed, downPressed;
private Timer timer;
public PongGame() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(Color.BLACK);
setFocusable(true);
addKeyListener(this);
resetBall();
leftPaddleY = HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
rightPaddleY = HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
leftScore = 0;
rightScore = 0;
timer = new Timer(16, this); // 约60 FPS
timer.start();
}
/**
* 重置小球到球场中心并随机发射
*/
private void resetBall() {
ballX = WIDTH / 2.0;
ballY = HEIGHT / 2.0;
double angle = (Math.random() - 0.5) * Math.PI / 2;
int direction = Math.random() < 0.5 ? -1 : 1;
ballVx = direction * BALL_SPEED * Math.cos(angle);
ballVy = BALL_SPEED * Math.sin(angle);
}
/**
* 更新游戏逻辑(每帧调用)
*/
public void update() {
// 1. 更新小球位置
ballX += ballVx;
ballY += ballVy;
// 2. 上下墙壁碰撞
if (ballY - BALL_RADIUS < 0) {
ballY = BALL_RADIUS;
ballVy = -ballVy;
} else if (ballY + BALL_RADIUS > HEIGHT) {
ballY = HEIGHT - BALL_RADIUS;
ballVy = -ballVy;
}
// 3. 挡板碰撞检测与反射
checkPaddleCollision();
// 4. 得分判定
if (ballX < 0) {
rightScore++;
resetBall();
} else if (ballX > WIDTH) {
leftScore++;
resetBall();
}
// 5. 更新左挡板(玩家控制)
if (upPressed && leftPaddleY > 0) {
leftPaddleY -= PADDLE_SPEED;
}
if (downPressed && leftPaddleY + PADDLE_HEIGHT < HEIGHT) {
leftPaddleY += PADDLE_SPEED;
}
// 6. 更新右挡板(AI控制)
updateAI();
}
/**
* AABB与圆的碰撞检测 + 反射向量计算
*/
private void checkPaddleCollision() {
// 左挡板
if (ballVx < 0 && circleRectCollision(ballX, ballY, BALL_RADIUS,
PADDLE_MARGIN, leftPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT)) {
// 计算击中挡板的相对位置 (-1 到 1)
double paddleCenterY = leftPaddleY + PADDLE_HEIGHT / 2.0;
double relativeY = (paddleCenterY - ballY) / (PADDLE_HEIGHT / 2.0);
relativeY = Math.max(-1.0, Math.min(1.0, relativeY));
// 根据相对位置计算反射角度
double bounceAngle = relativeY * MAX_BOUNCE_ANGLE;
double speed = Math.sqrt(ballVx * ballVx + ballVy * ballVy);
// 每次碰撞略微加速,防止无限对局
speed = Math.min(speed * 1.02, BALL_SPEED * 2);
ballVx = speed * Math.cos(bounceAngle);
ballVy = -speed * Math.sin(bounceAngle);
ballX = PADDLE_MARGIN + PADDLE_WIDTH + BALL_RADIUS;
}
// 右挡板
if (ballVx > 0 && circleRectCollision(ballX, ballY, BALL_RADIUS,
WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, rightPaddleY,
PADDLE_WIDTH, PADDLE_HEIGHT)) {
double paddleCenterY = rightPaddleY + PADDLE_HEIGHT / 2.0;
double relativeY = (paddleCenterY - ballY) / (PADDLE_HEIGHT / 2.0);
relativeY = Math.max(-1.0, Math.min(1.0, relativeY));
double bounceAngle = relativeY * MAX_BOUNCE_ANGLE;
double speed = Math.sqrt(ballVx * ballVx + ballVy * ballVy);
speed = Math.min(speed * 1.02, BALL_SPEED * 2);
ballVx = -speed * Math.cos(bounceAngle);
ballVy = -speed * Math.sin(bounceAngle);
ballX = WIDTH - PADDLE_MARGIN - PADDLE_WIDTH - BALL_RADIUS;
}
}
/**
* 圆与轴对齐矩形的碰撞检测
* @return true if colliding
*/
private boolean circleRectCollision(double cx, double cy, double radius,
double rx, double ry, double rw, double rh) {
// 找到矩形上距离圆心最近的点
double closestX = Math.max(rx, Math.min(cx, rx + rw));
double closestY = Math.max(ry, Math.min(cy, ry + rh));
// 计算距离的平方
double dx = cx - closestX;
double dy = cy - closestY;
return (dx * dx + dy * dy) < (radius * radius);
}
/**
* AI 更新:预测拦截 + 贪婪追踪混合策略
*/
private void updateAI() {
double targetY;
// 只有球向AI方向移动时才做预测,否则回中
if (ballVx > 0) {
// 预测小球到达右挡板时的Y坐标(考虑墙壁反射)
targetY = predictBallY(WIDTH - PADDLE_MARGIN - PADDLE_WIDTH);
// 添加拟人化误差
double error = (Math.random() - 0.5) * PADDLE_HEIGHT * AI_REACTION_DELAY;
targetY += error;
} else {
// 球远离时,回中防守
targetY = HEIGHT / 2.0;
}
// 贪婪追踪:挡板中心向目标移动
double paddleCenterY = rightPaddleY + PADDLE_HEIGHT / 2.0;
double diff = targetY - paddleCenterY;
if (Math.abs(diff) > PADDLE_SPEED) {
rightPaddleY += Math.signum(diff) * PADDLE_SPEED;
} else {
rightPaddleY += diff;
}
// 限制挡板在球场内
rightPaddleY = Math.max(0, Math.min(HEIGHT - PADDLE_HEIGHT, rightPaddleY));
}
/**
* 预测小球到达指定X坐标时的Y坐标(考虑多次墙壁反射)
*/
private double predictBallY(double targetX) {
double predictX = ballX;
double predictY = ballY;
double predictVx = ballVx;
double predictVy = ballVy;
// 模拟小球运动直到到达目标X
while ((predictVx > 0 && predictX < targetX) ||
(predictVx < 0 && predictX > targetX)) {
// 计算到下一个墙壁或目标的时间
double timeToTarget = (targetX - predictX) / predictVx;
double timeToWallY = predictVy > 0 ?
(HEIGHT - BALL_RADIUS - predictY) / predictVy :
(BALL_RADIUS - predictY) / predictVx;
if (predictVy == 0) {
timeToWallY = Double.MAX_VALUE;
} else {
timeToWallY = predictVy > 0 ?
(HEIGHT - BALL_RADIUS - predictY) / predictVy :
(BALL_RADIUS - predictY) / predictVy;
}
// 先到达目标
if (timeToTarget <= timeToWallY || timeToWallY < 0) {
predictY += predictVy * timeToTarget;
return predictY;
}
// 先碰到墙壁
predictX += predictVx * timeToWallY;
predictY += predictVy * timeToWallY;
predictVy = -predictVy;
}
return predictY;
}
/**
* 渲染画面
*/
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// 绘制中线
g2d.setColor(Color.GRAY);
for (int y = 0; y < HEIGHT; y += 20) {
g2d.fillRect(WIDTH / 2 - 2, y, 4, 10);
}
// 绘制小球
g2d.setColor(Color.WHITE);
g2d.fillOval((int)(ballX - BALL_RADIUS), (int)(ballY - BALL_RADIUS),
BALL_RADIUS * 2, BALL_RADIUS * 2);
// 绘制挡板
g2d.fillRect(PADDLE_MARGIN, (int)leftPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT);
g2d.fillRect(WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, (int)rightPaddleY,
PADDLE_WIDTH, PADDLE_HEIGHT);
// 绘制分数
g2d.setFont(new Font("Arial", Font.BOLD, 40));
g2d.drawString(String.valueOf(leftScore), WIDTH / 2 - 60, 50);
g2d.drawString(String.valueOf(rightScore), WIDTH / 2 + 40, 50);
}
@Override
public void actionPerformed(ActionEvent e) {
update();
repaint();
}
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_UP) upPressed = true;
if (e.getKeyCode() == KeyEvent.VK_DOWN) downPressed = true;
}
@Override
public void keyReleased(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_UP) upPressed = false;
if (e.getKeyCode() == KeyEvent.VK_DOWN) downPressed = false;
}
@Override
public void keyTyped(KeyEvent e) {}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("Pong - 乒乓球");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setResizable(false);
frame.add(new PongGame());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
四、算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 碰撞检测 | O(1) | O(1) | 仅检测两个挡板,常数时间 |
| 反射计算 | O(1) | O(1) | 三角函数计算,常数时间 |
| 预测拦截 | O(k) | O(1) | k 为墙壁反射次数,通常 k ≤ 5 |
| 贪婪追踪 | O(1) | O(1) | 简单的位置差计算 |
整个游戏的主循环在 60 FPS 下运行,每帧的计算量极小,现代计算机可以轻松处理。
五、总结
乒乓游戏虽小,却浓缩了游戏物理的核心要素:碰撞检测、反射向量计算和运动预测。本文实现的预测拦截 AI 通过物理模拟预判小球的落点,比简单的贪婪追踪具有更高的拦截成功率,同时通过人为误差控制保持了游戏的可玩性。读者可以在此基础上扩展更多高级特性,如旋转球效果(Magnus 效应)、挡板技能释放或基于强化学习的自适应 AI 对手。