每日算法 — 使用java实现冰球:刚体碰撞物理与AI策略决策

引言

冰球(Air Hockey)是一款经典的桌上竞技游戏,玩家通过操控球拍击打圆盘,试图将圆盘送入对方球门得分。本文将用Java实现一个带AI的冰球对战系统,核心聚焦于刚体碰撞物理模拟与AI goalie策略决策两大算法模块。读者将学习到弹性碰撞的向量分解、动量守恒在离散时间步的数值实现,以及基于球轨预测的门将拦截策略。

核心算法一:刚体碰撞物理模拟

冰球系统的物理模型包含三个核心对象:冰球(puck)、玩家球拍(mallet)和边界挡板(boundary)。每一帧的物理更新遵循以下流程:

  1. 位置更新:根据当前速度更新位置
  2. 摩擦力衰减:模拟冰面阻力使速度逐渐减小
  3. 碰撞检测:检测冰球与球拍/边界的相交
  4. 碰撞响应:计算碰撞后的新速度向量

弹性碰撞的向量分解

当两个圆形物体发生碰撞时,将速度向量分解为法向分量(沿碰撞中心连线)和切向分量(垂直于碰撞中心连线):

  • 法向分量遵循一维弹性碰撞公式交换
  • 切向分量保持不变(忽略旋转摩擦)

设物体A、B的质量分别为 $m_A$、$m_B$,速度向量为 $\vec{v}_A$、$\vec{v}_B$,碰撞方向单位向量为 $\vec{n}$,则:

$$v_{An} = \vec{v}A \cdot \vec{n}, \quad v{Bn} = \vec{v}_B \cdot \vec{n}$$

法向碰撞后的速度(一维弹性碰撞):

$$v’{An} = \frac{(m_A – m_B) v{An} + 2 m_B v_{Bn}}{m_A + m_B}$$
$$v’{Bn} = \frac{(m_B – m_A) v{Bn} + 2 m_A v_{An}}{m_A + m_B}$$

Java实现:物理引擎核心类

import java.awt.*;
import java.awt.geom.*;

/**
 * 二维向量工具类,封装向量运算
 */
class Vec2 {
    public double x, y;
    public Vec2(double x, double y) { this.x = x; this.y = y; }
    public Vec2 add(Vec2 v) { return new Vec2(x + v.x, y + v.y); }
    public Vec2 sub(Vec2 v) { return new Vec2(x - v.x, y - v.y); }
    public Vec2 mul(double s) { return new Vec2(x * s, y * s); }
    public double dot(Vec2 v) { return x * v.x + y * v.y; }
    public double len() { return Math.sqrt(x * x + y * y); }
    public Vec2 normalize() {
        double l = len();
        return l < 1e-9 ? new Vec2(0, 0) : new Vec2(x / l, y / l);
    }
    public Vec2 proj(Vec2 onto) {
        // 投影到 onto 方向
        double ontoLenSq = onto.dot(onto);
        if (ontoLenSq < 1e-12) return new Vec2(0, 0);
        double scale = dot(onto) / ontoLenSq;
        return onto.mul(scale);
    }
}

/**
 * 圆形刚体:冰球或球拍
 */
class RigidBody {
    Vec2 pos;       // 位置
    Vec2 vel;       // 速度
    double radius;  // 半径
    double mass;    // 质量
    double restitution = 0.98; // 弹性系数(接近1表示几乎完全弹性)

    public RigidBody(double x, double y, double r, double m) {
        this.pos = new Vec2(x, y);
        this.vel = new Vec2(0, 0);
        this.radius = r;
        this.mass = m;
    }

    /**
     * 更新位置并应用摩擦力
     * @param dt 时间步长
     * @param friction 摩擦衰减系数(每帧乘以此系数)
     */
    public void update(double dt, double friction) {
        pos = pos.add(vel.mul(dt));
        vel = vel.mul(friction);
        // 当速度极小时归零,避免抖动
        if (vel.len() < 0.5) vel = new Vec2(0, 0);
    }
}

/**
 * 碰撞检测与响应处理器
 */
class PhysicsEngine {
    private static final double GOAL_WIDTH = 120;

