每日算法 — 使用java实现节奏游戏:音符映射与判定窗口算法

节奏游戏(Rhythm Game)要求玩家在精确的时间点按下对应按键,核心难点在于音符时间映射、判定窗口计算与连击系统。本文用Java实现节奏游戏的核心算法引擎,涵盖BPM转时间轴、判定区间分类与连击评分系统。

算法核心思路

1. BPM到毫秒时间轴转换

音乐以BPM(每分钟节拍数)定义速度。一拍时长 = 60000 / BPM 毫秒。将乐谱中的拍号转换为绝对时间戳,建立音符到达判定线的时间轴。支持变速(BPM变化)和偏移量(offset)校准。

2. 判定窗口算法

玩家按键时间与音符理想时间的差值称为时间偏差Δt。根据Δt的大小划分判定等级:Perfect(±25ms)、Great(±50ms)、Good(±100ms)、Bad(±150ms)、Miss(>150ms)。窗口大小直接影响游戏难度。

3. 音符排序与活跃池管理

音符按到达时间排序。每帧只检查当前时间附近的音符(活跃池),避免全量遍历。音符被击中或错过判定窗口后从活跃池移除。

4. 连击与评分系统

连续正确判定累计连击数(Combo),Combo越高单音符得分越高。引入准确率(Accuracy)统计,综合反映玩家表现。评分公式采用非线性增长,高Combo时失误惩罚更重。

Java实现

import java.util.*;

public class RhythmGameEngine {
    // 判定窗口(毫秒,双边)
    static final int PERFECT_WINDOW = 25;
    static final int GREAT_WINDOW = 50;
    static final int GOOD_WINDOW = 100;
    static final int BAD_WINDOW = 150;

    enum Judgment { PERFECT, GREAT, GOOD, BAD, MISS }

    static class Note {
        long time;      // 到达判定线的绝对时间(ms)
        int lane;       // 轨道编号(0~3对应4K)
        boolean hit;    // 是否已击中
        boolean missed; // 是否已错过

        Note(long time, int lane) {
            this.time = time; this.lane = lane;
        }
    }

    static class JudgeResult {
        Judgment judgment;
        long delta; // 时间偏差(正为早按,负为晚按,这里取绝对值展示)
        JudgeResult(Judgment j, long d) { judgment = j; delta = d; }
    }

    private List<Note> notes = new ArrayList<>();
    private int combo = 0;
    private int maxCombo = 0;
    private int score = 0;
    private int totalNotes = 0;
    private double accuracySum = 0;
    private int judgmentCounts[] = new int[5]; // PERFECT~MISS

    private long songStartTime = 0;
    private boolean playing = false;

    /**
     * 从BPM和拍号生成音符时间轴
     * @param bpm 每分钟节拍数
     * @param beats 每个音符所在的拍数(从0开始)
     * @param offset 起始偏移(毫秒)
     */
    public void generateFromBPM(double bpm, double[] beats, int lane, long offset) {
        double msPerBeat = 60000.0 / bpm;
        for (double beat : beats) {
            long time = (long) (beat * msPerBeat + offset);
            notes.add(new Note(time, lane));
        }
        notes.sort(Comparator.comparingLong(n -> n.time));
    }

    /**
     * 判定一次按键
     * @param lane 按键轨道
     * @param pressTime 按键时间(相对songStartTime的毫秒数)
     */
    public JudgeResult judge(int lane, long pressTime) {
        // 在活跃窗口内寻找该轨道最近且未处理的音符
        Note best = null;
        long bestDelta = Long.MAX_VALUE;

        for (Note n : notes) {
            if (n.lane != lane || n.hit || n.missed) continue;
            long delta = Math.abs(n.time - pressTime);
            if (delta > BAD_WINDOW) {
                if (n.time < pressTime - BAD_WINDOW) n.missed = true;
                continue;
            }
            if (delta < bestDelta) {
                bestDelta = delta;
                best = n;
            }
        }

        if (best == null) return null;

        best.hit = true;
        Judgment j = getJudgment(bestDelta);
        updateScore(j);
        return new JudgeResult(j, bestDelta);
    }

