每日算法 — 使用java实现抓娃娃:物理碰撞检测与贪心抓取策略

引言

抓娃娃机是游乐场和商场中最常见的游戏设备之一。本文将用Java实现一个简化版的抓娃娃游戏,重点讲解其中的核心算法:圆形与矩形的碰撞检测、爪子张合角度计算,以及基于贪心策略的最优抓取目标选择。读者不仅能理解物理碰撞的基本原理,还能掌握如何将几何算法应用到游戏开发中。

问题建模

抓娃娃游戏可以抽象为以下核心问题:

  1. 娃娃表示:每个娃娃用一个圆形表示,包含圆心坐标 (x, y) 和半径 r
  2. 爪子表示:爪子由两个对称的夹臂组成,每个夹臂是一条线段,张开角度决定抓取范围
  3. 碰撞检测:判断爪子是否与某个娃娃发生有效碰撞(即娃娃进入爪子闭合区域)
  4. 目标选择:在多个娃娃中选择最容易被抓取的目标

核心算法一:圆形与线段的碰撞检测

爪子夹臂可视为线段,判断娃娃(圆)是否与线段相交是抓取检测的基础。

/**
 * 抓娃娃游戏核心算法实现
 */
public class ClawMachineGame {

    /**
     * 二维点
     */
    static class Point {
        double x, y;
        Point(double x, double y) { this.x = x; this.y = y; }
    }

    /**
     * 圆形娃娃
     */
    static class Doll {
        Point center;   // 圆心
        double radius;  // 半径
        double weight;  // 重量(影响抓取难度)
        String name;

        Doll(double x, double y, double r, double w, String name) {
            this.center = new Point(x, y);
            this.radius = r;
            this.weight = w;
            this.name = name;
        }
    }

    /**
     * 计算点到线段的最近距离
     * 这是碰撞检测的核心:圆与线段相交当且仅当圆心到线段距离 <= 半径
     */
    public static double pointToSegmentDistance(Point p, Point a, Point b) {
        // 向量 AP 和 AB
        double apx = p.x - a.x;
        double apy = p.y - a.y;
        double abx = b.x - a.x;
        double aby = b.y - a.y;

        // 计算投影参数 t = (AP · AB) / |AB|^2
        double abLenSq = abx * abx + aby * aby;

        // 如果线段退化为点,直接返回点到点距离
        if (abLenSq < 1e-10) {
            return Math.sqrt(apx * apx + apy * apy);
        }

        double t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));

        // 最近点坐标 = A + t * AB
        double closestX = a.x + t * abx;
        double closestY = a.y + t * aby;

        // 返回点P到最近点的距离
        double dx = p.x - closestX;
        double dy = p.y - closestY;
        return Math.sqrt(dx * dx + dy * dy);
    }

    /**
     * 判断圆形是否与线段相交
     */
    public static boolean circleIntersectsSegment(Point center, double radius,
                                                   Point a, Point b) {
        return pointToSegmentDistance(center, a, b) <= radius;
    }

核心算法二:爪子几何模型与闭合检测

    /**
     * 爪子类:由两个对称夹臂组成
     */
    static class Claw {
        Point pivot;      // 爪子旋转中心(悬吊点)
        double length;    // 夹臂长度
        double angle;     // 爪子朝向角度(弧度)
        double openAngle; // 张角(弧度,0表示完全闭合)

        Claw(double x, double y, double len) {
            this.pivot = new Point(x, y);
            this.length = len;
            this.angle = 0;
            this.openAngle = Math.PI / 3; // 默认张开60度
        }

        /**
         * 计算左右夹臂的端点坐标
         * 左臂:pivot -> 沿 (angle - openAngle/2) 方向延伸 length
         * 右臂:pivot -> 沿 (angle + openAngle/2) 方向延伸 length
         */
        public Point getLeftTip() {
            double leftAngle = angle - openAngle / 2;
            return new Point(
                pivot.x + length * Math.sin(leftAngle),
                pivot.y + length * Math.cos(leftAngle)
            );
        }

        public Point getRightTip() {
            double rightAngle = angle + openAngle / 2;
            return new Point(
                pivot.x + length * Math.sin(rightAngle),
                pivot.y + length * Math.cos(rightAngle)
            );
        }

        /**
         * 判断爪子是否能抓住某个娃娃
         * 条件:娃娃圆心到两个夹臂的距离均小于娃娃半径,且娃娃位于爪子下方区域
         */
        public boolean canGrab(Doll doll) {
            Point leftTip = getLeftTip();
            Point rightTip = getRightTip();

            // 娃娃必须同时与两个夹臂相交(进入闭合区域)
            boolean touchesLeft = circleIntersectsSegment(
                doll.center, doll.radius, pivot, leftTip);
            boolean touchesRight = circleIntersectsSegment(
                doll.center, doll.radius, pivot, rightTip);

            // 娃娃必须在爪子下方(y坐标更大,假设y轴向下)
            boolean belowPivot = doll.center.y > pivot.y;

            return touchesLeft && touchesRight && belowPivot;
        }

        /**
         * 闭合爪子:减小张角
         */
        public void close(double delta) {
            this.openAngle = Math.max(0, this.openAngle - delta);
        }
    }

