概述
割绳子(Cut the Rope)是一款经典的物理解谜游戏,玩家通过切断绳子让糖果落入小怪兽口中。游戏的核心在于绳索物理模拟、抛物线运动轨迹计算,以及碰撞与切割判定。本文用Java实现其中的核心物理算法,包括基于Verlet积分的绳索模拟、二次贝塞尔曲线描述绳索形态,以及射线与线段相交检测实现割绳判定。
核心算法设计
1. 质点-弹簧绳索模型
我们将绳索离散为一系列质点,相邻质点之间用弹簧连接。通过Verlet积分更新质点位置,该方法无需显式存储速度,稳定性优于欧拉法。
public class RopeNode {
public double x, y; // 当前位置
public double oldX, oldY; // 上一帧位置(Verlet积分用)
public boolean fixed; // 是否固定(如挂在钉子上)
public double mass = 1.0;
public RopeNode(double x, double y, boolean fixed) {
this.x = this.oldX = x;
this.y = this.oldY = y;
this.fixed = fixed;
}
/**
* Verlet积分更新位置
* x(t+dt) = 2*x(t) - x(t-dt) + a(t)*dt^2
*/
public void verletIntegrate(double dt, double gravity) {
if (fixed) return;
double tempX = x;
double tempY = y;
double damping = 0.98; // 速度阻尼
x += (x - oldX) * damping + 0 * dt * dt; // 水平无外力
y += (y - oldY) * damping + gravity * dt * dt;
oldX = tempX;
oldY = tempY;
}
}
public class RopeSpring {
public RopeNode a, b;
public double restLength; // 弹簧原长
public double stiffness = 0.5;
public RopeSpring(RopeNode a, RopeNode b) {
this.a = a;
this.b = b;
this.restLength = Math.hypot(a.x - b.x, a.y - b.y);
}
/**
* 弹簧约束松弛:将两质点距离拉回至restLength
*/
public void relax() {
double dx = b.x - a.x;
double dy = b.y - a.y;
double dist = Math.hypot(dx, dy);
if (dist == 0) return;
double diff = (dist - restLength) / dist * stiffness;
double offsetX = dx * diff * 0.5;
double offsetY = dy * diff * 0.5;
if (!a.fixed) { a.x += offsetX; a.y += offsetY; }
if (!b.fixed) { b.x -= offsetX; b.y -= offsetY; }
}
}
2. 完整绳索模拟系统
每帧执行多次约束松弛迭代,使绳索达到力学平衡。
public class RopeSimulation {
private List<RopeNode> nodes = new ArrayList<>();
private List<RopeSpring> springs = new ArrayList<>();
private static final double GRAVITY = 500.0;
private static final double DT = 0.016; // 60FPS
private static final int ITERATIONS = 5; // 每帧松弛迭代次数
public void createRope(double startX, double startY, double endX, double endY, int segments) {
nodes.clear();
springs.clear();
for (int i = 0; i <= segments; i++) {
double t = (double) i / segments;
double x = startX + (endX - startX) * t;
double y = startY + (endY - startY) * t;
// 首尾固定(挂在钉子和糖果上)
nodes.add(new RopeNode(x, y, i == 0));
}
for (int i = 0; i < segments; i++) {
springs.add(new RopeSpring(nodes.get(i), nodes.get(i + 1)));
}
}
public void update() {
// 1. Verlet积分更新所有质点
for (RopeNode n : nodes) {
n.verletIntegrate(DT, GRAVITY);
}
// 2. 多次迭代约束松弛
for (int i = 0; i < ITERATIONS; i++) {
for (RopeSpring s : springs) {
s.relax();
}
}
}
}
3. 抛物线轨迹预测
当绳子被割断后,糖果受重力影响做抛物线运动。我们预先计算糖果的理论落点,辅助玩家判断切割时机。
public class TrajectoryPredictor {
/**
* 计算抛物线轨迹上的若干采样点
* 运动方程:y = y0 + vy0*t + 0.5*g*t^2
* x = x0 + vx0*t
*/
public List<double[]> predictTrajectory(
double x0, double y0,
double vx0, double vy0,
double gravity, int steps) {
List<double[]> points = new ArrayList<>();
double dt = 0.05; // 每0.05秒采样一次
for (int i = 0; i < steps; i++) {
double t = i * dt;
double x = x0 + vx0 * t;
double y = y0 + vy0 * t + 0.5 * gravity * t * t;
if (y > 800) break; // 超出屏幕底部停止
points.add(new double[]{x, y});
}
return points;
}
/**
* 计算糖果到达目标水平位置所需时间
* 解方程:targetX = x0 + vx0 * t => t = (targetX - x0) / vx0
*/
public Double timeToReachX(double x0, double vx0, double targetX) {
if (Math.abs(vx0) < 1e-6) return null;
return (targetX - x0) / vx0;
}
/**
* 计算给定时刻的Y坐标
*/
public double heightAtTime(double y0, double vy0, double gravity, double t) {
return y0 + vy0 * t + 0.5 * gravity * t * t;
}
}
4. 射线-线段相交检测(割绳判定)
玩家滑动屏幕产生一条切割射线,我们需要检测该射线与哪段绳索相交。使用方向向量叉积法判断两线段是否相交。
public class LineIntersection {
/**
* 计算向量叉积 (P1P2 × P1P3)
*/
private static double cross(double x1, double y1, double x2, double y2,
double x3, double y3) {
return (x2 - x1) * (y3 - y1) - (y3 - y1) * (x2 - x1);
}
/**
* 判断点C是否在线段AB上(共线且在包围盒内)
*/
private static boolean onSegment(double ax, double ay, double bx, double by,
double cx, double cy) {
return Math.min(ax, bx) <= cx && cx <= Math.max(ax, bx) &&
Math.min(ay, by) <= cy && cy <= Math.max(ay, by);
}
/**
* 射线(线段)与绳索弹簧的相交检测
* 基于方向向量叉积符号判断
*/
public static boolean segmentsIntersect(
double ax1, double ay1, double ax2, double ay2, // 切割线
double bx1, double by1, double bx2, double by2 // 绳索段
) {
double d1 = direction(bx1, by1, bx2, by2, ax1, ay1);
double d2 = direction(bx1, by1, bx2, by2, ax2, ay2);
double d3 = direction(ax1, ay1, ax2, ay2, bx1, by1);
double d4 = direction(ax1, ay1, ax2, ay2, bx2, by2);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true;
}
// 共线特殊情况
if (d1 == 0 && onSegment(bx1, by1, bx2, by2, ax1, ay1)) return true;
if (d2 == 0 && onSegment(bx1, by1, bx2, by2, ax2, ay2)) return true;
if (d3 == 0 && onSegment(ax1, ay1, ax2, ay2, bx1, by1)) return true;
if (d4 == 0 && onSegment(ax1, ay1, ax2, ay2, bx2, by2)) return true;
return false;
}
private static double direction(double ax, double ay, double bx, double by,
double cx, double cy) {
return (cx - ax) * (by - ay) - (cy - ay) * (bx - ax);
}
}
5. 二次贝塞尔曲线渲染绳索
为了使绳索看起来自然平滑,我们用相邻质点作为控制点,通过二次贝塞尔曲线插值渲染。
public class BezierCurve {
/**
* 二次贝塞尔曲线插值
* B(t) = (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2, t ∈ [0,1]
*/
public static double[] quadraticBezier(double t,
double x0, double y0,
double x1, double y1,
double x2, double y2) {
double u = 1 - t;
double x = u * u * x0 + 2 * u * t * x1 + t * t * x2;
double y = u * u * y0 + 2 * u * t * y1 + t * t * y2;
return new double[]{x, y};
}
/**
* 将绳索质点序列转换为平滑的贝塞尔曲线点集
*/
public List<double[]> smoothRopePoints(List<RopeNode> nodes, int samplesPerSegment) {
List<double[]> result = new ArrayList<>();
for (int i = 0; i < nodes.size() - 1; i++) {
RopeNode p0 = nodes.get(Math.max(0, i - 1));
RopeNode p1 = nodes.get(i);
RopeNode p2 = nodes.get(Math.min(nodes.size() - 1, i + 1));
// 使用P1作为中点控制点
double midX = (p0.x + p2.x) / 2;
double midY = (p0.y + p2.y) / 2;
for (int s = 0; s < samplesPerSegment; s++) {
double t = (double) s / samplesPerSegment;
double[] pt = quadraticBezier(t, p1.x, p1.y, midX, midY, p2.x, p2.y);
result.add(pt);
}
}
return result;
}
}
6. 完整游戏逻辑
public class CutTheRopeGame {
private RopeSimulation ropeSim;
private Candy candy;
private Monster monster;
private List<Star> stars = new ArrayList<>();
private TrajectoryPredictor predictor = new TrajectoryPredictor();
public CutTheRopeGame() {
ropeSim = new RopeSimulation();
// 钉子固定在(200,50),糖果初始在(200,300)
ropeSim.createRope(200, 50, 200, 300, 20);
candy = new Candy(200, 300);
monster = new Monster(400, 600);
stars.add(new Star(300, 400));
stars.add(new Star(350, 500));
stars.add(new Star(450, 450));
}
public void cutRope(double x1, double y1, double x2, double y2) {
List<RopeSpring> toRemove = new ArrayList<>();
for (RopeSpring spring : ropeSim.getSprings()) {
if (LineIntersection.segmentsIntersect(
x1, y1, x2, y2,
spring.a.x, spring.a.y, spring.b.x, spring.b.y)) {
toRemove.add(spring);
}
}
// 移除被切断的弹簧
for (RopeSpring s : toRemove) {
ropeSim.removeSpring(s);
}
}
public void update(double dt) {
ropeSim.update();
// 更新糖果(如果与绳索断开,受重力下落)
if (!candy.isAttached()) {
candy.applyGravity(500, dt);
}
// 检测糖果是否吃到星星
for (Star star : stars) {
if (!star.collected && candy.collidesWith(star)) {
star.collected = true;
}
}
// 检测糖果是否落入怪兽口中
if (candy.collidesWith(monster)) {
levelComplete();
}
}
}
复杂度分析
| 算法模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| Verlet积分 | O(N) | O(N) | N=质点数量 |
| 弹簧约束松弛 | O(M×I) | O(M) | M=弹簧数, I=迭代次数 |
| 射线-线段检测 | O(M) | O(1) | 遍历所有弹簧段 |
| 贝塞尔曲线采样 | O(M×S) | O(M×S) | S=每段采样数 |
| 抛物线预测 | O(K) | O(K) | K=预测步数 |
总结
割绳子游戏的物理核心在于质点-弹簧模型与Verlet积分的结合,该方法以较低的计算成本实现了稳定的绳索摆动效果。射线-线段相交检测为切割交互提供了精确判定,而抛物线轨迹预测则是连接玩家操作与物理解谜的关键桥梁。二次贝塞尔曲线渲染将离散的质点转化为视觉流畅的绳索形态,体现了算法与表现层分离的经典设计思想。