每日算法 — 使用java实现乒乓球:轨迹预测与贪心挡板AI

乒乓球(Pong)是电子游戏史上最具影响力的经典作品之一,1972年由雅达利推出。这款看似简单的两人对击球游戏,背后蕴含着丰富的算法原理:从刚体运动的线性方程,到边界反射的几何计算,再到基于轨迹预测的AI决策策略。本文将用Java完整实现一个带AI对手的乒乓球游戏,重点讲解轨迹预测算法贪心挡板控制策略,让读者在熟悉场景中理解运动学与AI决策的核心思想。

一、游戏机制与算法拆解

乒乓球游戏的核心规则极为简洁:一个球在矩形场地中运动,两名玩家各控制一块垂直挡板,球触碰到挡板或上下边界时发生反射,触碰左右边界则对方得分。将这个物理过程抽象为算法问题,可以拆解为三个核心模块:

  • 运动状态更新:根据球的速度向量逐帧更新位置,属于一阶线性运动学方程的直接应用。
  • 碰撞检测与反射:判断球是否与边界或挡板相交,并依据入射角等于反射角的光学原理更新速度方向。
  • AI挡板控制:计算机对手需要根据球的运动轨迹,实时决策挡板的移动方向与速度,以最大化接球概率。

这三个模块层层递进,前两者构成了物理引擎的骨架,第三者则是赋予游戏对抗性的智能核心。

二、核心算法:线性轨迹预测

AI要成功接球,最关键的能力是预测球到达挡板位置时的纵坐标。假设球当前位置为 $(x, y)$,速度向量为 $(vx, vy)$,AI挡板位于固定横坐标 $x_{paddle}$ 处。由于球在水平方向做匀速直线运动,到达挡板所需时间为:

$$
t = \frac{x_{paddle} – x}{vx}
$$

在此时间内,球在垂直方向移动的距离为 $vy \times t$。考虑到球可能在到达挡板前先触碰到上下边界并反射,需要对这段轨迹进行分段模拟。具体做法是从当前位置开始,按时间步长逐步推演球的位置,每次遇到上下边界时将 $vy$ 取反,直到球的水平坐标越过挡板位置为止。

这种预测方法的时间复杂度取决于反射次数。在标准场地尺寸下,球从一侧到达另一侧最多经历数次边界反射,因此预测过程几乎是常数时间完成的。代码实现上,可以用一个循环不断累加时间片段,直到满足条件。

三、核心算法:贪心挡板控制

得到预测落点后,AI需要控制挡板移动至该位置。这里采用贪心策略:每一帧都计算挡板中心与预测落点的垂直差值,若差值大于阈值则向落点方向移动,否则保持静止。这种策略的优势在于响应即时、实现简单,且不会出现”过度补偿”导致的振荡现象。

贪心策略的决策逻辑可形式化描述为:设挡板当前中心纵坐标为 $y_{paddle}$,预测落点为 $y_{target}$,挡板移动速度为 $v_{paddle}$,则下一帧的挡板纵坐标更新为:

$$
y_{paddle}^{new} = y_{paddle} + \text{sign}(y_{target} – y_{paddle}) \times \min(v_{paddle}, |y_{target} – y_{paddle}|)
$$

其中 $\text{sign}$ 函数取目标方向的符号,最小值函数确保挡板不会越过目标点。这种”就近趋近”的策略,在乒乓球这种实时性要求高的场景中表现优异。

四、碰撞检测与反射的向量计算

球与垂直挡板的碰撞可简化为矩形与圆的相交检测。当球的圆心横坐标与挡板横坐标的绝对差小于球半径与挡板半宽之和,且球的纵坐标位于挡板垂直范围内时,判定为碰撞。碰撞后,水平速度分量 $vx$ 取反,垂直速度分量 $vy$ 可根据球击中挡板的位置进行微调——击中挡板上部时赋予向上的额外速度,击中下部时赋予向下的额外速度,以此增加游戏的可玩性。

球与上下边界的碰撞更为简单:当球的纵坐标超出边界时,将 $vy$ 取反即可。这种完美的弹性碰撞假设,忽略了能量损耗,但在游戏场景中完全可接受。

五、完整Java实现

以下代码提供了一个可直接运行的完整实现,基于Java Swing进行图形渲染。代码分为四个主要部分:Ball 类封装球的运动状态,Paddle 类封装挡板属性,AIController 类实现轨迹预测与贪心决策,PongGame 类负责游戏循环与渲染。

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;

