每日算法 — 使用java实现乒乓:物理反射向量与贪婪追踪AI

乒乓(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 对手。