每日算法 — 使用java实现钢琴块:泊松过程建模与最优点击序列决策

引言

钢琴块(Piano Tiles)是一款考验玩家反应速度的经典音乐节奏游戏,黑色方块以不同速度从屏幕上方落下,玩家需要准确点击每一个黑块。本文将从概率论角度出发,用Java实现基于泊松过程(Poisson Process)的点击建模系统,分析玩家在连续高速点击下的最优决策策略,并给出完整的工程实现。

问题建模

游戏机制分析

在钢琴块游戏中:
– 黑块以固定速率从屏幕顶部生成并向下移动
– 玩家需要在黑块到达屏幕底部前点击
– 点击过早(黑块未进入有效区域)或过晚(黑块已滑出屏幕)均判定为失败
– 随着游戏进行,下落速度逐渐加快

泊松过程建模

将黑块到达屏幕底部判定线的事件建模为泊松过程:
– 事件发生率 λ:单位时间内到达判定线的黑块数量
– 相邻黑块到达时间间隔服从指数分布:P(T > t) = e^(-λt)
– 在时间段 [0, t] 内到达的黑块数量 N(t) ~ Poisson(λt)

核心算法:最优点击时机决策

基于泊松过程,我们可以计算:
1. 安全点击窗口:黑块在判定线上的有效持续时间
2. 最优点击时机:在安全窗口内使期望得分最大化的点击策略
3. 连击奖励优化:考虑连续正确点击的累积奖励机制

完整Java实现

package piano.tiles;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
 * 钢琴块 - 泊松过程建模与最优点击序列决策系统
 * 
 * 核心功能:
 * 1. 基于泊松过程模拟黑块生成与下落
 * 2. 计算最优点击时机,最大化期望得分
 * 3. 评估不同反应时间策略下的生存概率
 */
public class PianoTilesPoisson {

    private static final Random RANDOM = new Random();

    // 游戏参数
    private static final double SCREEN_HEIGHT = 800.0;      // 屏幕高度(像素)
    private static final double TILE_HEIGHT = 120.0;        // 方块高度
    private static final double JUDGMENT_ZONE = 80.0;       // 判定区域高度
    private static final double BASE_SPEED = 200.0;         // 基础下落速度(像素/秒)
    private static final double SPEED_INCREMENT = 15.0;     // 每10分增加的速度

    /**
     * 黑块类
     */
    public static class BlackTile {
        public final int track;             // 所在轨道(0-3)
        public final double spawnTime;      // 生成时间(秒)
        public final double fallSpeed;      // 下落速度(像素/秒)
        public boolean clicked;             // 是否已被点击
        public boolean missed;              // 是否已错过

        public BlackTile(int track, double spawnTime, double fallSpeed) {
            this.track = track;
            this.spawnTime = spawnTime;
            this.fallSpeed = fallSpeed;
            this.clicked = false;
            this.missed = false;
        }

        /**
         * 计算t时刻黑块的Y坐标
         */
        public double getY(double currentTime) {
            double elapsed = currentTime - spawnTime;
            return elapsed * fallSpeed;
        }

        /**
         * 检查黑块是否在有效判定区域内
         */
        public boolean isInJudgmentZone(double currentTime) {
            double y = getY(currentTime);
            // 判定区域:屏幕底部向上JUDGMENT_ZONE像素
            double zoneTop = SCREEN_HEIGHT - JUDGMENT_ZONE;
            return y >= zoneTop && y <= SCREEN_HEIGHT;
        }

        /**
         * 检查黑块是否已经滑出屏幕
         */
        public boolean isOffScreen(double currentTime) {
            return getY(currentTime) > SCREEN_HEIGHT + TILE_HEIGHT;
        }
    }

    /**
     * 玩家点击事件
     */
    public static class ClickEvent {
        public final double time;       // 点击时间
        public final int track;         // 点击的轨道
        public final double reactionDelay; // 反应延迟(秒)

        public ClickEvent(double time, int track, double reactionDelay) {
            this.time = time;
            this.track = track;
            this.reactionDelay = reactionDelay;
        }
    }

    /**
     * 泊松过程黑块生成器
     * 
     * @param lambda    黑块到达率(个/秒)
     * @param duration  游戏持续时间(秒)
     * @param baseSpeed 基础下落速度
     * @return 生成的黑块列表
     */
    public static List<BlackTile> generateTilesByPoisson(double lambda,
                                                          double duration,
                                                          double baseSpeed) {
        List<BlackTile> tiles = new ArrayList<>();
        double currentTime = 0;
        int score = 0;

        while (currentTime < duration) {
            // 指数分布:生成下一个黑块的时间间隔
            // P(T > t) = e^(-λt),因此 T = -ln(U) / λ,U~Uniform(0,1)
            double interval = -Math.log(1 - RANDOM.nextDouble()) / lambda;
            currentTime += interval;

            if (currentTime >= duration) break;

            // 随机选择轨道
            int track = RANDOM.nextInt(4);

            // 速度随分数递增
            double speed = baseSpeed + (score / 10) * SPEED_INCREMENT;

            tiles.add(new BlackTile(track, currentTime, speed));
            score++;
        }

        return tiles;
    }