/**
 * 乒乓球游戏主入口
 * 包含完整的物理引擎、碰撞检测与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 Ball ball;
    private Paddle playerPaddle;   // 玩家挡板(右侧)
    private Paddle aiPaddle;       // AI挡板(左侧)
    private Timer timer;
    private int playerScore = 0;
    private int aiScore = 0;

    public PongGame() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(Color.BLACK);
        setFocusable(true);
        addKeyListener(this);

        initGame();
        // 16ms约等于60FPS
        timer = new Timer(16, this);
        timer.start();
    }

    /**
     * 初始化或重置游戏状态
     */
    private void initGame() {
        // 球从场地中央出发,随机初始速度
        ball = new Ball(WIDTH / 2, HEIGHT / 2, 5, 3);

        // 玩家挡板在右侧
        playerPaddle = new Paddle(
            WIDTH - PADDLE_MARGIN - PADDLE_WIDTH / 2, 
            HEIGHT / 2, 
            8  // 玩家挡板移动速度
        );

        // AI挡板在左侧
        aiPaddle = new Paddle(
            PADDLE_MARGIN + PADDLE_WIDTH / 2, 
            HEIGHT / 2, 
            6  // AI挡板移动速度(略低于玩家,保持公平性)
        );
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        updateGame();
        repaint();
    }

    /**
     * 核心游戏更新逻辑:物理更新 -> AI决策 -> 碰撞检测 -> 得分判定
     */
    private void updateGame() {
        // 1. 更新球的位置(线性运动学)
        ball.update();

        // 2. 更新玩家挡板(基于键盘输入)
        playerPaddle.update();

        // 3. AI决策:预测轨迹并贪心移动
        double predictedY = AIController.predictBallLanding(ball, aiPaddle.x);
        AIController.movePaddleGreedy(aiPaddle, predictedY);
        aiPaddle.update();

        // 4. 碰撞检测与反射
        checkCollisions();

        // 5. 得分判定
        if (ball.x < 0) {
            playerScore++;
            resetBall();
        } else if (ball.x > WIDTH) {
            aiScore++;
            resetBall();
        }
    }

    /**
     * 碰撞检测核心逻辑
     */
    private void checkCollisions() {
        // 上下边界反射
        if (ball.y - BALL_RADIUS < 0 || ball.y + BALL_RADIUS > HEIGHT) {
            ball.vy = -ball.vy;
            // 防止球卡死在边界
            ball.y = Math.max(BALL_RADIUS, Math.min(HEIGHT - BALL_RADIUS, ball.y));
        }

        // 与AI挡板(左侧)碰撞检测
        if (ballIntersectsPaddle(ball, aiPaddle)) {
            ball.vx = Math.abs(ball.vx); // 确保向右反弹
            // 根据击中位置调整垂直速度,增加游戏性
            double hitOffset = (ball.y - aiPaddle.y) / (PADDLE_HEIGHT / 2.0);
            ball.vy += hitOffset * 2;
            // 限制最大垂直速度
            ball.vy = Math.max(-8, Math.min(8, ball.vy));
        }

        // 与玩家挡板(右侧)碰撞检测
        if (ballIntersectsPaddle(ball, playerPaddle)) {
            ball.vx = -Math.abs(ball.vx); // 确保向左反弹
            double hitOffset = (ball.y - playerPaddle.y) / (PADDLE_HEIGHT / 2.0);
            ball.vy += hitOffset * 2;
            ball.vy = Math.max(-8, Math.min(8, ball.vy));
        }
    }

    /**
     * 判断球是否与挡板相交(矩形-圆粗略检测)
     */
    private boolean ballIntersectsPaddle(Ball ball, Paddle paddle) {
        double closestX = Math.max(
            paddle.x - PADDLE_WIDTH / 2.0,
            Math.min(ball.x, paddle.x + PADDLE_WIDTH / 2.0)
        );
        double closestY = Math.max(
            paddle.y - PADDLE_HEIGHT / 2.0,
            Math.min(ball.y, paddle.y + PADDLE_HEIGHT / 2.0)
        );
        double distanceX = ball.x - closestX;
        double distanceY = ball.y - closestY;
        return (distanceX * distanceX + distanceY * distanceY) < (BALL_RADIUS * BALL_RADIUS);
    }

    /**
     * 重置球到中央,随机发球方向
     */
    private void resetBall() {
        ball.x = WIDTH / 2;
        ball.y = HEIGHT / 2;
        ball.vx = (Math.random() > 0.5 ? 5 : -5);
        ball.vy = (Math.random() * 6) - 3;
    }

    @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 i = 0; i < HEIGHT; i += 30) {
            g2d.fillRect(WIDTH / 2 - 2, i, 4, 15);
        }

        // 绘制球
        g2d.setColor(Color.WHITE);
        g2d.fillOval(
            (int)(ball.x - BALL_RADIUS), 
            (int)(ball.y - BALL_RADIUS), 
            BALL_RADIUS * 2, 
            BALL_RADIUS * 2
        );

        // 绘制挡板
        g2d.fillRect(
            (int)(aiPaddle.x - PADDLE_WIDTH / 2), 
            (int)(aiPaddle.y - PADDLE_HEIGHT / 2), 
            PADDLE_WIDTH, 
            PADDLE_HEIGHT
        );
        g2d.fillRect(
            (int)(playerPaddle.x - PADDLE_WIDTH / 2), 
            (int)(playerPaddle.y - PADDLE_HEIGHT / 2), 
            PADDLE_WIDTH, 
            PADDLE_HEIGHT
        );

        // 绘制比分
        g2d.setFont(new Font("Arial", Font.BOLD, 40));
        g2d.drawString(String.valueOf(aiScore), WIDTH / 2 - 60, 50);
        g2d.drawString(String.valueOf(playerScore), WIDTH / 2 + 40, 50);
    }

    @Override public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_UP) playerPaddle.setMovingUp(true);
        if (e.getKeyCode() == KeyEvent.VK_DOWN) playerPaddle.setMovingDown(true);
    }

    @Override public void keyReleased(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_UP) playerPaddle.setMovingUp(false);
        if (e.getKeyCode() == KeyEvent.VK_DOWN) playerPaddle.setMovingDown(false);
    }

    @Override public void keyTyped(KeyEvent e) {}

    public static void main(String[] args) {
        JFrame frame = new JFrame("Pong - 乒乓球轨迹预测与AI");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setResizable(false);
        frame.add(new PongGame());
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
}