    /**
     * 处理两个圆形刚体的弹性碰撞
     */
    public static void resolveCollision(RigidBody a, RigidBody b) {
        Vec2 diff = b.pos.sub(a.pos);
        double dist = diff.len();
        double minDist = a.radius + b.radius;

        if (dist >= minDist || dist < 1e-9) return;

        // 碰撞方向单位向量
        Vec2 normal = diff.normalize();

        // 将速度分解为法向与切向
        double vAn = a.vel.dot(normal);
        double vBn = b.vel.dot(normal);

        // 一维弹性碰撞公式(考虑质量)
        double mA = a.mass, mB = b.mass;
        double vAnNew = ((mA - mB) * vAn + 2 * mB * vBn) / (mA + mB);
        double vBnNew = ((mB - mA) * vBn + 2 * mA * vAn) / (mA + mB);

        // 重构速度:切向不变 + 新法向
        Vec2 tangent = new Vec2(-normal.y, normal.x);
        Vec2 vAt = tangent.mul(a.vel.dot(tangent));
        Vec2 vBt = tangent.mul(b.vel.dot(tangent));

        a.vel = vAt.add(normal.mul(vAnNew * a.restitution));
        b.vel = vBt.add(normal.mul(vBnNew * b.restitution));

        // 防止穿透:沿法向推开
        double overlap = minDist - dist;
        Vec2 correction = normal.mul(overlap / 2.0 + 0.5);
        a.pos = a.pos.sub(correction);
        b.pos = b.pos.add(correction);
    }

    /**
     * 处理冰球与边界的碰撞
     * @param tableWidth 桌面宽度
     * @param tableHeight 桌面高度
     * @return 0=无得分, 1=玩家得分(AI失球), 2=AI得分(玩家失球)
     */
    public static int resolveBoundaryCollision(RigidBody puck,
                                                double tableWidth,
                                                double tableHeight) {
        double hw = tableWidth / 2;
        double hh = tableHeight / 2;
        double r = puck.radius;

        // 左右边界(反弹)
        if (puck.pos.x < -hw + r) {
            puck.pos.x = -hw + r;
            puck.vel.x = -puck.vel.x * puck.restitution;
        } else if (puck.pos.x > hw - r) {
            puck.pos.x = hw - r;
            puck.vel.x = -puck.vel.x * puck.restitution;
        }

        // 上下边界:检测是否进球门
        if (puck.pos.y < -hh + r) {
            if (Math.abs(puck.pos.x) < GOAL_WIDTH / 2) {
                return 1; // 玩家得分
            }
            puck.pos.y = -hh + r;
            puck.vel.y = -puck.vel.y * puck.restitution;
        } else if (puck.pos.y > hh - r) {
            if (Math.abs(puck.pos.x) < GOAL_WIDTH / 2) {
                return 2; // AI得分
            }
            puck.pos.y = hh - r;
            puck.vel.y = -puck.vel.y * puck.restitution;
        }
        return 0;
    }
}

核心算法二:AI门将策略决策

AI球拍的决策核心在于预测冰球轨迹并移动到最佳拦截位置。采用两级策略:

  1. 轨迹预测:假设冰球当前速度方向不变(忽略未来碰撞),计算冰球到达AI防守线(y = 固定值)时的x坐标
  2. 位置控制:将AI球拍向预测位置移动,同时限制在合理防守区域内

轨迹预测公式

冰球位置随时间变化:$\vec{p}(t) = \vec{p}_0 + \vec{v} \cdot t$

设AI防守线纵坐标为 $y_{defend}$,当前冰球位置为 $(x_0, y_0)$,速度为 $(v_x, v_y)$,则到达时间:

$$t = \frac{y_{defend} – y_0}{v_y}$$

预测到达位置的x坐标:

$$x_{predict} = x_0 + v_x \cdot t$$

Java实现:AI策略类

/**
 * AI goalie策略控制器
 */
class AIGoalie {
    private RigidBody aiMallet;   // AI球拍
    private double defendY;        // AI防守线y坐标
    private double maxSpeed;       // AI最大移动速度
    private double tableHalfWidth; // 桌面半宽

    public AIGoalie(RigidBody mallet, double defendY,
                    double maxSpeed, double tableWidth) {
        this.aiMallet = mallet;
        this.defendY = defendY;
        this.maxSpeed = maxSpeed;
        this.tableHalfWidth = tableWidth / 2;
    }

    /**
     * 每帧更新AI球拍速度,朝向预测拦截点移动
     * @param puck 冰球状态
     */
    public void update(RigidBody puck) {
        double targetX;

        // 只有当冰球向AI方向运动时才积极拦截
        if (puck.vel.y > 0) {
            // 预测冰球到达防守线时的x坐标
            double dy = defendY - puck.pos.y;
            double t = dy / puck.vel.y;
            targetX = puck.pos.x + puck.vel.x * t;

            // 考虑边界反弹一次后的修正(简化处理:限制在桌面范围内)
            while (Math.abs(targetX) > tableHalfWidth) {
                if (targetX > tableHalfWidth) {
                    targetX = 2 * tableHalfWidth - targetX;
                } else {
                    targetX = -2 * tableHalfWidth - targetX;
                }
            }
        } else {
            // 冰球远离时,回防到球门中央
            targetX = 0;
        }

        // 计算移动方向与速度
        double dx = targetX - aiMallet.pos.x;
        double moveSpeed = Math.min(maxSpeed, Math.abs(dx) * 5);
        if (Math.abs(dx) < 2) {
            aiMallet.vel.x = 0;
        } else {
            aiMallet.vel.x = (dx > 0 ? 1 : -1) * moveSpeed;
        }
        aiMallet.vel.y = 0; // AI球拍仅在水平方向移动(简化 goalie 模式)
    }

