钢琴块(别踩白块儿)是一款考验反应速度与节奏感的音乐游戏,黑色方块随音乐节奏从屏幕上方落下,玩家需要在正确时机点击黑色方块。本文用Java实现钢琴块游戏的核心算法,重点讲解节奏判定算法、音符时序映射与动态规划最优按键序列,帮助读者理解实时判定系统与序列优化策略。
一、游戏规则与算法建模
钢琴块的核心机制包含三个要素:
- 音符序列:每首歌曲对应一组带时间戳的音符,表示何时哪个轨道出现黑块;
- 判定窗口:玩家点击时机与理想时机的偏差决定了判定等级(完美/良好/错过);
- 得分系统:根据判定等级累计得分,连击数越高加分越多。
从算法角度,我们需要解决两个核心问题:一是将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),计算量完全可接受。
七、总结
本文实现了钢琴块游戏的核心算法体系,涵盖:
- BPM时序映射:将音乐节拍转换为精确的方块生成与下落时序;
- 判定窗口系统:基于时间偏差的等级判定,模拟真实音游的打击感;
- 动态规划策略:在玩家反应时间存在波动的情况下,计算最优期望得分路径;
- 连击得分模型:体现连击加成对总分的影响,鼓励持续精准操作。
读者可以在此基础上扩展更多功能,如多指同时点击、滑动音符、长按音符等复杂机制,进一步探索实时判定系统与序列优化的算法设计。