/**
 * 球类:封装位置、速度与运动更新逻辑
 */
class Ball {
    double x, y;    // 位置(圆心坐标)
    double vx, vy;  // 速度向量(每帧像素位移)

    Ball(double x, double y, double vx, double vy) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
    }

    /**
     * 一阶线性运动学更新:p' = p + v * dt
     * 这里dt固定为1帧(约16ms),速度已按帧缩放
     */
    void update() {
        x += vx;
        y += vy;
    }
}

/**
 * 挡板类:封装位置、速度与输入状态
 */
class Paddle {
    double x, y;      // 挡板中心坐标
    double speed;     // 最大移动速度
    private boolean movingUp = false;
    private boolean movingDown = false;

    Paddle(double x, double y, double speed) {
        this.x = x;
        this.y = y;
        this.speed = speed;
    }

    void setMovingUp(boolean up) { this.movingUp = up; }
    void setMovingDown(boolean down) { this.movingDown = down; }

    /**
     * 根据当前输入状态更新位置
     */
    void update() {
        if (movingUp) y -= speed;
        if (movingDown) y += speed;
        // 限制挡板不超出场地边界
        y = Math.max(PongGame.PADDLE_HEIGHT / 2.0, 
                     Math.min(PongGame.HEIGHT - PongGame.PADDLE_HEIGHT / 2.0, y));
    }
}

/**
 * AI控制器:轨迹预测 + 贪心策略
 */
class AIController {

    /**
     * 预测球到达指定横坐标时的纵坐标
     * 采用分段模拟法,逐次处理上下边界反射
     * 
     * @param ball 当前球状态
     * @param targetX 目标横坐标(挡板位置)
     * @return 预测落点的纵坐标
     */
    static double predictBallLanding(Ball ball, double targetX) {
        double simX = ball.x;
        double simY = ball.y;
        double simVx = ball.vx;
        double simVy = ball.vy;

        // 如果球正在远离AI挡板(向右运动),返回场地中央作为默认位置
        if (simVx > 0) {
            return PongGame.HEIGHT / 2.0;
        }

        // 分段模拟:逐步推进直到球到达目标横坐标
        // 设置安全上限,防止无限循环
        int maxSteps = 1000;
        for (int i = 0; i < maxSteps && simVx < 0; i++) {
            double nextX = simX + simVx;
            double nextY = simY + simVy;

            // 检查是否已越过目标横坐标
            if (nextX <= targetX) {
                // 线性插值计算精确的交点纵坐标
                double ratio = (targetX - simX) / simVx;
                return simY + simVy * ratio;
            }

            // 检查上下边界反射
            if (nextY < PongGame.BALL_RADIUS) {
                // 触顶上边界,计算反射后的位置
                double ratio = (PongGame.BALL_RADIUS - simY) / simVy;
                simX = simX + simVx * ratio;
                simY = PongGame.BALL_RADIUS;
                simVy = -simVy; // 垂直速度反向
            } else if (nextY > PongGame.HEIGHT - PongGame.BALL_RADIUS) {
                // 触底下边界
                double ratio = (PongGame.HEIGHT - PongGame.BALL_RADIUS - simY) / simVy;
                simX = simX + simVx * ratio;
                simY = PongGame.HEIGHT - PongGame.BALL_RADIUS;
                simVy = -simVy;
            } else {
                // 无碰撞,直接更新
                simX = nextX;
                simY = nextY;
            }
        }

        // 若模拟超时,返回当前位置
        return simY;
    }

