踢毽子是中国传统民间体育活动,玩家用脚将毽子踢起并控制其不落地。本文用Java实现踢毽子的物理模拟与AI控制系统,重点讲解抛体运动学建模、空气阻力数值积分,以及基于PID控制器的动态平衡调节算法。
问题建模
将毽子简化为质点,在二维平面内运动(忽略水平偏航,仅考虑垂直和前后运动):
– 位置(x, y):x为水平位移,y为高度
– 速度(vx, vy):水平和垂直分量
– 受力:重力、空气阻力、脚部踢击力
玩家需要预测毽子的落点,移动脚部到合适位置施加踢击力,使毽子保持在可控高度范围内。
核心算法
1. 抛体运动与空气阻力
空气阻力与速度平方成正比,方向与速度相反。使用四阶龙格-库塔法(RK4)进行高精度数值积分。
2. 落点预测
基于当前位置和速度,求解毽子到达地面(y=0)的时间和水平位置。
3. PID控制器
控制脚部移动速度,使落点位置与脚部位置误差收敛到零。
完整Java实现
import java.util.*;
/**
* 踢毽子 — 物理抛物线模拟与动态平衡控制
* 包含RK4数值积分、落点预测、PID控制器
*/
public class JianziGame {
// 物理常量
private static final double GRAVITY = 9.8; // 重力加速度 (m/s²)
private static final double AIR_DENSITY = 1.225; // 空气密度 (kg/m³)
private static final double DRAG_COEFF = 0.47; // 球形阻力系数
private static final double JIANZI_AREA = 0.005; // 毽子截面积 (m²)
private static final double JIANZI_MASS = 0.015; // 毽子质量 (kg)
private static final double DT = 0.01; // 模拟时间步长 (s)
// PID参数
private double kp = 2.0; // 比例增益
private double ki = 0.1; // 积分增益
private double kd = 0.5; // 微分增益
private double integral = 0;
private double prevError = 0;
/**
* 毽子状态向量
*/
public static class State {
double x, y; // 位置 (m)
double vx, vy; // 速度 (m/s)
double t; // 时间 (s)
public State(double x, double y, double vx, double vy) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy; this.t = 0;
}
public State clone() {
State s = new State(x, y, vx, vy);
s.t = t;
return s;
}
}
/**
* 计算空气阻力加速度
* F_drag = 0.5 * ρ * C_d * A * v²
* a_drag = F_drag / m,方向与速度相反
*/
private double[] dragAcceleration(double vx, double vy) {
double v = Math.sqrt(vx * vx + vy * vy);
if (v < 0.001) return new double[]{0, 0};
double fDrag = 0.5 * AIR_DENSITY * DRAG_COEFF * JIANZI_AREA * v * v;
double aDrag = fDrag / JIANZI_MASS;
return new double[]{
-aDrag * vx / v, // x方向阻力加速度
-aDrag * vy / v // y方向阻力加速度
};
}
/**
* 计算状态导数(用于RK4)
* 返回 [dx/dt, dy/dt, dvx/dt, dvy/dt]
*/
private double[] derivatives(State s) {
double[] aDrag = dragAcceleration(s.vx, s.vy);
return new double[]{
s.vx, // dx/dt = vx
s.vy, // dy/dt = vy
aDrag[0], // dvx/dt = 空气阻力x分量
aDrag[1] - GRAVITY // dvy/dt = 空气阻力y分量 - 重力
};
}
/**
* RK4单步积分
*/
public State rk4Step(State s, double dt) {
double[] k1 = derivatives(s);
State s2 = s.clone();
s2.x += k1[0] * dt / 2;
s2.y += k1[1] * dt / 2;
s2.vx += k1[2] * dt / 2;
s2.vy += k1[3] * dt / 2;
double[] k2 = derivatives(s2);
State s3 = s.clone();
s3.x += k2[0] * dt / 2;
s3.y += k2[1] * dt / 2;
s3.vx += k2[2] * dt / 2;
s3.vy += k2[3] * dt / 2;
double[] k3 = derivatives(s3);
State s4 = s.clone();
s4.x += k3[0] * dt;
s4.y += k3[1] * dt;
s4.vx += k3[2] * dt;
s4.vy += k3[3] * dt;
double[] k4 = derivatives(s4);
State next = s.clone();
next.x += (k1[0] + 2*k2[0] + 2*k3[0] + k4[0]) * dt / 6;
next.y += (k1[1] + 2*k2[1] + 2*k3[1] + k4[1]) * dt / 6;
next.vx += (k1[2] + 2*k2[2] + 2*k3[2] + k4[2]) * dt / 6;
next.vy += (k1[3] + 2*k2[3] + 2*k3[3] + k4[3]) * dt / 6;
next.t += dt;
return next;
}
/**
* 模拟自由飞行直到落地或超时
*/
public List<State> simulateFlight(State initial, double maxTime) {
List<State> trajectory = new ArrayList<>();
State current = initial.clone();
trajectory.add(current.clone());
int steps = (int)(maxTime / DT);
for (int i = 0; i < steps; i++) {
current = rk4Step(current, DT);
trajectory.add(current.clone());
// 落地检测
if (current.y <= 0 && current.vy < 0) {
break;
}
}
return trajectory;
}
/**
* 预测落点:基于当前状态计算落地时间和位置
* 使用二分搜索在轨迹中精确找到y=0的点
*/
public double[] predictLanding(State current) {
List<State> traj = simulateFlight(current, 10.0);
// 找到第一个y<=0的点
int idx = -1;
for (int i = 1; i < traj.size(); i++) {
if (traj.get(i).y <= 0) {
idx = i;
break;
}
}
if (idx == -1) {
// 未落地,返回预测终点
State last = traj.get(traj.size() - 1);
return new double[]{last.t, last.x};
}
// 线性插值精确落点
State before = traj.get(idx - 1);
State after = traj.get(idx);
double ratio = before.y / (before.y - after.y);
double landTime = before.t + ratio * (after.t - before.t);
double landX = before.x + ratio * (after.x - before.x);
return new double[]{landTime, landX};
}
/**
* 脚部踢击:施加瞬时冲量改变毽子速度
* 目标是将毽子踢到(targetHeight, 0水平偏移)附近
*/
public State kick(State current, double footX, double footY,
double targetHeight, double targetX) {
State after = current.clone();
// 计算需要的垂直速度:vy² = 2gh(忽略阻力近似)
double neededVy = Math.sqrt(2 * GRAVITY * targetHeight);
// 更精确:考虑空气阻力的修正(经验系数)
neededVy *= 1.15;
// 计算需要的水平速度:使毽子落回目标位置
double flightTime = 2 * neededVy / GRAVITY;
double neededVx = (targetX - current.x) / flightTime;
// 脚部碰撞导致的速度变化(冲量模型)
double deltaVx = neededVx - current.vx;
double deltaVy = neededVy - current.vy;
// 限制最大踢击力度
double maxKick = 8.0;
double kickMag = Math.sqrt(deltaVx * deltaVx + deltaVy * deltaVy);
if (kickMag > maxKick) {
double scale = maxKick / kickMag;
deltaVx *= scale;
deltaVy *= scale;
}
after.vx += deltaVx;
after.vy += deltaVy;
after.y = footY + 0.1; // 踢击后略微升高
return after;
}
/**
* PID控制器:计算脚部水平移动速度
* error = 目标落点 - 当前脚部位置
*/
public double pidControl(double footX, double landingX, double dt) {
double error = landingX - footX;
integral += error * dt;
double derivative = (error - prevError) / dt;
prevError = error;
double output = kp * error + ki * integral + kd * derivative;
// 限制最大移动速度 (m/s)
double maxSpeed = 3.0;
return Math.max(-maxSpeed, Math.min(maxSpeed, output));
}
/**
* 完整游戏模拟:AI自动踢毽子
*/
public void simulateGame(int maxKicks) {
// 初始状态:从空中下落
State jianzi = new State(0, 1.5, 0.3, -1.0);
double footX = 0;
double footY = 0;
List<Double> heights = new ArrayList<>();
List<Double> footPositions = new ArrayList<>();
int kickCount = 0;
System.out.println("===== 踢毽子AI模拟 =====");
System.out.println("时间(s)\t高度(m)\t水平(m)\t脚位置(m)\t状态");
double simTime = 0;
double printInterval = 0.1;
double nextPrint = 0;
while (kickCount < maxKicks && simTime < 30) {
// 模拟飞行
List<State> traj = simulateFlight(jianzi, 5.0);
// 检查是否落地
if (traj.get(traj.size() - 1).y <= 0) {
// 预测落点
double[] landing = predictLanding(jianzi);
double landTime = landing[0];
double landX = landing[1];
// 打印飞行过程关键点
for (State s : traj) {
if (s.t >= nextPrint) {
System.out.printf("%.2f\t%.3f\t%.3f\t%.3f\t%s%n",
simTime + s.t, s.y, s.x, footX,
s.y > 0.1 ? "飞行" : "即将落地");
nextPrint += printInterval;
}
if (s.y <= 0) break;
}
// PID控制脚部移动
double moveSpeed = pidControl(footX, landX, landTime);
footX += moveSpeed * landTime;
// 踢击
jianzi = kick(jianzi, footX, footY, 1.5 + Math.random() * 0.5, footX);
kickCount++;
simTime += landTime;
// 重置PID积分项(每次踢击后重新适应)
integral *= 0.5;
System.out.printf("%.2f\t---\t---\t%.3f\t踢击 #%d (目标高度=%.2f)%n",
simTime, footX, kickCount, 1.5);
nextPrint = simTime + printInterval;
} else {
// 异常:未落地但超时
break;
}
}
System.out.println("\n===== 统计 =====");
System.out.println("总踢击次数: " + kickCount);
System.out.println("模拟时长: " + String.format("%.2f", simTime) + "秒");
}
/**
* 对比实验:有空气阻力 vs 无空气阻力
*/
public void compareDragEffect() {
System.out.println("\n===== 空气阻力影响对比 =====");
State noDrag = new State(0, 2.0, 2.0, 4.0);
State withDrag = noDrag.clone();
// 无阻力模拟(简化为解析解)
double flightTimeNoDrag = (noDrag.vy + Math.sqrt(noDrag.vy * noDrag.vy + 2 * GRAVITY * noDrag.y)) / GRAVITY;
double landXNoDrag = noDrag.x + noDrag.vx * flightTimeNoDrag;
// 有阻力模拟
List<State> traj = simulateFlight(withDrag, 10.0);
State last = traj.get(traj.size() - 1);
System.out.printf("初始状态: 高度=%.2fm, vx=%.2f, vy=%.2f%n", noDrag.y, noDrag.vx, noDrag.vy);
System.out.printf("无阻力预测: 飞行%.2fs, 落点%.2fm%n", flightTimeNoDrag, landXNoDrag);
System.out.printf("有阻力模拟: 飞行%.2fs, 落点%.2fm%n", last.t, last.x);
System.out.printf("水平偏差: %.2fm (%.1f%%)%n",
landXNoDrag - last.x, (landXNoDrag - last.x) / landXNoDrag * 100);
}
/**
* 参数调优:扫描PID参数组合
*/
public void tunePidParameters() {
System.out.println("\n===== PID参数扫描 =====");
double bestKp = 0, bestKi = 0, bestKd = 0;
double bestScore = Double.NEGATIVE_INFINITY;
for (double p = 0.5; p <= 5.0; p += 0.5) {
for (double i = 0.0; i <= 0.5; i += 0.1) {
for (double d = 0.0; d <= 2.0; d += 0.5) {
this.kp = p; this.ki = i; this.kd = d;
this.integral = 0; this.prevError = 0;
double score = evaluatePid();
if (score > bestScore) {
bestScore = score;
bestKp = p; bestKi = i; bestKd = d;
}
}
}
}
System.out.printf("最优参数: Kp=%.1f, Ki=%.1f, Kd=%.1f, 评分=%.2f%n",
bestKp, bestKi, bestKd, bestScore);
// 恢复最优参数
this.kp = bestKp;
this.ki = bestKi;
this.kd = bestKd;
this.integral = 0;
this.prevError = 0;
}
private double evaluatePid() {
// 简化的评分:模拟5次踢击,计算落点误差累积
State jianzi = new State(0, 1.5, 0.2, -0.8);
double footX = 0;
double totalError = 0;
for (int kick = 0; kick < 5; kick++) {
double[] landing = predictLanding(jianzi);
double landX = landing[1];
double error = landX - footX;
double moveSpeed = pidControl(footX, landX, landing[0]);
footX += moveSpeed * landing[0];
totalError += Math.abs(error);
jianzi = kick(jianzi, footX, 0, 1.5, footX);
}
return -totalError; // 误差越小,评分越高
}
public static void main(String[] args) {
JianziGame game = new JianziGame();
// PID参数调优
game.tunePidParameters();
// 空气阻力对比
game.compareDragEffect();
// 完整游戏模拟
game.simulateGame(20);
}
}
物理模型详解
空气阻力建模
空气阻力公式采用经典形式:
F_drag = ½ × ρ × C_d × A × v²
对于毽子这类轻小物体,空气阻力与重力的比值可达0.3-0.5,不可忽略。RK4数值积分以O(dt⁴)的精度追踪运动轨迹,10ms步长即可达到厘米级精度。
PID控制器设计
控制器输入为”预测落点与脚部位置的水平偏差”,输出为”脚部水平移动速度”。三个增益的作用:
– Kp(比例):快速响应大偏差
– Ki(积分):消除稳态误差
– Kd(微分):抑制振荡、提前减速
复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| RK4单步积分 | O(1) | O(1) |
| 完整飞行模拟 | O(maxTime/DT) | O(maxTime/DT) |
| 落点预测 | O(maxTime/DT) | O(maxTime/DT) |
| PID控制 | O(1) | O(1) |
| 参数扫描 | O(Kp_range × Ki_range × Kd_range × 模拟代价) | O(1) |
扩展方向
- 风力扰动:引入随机水平力,测试控制器鲁棒性
- 多毽子协同:马尔可夫链建模多个毽子的运动模式
- 神经网络替代PID:用强化学习训练端到端控制器
- 三维扩展:加入前后深度方向的控制
总结
踢毽子AI将经典控制理论与物理仿真结合。RK4数值积分精确追踪非线性运动,PID控制器实现实时平衡调节,落点预测提供前瞻决策能力。这种”预测-控制-执行”的架构是机器人控制、无人机姿态稳定和自动驾驶路径跟踪的核心方法论。