乒乓球(Pong)是电子游戏史上最经典的作品之一,玩家控制屏幕两侧的挡板来回击打小球,防止球飞出己方边界。本文用Java实现一个带智能AI对手的乒乓球游戏,核心讲解线性弹道预测算法与自适应PID挡板控制策略,让AI能够预判球的落点并平滑移动。
一、游戏规则与坐标系
1.1 基本设定
- 游戏区域:800×600像素
- 挡板:宽20×高100像素,位于左右边界
- 球:半径10像素的圆形,以恒定速度运动
- 得分规则:球越过对方底线得1分,先得11分者获胜
1.2 物理参数
球初始速度:400 px/s
碰撞后速度增幅:1.02倍(每碰撞一次加速2%)
最大球速:800 px/s
挡板最大移动速度:500 px/s
二、核心算法:弹道预测与AI控制
2.1 弹道预测
当球向AI方向运动时,AI需要预测球到达己方挡板位置时的y坐标:
已知:球当前位置 (bx, by),速度 (vx, vy),AI挡板x坐标 paddleX
求:球到达 paddleX 时的 y 坐标
时间 t = (paddleX - bx) / vx
预测 y = by + vy * t
考虑边界反弹:
while 预测 y 超出 [0, height]:
若 y < 0: y = -y, vy = -vy
若 y > height: y = 2*height - y, vy = -vy
这种射线追踪方法可以精确预测考虑多次反弹后的落点。
2.2 自适应AI难度
通过控制AI的”反应延迟”和”预测精度”来调节难度:
- 简单AI:不预测,仅朝当前球y坐标移动,移动速度为挡板最大速度的60%
- 普通AI:预测落点,但加入±30像素的随机误差,移动速度80%
- 困难AI:精确预测,无误差,移动速度100%,并可预判球碰挡板后的反弹角度
2.3 PID控制器(进阶)
为了让AI挡板移动更平滑自然,可以使用PID控制器:
error = targetY - paddleY
d_error = error - prevError
i_error += error
output = Kp * error + Ki * i_error + Kd * d_error
paddleVelocity = clamp(output, -maxSpeed, +maxSpeed)
通过调节 Kp、Ki、Kd 三个参数,可以控制挡板的响应速度、超调量和稳定性。
三、Java完整实现
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
/**
* 乒乓球 — 弹道预测与自适应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 PADDLE_WIDTH = 20;
private static final int PADDLE_HEIGHT = 100;
private static final int BALL_RADIUS = 10;
private static final double INITIAL_SPEED = 400;
private static final double MAX_SPEED = 800;
private static final double SPEED_INC = 1.02;
private static final double PADDLE_SPEED = 500;
private static final double DT = 1.0 / 60.0;
private Ball ball = new Ball(WIDTH / 2, HEIGHT / 2);
private Paddle playerPaddle = new Paddle(30, HEIGHT / 2 - PADDLE_HEIGHT / 2, true);
private Paddle aiPaddle = new Paddle(WIDTH - 50, HEIGHT / 2 - PADDLE_HEIGHT / 2, false);
private int playerScore = 0;
private int aiScore = 0;
private int winScore = 11;
private Timer timer;
private boolean gameOver = false;
public PongGame() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(Color.BLACK);
setFocusable(true);
addKeyListener(this);
resetBall();
timer = new Timer(16, this);
timer.start();
}
private void resetBall() {
ball.x = WIDTH / 2;
ball.y = HEIGHT / 2;
double angle = Math.random() * Math.PI / 2 - Math.PI / 4;
if (Math.random() > 0.5) angle += Math.PI;
ball.vx = INITIAL_SPEED * Math.cos(angle);
ball.vy = INITIAL_SPEED * Math.sin(angle);
}
@Override
public void actionPerformed(ActionEvent e) {
if (gameOver) return;
updatePhysics();
repaint();
}
private void updatePhysics() {
// 更新球位置
ball.x += ball.vx * DT;
ball.y += ball.vy * DT;
// 上下边界反弹
if (ball.y - BALL_RADIUS < 0) {
ball.y = BALL_RADIUS;
ball.vy = -ball.vy;
}
if (ball.y + BALL_RADIUS > HEIGHT) {
ball.y = HEIGHT - BALL_RADIUS;
ball.vy = -ball.vy;
}
// 挡板碰撞检测
checkPaddleCollision(playerPaddle);
checkPaddleCollision(aiPaddle);
// 左右出界得分
if (ball.x < 0) {
aiScore++;
checkWin();
resetBall();
}
if (ball.x > WIDTH) {
playerScore++;
checkWin();
resetBall();
}
// 更新玩家挡板(键盘控制)
playerPaddle.update(DT);
// 限制在屏幕内
playerPaddle.y = clamp(playerPaddle.y, 0, HEIGHT - PADDLE_HEIGHT);
// 更新AI挡板
updateAI(DT);
aiPaddle.y = clamp(aiPaddle.y, 0, HEIGHT - PADDLE_HEIGHT);
}
private void checkPaddleCollision(Paddle paddle) {
double paddleLeft = paddle.x;
double paddleRight = paddle.x + PADDLE_WIDTH;
double paddleTop = paddle.y;
double paddleBottom = paddle.y + PADDLE_HEIGHT;
// 检测球与挡板矩形相交
double closestX = clamp(ball.x, paddleLeft, paddleRight);
double closestY = clamp(ball.y, paddleTop, paddleBottom);
double dx = ball.x - closestX;
double dy = ball.y - closestY;
double dist = Math.sqrt(dx * dx + dy * dy);
if (dist < BALL_RADIUS) {
// 碰撞响应:根据击中挡板的位置调整反弹角度
double hitRatio = (ball.y - (paddle.y + PADDLE_HEIGHT / 2.0)) / (PADDLE_HEIGHT / 2.0);
hitRatio = clamp(hitRatio, -1, 1);
double maxBounceAngle = Math.toRadians(60);
double bounceAngle = hitRatio * maxBounceAngle;
double speed = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) * SPEED_INC, MAX_SPEED);
int dir = paddle.isPlayer ? 1 : -1;
ball.vx = dir * speed * Math.cos(bounceAngle);
ball.vy = speed * Math.sin(bounceAngle);
// 将球推出挡板防止粘连
ball.x = paddle.isPlayer ? paddleRight + BALL_RADIUS : paddleLeft - BALL_RADIUS;
}
}
/**
* AI核心:弹道预测与挡板控制
*/
private void updateAI(double dt) {
// 只预测向AI方向运动的球
if (ball.vx <= 0) {
// 球远离AI,回到中心位置
moveAIPaddle(HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0, dt);
return;
}
// 弹道预测(考虑边界反弹)
double predictX = ball.x;
double predictY = ball.y;
double predictVx = ball.vx;
double predictVy = ball.vy;
double targetX = aiPaddle.x;
// 模拟球的运动直到到达AI挡板
while (predictX < targetX && predictVx > 0) {
double timeToWall = Double.MAX_VALUE;
double timeToTop = Double.MAX_VALUE;
double timeToBottom = Double.MAX_VALUE;
double timeToPaddle = (targetX - predictX) / predictVx;
if (predictVy > 0) {
timeToBottom = (HEIGHT - BALL_RADIUS - predictY) / predictVy;
} else if (predictVy < 0) {
timeToTop = (predictY - BALL_RADIUS) / -predictVy;
}
double minTime = Math.min(timeToPaddle, Math.min(timeToTop, timeToBottom));
predictX += predictVx * minTime;
predictY += predictVy * minTime;
if (minTime == timeToTop || minTime == timeToBottom) {
predictVy = -predictVy;
} else {
break;
}
}
// 预测落点,加入轻微误差模拟人类反应(难度调节)
double noise = (Math.random() - 0.5) * 20; // ±10像素随机误差
double targetY = predictY - PADDLE_HEIGHT / 2.0 + noise;
moveAIPaddle(targetY, dt);
}
private void moveAIPaddle(double targetY, double dt) {
double diff = targetY - aiPaddle.y;
double maxMove = PADDLE_SPEED * dt;
if (Math.abs(diff) > maxMove) {
aiPaddle.y += Math.signum(diff) * maxMove;
} else {
aiPaddle.y = targetY;
}
}
private double clamp(double val, double min, double max) {
return Math.max(min, Math.min(max, val));
}
private void checkWin() {
if (playerScore >= winScore || aiScore >= winScore) {
gameOver = true;
}
}
@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.DARK_GRAY);
for (int y = 0; y < HEIGHT; y += 20) {
g2d.drawLine(WIDTH / 2, y, WIDTH / 2, y + 10);
}
// 挡板
g2d.setColor(Color.WHITE);
g2d.fillRect((int)playerPaddle.x, (int)playerPaddle.y, PADDLE_WIDTH, PADDLE_HEIGHT);
g2d.fillRect((int)aiPaddle.x, (int)aiPaddle.y, PADDLE_WIDTH, PADDLE_HEIGHT);
// 球
g2d.fillOval((int)(ball.x - BALL_RADIUS), (int)(ball.y - BALL_RADIUS),
BALL_RADIUS * 2, BALL_RADIUS * 2);
// 分数
g2d.setFont(new Font("Arial", Font.BOLD, 40));
g2d.drawString(String.valueOf(playerScore), WIDTH / 4, 50);
g2d.drawString(String.valueOf(aiScore), WIDTH * 3 / 4, 50);
// 游戏结束
if (gameOver) {
g2d.setFont(new Font("Arial", Font.BOLD, 60));
String msg = playerScore >= winScore ? "YOU WIN!" : "AI WINS!";
g2d.drawString(msg, WIDTH / 2 - 150, HEIGHT / 2);
}
}
// ====== 键盘控制 ======
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_UP) playerPaddle.velocity = -PADDLE_SPEED;
if (e.getKeyCode() == KeyEvent.VK_DOWN) playerPaddle.velocity = PADDLE_SPEED;
if (e.getKeyCode() == KeyEvent.VK_R && gameOver) {
playerScore = 0; aiScore = 0; gameOver = false; resetBall();
}
}
@Override
public void keyReleased(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_UP || e.getKeyCode() == KeyEvent.VK_DOWN) {
playerPaddle.velocity = 0;
}
}
@Override public void keyTyped(KeyEvent e) {}
// ====== 数据类 ======
static class Ball {
double x, y, vx, vy;
Ball(double x, double y) { this.x = x; this.y = y; }
}
static class Paddle {
double x, y, velocity;
boolean isPlayer;
Paddle(double x, double y, boolean player) {
this.x = x; this.y = y; this.isPlayer = player;
}
void update(double dt) {
y += velocity * dt;
}
}
// ====== 主程序 ======
public static void main(String[] args) {
JFrame frame = new JFrame("Pong - Ballistic Prediction AI");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new PongGame());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}
四、弹道预测算法的数学原理
4.1 射线追踪法
假设球在矩形区域内做匀速直线运动,碰到边界时发生镜面反射。我们需要求解球到达某一垂直线 x = X 时的y坐标。
将运动分解为一系列直线段。对于每一段:
– 当前位置 (x₀, y₀),速度 (vx, vy)
– 到右边界的距离:dx = X - x₀(若 vx > 0)
– 到上边界的距离:dy_top = y₀ - R(若 vy < 0)
– 到下边界的距离:dy_bottom = H - R - y₀(若 vy > 0)
取最小到达时间 dt_min,更新位置。若先碰到水平边界,则 vy = -vy 继续追踪;若先到达目标线,则返回当前y坐标。
4.2 击中位置对反弹角度的影响
球击中挡板不同位置会产生不同反弹角度,这增加了游戏的可控性和策略深度:
hitRatio = (ballY - paddleCenterY) / (paddleHeight / 2)
bounceAngle = hitRatio * maxAngle
当 hitRatio = -1(击中挡板顶部),球向上反弹 maxAngle;当 hitRatio = 1(击中底部),球向下反弹 maxAngle。玩家可通过精确控制挡板位置来操控球的飞行轨迹。
五、复杂度分析
- 弹道预测:最坏情况下球在到达挡板前经历多次反弹,但每次反弹都会减少到达目标的时间,循环次数上限为
O(边界反弹次数),实际运行中通常不超过5次。 - 碰撞检测:每帧执行固定次数的矩形-圆检测,时间复杂度
O(1)。 - 空间复杂度:
O(1),仅存储游戏对象状态。
六、扩展方向
- 旋转球(Spin):引入角速度概念,碰撞时根据挡板移动速度给球施加旋转,影响反弹轨迹(马格努斯效应简化模型)。
- 多人联机:使用客户端预测(Client-side Prediction)与服务器 reconciliation 技术实现低延迟多人对战。
- 机器学习AI:使用DQN或PPO训练AI,让AI通过自我对弈学习最优挡板控制策略。
- 3D乒乓球:扩展到三维空间,球在三维桌面上弹跳,碰撞检测升级为球与平面的相交测试。
乒乓球游戏虽小,却蕴含了计算机图形学、物理模拟和人工智能的基础原理,是理解实时游戏AI决策流程的绝佳入门案例。