音游(Rhythm Game)是考验玩家节奏感的经典游戏类型。本文将用 Java 实现一个核心音游引擎,重点讲解音符下落的时间窗口判定算法、BPM(每分钟节拍数)同步机制,以及基于命中精度的评分系统。读者将掌握如何处理音频时间轴、设计宽容度分级判定,并构建完整的连击与评分逻辑。
一、音游核心机制概述
音游的核心玩法可以抽象为:在正确的时间点按下正确的按键。系统需要:
- 解析谱面数据:记录每个音符的目标击打时间(以毫秒为单位)。
- BPM 同步:根据音乐的 BPM 将拍子映射到时间轴,处理变速与多 BPM 段落。
- 时间窗口判定:计算玩家按键时间与目标时间的差值,落在不同区间对应不同判定(Perfect/Great/Good/Miss)。
- 评分与连击:根据判定结果累计分数,并维护连击数以触发奖励倍率。
二、谱面数据模型与 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),进一步探索节奏游戏的算法世界。