    /**
     * 贪心策略:挡板向预测落点方向移动
     * 每帧只移动一步,确保平滑跟踪
     * 
     * @param paddle AI挡板对象
     * @param targetY 目标纵坐标
     */
    static void movePaddleGreedy(Paddle paddle, double targetY) {
        double diff = targetY - paddle.y;
        double threshold = 2.0; // 停止阈值,防止抖动

        if (Math.abs(diff) < threshold) {
            // 已足够接近,停止移动
            paddle.setMovingUp(false);
            paddle.setMovingDown(false);
        } else if (diff < 0) {
            // 目标在上方
            paddle.setMovingUp(true);
            paddle.setMovingDown(false);
        } else {
            // 目标在下方
            paddle.setMovingUp(false);
            paddle.setMovingDown(true);
        }
    }
}

六、关键算法解析

6.1 轨迹预测的分段模拟

predictBallLanding 方法是整个AI系统的核心。它并非简单地用一次线性外推,而是通过循环逐步推演球的运动轨迹,每次遇到边界时精确计算反射点并更新速度方向。这种”事件驱动”的模拟方式,避免了球速过快时直接穿墙的问题,也准确处理了多次反射的复杂场景。

代码中的安全上限 maxSteps = 1000 是一个防御性编程措施。在标准场地和速度下,球从一侧到另一侧通常只需数十次迭代即可到达目标,上限几乎不会触发。

6.2 贪心策略的平滑性

movePaddleGreedy 方法引入了 threshold 阈值。这个设计至关重要:如果没有阈值,当预测落点与挡板中心极为接近时,AI会在每一帧反复切换上下方向,产生肉眼可见的抖动。阈值给AI创造了一个”舒适区”,在区域内保持静止,大幅提升了观感上的自然度。

此外,AI挡板的移动速度(6像素/帧)略低于玩家(8像素/帧),这一人为设置的不对称性,既保证了AI的”拟人化”失误率,也为玩家留出了获胜空间。

6.3 碰撞检测的精度

ballIntersectsPaddle 采用了圆到矩形最近点距离的检测方法。对于矩形中的任意一点,找到矩形边界上离该点最近的点,再计算两点距离是否小于半径。这种方法比简单的AABB包围盒检测更精确,尤其是在球从挡板角落擦过时,能给出更真实的物理反馈。

七、时间复杂度分析

模块 时间复杂度 说明
球位置更新 $O(1)$ 常数次浮点运算
轨迹预测 $O(k)$ $k$ 为边界反射次数,实际中 $k \leq 5$
贪心挡板移动 $O(1)$ 单次比较与赋值
碰撞检测 $O(1)$ 固定次数的算术运算
每帧总更新 $O(1)$ 整体为常数时间

在60FPS的运行频率下,每帧的全部计算量控制在数百次浮点运算以内,现代CPU可轻松承载数千个此类实例并行运行。

八、扩展方向

基于本文实现的框架,可以向多个方向深化:

  • 强化学习AI:将Q-Learning或策略梯度方法引入,让AI通过与自己对弈持续进化,替代固定的贪心策略。
  • 多球模式:同时追踪多个球的运动轨迹,挡板需要在多个预测落点之间做最优选择,可建模为带权中位数问题。
  • 旋转物理:引入球的自旋(spin)属性,碰撞时根据接触点的切向速度分量产生马格努斯效应,使轨迹呈曲线运动。
  • 网络对战:将本地AI替换为网络对手,通过WebSocket同步双方状态,实现实时PVP对战。

九、总结

乒乓球游戏虽小,却浓缩了游戏开发的三大核心议题:物理模拟、碰撞检测与AI决策。本文实现的轨迹预测算法,本质上是分段线性插值在边界约束下的应用;贪心挡板策略,则是实时控制系统中最直观的反馈机制。读者若将这两个模块独立抽离,稍加改造即可应用于弹球、打砖块、空气曲棍球等同类游戏的开发中。掌握这些基础,便为后续更复杂的寻路算法、博弈树搜索与强化学习奠定了坚实的直觉基础。