每日算法 — 使用java实现娃娃机:几何抓取规划与概率控制算法

引言

娃娃机(Claw Machine)是商场与游乐场中常见的娱乐设备,玩家通过操控机械爪来抓取奖品。表面上看这是纯运气游戏,但背后的算法设计却涉及几何抓取规划、概率控制与期望价值计算。本文将用Java实现一个娃娃机模拟系统,重点讲解爪子与奖品的碰撞检测、最优抓取姿态计算,以及商家常用的概率控制策略。

核心算法一:几何抓取规划

问题建模

将三维娃娃机简化为二维俯视图(或侧视图截面),系统包含以下几何对象:
– 机械爪(Claw):由三个可开合的夹指组成,夹指末端为圆形碰撞体
– 奖品(Prize):简化为圆形或矩形刚体
– 投放口(Drop Zone):矩形目标区域

抓取成功的判定条件:
1. 爪子下降过程中与奖品发生有效碰撞
2. 闭合时夹指包围范围覆盖奖品重心
3. 提升过程中奖品不滑落(基于摩擦与重心稳定性)

夹指-奖品碰撞检测

每个夹指简化为一条带圆帽的线段(capsule)。检测线段与圆形奖品的相交:

import java.util.*;

/**
 * 二维向量与几何工具
 */
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 lenSq() { return x * x + y * y; }
    public double len() { return Math.sqrt(lenSq()); }
    public Vec2 normalize() {
        double l = len();
        return l < 1e-9 ? new Vec2(0, 0) : new Vec2(x / l, y / l);
    }
    public Vec2 rotate(double angle) {
        double cos = Math.cos(angle), sin = Math.sin(angle);
        return new Vec2(x * cos - y * sin, x * sin + y * cos);
    }
}

/**
 * 圆形奖品
 */
class Prize {
    Vec2 pos;       // 重心位置
    double radius;  // 外接圆半径
    double mass;    // 质量(影响滑落概率)
    double friction;// 表面摩擦系数
    boolean grabbed;// 是否被抓住

    public Prize(double x, double y, double r, double m, double f) {
        this.pos = new Vec2(x, y);
        this.radius = r;
        this.mass = m;
        this.friction = f;
        this.grabbed = false;
    }
}

/**
 * 机械爪夹指(capsule碰撞体)
 */
class ClawFinger {
    Vec2 base;      // 根部连接点(相对于爪子中心)
    Vec2 tip;       // 末端位置
    double tipRadius = 8; // 末端圆帽半径
    double maxLength = 60; // 夹指最大延伸长度

    public ClawFinger(Vec2 base) {
        this.base = base;
        this.tip = base;
    }

    /**
     * 根据开合角度更新末端位置
     * @param openAngle 开合角度(0=完全闭合,60度=完全张开)
     */
    public void updateTip(double openAngle) {
        Vec2 dir = base.normalize().rotate(openAngle * Math.PI / 180);
        tip = base.add(dir.mul(maxLength));
    }
}

/**
 * 几何碰撞检测器
 */
class GeometryUtils {
    /**
     * 计算点到线段的最短距离平方
     * @param p 点
     * @param a 线段起点
     * @param b 线段终点
     */
    public static double pointToSegmentDistSq(Vec2 p, Vec2 a, Vec2 b) {
        Vec2 ab = b.sub(a);
        Vec2 ap = p.sub(a);
        double t = Math.max(0, Math.min(1, ap.dot(ab) / ab.lenSq()));
        Vec2 closest = a.add(ab.mul(t));
        return p.sub(closest).lenSq();
    }

    /**
     * 检测capsule(线段+圆帽)与圆的碰撞
     */
    public static boolean capsuleCircleCollision(ClawFinger finger,
                                                  Vec2 clawCenter,
                                                  Prize prize) {
        Vec2 a = clawCenter.add(finger.base);
        Vec2 b = clawCenter.add(finger.tip);
        double distSq = pointToSegmentDistSq(prize.pos, a, b);
        double totalRadius = finger.tipRadius + prize.radius;
        return distSq <= totalRadius * totalRadius;
    }

    /**
     * 计算夹指对奖品的包围度(0~1,越高越稳定)
     * 通过计算奖品重心到三夹指形成三角形的重心距离来评估
     */
    public static double calcGraspStability(ClawFinger[] fingers,
                                             Vec2 clawCenter,
                                             Prize prize) {
        Vec2[] tips = new Vec2[3];
        for (int i = 0; i < 3; i++) {
            tips[i] = clawCenter.add(fingers[i].tip);
        }
        // 三夹指末端形成三角形的重心
        Vec2 triCenter = tips[0].add(tips[1]).add(tips[2]).mul(1.0 / 3.0);
        double dist = prize.pos.sub(triCenter).len();
        // 归一化:距离越小包围度越高
        return Math.max(0, 1 - dist / 50.0);
    }
}

核心算法二:概率控制与期望价值

商家通常不会让娃娃机成为纯粹的技巧游戏,而是通过概率控制调节玩家的胜率。常见策略包括:

  1. 力度控制:爪子闭合力度按概率分布,有时强有时弱
  2. 滑落控制:即使抓住,提升阶段也有概率滑落
  3. 强抓周期:每N次中设置1次”必中”机会(强力爪)

