引言
水果忍者(Fruit Ninja)是风靡全球的休闲游戏:屏幕上不断抛出水果,玩家通过滑动屏幕进行切割,切中水果得分,切中炸弹则游戏结束。本文将用 Java 完整实现水果忍者的核心算法系统,重点讲解滑动轨迹与水果的线段-圆碰撞检测以及贪心连击策略下的最优切割序列规划。
几何基础:线段与圆的碰撞检测
核心问题
玩家的一次滑动对应平面上的一条线段(从触摸开始到释放),水果是一个圆形。需要判断线段是否与圆相交,即是否存在线段上的点到圆心的距离小于等于半径。
最近点投影法
设线段端点为 P1(x1,y1)、P2(x2,y2),圆心为 C(cx,cy),半径为 r。计算 C 在线段所在直线上的投影点 Q:
线段方向向量: d = P2 - P1
参数 t = dot(C - P1, d) / dot(d, d)
若 0 ≤ t ≤ 1,投影点在线段上,Q = P1 + t * d
若 t < 0,最近点为 P1
若 t > 1,最近点为 P2
碰撞判定:distance(C, Q) ≤ r
/**
* 二维向量工具类
*/
public class Vec2 {
public final 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 length() { return Math.hypot(x, y); }
public double distTo(Vec2 v) { return sub(v).length(); }
}
/**
* 几何碰撞检测工具
*/
public class GeometryUtils {
/**
* 线段与圆的碰撞检测(最近点投影法)
* @param p1 线段起点
* @param p2 线段终点
* @param center 圆心
* @param radius 半径
* @return 是否相交,相交时返回true并填充outClosest
*/
public static boolean segmentCircleIntersect(Vec2 p1, Vec2 p2,
Vec2 center, double radius,
Vec2[] outClosest) {
Vec2 d = p2.sub(p1); // 线段方向向量
Vec2 f = p1.sub(center);
double a = d.dot(d);
double b = 2 * f.dot(d);
double c = f.dot(f) - radius * radius;
double discriminant = b * b - 4 * a * c;
if (discriminant < 0) return false; // 无交点
// 计算投影参数t
discriminant = Math.sqrt(discriminant);
double t1 = (-b - discriminant) / (2 * a);
double t2 = (-b + discriminant) / (2 * a);
// 检查是否有交点落在线段范围内 [0, 1]
if ((t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1)) {
// 计算最近点用于切点效果
double t = Math.max(0, Math.min(1, -b / (2 * a)));
if (outClosest != null) {
outClosest[0] = p1.add(d.mul(t));
}
return true;
}
return false;
}
/**
* 简化版:仅判断线段与圆是否相交
*/
public static boolean segmentHitsCircle(Vec2 p1, Vec2 p2, Vec2 c, double r) {
Vec2 d = p2.sub(p1);
Vec2 f = p1.sub(c);
double a = d.dot(d);
double b = 2 * f.dot(d);
double cc = f.dot(f) - r * r;
double disc = b * b - 4 * a * cc;
if (disc < 0) return false;
disc = Math.sqrt(disc);
double t1 = (-b - disc) / (2 * a);
double t2 = (-b + disc) / (2 * a);
return (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
}
}
游戏实体系统
import java.util.*;
/**
* 水果实体
*/
public class Fruit {
public enum Type { APPLE, BANANA, WATERMELON, BOMB }
private Vec2 pos; // 位置
private Vec2 vel; // 速度
private double radius; // 半径
private Type type;
private boolean sliced; // 是否已被切
private double gravity; // 重力加速度
public Fruit(double x, double y, double vx, double vy,
double radius, Type type) {
this.pos = new Vec2(x, y);
this.vel = new Vec2(vx, vy);
this.radius = radius;
this.type = type;
this.sliced = false;
this.gravity = 0.5;
}
/**
* 物理更新
*/
public void update() {
if (sliced) return;
pos = pos.add(vel);
vel = new Vec2(vel.x, vel.y + gravity);
}
/**
* 尝试切割
* @param swipe 滑动线段
* @return 是否切中
*/
public boolean trySlice(Swipe swipe) {
if (sliced) return false;
if (GeometryUtils.segmentHitsCircle(swipe.start, swipe.end, pos, radius)) {
sliced = true;
return true;
}
return false;
}
public boolean isOffScreen(double width, double height) {
return pos.y > height + radius || pos.x < -radius || pos.x > width + radius;
}
public boolean isBomb() { return type == Type.BOMB; }
public boolean isSliced() { return sliced; }
public Vec2 getPos() { return pos; }
public double getRadius() { return radius; }
public Type getType() { return type; }
public int getScore() {
return switch(type) {
case APPLE -> 1;
case BANANA -> 2;
case WATERMELON -> 3;
default -> 0;
};
}
}
/**
* 滑动轨迹
*/
public class Swipe {
public final Vec2 start;
public final Vec2 end;
public final long timestamp;
public Swipe(Vec2 start, Vec2 end) {
this.start = start;
this.end = end;
this.timestamp = System.currentTimeMillis();
}
}
贪心连击策略
问题定义
一次滑动可能同时切中多个水果。若一次切中 n 个水果,得分为 n × (n + 1) / 2(连击加成)。给定一组水果和滑动能力,如何规划滑动轨迹使总得分最大化?
贪心策略:最大覆盖直线
由于滑动极快,可假设一次滑动是一条直线段。问题转化为:找一条线段,使其穿过的水果圆数量最多。
/**
* 贪心连击策略:寻找最优切割直线
*/
public class ComboStrategy {
/**
* 贪心算法:枚举所有水果对的连线作为候选切割方向
* 选择能穿过最多水果(且不切中炸弹)的方向
*/
public static Swipe findBestSwipe(List<Fruit> fruits, double screenWidth,
double screenHeight) {
List<Fruit> aliveFruits = fruits.stream()
.filter(f -> !f.isSliced() && !f.isOffScreen(screenWidth, screenHeight))
.toList();
if (aliveFruits.isEmpty()) return null;
Swipe bestSwipe = null;
int bestScore = 0;
// 策略1:枚举每对水果,以它们连线方向作为候选
for (int i = 0; i < aliveFruits.size(); i++) {
for (int j = i + 1; j < aliveFruits.size(); j++) {
Fruit f1 = aliveFruits.get(i);
Fruit f2 = aliveFruits.get(j);
Swipe candidate = createSwipeThrough(f1.getPos(), f2.getPos(),
screenWidth, screenHeight);
int score = evaluateSwipe(candidate, aliveFruits);
if (score > bestScore) {
bestScore = score;
bestSwipe = candidate;
}
}
}
// 策略2:如果无有效连线,切最近的一个水果
if (bestSwipe == null || bestScore == 0) {
Fruit nearest = aliveFruits.stream()
.filter(f -> !f.isBomb())
.min(Comparator.comparingDouble(f -> f.getPos().y))
.orElse(null);
if (nearest != null) {
bestSwipe = createSwipeThrough(nearest.getPos(),
new Vec2(nearest.getPos().x + 100, nearest.getPos().y),
screenWidth, screenHeight);
}
}
return bestSwipe;
}
/**
* 评估一次滑动能获得的得分(考虑炸弹惩罚)
*/
private static int evaluateSwipe(Swipe swipe, List<Fruit> fruits) {
if (swipe == null) return 0;
int fruitCount = 0;
boolean hitsBomb = false;
for (Fruit f : fruits) {
if (GeometryUtils.segmentHitsCircle(swipe.start, swipe.end,
f.getPos(), f.getRadius())) {
if (f.isBomb()) {
hitsBomb = true;
break;
}
fruitCount++;
}
}
if (hitsBomb) return -1000; // 切中炸弹,极大惩罚
return fruitCount * (fruitCount + 1) / 2; // 连击加成
}
/**
* 构造穿过两点的屏幕宽度滑动线段
*/
private static Swipe createSwipeThrough(Vec2 p1, Vec2 p2,
double w, double h) {
Vec2 dir = p2.sub(p1);
if (dir.length() < 0.001) return null;
dir = new Vec2(dir.x / dir.length(), dir.y / dir.length());
// 将线段延伸到屏幕边界
Vec2 start = new Vec2(p1.x - dir.x * w, p1.y - dir.y * h);
Vec2 end = new Vec2(p2.x + dir.x * w, p2.y + dir.y * h);
return new Swipe(start, end);
}
}
完整游戏引擎
/**
* 水果忍者游戏主引擎
*/
public class FruitNinjaGame {
private static final double SCREEN_W = 800;
private static final double SCREEN_H = 600;
private static final int MAX_FRUITS = 8;
private List<Fruit> fruits;
private int score;
private int lives;
private int combo;
private Random random;
private int frameCount;
public FruitNinjaGame() {
this.fruits = new ArrayList<>();
this.score = 0;
this.lives = 3;
this.combo = 0;
this.random = new Random();
this.frameCount = 0;
}
/**
* 生成新水果
*/
private void spawnFruit() {
if (fruits.size() >= MAX_FRUITS) return;
double x = 100 + random.nextDouble() * 600;
double y = SCREEN_H + 20;
double vx = (random.nextDouble() - 0.5) * 8;
double vy = -(12 + random.nextDouble() * 6); // 向上抛
double radius = 20 + random.nextDouble() * 15;
Fruit.Type type;
double r = random.nextDouble();
if (r < 0.15) type = Fruit.Type.BOMB;
else if (r < 0.5) type = Fruit.Type.APPLE;
else if (r < 0.8) type = Fruit.Type.BANANA;
else type = Fruit.Type.WATERMELON;
fruits.add(new Fruit(x, y, vx, vy, radius, type));
}
/**
* AI自动切割
*/
public void aiSlice() {
Swipe swipe = ComboStrategy.findBestSwipe(fruits, SCREEN_W, SCREEN_H);
if (swipe == null) return;
int slicedCount = 0;
boolean hitBomb = false;
for (Fruit f : fruits) {
if (f.trySlice(swipe)) {
if (f.isBomb()) {
hitBomb = true;
} else {
slicedCount++;
}
}
}
if (hitBomb) {
lives--;
combo = 0;
System.out.println("BOOM! 切中炸弹,剩余生命: " + lives);
} else if (slicedCount > 0) {
combo += slicedCount;
int points = slicedCount * (slicedCount + 1) / 2;
score += points;
System.out.printf("切中 %d 个水果!+%d分 (连击x%d), 总分: %d%n",
slicedCount, points, combo, score);
}
}
/**
* 游戏主循环
*/
public void update() {
frameCount++;
// 每30帧生成一波水果
if (frameCount % 30 == 0) {
int count = 1 + random.nextInt(3);
for (int i = 0; i < count; i++) spawnFruit();
}
// AI切割
aiSlice();
// 物理更新
for (Fruit f : fruits) f.update();
// 清理掉出屏幕的水果(未切中的扣生命)
Iterator<Fruit> it = fruits.iterator();
while (it.hasNext()) {
Fruit f = it.next();
if (f.isOffScreen(SCREEN_W, SCREEN_H)) {
if (!f.isSliced() && !f.isBomb()) {
lives--;
combo = 0;
System.out.println("水果掉落!剩余生命: " + lives);
}
it.remove();
}
}
}
public boolean isGameOver() { return lives <= 0; }
public int getScore() { return score; }
public static void main(String[] args) {
FruitNinjaGame game = new FruitNinjaGame();
while (!game.isGameOver() && game.frameCount < 1000) {
game.update();
}
System.out.println("\n游戏结束!最终得分: " + game.getScore());
}
}
复杂度分析
| 模块 | 时间复杂度 | 说明 |
|---|---|---|
| 线段-圆碰撞 | O(1) |
固定次数算术运算 |
| 贪心最优滑动 | O(n²) |
枚举所有水果对,n为场上水果数 |
| 单帧更新 | O(n²) |
含滑动评估 |
| 空间复杂度 | O(n) |
水果列表存储 |
总结
水果忍者的核心算法将计算几何与贪心策略完美结合:线段-圆碰撞检测用投影法实现 O(1) 判定,贪心连击策略通过枚举水果对连线寻找最大覆盖方向。这种”几何检测 + 组合优化”的架构广泛应用于物理引擎、图形编辑器和自动驾驶感知系统中。