    /**
     * 计算最优点击时机
     * 
     * 基于泊松过程的期望分析,最优策略是在黑块中心通过判定线时点击。
     * 但由于玩家反应延迟,需要提前预判。
     * 
     * @param tile      目标黑块
     * @param currentTime   当前时间
     * @param avgReaction   平均反应时间
     * @return 建议点击时间,若错过则返回-1
     */
    public static double calculateOptimalClickTime(BlackTile tile,
                                                    double currentTime,
                                                    double avgReaction) {
        // 黑块中心到达判定线中心的时间
        double judgmentCenter = SCREEN_HEIGHT - JUDGMENT_ZONE / 2.0;
        double timeToCenter = judgmentCenter / tile.fallSpeed;
        double optimalTime = tile.spawnTime + timeToCenter - avgReaction;

        // 如果最优时间已过,返回-1
        if (optimalTime < currentTime) {
            return -1;
        }

        return optimalTime;
    }

    /**
     * 基于生存概率的最优策略评估
     * 
     * 假设玩家反应时间服从正态分布 N(μ, σ²)
     * 计算在不同策略下的生存概率
     * 
     * @param tiles         黑块序列
     * @param meanReaction  平均反应时间(秒)
     * @param stdReaction   反应时间标准差
     * @return 生存概率(0-1)
     */
    public static double calculateSurvivalProbability(List<BlackTile> tiles,
                                                       double meanReaction,
                                                       double stdReaction) {
        int successCount = 0;
        int simulations = 10000;

        for (int sim = 0; sim < simulations; sim++) {
            boolean survived = true;

            for (BlackTile tile : tiles) {
                // 模拟本次点击的反应时间
                double reaction = meanReaction + stdReaction * randomGaussian();

                double judgmentCenter = SCREEN_HEIGHT - JUDGMENT_ZONE / 2.0;
                double timeToCenter = judgmentCenter / tile.fallSpeed;
                double clickTime = tile.spawnTime + timeToCenter - reaction;

                // 计算点击时刻黑块位置
                double clickY = tile.getY(clickTime);
                double zoneTop = SCREEN_HEIGHT - JUDGMENT_ZONE;

                // 判断是否命中
                if (clickY < zoneTop || clickY > SCREEN_HEIGHT) {
                    survived = false;
                    break;
                }
            }

            if (survived) successCount++;
        }

        return (double) successCount / simulations;
    }

    /**
     * Box-Muller变换生成标准正态分布随机数
     */
    private static double randomGaussian() {
        double u1 = RANDOM.nextDouble();
        double u2 = RANDOM.nextDouble();
        return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
    }

    /**
     * 最优点击序列生成器
     * 
     * 根据泊松过程的黑块到达序列,生成带预判的最优点击序列
     */
    public static List<ClickEvent> generateOptimalClicks(List<BlackTile> tiles,
                                                          double meanReaction) {
        List<ClickEvent> clicks = new ArrayList<>();

        for (BlackTile tile : tiles) {
            double judgmentCenter = SCREEN_HEIGHT - JUDGMENT_ZONE / 2.0;
            double timeToCenter = judgmentCenter / tile.fallSpeed;
            // 提前反应时间点击
            double clickTime = tile.spawnTime + timeToCenter - meanReaction;
            clicks.add(new ClickEvent(clickTime, tile.track, meanReaction));
        }

        return clicks;
    }

    /**
     * 运行一次完整的游戏模拟
     */
    public static GameResult simulateGame(double lambda,
                                           double duration,
                                           double meanReaction,
                                           double stdReaction) {
        List<BlackTile> tiles = generateTilesByPoisson(lambda, duration, BASE_SPEED);
        List<ClickEvent> clicks = generateOptimalClicks(tiles, meanReaction);

        int perfect = 0, good = 0, miss = 0;
        double totalScore = 0;

        for (int i = 0; i < tiles.size(); i++) {
            BlackTile tile = tiles.get(i);
            ClickEvent click = clicks.get(i);

            // 模拟实际反应时间的波动
            double actualDelay = meanReaction + stdReaction * randomGaussian();
            double actualClickTime = click.time + (actualDelay - meanReaction);

            double clickY = tile.getY(actualClickTime);
            double zoneTop = SCREEN_HEIGHT - JUDGMENT_ZONE;
            double zoneCenter = SCREEN_HEIGHT - JUDGMENT_ZONE / 2.0;

            if (clickY < zoneTop || clickY > SCREEN_HEIGHT) {
                miss++;
            } else {
                double deviation = Math.abs(clickY - zoneCenter);
                if (deviation < JUDGMENT_ZONE * 0.2) {
                    perfect++;
                    totalScore += 1.0;
                } else {
                    good++;
                    totalScore += 0.6;
                }
            }
        }

        return new GameResult(tiles.size(), perfect, good, miss, totalScore);
    }