概率模型

定义三阶段成功率:
– $P_{grab}$:闭合阶段成功夹住奖品的概率(与包围度正相关,但受商家设定上限)
– $P_{hold}$:提升阶段保持不掉的概率(与奖品摩擦、质量相关)
– $P_{win}$:最终获胜概率 = $P_{grab} \times P_{hold}$

商家通过调整 $P_{win}$ 的目标期望值来控制盈利。

import java.util.Random;

/**
 * 娃娃机概率控制器
 */
class ProbabilityController {
    private Random rand = new Random();
    private double targetWinRate;   // 目标胜率(如0.15表示15%)
    private int totalPlays = 0;     // 总游戏次数
    private int totalWins = 0;      // 总获胜次数
    private int forcedWinCounter;   // 强制必中计数器
    private int forcedWinInterval;  // 强制必中间隔

    public ProbabilityController(double targetRate, int forcedInterval) {
        this.targetWinRate = targetRate;
        this.forcedWinInterval = forcedInterval;
        this.forcedWinCounter = rand.nextInt(forcedInterval);
    }

    /**
     * 判定本次游戏是否为强制必中
     */
    public boolean isForcedWin() {
        forcedWinCounter++;
        if (forcedWinCounter >= forcedWinInterval) {
            forcedWinCounter = 0;
            return true;
        }
        return false;
    }

    /**
     * 计算当前闭合力度系数(0~1)
     * @param graspStability 几何包围度(0~1)
     */
    public double calcGripStrength(double graspStability) {
        if (isForcedWin()) {
            return 1.0; // 强力爪:最大力度
        }

        // 动态调整:当前胜率低于目标时适当提高力度
        double currentRate = totalPlays > 0 ? (double) totalWins / totalPlays : 0;
        double adjustment = Math.max(0, (targetWinRate - currentRate) * 2);

        // 基础力度 = 包围度 * (基础系数 + 动态调整)
        double baseStrength = graspStability * (0.3 + adjustment);
        return Math.min(1.0, baseStrength);
    }

    /**
     * 判定提升阶段是否滑落
     * @param prize 奖品属性
     * @param gripStrength 当前闭合力度
     */
    public boolean doesPrizeSlip(Prize prize, double gripStrength) {
        // 滑落概率 = (1 - 力度) * 质量因子 * (1 - 摩擦系数)
        double massFactor = Math.min(1.0, prize.mass / 2.0);
        double slipProb = (1 - gripStrength) * massFactor * (1 - prize.friction);
        return rand.nextDouble() < slipProb;
    }

    public void recordResult(boolean win) {
        totalPlays++;
        if (win) totalWins++;
    }

    public double getActualWinRate() {
        return totalPlays > 0 ? (double) totalWins / totalPlays : 0;
    }
}

核心算法三:最优抓取姿态搜索

对于自动操控或辅助瞄准系统,需要搜索最佳的爪子下落位置与角度。采用网格采样+评分函数策略:

/**
 * 最优抓取姿态搜索器
 */
class GraspPlanner {
    private static final int GRID_SIZE = 10; // 10x10网格采样

    /**
     * 在候选区域内搜索最优下落位置
     * @param prizes 所有奖品列表
     * @param clawRange 爪子可移动范围(矩形)
     * @return 最优爪子中心位置
     */
    public Vec2 findBestDropPosition(List<Prize> prizes,
                                      double[] clawRange) {
        double minX = clawRange[0], maxX = clawRange[1];
        double minY = clawRange[2], maxY = clawRange[3];
        double bestScore = -1;
        Vec2 bestPos = new Vec2((minX + maxX) / 2, (minY + maxY) / 2);

        for (int i = 0; i < GRID_SIZE; i++) {
            for (int j = 0; j < GRID_SIZE; j++) {
                double x = minX + (maxX - minX) * i / (GRID_SIZE - 1);
                double y = minY + (maxY - minY) * j / (GRID_SIZE - 1);
                Vec2 pos = new Vec2(x, y);
                double score = evaluatePosition(pos, prizes);
                if (score > bestScore) {
                    bestScore = score;
                    bestPos = pos;
                }
            }
        }
        return bestPos;
    }

    /**
     * 评分函数:综合考虑可达奖品数量、价值、抓取稳定性
     */
    private double evaluatePosition(Vec2 pos, List<Prize> prizes) {
        double score = 0;
        ClawFinger[] fingers = createFingers(30); // 30度张开

        for (Prize p : prizes) {
            // 距离因子:越近越好
            double dist = p.pos.sub(pos).len();
            if (dist > 100) continue; // 太远不可达

            double distScore = 1 - dist / 100.0;

            // 计算该位置对此奖品的包围度
            double stability = GeometryUtils.calcGraspStability(fingers, pos, p);

            // 价值加权(假设质量越大的奖品越有价值)
            double value = p.mass;

            score += distScore * stability * value;
        }
        return score;
    }

