每日算法 — 使用java实现乒乓球:弹道预测与自适应AI挡板控制

乒乓球(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),仅存储游戏对象状态。

六、扩展方向

  1. 旋转球(Spin):引入角速度概念,碰撞时根据挡板移动速度给球施加旋转,影响反弹轨迹(马格努斯效应简化模型)。
  2. 多人联机:使用客户端预测(Client-side Prediction)与服务器 reconciliation 技术实现低延迟多人对战。
  3. 机器学习AI:使用DQN或PPO训练AI,让AI通过自我对弈学习最优挡板控制策略。
  4. 3D乒乓球:扩展到三维空间,球在三维桌面上弹跳,碰撞检测升级为球与平面的相交测试。

乒乓球游戏虽小,却蕴含了计算机图形学、物理模拟和人工智能的基础原理,是理解实时游戏AI决策流程的绝佳入门案例。