每日算法 — 使用java实现钢琴块:节奏判定算法与动态规划最优按键序列

钢琴块(别踩白块儿)是一款考验反应速度与节奏感的音乐游戏,黑色方块随音乐节奏从屏幕上方落下,玩家需要在正确时机点击黑色方块。本文用Java实现钢琴块游戏的核心算法,重点讲解节奏判定算法、音符时序映射与动态规划最优按键序列,帮助读者理解实时判定系统与序列优化策略。

一、游戏规则与算法建模

钢琴块的核心机制包含三个要素:

  1. 音符序列:每首歌曲对应一组带时间戳的音符,表示何时哪个轨道出现黑块;
  2. 判定窗口:玩家点击时机与理想时机的偏差决定了判定等级(完美/良好/错过);
  3. 得分系统:根据判定等级累计得分,连击数越高加分越多。

从算法角度,我们需要解决两个核心问题:一是将BPM(每分钟节拍数)映射为具体的方块下落时序;二是根据玩家历史表现,用动态规划预测最优按键策略,最大化期望得分。

二、音符时序映射与BPM转换

首先实现音符数据结构与BPM到时序的转换:

import java.util.*;

/**
 * 钢琴块游戏核心算法实现
 * 包含节奏判定、音符映射与动态规划最优策略
 */
public class PianoTiles {

    // 轨道数(4列钢琴块)
    private static final int LANES = 4;

    // 判定窗口(毫秒)
    private static final int PERFECT_WINDOW = 50;   // ±50ms完美
    private static final int GOOD_WINDOW = 120;     // ±120ms良好
    private static final int MISS_WINDOW = 200;     // ±200ms内算点击

    // 屏幕参数
    private static final int SCREEN_HEIGHT = 800;   // 屏幕高度(像素)
    private static final int TILE_SPEED = 400;      // 方块下落速度(像素/秒)

    // 音符列表
    private List<Note> notes;

    public PianoTiles() {
        this.notes = new ArrayList<>();
    }

    /**
     * 音符类:表示一个需要点击的方块
     */
    public static class Note {
        int lane;           // 所在轨道 0-3
        long idealTime;     // 理想点击时间(毫秒,相对于歌曲开始)
        long spawnTime;     // 生成时间(提前计算)
        boolean hit;        // 是否已被点击
        int judgment;       // 判定结果:0=未判定, 1=完美, 2=良好, 3=错过

        public Note(int lane, long idealTime) {
            this.lane = lane;
            this.idealTime = idealTime;
            // 方块需要提前生成,计算生成时间
            // 下落距离 = SCREEN_HEIGHT,时间 = 距离 / 速度
            long fallTime = (long)(SCREEN_HEIGHT * 1000.0 / TILE_SPEED);
            this.spawnTime = idealTime - fallTime;
            this.hit = false;
            this.judgment = 0;
        }
    }

    /**
     * 从BPM和节拍序列生成音符时序
     * @param bpm 每分钟节拍数
     * @param beatPattern 节拍模式,每个元素表示该拍哪个轨道有音符(-1表示无)
     */
    public void generateFromBPM(int bpm, int[] beatPattern) {
        notes.clear();
        long beatInterval = (long)(60000.0 / bpm); // 每拍间隔(毫秒)

        for (int i = 0; i < beatPattern.length; i++) {
            if (beatPattern[i] >= 0 && beatPattern[i] < LANES) {
                long idealTime = i * beatInterval;
                notes.add(new Note(beatPattern[i], idealTime));
            }
        }
        // 按理想时间排序
        notes.sort(Comparator.comparingLong(n -> n.idealTime));
    }

    /**
     * 获取当前时刻需要显示的音符
     * @param currentTime 当前游戏时间(毫秒)
     */
    public List<Note> getVisibleNotes(long currentTime) {
        List<Note> visible = new ArrayList<>();
        for (Note note : notes) {
            if (!note.hit && note.spawnTime <= currentTime && currentTime <= note.idealTime + MISS_WINDOW) {
                visible.add(note);
            }
        }
        return visible;
    }

三、节奏判定算法

判定算法根据玩家点击时间与理想时间的偏差给出评级:

    /**
     * 判定玩家点击
     * @param lane 点击的轨道
     * @param currentTime 点击时间
     * @return 判定结果字符串
     */
    public String judgeHit(int lane, long currentTime) {
        // 找到该轨道上最接近当前时间的未点击音符
        Note target = null;
        long minDiff = Long.MAX_VALUE;

        for (Note note : notes) {
            if (note.lane == lane && !note.hit) {
                long diff = Math.abs(note.idealTime - currentTime);
                if (diff < minDiff) {
                    minDiff = diff;
                    target = note;
                }
            }
        }

        if (target == null) {
            return "NO_NOTE"; // 该轨道没有可点击的音符
        }

        // 判定
        if (minDiff <= PERFECT_WINDOW) {
            target.hit = true;
            target.judgment = 1;
            return "PERFECT";
        } else if (minDiff <= GOOD_WINDOW) {
            target.hit = true;
            target.judgment = 2;
            return "GOOD";
        } else if (minDiff <= MISS_WINDOW) {
            target.hit = true;
            target.judgment = 3;
            return "MISS";
        } else {
            return "NO_HIT"; // 偏差太大,不算点击
        }
    }

四、动态规划最优按键序列

考虑玩家反应时间存在波动,我们使用动态规划来规划最优按键序列。假设玩家在每个音符上的反应时间服从某个概率分布,目标是最大化期望总得分。

    /**
     * 动态规划求解最优按键序列
     * 状态:dp[i][j] = 处理到第i个音符,当前连击数为j时的最大期望得分
     * 
     * @param reactionDist 反应时间概率分布(离散化)
     * @return 最大期望得分
     */
    public double optimalSequenceDP(double[] reactionDist) {
        int n = notes.size();
        if (n == 0) return 0;

        // dp[j] 表示当前连击数为j时的最大期望得分
        // 由于连击数理论上无上限,实际中限制最大连击数
        int MAX_COMBO = 100;
        double[] dp = new double[MAX_COMBO + 1];
        double[] newDp = new double[MAX_COMBO + 1];

        // 得分权重
        int PERFECT_SCORE = 100;
        int GOOD_SCORE = 50;
        int MISS_SCORE = 0;

        for (int i = 0; i < n; i++) {
            Arrays.fill(newDp, Double.NEGATIVE_INFINITY);

            for (int combo = 0; combo <= MAX_COMBO; combo++) {
                if (dp[combo] == Double.NEGATIVE_INFINITY) continue;

                double expectedScore = 0;
                // 遍历所有可能的反应时间
                for (int rt = 0; rt < reactionDist.length; rt++) {
                    double prob = reactionDist[rt];
                    if (prob < 1e-9) continue;

                    // 根据反应时间计算判定
                    int judgment = getJudgmentFromReactionTime(rt);
                    int addScore;
                    int newCombo;

                    switch (judgment) {
                        case 1: // PERFECT
                            addScore = PERFECT_SCORE + combo * 10;
                            newCombo = Math.min(combo + 1, MAX_COMBO);
                            break;
                        case 2: // GOOD
                            addScore = GOOD_SCORE + combo * 5;
                            newCombo = Math.min(combo + 1, MAX_COMBO);
                            break;
                        default: // MISS
                            addScore = MISS_SCORE;
                            newCombo = 0;
                            break;
                    }

                    double nextScore = dp[combo] + addScore;
                    if (nextScore > newDp[newCombo]) {
                        newDp[newCombo] = nextScore;
                    }
                }
            }

            // 交换dp数组
            double[] temp = dp;
            dp = newDp;
            newDp = temp;
        }

        double maxScore = 0;
        for (int combo = 0; combo <= MAX_COMBO; combo++) {
            maxScore = Math.max(maxScore, dp[combo]);
        }
        return maxScore;
    }

    /**
     * 将离散化的反应时间映射为判定等级
     */
    private int getJudgmentFromReactionTime(int rt) {
        // rt单位为毫秒,假设rt范围是0到反应分布长度
        if (rt <= PERFECT_WINDOW) return 1;
        else if (rt <= GOOD_WINDOW) return 2;
        else return 3;
    }

五、连击加成与得分计算

完整的得分系统需要考虑连击加成:

    /**
     * 计算最终得分
     */
    public ScoreResult calculateFinalScore() {
        int totalScore = 0;
        int maxCombo = 0;
        int currentCombo = 0;
        int perfectCount = 0;
        int goodCount = 0;
        int missCount = 0;

        for (Note note : notes) {
            switch (note.judgment) {
                case 1: // PERFECT
                    currentCombo++;
                    totalScore += 100 + currentCombo * 10;
                    perfectCount++;
                    break;
                case 2: // GOOD
                    currentCombo++;
                    totalScore += 50 + currentCombo * 5;
                    goodCount++;
                    break;
                case 3: // MISS
                    currentCombo = 0;
                    missCount++;
                    break;
            }
            maxCombo = Math.max(maxCombo, currentCombo);
        }

        return new ScoreResult(totalScore, maxCombo, perfectCount, goodCount, missCount);
    }

    /**
     * 得分结果
     */
    public static class ScoreResult {
        public int totalScore;
        public int maxCombo;
        public int perfectCount;
        public int goodCount;
        public int missCount;

        public ScoreResult(int totalScore, int maxCombo, int perfectCount, int goodCount, int missCount) {
            this.totalScore = totalScore;
            this.maxCombo = maxCombo;
            this.perfectCount = perfectCount;
            this.goodCount = goodCount;
            this.missCount = missCount;
        }

        @Override
        public String toString() {
            return String.format("总分: %d, 最大连击: %d, 完美: %d, 良好: %d, 错过: %d",
                totalScore, maxCombo, perfectCount, goodCount, missCount);
        }
    }

    /**
     * 打印所有音符信息
     */
    public void printNotes() {
        for (Note note : notes) {
            System.out.printf("轨道: %d, 理想时间: %dms, 生成时间: %dms%n",
                note.lane, note.idealTime, note.spawnTime);
        }
    }

    public static void main(String[] args) {
        PianoTiles game = new PianoTiles();

        // BPM = 120,每拍一个音符,共32拍
        int[] pattern = {0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3,
                         3, 2, 1, 0, 3, 2, 1, 0, 3, 2, 1, 0, 3, 2, 1, 0};
        game.generateFromBPM(120, pattern);

        System.out.println("生成的音符序列:");
        game.printNotes();

        // 模拟玩家点击(带随机偏差)
        Random rand = new Random(42);
        for (Note note : game.notes) {
            long deviation = (long)(rand.nextGaussian() * 30); // 平均偏差30ms
            long hitTime = note.idealTime + deviation;
            String result = game.judgeHit(note.lane, hitTime);
            System.out.printf("轨道%d, 理想%dms, 点击%dms -> %s%n",
                note.lane, note.idealTime, hitTime, result);
        }

        // 计算最终得分
        ScoreResult score = game.calculateFinalScore();
        System.out.println(score);

        // 动态规划计算理论最优期望得分
        double[] reactionDist = new double[201];
        // 假设反应时间服从均值为0、标准差为30ms的正态分布
        for (int i = 0; i <= 200; i++) {
            double x = i - 100; // 偏差范围 -100 到 100
            reactionDist[i] = Math.exp(-x * x / (2 * 30 * 30));
        }
        // 归一化
        double sum = Arrays.stream(reactionDist).sum();
        for (int i = 0; i < reactionDist.length; i++) {
            reactionDist[i] /= sum;
        }
        double optimalScore = game.optimalSequenceDP(reactionDist);
        System.out.printf("理论最优期望得分: %.2f%n", optimalScore);
    }
}

六、复杂度分析

  • 音符生成:O(N),N为节拍数,一次遍历即可完成;
  • 判定算法:O(M),M为当前可见音符数,实际中每轨道通常只有1-2个可见音符;
  • 动态规划:O(N × C × R),N为音符数,C为最大连击数,R为反应时间离散点数。对于一般歌曲(N<1000, C<100, R<200),计算量完全可接受。

七、总结

本文实现了钢琴块游戏的核心算法体系,涵盖:

  1. BPM时序映射:将音乐节拍转换为精确的方块生成与下落时序;
  2. 判定窗口系统:基于时间偏差的等级判定,模拟真实音游的打击感;
  3. 动态规划策略:在玩家反应时间存在波动的情况下,计算最优期望得分路径;
  4. 连击得分模型:体现连击加成对总分的影响,鼓励持续精准操作。

读者可以在此基础上扩展更多功能,如多指同时点击、滑动音符、长按音符等复杂机制,进一步探索实时判定系统与序列优化的算法设计。