    private Judgment getJudgment(long delta) {
        if (delta <= PERFECT_WINDOW) return Judgment.PERFECT;
        if (delta <= GREAT_WINDOW) return Judgment.GREAT;
        if (delta <= GOOD_WINDOW) return Judgment.GOOD;
        if (delta <= BAD_WINDOW) return Judgment.BAD;
        return Judgment.MISS;
    }

    private void updateScore(Judgment j) {
        int idx = j.ordinal();
        judgmentCounts[idx]++;
        totalNotes++;

        int baseScore = switch (j) {
            case PERFECT -> 300;
            case GREAT -> 200;
            case GOOD -> 100;
            case BAD -> 50;
            case MISS -> 0;
        };

        if (j == Judgment.MISS) {
            combo = 0;
        } else {
            combo++;
            if (combo > maxCombo) maxCombo = combo;
        }

        // 连击加成:Combo越高单音符分越高,最高2倍
        double comboBonus = 1.0 + Math.min(1.0, combo / 100.0);
        score += (int) (baseScore * comboBonus);

        // 准确率:PERFECT=100%, GREAT=75%, GOOD=50%, BAD=25%, MISS=0%
        double accWeight = switch (j) {
            case PERFECT -> 1.0;
            case GREAT -> 0.75;
            case GOOD -> 0.5;
            case BAD -> 0.25;
            case MISS -> 0.0;
        };
        accuracySum += accWeight;
    }

    /**
     * 每帧更新:将超过判定窗口仍未击中的音符标记为MISS
     */
    public void update(long currentTime) {
        for (Note n : notes) {
            if (!n.hit && !n.missed && currentTime > n.time + BAD_WINDOW) {
                n.missed = true;
                updateScore(Judgment.MISS);
            }
        }
    }

    public void start(long currentTimeMillis) {
        songStartTime = currentTimeMillis;
        playing = true;
    }

    public long getSongTime(long currentTimeMillis) {
        return currentTimeMillis - songStartTime;
    }

    public void printStats() {
        double accuracy = totalNotes > 0 ? (accuracySum / totalNotes) * 100 : 0;
        System.out.println("===== 节奏游戏成绩 =====");
        System.out.printf("总分: %d | 最大连击: %d | 准确率: %.2f%%%n", score, maxCombo, accuracy);
        System.out.printf("PERFECT: %d | GREAT: %d | GOOD: %d | BAD: %d | MISS: %d%n",
            judgmentCounts[0], judgmentCounts[1], judgmentCounts[2], judgmentCounts[3], judgmentCounts[4]);
    }

    public static void main(String[] args) throws InterruptedException {
        RhythmGameEngine engine = new RhythmGameEngine();
        // BPM=120,每拍一个音符,轨道0,偏移0ms
        double[] beats = {0, 1, 2, 2.5, 3, 4, 5, 6, 6.5, 7};
        engine.generateFromBPM(120, beats, 0, 0);

        engine.start(System.currentTimeMillis());
        System.out.println("=== 节奏游戏演示 ===");

        // 模拟玩家按键(带随机误差)
        Random rand = new Random(42);
        for (Note n : engine.notes) {
            long idealTime = n.time;
            long error = (long) (rand.nextGaussian() * 30); // 正态分布误差
            long pressTime = Math.max(0, idealTime + error);

            // 等待到按键时间
            long now = engine.getSongTime(System.currentTimeMillis());
            while (now < pressTime) {
                Thread.sleep(1);
                now = engine.getSongTime(System.currentTimeMillis());
            }

            engine.update(now);
            JudgeResult res = engine.judge(n.lane, pressTime);
            if (res != null) {
                System.out.printf("Note@%dms -> %s (误差=%dms)%n", idealTime, res.judgment, res.delta);
            }
        }
        engine.printStats();
    }
}

复杂度分析

  • 时间复杂度:每帧活跃池检查为O(N),N为音符总数。可通过双指针维护活跃窗口起点,降为O(1)均摊。
  • 空间复杂度:O(N),存储所有音符状态。

扩展方向

  1. 滑条(Long Note)处理:引入按键持续时间判定,需持续按住直到滑条结束。
  2. 多指冲突解决:同一轨道短时间内多个音符时,优先匹配时间最近的未处理音符。
  3. 动态难度调整(DDP):根据玩家历史准确率实时缩窄或放宽判定窗口。