每日算法 — 使用java实现音游:时间窗口判定与BPM同步评分算法

音游(Rhythm Game)是考验玩家节奏感的经典游戏类型。本文将用 Java 实现一个核心音游引擎,重点讲解音符下落的时间窗口判定算法、BPM(每分钟节拍数)同步机制,以及基于命中精度的评分系统。读者将掌握如何处理音频时间轴、设计宽容度分级判定,并构建完整的连击与评分逻辑。

一、音游核心机制概述

音游的核心玩法可以抽象为:在正确的时间点按下正确的按键。系统需要:

  1. 解析谱面数据:记录每个音符的目标击打时间(以毫秒为单位)。
  2. BPM 同步:根据音乐的 BPM 将拍子映射到时间轴,处理变速与多 BPM 段落。
  3. 时间窗口判定:计算玩家按键时间与目标时间的差值,落在不同区间对应不同判定(Perfect/Great/Good/Miss)。
  4. 评分与连击:根据判定结果累计分数,并维护连击数以触发奖励倍率。

二、谱面数据模型与 BPM 同步

2.1 音符与谱面结构

每个音符至少包含目标时间和轨道信息。为了支持多 BPM,谱面按时间分段存储:

import java.util.*;

/**
 * 单条音符记录
 */
class Note {
    long targetTimeMs;   // 目标击打时间(毫秒,相对于歌曲开始)
    int lane;            // 轨道编号(0 ~ laneCount-1)
    boolean hit;         // 是否已被击打
    Judgment judgment;   // 最终判定结果

    public Note(long targetTimeMs, int lane) {
        this.targetTimeMs = targetTimeMs;
        this.lane = lane;
        this.hit = false;
    }
}

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

/**
 * BPM 变化点:从某个时间开始,BPM 变为指定值
 */
class BpmEvent {
    long startTimeMs;
    double bpm;

    public BpmEvent(long startTimeMs, double bpm) {
        this.startTimeMs = startTimeMs;
        this.bpm = bpm;
    }
}

/**
 * 谱面容器
 */
class Chart {
    List<BpmEvent> bpmEvents = new ArrayList<>();
    List<Note> notes = new ArrayList<>();
    int laneCount = 4; // 默认4轨

    public Chart() {}
}

2.2 BPM 到毫秒时间的转换

音游谱面通常以”拍”(beat)为单位描述音符位置。给定 BPM,每拍对应的毫秒数为 60000.0 / BPM。当存在 BPM 变化时,需要分段累加:

/**
 * BPM 同步器:将拍数转换为绝对毫秒时间
 */
class BpmSync {
    private final List<BpmEvent> events;

    public BpmSync(List<BpmEvent> events) {
        this.events = new ArrayList<>(events);
        // 按时间排序,确保分段正确
        this.events.sort(Comparator.comparingLong(e -> e.startTimeMs));
    }

    /**
     * 将第 beat 拍转换为绝对毫秒时间
     * 假设初始 BPM 从时间 0 开始
     */
    public long beatToTime(double beat) {
        if (events.isEmpty()) {
            return (long) (beat * 60000.0 / 120.0); // 默认 120 BPM
        }

        double currentBeat = 0.0;
        long currentTime = 0L;
        double currentBpm = events.get(0).bpm;
        long nextEventTime = events.size() > 1 ? events.get(1).startTimeMs : Long.MAX_VALUE;
        int eventIdx = 1;

        while (true) {
            double beatsToNextEvent = (nextEventTime - currentTime) * currentBpm / 60000.0;
            if (beat <= currentBeat + beatsToNextEvent) {
                double remainingBeats = beat - currentBeat;
                return currentTime + (long) (remainingBeats * 60000.0 / currentBpm);
            }
            currentBeat += beatsToNextEvent;
            currentTime = nextEventTime;
            if (eventIdx >= events.size()) {
                // 没有更多 BPM 变化,直接按当前 BPM 计算
                double remainingBeats = beat - currentBeat;
                return currentTime + (long) (remainingBeats * 60000.0 / currentBpm);
            }
            currentBpm = events.get(eventIdx).bpm;
            nextEventTime = (eventIdx + 1 < events.size()) ? events.get(eventIdx + 1).startTimeMs : Long.MAX_VALUE;
            eventIdx++;
        }
    }
}

三、时间窗口判定算法

判定算法是音游的灵魂。系统维护一个时间窗口(通常 ±50ms ~ ±200ms),根据偏移量决定判定等级:

/**
 * 判定引擎
 */
class JudgmentEngine {
    // 判定窗口(毫秒,双向对称)
    private final int perfectWindow = 45;
    private final int greatWindow   = 90;
    private final int goodWindow    = 150;
    // miss 窗口外或完全未按则记为 miss

    /**
     * 根据时间差计算判定结果
     * @param deltaMs 玩家时间 - 目标时间(可为负,表示提前)
     * @return 判定等级
     */
    public Judgment judge(long deltaMs) {
        long abs = Math.abs(deltaMs);
        if (abs <= perfectWindow) return Judgment.PERFECT;
        if (abs <= greatWindow)   return Judgment.GREAT;
        if (abs <= goodWindow)    return Judgment.GOOD;
        return Judgment.MISS;
    }

