概述
别踩白块(Piano Tiles)是一款考验反应速度的节奏类游戏,黑色方块从屏幕上方不断下落,玩家需要在正确时机点击黑块、避开白块。本文用Java实现该游戏的核心机制,重点讲解节奏模式生成算法、基于时间戳的判定系统,以及滑动窗口平均法优化响应时间计算。
核心算法设计
1. 游戏面板与方块表示
游戏面板由4列组成,每行恰好包含1个黑块和3个白块。我们用二维布尔数组表示棋盘状态,true代表黑块,false代表白块。
public class TileBoard {
public static final int COLS = 4;
private List<Row> rows = new ArrayList<>();
private float rowHeight = 150f; // 每行高度(像素)
private float scrollSpeed = 8f; // 下落速度(像素/帧)
private int score = 0;
private long gameStartTime;
static class Row {
boolean[] tiles; // 4列,仅1个为true(黑块)
float y; // 当前Y坐标
boolean clicked; // 黑块是否已被点击
Row(int blackCol, float startY) {
tiles = new boolean[COLS];
tiles[blackCol] = true;
this.y = startY;
this.clicked = false;
}
}
public TileBoard() {
gameStartTime = System.currentTimeMillis();
// 预生成初始20行
for (int i = 0; i < 20; i++) {
rows.add(new Row(randomCol(), i * rowHeight));
}
}
private int randomCol() {
return (int)(Math.random() * COLS);
}
}
2. 伪随机模式生成算法
为了保证游戏的可玩性,模式生成需要满足两个约束:相邻两行的黑块不在同一列(增加横向移动需求),以及避免连续3次出现在同一侧(防止单手可通关)。
/**
* 带约束的伪随机列生成
* 规则1:不与上一行同列
* 规则2:连续同侧(左2列/右2列)不超过2次
*/
public int nextConstrainedCol(int prevCol, Deque<Integer> history) {
List<Integer> candidates = new ArrayList<>(Arrays.asList(0, 1, 2, 3));
candidates.remove(Integer.valueOf(prevCol)); // 规则1
// 规则2:检查连续同侧次数
if (history.size() >= 2) {
int last = history.getLast();
int secondLast = history.stream().skip(history.size() - 2).findFirst().orElse(-1);
boolean sameSide = (last <= 1 && secondLast <= 1) || (last >= 2 && secondLast >= 2);
if (sameSide) {
// 强制切换到另一侧
if (last <= 1) {
candidates.removeIf(c -> c <= 1);
} else {
candidates.removeIf(c -> c >= 2);
}
}
}
int chosen = candidates.get((int)(Math.random() * candidates.size()));
history.addLast(chosen);
if (history.size() > 3) history.removeFirst();
return chosen;
}
public void generateNewRows() {
// 当顶部行接近屏幕底部时,在上方补充新行
if (rows.get(rows.size() - 1).y > -rowHeight) {
int lastCol = -1;
for (int i = rows.size() - 1; i >= 0; i--) {
for (int c = 0; c < COLS; c++) {
if (rows.get(i).tiles[c]) { lastCol = c; break; }
}
if (lastCol != -1) break;
}
Deque<Integer> hist = new ArrayDeque<>();
// 回溯最近2行加入历史
for (int i = Math.max(0, rows.size() - 2); i < rows.size(); i++) {
for (int c = 0; c < COLS; c++) {
if (rows.get(i).tiles[c]) hist.add(c);
}
}
int newCol = nextConstrainedCol(lastCol, hist);
float topY = rows.get(rows.size() - 1).y - rowHeight;
rows.add(new Row(newCol, topY));
}
}
3. 点击判定与响应时间计算
精确判定是节奏游戏的核心。我们记录玩家点击的时间戳,与黑块经过判定线(屏幕下方固定位置)的理论时间进行比较,计算偏移量。
public class TimingJudge {
public static final float JUDGE_LINE_Y = 700f; // 判定线Y坐标
public static final long PERFECT_WINDOW = 50; // 完美判定窗口±50ms
public static final long GOOD_WINDOW = 120; // 良好判定窗口±120ms
/**
* 计算理论击中时间:黑块中心到达判定线的时间戳
*/
public long calculateTheoreticalTime(Row row, float speed, long startTime) {
float distance = row.y + rowHeight / 2 - JUDGE_LINE_Y;
float frames = distance / speed;
long msOffset = (long)(frames * 16.67f); // 假设60FPS,每帧16.67ms
return startTime + msOffset;
}
/**
* 判定点击质量
* @param clickTime 实际点击时间戳
* @param theoryTime 理论击中时间戳
*/
public JudgeResult judge(long clickTime, long theoryTime) {
long delta = Math.abs(clickTime - theoryTime);
if (delta <= PERFECT_WINDOW) return JudgeResult.PERFECT;
if (delta <= GOOD_WINDOW) return JudgeResult.GOOD;
return JudgeResult.MISS;
}
}
enum JudgeResult {
PERFECT(300), GOOD(100), MISS(0);
final int score;
JudgeResult(int score) { this.score = score; }
}
4. 滑动窗口平均法优化响应时间
为了评估玩家的反应速度变化趋势,我们使用滑动窗口平均法计算最近N次点击的平均响应时间,剔除异常值(超过3倍标准差的离群点),使统计结果更稳定。
public class ResponseTimeAnalyzer {
private Deque<Long> recentDeltas = new ArrayDeque<>();
private static final int WINDOW_SIZE = 10;
/**
* 滑动窗口平均法:记录最近10次点击的时间偏移
*/
public void recordDelta(long deltaMs) {
recentDeltas.addLast(deltaMs);
if (recentDeltas.size() > WINDOW_SIZE) {
recentDeltas.removeFirst();
}
}
/**
* 计算去除异常值后的平均响应时间
* 使用3-sigma法则过滤离群点
*/
public double getSmoothedAverage() {
if (recentDeltas.isEmpty()) return 0;
List<Long> values = new ArrayList<>(recentDeltas);
// 计算均值和标准差
double mean = values.stream().mapToLong(Long::longValue).average().orElse(0);
double variance = values.stream()
.mapToDouble(v -> Math.pow(v - mean, 2))
.average().orElse(0);
double stdDev = Math.sqrt(variance);
// 过滤超过3-sigma的离群点
double sum = 0;
int count = 0;
for (long v : values) {
if (stdDev == 0 || Math.abs(v - mean) <= 3 * stdDev) {
sum += v;
count++;
}
}
return count > 0 ? sum / count : mean;
}
/**
* 根据平均响应时间动态调整下落速度
* 响应越快,速度越高,难度越大
*/
public float adjustSpeed(float currentSpeed) {
double avg = getSmoothedAverage();
if (avg < 30) return Math.min(currentSpeed + 0.3f, 18f); // 极快
if (avg < 60) return Math.min(currentSpeed + 0.15f, 15f); // 快
if (avg < 100) return currentSpeed; // 正常
return Math.max(currentSpeed - 0.2f, 5f); // 偏慢,降低难度
}
}
5. 连击系统与BPM同步
将游戏节奏与虚拟BPM(每分钟节拍数)绑定,使方块生成与音乐节奏对齐。
public class RhythmEngine {
private int bpm = 120; // 初始BPM
private long msPerBeat = 500; // 每拍毫秒数
private long lastBeatTime;
private int combo = 0;
private int maxCombo = 0;
public void onHit(JudgeResult result) {
if (result == JudgeResult.MISS) {
combo = 0;
bpm = Math.max(100, bpm - 5); // 失误后降速
} else {
combo++;
maxCombo = Math.max(maxCombo, combo);
if (combo % 10 == 0) {
bpm = Math.min(180, bpm + 2); // 每10连击小幅提速
}
}
msPerBeat = 60000 / bpm;
}
/**
* 根据BPM计算下一行应该生成的时机
*/
public boolean shouldSpawnRow(long currentTime) {
if (currentTime - lastBeatTime >= msPerBeat) {
lastBeatTime = currentTime;
return true;
}
return false;
}
public int getScoreMultiplier() {
return 1 + combo / 20; // 每20连击分数倍率+1
}
}
6. 完整主循环
public class PianoTilesGame {
private TileBoard board;
private TimingJudge judge;
private ResponseTimeAnalyzer analyzer;
private RhythmEngine rhythm;
private long startTime;
public PianoTilesGame() {
board = new TileBoard();
judge = new TimingJudge();
analyzer = new ResponseTimeAnalyzer();
rhythm = new RhythmEngine();
startTime = System.currentTimeMillis();
}
public void update() {
board.scrollDown();
board.generateNewRows();
board.removeOffScreenRows();
long now = System.currentTimeMillis();
if (rhythm.shouldSpawnRow(now)) {
board.generateNewRows();
}
}
public JudgeResult onPlayerClick(int col) {
long clickTime = System.currentTimeMillis();
Row targetRow = board.findLowestUnclickedBlackTile(col);
if (targetRow == null) {
rhythm.onHit(JudgeResult.MISS);
return JudgeResult.MISS; // 点错列或无黑块
}
long theoryTime = judge.calculateTheoreticalTime(targetRow, board.getSpeed(), startTime);
JudgeResult result = judge.judge(clickTime, theoryTime);
if (result != JudgeResult.MISS) {
targetRow.clicked = true;
analyzer.recordDelta(Math.abs(clickTime - theoryTime));
board.setSpeed(analyzer.adjustSpeed(board.getSpeed()));
}
rhythm.onHit(result);
return result;
}
}
复杂度分析
| 算法模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 约束随机生成 | O(1) | O(1) | 固定4列,候选集极小 |
| 点击判定 | O(R) | O(1) | R=当前行数,需找到对应行 |
| 滑动窗口平均 | O(W) | O(W) | W=窗口大小(固定10) |
| 整体更新 | O(R) | O(R) | 每帧遍历所有行更新位置 |
总结
别踩白块的核心算法亮点在于约束驱动的模式生成和基于时间戳的精确判定。通过滑动窗口平均法追踪玩家响应时间,实现了动态难度调整(Dynamic Difficulty Adjustment)。将BPM与方块生成绑定的设计,使游戏具有音乐游戏的节奏感,同时也为后续接入真实音频谱面数据预留了扩展接口。