    /**
     * 限制AI球拍在防守半区内
     */
    public void clampPosition(double tableHeight) {
        double minY = 0;
        double maxY = tableHeight / 2 - aiMallet.radius;
        aiMallet.pos.y = Math.max(minY, Math.min(maxY, aiMallet.pos.y));
        aiMallet.pos.x = Math.max(-tableHalfWidth + aiMallet.radius,
                                   Math.min(tableHalfWidth - aiMallet.radius,
                                            aiMallet.pos.x));
    }
}

完整游戏框架与主循环

/**
 * 冰球游戏主控类
 */
public class AirHockeyGame {
    private static final double TABLE_W = 400;
    private static final double TABLE_H = 700;
    private static final double PUCK_R = 15;
    private static final double MALLET_R = 25;
    private static final double DT = 1.0 / 60.0;
    private static final double FRICTION = 0.995;

    private RigidBody puck;
    private RigidBody playerMallet;
    private RigidBody aiMallet;
    private AIGoalie ai;
    private int playerScore = 0;
    private int aiScore = 0;

    public AirHockeyGame() {
        // 冰球质量设为1,球拍质量设为5(球拍几乎不动)
        puck = new RigidBody(0, 0, PUCK_R, 1.0);
        playerMallet = new RigidBody(0, -200, MALLET_R, 5.0);
        aiMallet = new RigidBody(0, 200, MALLET_R, 5.0);
        ai = new AIGoalie(aiMallet, 150, 300, TABLE_W);
        resetPuck();
    }

    private void resetPuck() {
        puck.pos = new Vec2(0, 0);
        puck.vel = new Vec2((Math.random() - 0.5) * 200,
                             (Math.random() > 0.5 ? 1 : -1) * 200);
    }

    public void update() {
        // 1. 物理更新
        puck.update(DT, FRICTION);
        playerMallet.update(DT, FRICTION);
        aiMallet.update(DT, FRICTION);

        // 2. AI决策
        ai.update(puck);
        ai.clampPosition(TABLE_H);

        // 3. 碰撞处理
        PhysicsEngine.resolveCollision(puck, playerMallet);
        PhysicsEngine.resolveCollision(puck, aiMallet);
        int goal = PhysicsEngine.resolveBoundaryCollision(puck, TABLE_W, TABLE_H);

        // 4. 得分判定
        if (goal == 1) { playerScore++; resetPuck(); }
        else if (goal == 2) { aiScore++; resetPuck(); }

        // 5. 限制玩家球拍位置
        clampPlayerMallet();
    }

    private void clampPlayerMallet() {
        double hw = TABLE_W / 2 - MALLET_R;
        double hh = TABLE_H / 2 - MALLET_R;
        playerMallet.pos.x = Math.max(-hw, Math.min(hw, playerMallet.pos.x));
        playerMallet.pos.y = Math.max(-hh, Math.min(0, playerMallet.pos.y));
    }

    // Getters for rendering...
    public RigidBody getPuck() { return puck; }
    public RigidBody getPlayerMallet() { return playerMallet; }
    public RigidBody getAiMallet() { return aiMallet; }
    public int getPlayerScore() { return playerScore; }
    public int getAiScore() { return aiScore; }
}

算法复杂度分析

模块 时间复杂度 空间复杂度 说明
碰撞检测 $O(1)$ $O(1)$ 每帧固定检测两组碰撞(puck vs 两个mallets)
碰撞响应 $O(1)$ $O(1)$ 向量运算均为常数时间
AI轨迹预测 $O(1)$ $O(1)$ 线性运动方程直接求解
整体帧更新 $O(1)$ $O(1)$ 与对象数量无关的常数时间

扩展思路

  1. 多级AI难度:初级AI仅做轨迹预测;中级AI引入击球力度控制;高级AI可预测多次反弹并主动进攻
  2. 旋转模拟:引入冰球的角速度,碰撞时考虑切向摩擦力产生的旋转效应
  3. 机器学习增强:使用强化学习(如PPO)训练AI,让AI自主学习最优击球角度和力度

总结

本文实现了一个完整的冰球物理对战系统,核心算法包括:
– 向量分解法处理弹性碰撞,将二维问题转化为一维法向碰撞+切向守恒
– 线性轨迹预测使AI能够提前移动到拦截位置,实现类人反应
– 离散时间步模拟以固定帧率更新物理状态,保证游戏在不同设备上的行为一致性

完整代码可直接扩展为带Swing/JavaFX渲染的完整游戏,物理引擎部分亦可复用于其他圆形刚体碰撞场景。