    /**
     * 获取判定对应的分数倍率
     */
    public double scoreMultiplier(Judgment j) {
        return switch (j) {
            case PERFECT -> 1.0;
            case GREAT   -> 0.8;
            case GOOD    -> 0.5;
            default      -> 0.0;
        };
    }
}

四、游戏主循环与输入处理

在每一帧中,引擎需要:扫描未命中音符、处理玩家输入、更新连击与分数。

/**
 * 核心音游引擎
 */
class RhythmGameEngine {
    private final Chart chart;
    private final JudgmentEngine judgeEngine = new JudgmentEngine();
    private long songPositionMs = 0; // 当前歌曲播放位置
    private int combo = 0;
    private int maxCombo = 0;
    private double totalScore = 0;
    private final double maxPossibleScore;

    public RhythmGameEngine(Chart chart) {
        this.chart = chart;
        // 假设每音符满分 100,计算理论最高分
        this.maxPossibleScore = chart.notes.size() * 100.0;
    }

    /**
     * 每帧更新歌曲进度(由音频回调或定时器驱动)
     */
    public void update(long positionMs) {
        this.songPositionMs = positionMs;
        // 自动检测 Miss:若某未击打音符已超出 goodWindow 仍未被按,则标记为 Miss
        int missWindow = 150;
        for (Note n : chart.notes) {
            if (!n.hit && (songPositionMs - n.targetTimeMs) > missWindow) {
                n.hit = true;
                n.judgment = Judgment.MISS;
                combo = 0;
            }
        }
    }

    /**
     * 玩家按下指定轨道
     * @param lane 轨道编号
     */
    public void onInput(int lane) {
        Note best = null;
        long bestDelta = Long.MAX_VALUE;

        // 寻找该轨道中最近且未击中的音符
        for (Note n : chart.notes) {
            if (n.lane != lane || n.hit) continue;
            long delta = Math.abs(songPositionMs - n.targetTimeMs);
            if (delta < bestDelta) {
                bestDelta = delta;
                best = n;
            }
        }

        if (best == null) return;

        long delta = songPositionMs - best.targetTimeMs;
        Judgment j = judgeEngine.judge(delta);

        if (j != Judgment.MISS) {
            best.hit = true;
            best.judgment = j;
            combo++;
            if (combo > maxCombo) maxCombo = combo;
            double noteScore = 100.0 * judgeEngine.scoreMultiplier(j);
            // 连击奖励:每 10 连击倍率 +0.01,上限 2.0
            double comboBonus = Math.min(1.0 + (combo / 10) * 0.01, 2.0);
            totalScore += noteScore * comboBonus;
        } else {
            // 虽然落在 miss 窗口内,也标记已处理,避免重复判定
            best.hit = true;
            best.judgment = j;
            combo = 0;
        }
    }

    public double getAccuracy() {
        if (maxPossibleScore == 0) return 0.0;
        return totalScore / maxPossibleScore * 100.0;
    }

    public int getCombo() { return combo; }
    public int getMaxCombo() { return maxCombo; }
}

五、谱面生成示例

以下代码演示如何根据固定 BPM 生成一段简单谱面:

public class RhythmGameDemo {
    public static void main(String[] args) {
        Chart chart = new Chart();
        chart.laneCount = 4;
        chart.bpmEvents.add(new BpmEvent(0L, 128.0));

        BpmSync sync = new BpmSync(chart.bpmEvents);

        // 生成 16 分音符填充的 8 拍谱面
        for (int beat = 0; beat < 32; beat++) {
            int lane = beat % 4;
            long time = sync.beatToTime(beat / 4.0 + (beat % 4) / 4.0);
            chart.notes.add(new Note(time, lane));
        }

        RhythmGameEngine engine = new RhythmGameEngine(chart);

        // 模拟玩家输入(简化演示)
        for (Note n : chart.notes) {
            engine.update(n.targetTimeMs + 20); // 假设玩家晚 20ms
            engine.onInput(n.lane);
        }

        System.out.printf("Accuracy: %.2f%%%n", engine.getAccuracy());
        System.out.printf("Max Combo: %d%n", engine.getMaxCombo());
    }
}

六、复杂度分析

  • 谱面解析:$O(N)$,$N$ 为音符数量。
  • 每帧更新:$O(N)$ 扫描未命中音符;可优化为只检查头部指针附近的音符,降至均摊 $O(1)$。
  • 输入判定:$O(N)$ 线性搜索最近音符;若按轨道维护优先队列,可降至 $O(\log N)$。

七、总结

本文实现了一个可运行的音游核心引擎,涵盖 BPM 同步、时间窗口判定和评分系统。关键算法在于时间差绝对值的分级映射与连击倍率的动态计算。在此基础上,读者可以扩展长条音符(Hold Note)、滑条(Slide)以及更复杂的谱面格式(如 osu!mania 或 BMS),进一步探索节奏游戏的算法世界。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注