核心算法三:贪心策略选择最优抓取目标

    /**
     * 贪心策略:选择最容易被抓取的娃娃
     * 评估函数综合考虑:距离(越近越好)、重量(越轻越好)、周围拥挤度
     */
    public static Doll selectBestTarget(Claw claw, Doll[] dolls) {
        Doll best = null;
        double bestScore = Double.NEGATIVE_INFINITY;

        for (Doll doll : dolls) {
            // 计算爪子到娃娃的距离
            double dist = Math.sqrt(
                Math.pow(doll.center.x - claw.pivot.x, 2) +
                Math.pow(doll.center.y - claw.pivot.y, 2)
            );

            // 计算周围拥挤度:附近其他娃娃越多越难抓
            double crowding = 0;
            for (Doll other : dolls) {
                if (other == doll) continue;
                double d = Math.sqrt(
                    Math.pow(doll.center.x - other.center.x, 2) +
                    Math.pow(doll.center.y - other.center.y, 2)
                );
                if (d < doll.radius * 4) {
                    crowding += 1.0 / (d + 1);
                }
            }

            // 综合评分:距离近加分,重量轻加分,拥挤度低加分
            double score = -dist * 0.5 - doll.weight * 2 - crowding * 10;

            if (score > bestScore) {
                bestScore = score;
                best = doll;
            }
        }
        return best;
    }

    /**
     * 移动爪子到目标正上方(简化版,实际可用PID控制或匀速移动)
     */
    public static void moveClawAboveTarget(Claw claw, Doll target) {
        claw.pivot.x = target.center.x;
        // 保持一定高度
        claw.pivot.y = target.center.y - claw.length * 0.8;
    }

    /**
     * 主游戏流程演示
     */
    public static void main(String[] args) {
        // 初始化娃娃:随机放置在娃娃机底部区域
        Doll[] dolls = {
            new Doll(30, 80, 5, 1.0, "小熊"),
            new Doll(50, 85, 6, 1.5, "大熊"),
            new Doll(70, 82, 4, 0.8, "小兔"),
            new Doll(45, 90, 5, 1.2, "小狗"),
            new Doll(60, 88, 4.5, 0.9, "小猫")
        };

        // 初始化爪子
        Claw claw = new Claw(50, 20, 25);

        System.out.println("=== 抓娃娃游戏开始 ===");

        // 使用贪心策略选择目标
        Doll target = selectBestTarget(claw, dolls);
        System.out.println("贪心策略选择目标: " + target.name +
                           " (位置: " + target.center.x + ", " + target.center.y +
                           ", 重量: " + target.weight + ")");

        // 移动爪子
        moveClawAboveTarget(claw, target);
        System.out.println("爪子移动到: (" + claw.pivot.x + ", " + claw.pivot.y + ")");

        // 模拟下降并抓取
        claw.pivot.y = target.center.y - 5;
        System.out.println("爪子下降...");

        // 闭合爪子
        claw.close(Math.PI / 3);
        System.out.println("爪子闭合,张角: " + Math.toDegrees(claw.openAngle) + "°");

        // 检测是否抓住
        boolean grabbed = claw.canGrab(target);
        System.out.println("抓取结果: " + (grabbed ? "成功抓住 " + target.name + "!" : "抓取失败..."));

        // 展示碰撞检测原理
        System.out.println("\n=== 碰撞检测原理验证 ===");
        Point leftTip = claw.getLeftTip();
        Point rightTip = claw.getRightTip();
        double distLeft = pointToSegmentDistance(target.center, claw.pivot, leftTip);
        double distRight = pointToSegmentDistance(target.center, claw.pivot, rightTip);
        System.out.println("娃娃圆心到左臂距离: " + String.format("%.4f", distLeft) +
                           " (半径=" + target.radius + ")");
        System.out.println("娃娃圆心到右臂距离: " + String.format("%.4f", distRight) +
                           " (半径=" + target.radius + ")");
    }
}

复杂度分析

  • 碰撞检测:判断圆与线段相交的时间复杂度为 O(1),只需常数次向量运算
  • 目标选择:遍历所有娃娃计算评分,时间复杂度为 O(n²),其中 n 为娃娃数量(因为每个娃娃都要计算与其他所有娃娃的拥挤度)。可优化为 O(n log n) 使用空间索引
  • 空间复杂度:O(n) 存储娃娃信息

扩展思考

  1. 空间索引优化:当娃娃数量很多时,可用四叉树或网格划分加速邻近查询,将目标选择的拥挤度计算从 O(n²) 降到 O(n log n)
  2. 物理模拟增强:可引入重力、惯性、爪子摆动等物理效果,使用Verlet积分或欧拉方法更新位置
  3. 机器学习策略:训练强化学习模型(如DQN)替代贪心策略,学习更优的抓取时机和角度
  4. 多爪协同:扩展为多个爪子同时工作的场景,引入任务分配算法

总结

本文通过Java实现了抓娃娃游戏的核心算法,包括圆与线段的碰撞检测、爪子几何模型和贪心目标选择策略。碰撞检测使用向量投影计算点到线段的最短距离,目标选择则综合距离、重量和拥挤度三个维度进行贪心决策。这些算法在游戏开发、机器人抓取和几何计算领域都有广泛应用。