    public static class GameResult {
        public final int totalTiles;
        public final int perfect;
        public final int good;
        public final int miss;
        public final double score;

        public GameResult(int total, int perfect, int good, int miss, double score) {
            this.totalTiles = total;
            this.perfect = perfect;
            this.good = good;
            this.miss = miss;
            this.score = score;
        }
    }

    // ==================== 测试入口 ====================
    public static void main(String[] args) {
        double lambda = 2.5;            // 每秒2.5个黑块
        double duration = 30.0;         // 30秒游戏
        double meanReaction = 0.18;     // 平均反应180ms
        double stdReaction = 0.04;      // 标准差40ms

        System.out.println("===== 钢琴块 泊松过程建模 =====");
        System.out.printf("参数: λ=%.1f个/秒, 时长=%.0fs, 反应时间=%.0f±%.0fms%n%n",
            lambda, duration, meanReaction * 1000, stdReaction * 1000);

        // 生成黑块序列
        List<BlackTile> tiles = generateTilesByPoisson(lambda, duration, BASE_SPEED);
        System.out.printf("生成黑块数量: %d%n", tiles.size());
        System.out.printf("平均间隔: %.2fs%n%n", duration / tiles.size());

        // 计算生存概率
        double survivalProb = calculateSurvivalProbability(tiles, meanReaction, stdReaction);
        System.out.printf("生存概率(%.0f次模拟): %.2f%%%n%n", 10000.0, survivalProb * 100);

        // 运行完整游戏模拟
        GameResult result = simulateGame(lambda, duration, meanReaction, stdReaction);
        System.out.println("===== 单次游戏模拟结果 =====");
        System.out.printf("总黑块: %d | Perfect: %d | Good: %d | Miss: %d%n",
            result.totalTiles, result.perfect, result.good, result.miss);
        System.out.printf("得分: %.1f / %.1f (%.1f%%)%n",
            result.score, (double) result.totalTiles,
            result.score / result.totalTiles * 100);
    }
}

算法原理深入

泊松过程的核心性质

  1. 独立增量性:不相交时间区间内的黑块到达数量相互独立
  2. 稀有性:在极短的时间间隔内,最多只有一个黑块到达
  3. 指数分布:相邻黑块到达时间间隔服从指数分布

最优策略推导

设玩家反应时间服从 N(μ, σ²),黑块通过判定线的时间为 t₀。最优点击时间为:

t_click = t₀ - μ

这是因为在反应时间的期望意义下,提前平均反应时间点击,能够使实际点击时刻的期望恰好落在判定线中心。

生存概率计算

对于第 n 个黑块,成功点击的概率为:

P(success) = P(t₀ - μ - σZ ∈ [t_zone_start, t_zone_end])
           = Φ((t_zone_end - t₀ + μ)/σ) - Φ((t_zone_start - t₀ + μ)/σ)

其中 Φ 为标准正态分布的CDF。连续 N 个黑块的生存概率约为 P(success)^N。

复杂度分析

指标 复杂度 说明
黑块生成 O(k) k为生成的黑块数量,由 λ×duration 决定
最优序列计算 O(k) 遍历黑块序列计算点击时间
生存概率模拟 O(k×S) S为模拟次数,Monte Carlo方法
空间复杂度 O(k) 存储黑块列表和点击序列

扩展应用

  1. 自适应难度调整:根据玩家的历史反应时间分布动态调整 λ 和下落速度
  2. 网络延迟补偿:在多人在线模式中,利用泊松过程的预测性质补偿网络延迟
  3. 连击判定优化:将连击奖励纳入期望得分函数,优化长期策略而非单步最优

总结

本文用Java实现了基于泊松过程的钢琴块游戏建模系统。通过将黑块到达事件建模为泊松过程,我们能够:
– 模拟符合实际分布的黑块序列
– 计算带反应延迟的最优点击时机
– 通过Monte Carlo方法评估不同策略的生存概率

泊松过程作为排队论和随机过程的基石,在交通流量预测、网络请求建模、客户服务系统等领域有着广泛的应用价值。