    private ClawFinger[] createFingers(double openAngle) {
        ClawFinger[] fingers = new ClawFinger[3];
        for (int i = 0; i < 3; i++) {
            double baseAngle = i * 2 * Math.PI / 3;
            fingers[i] = new ClawFinger(
                new Vec2(Math.cos(baseAngle) * 10, Math.sin(baseAngle) * 10)
            );
            fingers[i].updateTip(openAngle);
        }
        return fingers;
    }
}

完整游戏流程控制

/**
 * 娃娃机游戏主控
 */
public class ClawMachineGame {
    private List<Prize> prizes = new ArrayList<>();
    private Vec2 clawPos = new Vec2(0, -150);
    private double clawZ = 0; // 高度:0=顶部,-100=底部
    private ClawFinger[] fingers = new ClawFinger[3];
    private ProbabilityController probCtrl;
    private GraspPlanner planner;
    private GameState state = GameState.IDLE;
    private Prize grabbedPrize = null;

    enum GameState { IDLE, DROPPING, GRABBING, LIFTING, MOVING_TO_DROP, RELEASING }

    public ClawMachineGame() {
        probCtrl = new ProbabilityController(0.20, 8); // 20%目标胜率,每8次强制1次
        planner = new GraspPlanner();
        initFingers();
        spawnPrizes();
    }

    private void initFingers() {
        for (int i = 0; i < 3; i++) {
            double angle = i * 2 * Math.PI / 3;
            fingers[i] = new ClawFinger(
                new Vec2(Math.cos(angle) * 10, Math.sin(angle) * 10)
            );
        }
    }

    private void spawnPrizes() {
        // 随机生成奖品
        for (int i = 0; i < 8; i++) {
            prizes.add(new Prize(
                (Math.random() - 0.5) * 200,
                (Math.random() - 0.5) * 100 + 50,
                15 + Math.random() * 15,
                0.5 + Math.random() * 1.5,
                0.3 + Math.random() * 0.4
            ));
        }
    }

    /**
     * 执行一次完整的自动抓取流程
     */
    public boolean playAutoRound() {
        // 1. 规划最优位置
        Vec2 bestPos = planner.findBestDropPosition(prizes,
            new double[]{-150, 150, -100, 100});
        clawPos = bestPos;

        // 2. 下降阶段
        state = GameState.DROPPING;
        clawZ = -100;

        // 3. 检测碰撞,选择目标奖品
        Prize target = findBestTarget();
        if (target == null) {
            state = GameState.IDLE;
            probCtrl.recordResult(false);
            return false;
        }

        // 4. 闭合阶段
        state = GameState.GRABBING;
        double stability = GeometryUtils.calcGraspStability(fingers, clawPos, target);
        double gripStrength = probCtrl.calcGripStrength(stability);

        // 5. 提升阶段:判定是否滑落
        state = GameState.LIFTING;
        clawZ = 0;
        boolean slip = probCtrl.doesPrizeSlip(target, gripStrength);

        if (!slip && gripStrength > 0.5) {
            // 成功抓取
            grabbedPrize = target;
            prizes.remove(target);
            state = GameState.MOVING_TO_DROP;
            state = GameState.RELEASING;
            probCtrl.recordResult(true);
            return true;
        } else {
            // 抓取失败
            state = GameState.IDLE;
            probCtrl.recordResult(false);
            return false;
        }
    }

    private Prize findBestTarget() {
        Prize best = null;
        double bestScore = -1;
        for (Prize p : prizes) {
            double dist = p.pos.sub(clawPos).len();
            if (dist > 80) continue;
            double score = GeometryUtils.calcGraspStability(fingers, clawPos, p);
            if (score > bestScore) {
                bestScore = score;
                best = p;
            }
        }
        return best;
    }
}

算法复杂度分析

模块 时间复杂度 空间复杂度 说明
碰撞检测 $O(n)$ $O(1)$ n为奖品数量,每帧检测所有候选
包围度计算 $O(1)$ $O(1)$ 固定3个夹指,常数时间
概率判定 $O(1)$ $O(1)$ 随机数生成与比较
姿态搜索 $O(k^2 \cdot n)$ $O(1)$ k×k网格,每个位置评估n个奖品

扩展思路

  1. 三维扩展:引入3D碰撞检测(球体与圆柱体/胶囊体),爪子角度增加俯仰与偏航维度
  2. 机器学习辅助:用神经网络预测不同奖品的最优抓取姿态,通过大量仿真数据训练
  3. 玩家心理模型:根据玩家历史行为动态调整概率曲线,维持”差一点就抓到”的体验

总结

本文实现了一个娃娃机模拟系统,核心算法涵盖:
– Capsule-圆形碰撞检测,将夹指建模为线段+圆帽进行高效相交判定
– 概率控制模型,通过目标胜率、动态调整和强制必中周期来平衡游戏性与商业收益
– 网格采样搜索,在离散候选位置中寻找最优下落点

这些算法不仅适用于娃娃机,其碰撞检测框架可复用于任意胶囊体与圆的相交场景,概率控制思想亦可迁移至抽奖、抽卡等系统的掉落率设计中。