跳舞的线(Dancing Line)是一款音乐节奏类游戏,玩家需要控制一条线在曲折的道路上跟随音乐节奏前进。本文用Java实现其核心机制:三次贝塞尔曲线生成平滑路径、音频节拍检测算法将音乐节奏映射为转向点,以及动态插值实现丝滑的线体移动。
核心算法拆解
1. 三次贝塞尔曲线路径生成
游戏道路不是折线而是平滑曲线。三次贝塞尔曲线由起点P₀、终点P₃及两个控制点P₁、P₂定义:
B(t) = (1−t)³P₀ + 3(1−t)²tP₁ + 3(1−t)t²P₂ + t³P₃, t∈[0,1]
通过连续拼接多段贝塞尔曲线,可形成任意复杂的平滑道路。
2. 音频节拍检测(Onset Detection)
将音乐转为节拍点序列,最简单有效的方法是能量差分法:计算相邻音频帧的频谱能量差,超过阈值即判定为节拍(Onset)。
3. 路径参数化跟随
线体在曲线上以恒定速度移动,需将”时间”映射到”曲线参数t”。由于贝塞尔曲线不是匀速参数化,我们采用自适应步长积分来近似弧长参数化。
完整Java实现
import javax.sound.sampled.*;
import java.awt.*;
import java.util.*;
import java.util.List;
/**
* 跳舞的线核心引擎
* 包含:贝塞尔曲线、节拍检测、路径跟随、渲染循环
*/
public class DancingLineEngine {
// ==================== 数学工具类 ====================
static class Vec2 {
double x, y;
Vec2(double x, double y) { this.x = x; this.y = y; }
Vec2 add(Vec2 v) { return new Vec2(x + v.x, y + v.y); }
Vec2 sub(Vec2 v) { return new Vec2(x - v.x, y - v.y); }
Vec2 mul(double s) { return new Vec2(x * s, y * s); }
double length() { return Math.sqrt(x * x + y * y); }
}
// ==================== 三次贝塞尔曲线段 ====================
static class CubicBezier {
Vec2 p0, p1, p2, p3; // 起点、控制点1、控制点2、终点
CubicBezier(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3) {
this.p0 = p0; this.p1 = p1; this.p2 = p2; this.p3 = p3;
}
/**
* 计算曲线上参数t对应的点
* B(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3
*/
Vec2 pointAt(double t) {
double u = 1 - t;
double tt = t * t;
double uu = u * u;
double uuu = uu * u;
double ttt = tt * t;
Vec2 p = p0.mul(uuu);
p = p.add(p1.mul(3 * uu * t));
p = p.add(p2.mul(3 * u * tt));
p = p.add(p3.mul(ttt));
return p;
}
/**
* 计算曲线上参数t对应的一阶导数(切线方向)
* B'(t) = 3*(1-t)^2*(P1-P0) + 6*(1-t)*t*(P2-P1) + 3*t^2*(P3-P2)
*/
Vec2 derivativeAt(double t) {
double u = 1 - t;
Vec2 d = p1.sub(p0).mul(3 * u * u);
d = d.add(p2.sub(p1).mul(6 * u * t));
d = d.add(p3.sub(p2).mul(3 * t * t));
return d;
}
/**
* 近似计算弧长:将曲线分为N段,累加弦长
*/
double approximateLength(int segments) {
double len = 0;
Vec2 prev = pointAt(0);
for (int i = 1; i <= segments; i++) {
Vec2 curr = pointAt(i / (double) segments);
len += curr.sub(prev).length();
prev = curr;
}
return len;
}
}
// ==================== 连续曲线路径 ====================
static class BezierPath {
List<CubicBezier> segments = new ArrayList<>();
List<Double> segmentLengths = new ArrayList<>(); // 每段弧长缓存
double totalLength = 0;
void addSegment(CubicBezier seg) {
segments.add(seg);
double len = seg.approximateLength(100);
segmentLengths.add(len);
totalLength += len;
}
/**
* 按弧长比例s∈[0,1]采样路径上的点
* 使用二分查找定位所在段,再在该段内线性插值参数t
*/
Vec2 pointAtDistanceRatio(double s) {
if (s <= 0) return segments.get(0).pointAt(0);
if (s >= 1) return segments.get(segments.size()-1).pointAt(1);
double targetLen = s * totalLength;
double accumulated = 0;
int segIdx = 0;
for (int i = 0; i < segmentLengths.size(); i++) {
if (accumulated + segmentLengths.get(i) >= targetLen) {
segIdx = i;
break;
}
accumulated += segmentLengths.get(i);
}
double segLocal = (targetLen - accumulated) / segmentLengths.get(segIdx);
return segments.get(segIdx).pointAt(Math.max(0, Math.min(1, segLocal)));
}
/**
* 获取路径上某点的切线角度(用于旋转线体)
*/
double tangentAngleAtRatio(double s) {
double targetLen = s * totalLength;
double accumulated = 0;
int segIdx = 0;
for (int i = 0; i < segmentLengths.size(); i++) {
if (accumulated + segmentLengths.get(i) >= targetLen) {
segIdx = i;
break;
}
accumulated += segmentLengths.get(i);
}
double segLocal = (targetLen - accumulated) / segmentLengths.get(segIdx);
Vec2 deriv = segments.get(segIdx).derivativeAt(segLocal);
return Math.atan2(deriv.y, deriv.x);
}
}
// ==================== 音频节拍检测器 ====================
static class BeatDetector {
List<Double> beatTimes = new ArrayList<>(); // 节拍时间戳(秒)
double threshold = 1.5; // 能量差分阈值,越大越严格
/**
* 从PCM音频数据中检测节拍
* 简化版:基于时域能量差分,实际可用FFT频谱Flux
*/
void detectBeats(byte[] pcmData, int sampleRate, int channels, int bitsPerSample) {
int bytesPerSample = bitsPerSample / 8;
int frameSize = bytesPerSample * channels;
int totalFrames = pcmData.length / frameSize;
// 分帧:每帧1024个采样
int frameLength = 1024;
double[] energies = new double[totalFrames / frameLength + 1];
for (int f = 0; f < energies.length; f++) {
double energy = 0;
int start = f * frameLength;
int end = Math.min(start + frameLength, totalFrames);
for (int i = start; i < end; i++) {
int idx = i * frameSize;
// 读取16位采样(简化:仅取左声道)
short sample = (short) ((pcmData[idx + 1] << 8) | (pcmData[idx] & 0xFF));
energy += sample * sample;
}
energies[f] = energy / (end - start);
}
// 能量差分检测Onset
double prevEnergy = 0;
for (int f = 1; f < energies.length; f++) {
double diff = energies[f] - prevEnergy;
if (diff > threshold * 1e6) { // 阈值需根据实际音频调整
double time = (f * frameLength) / (double) sampleRate;
beatTimes.add(time);
}
prevEnergy = energies[f];
}
System.out.println("检测到 " + beatTimes.size() + " 个节拍点");
}
/**
* 获取下一个节拍时间
*/
double getNextBeatTime(double currentTime) {
for (double t : beatTimes) {
if (t > currentTime) return t;
}
return Double.MAX_VALUE;
}
}
// ==================== 游戏线体实体 ====================
static class LineEntity {
double pathProgress = 0.0; // 路径进度 [0,1]
double speed = 0.3; // 每秒前进的路径比例(简化)
double turnCooldown = 0; // 转向冷却(避免连续转向)
boolean alive = true;
List<Vec2> trail = new ArrayList<>(); // 尾迹点
void update(double deltaTime, BezierPath path, BeatDetector beats, double gameTime) {
if (!alive) return;
// 基础前进
pathProgress += speed * deltaTime;
if (pathProgress >= 1.0) {
pathProgress = 1.0;
alive = false; // 到达终点
return;
}
// 节拍转向:在节拍点附近允许改变路径(简化:加速)
double nextBeat = beats.getNextBeatTime(gameTime);
if (Math.abs(nextBeat - gameTime) < 0.05 && turnCooldown <= 0) {
speed = Math.min(speed * 1.2, 0.8); // 节拍处小幅加速
turnCooldown = 0.3;
}
if (turnCooldown > 0) turnCooldown -= deltaTime;
// 记录尾迹
Vec2 pos = path.pointAtDistanceRatio(pathProgress);
trail.add(pos);
if (trail.size() > 200) trail.remove(0); // 限制尾迹长度
}
}
// ==================== 关卡生成器:自动生成转弯路径 ====================
static BezierPath generateLevelPath(int seed) {
Random rand = new Random(seed);
BezierPath path = new BezierPath();
Vec2 current = new Vec2(0, 0);
Vec2 currentDir = new Vec2(1, 0); // 初始向右
// 生成10段连续贝塞尔曲线
for (int i = 0; i < 10; i++) {
double segLen = 100 + rand.nextDouble() * 100;
double turnAngle = (rand.nextDouble() - 0.5) * Math.PI * 0.8; // ±72度转弯
// 计算终点
Vec2 endDir = new Vec2(
currentDir.x * Math.cos(turnAngle) - currentDir.y * Math.sin(turnAngle),
currentDir.x * Math.sin(turnAngle) + currentDir.y * Math.cos(turnAngle)
);
Vec2 end = current.add(endDir.mul(segLen));
// 控制点:在起点和终点方向延伸1/3处
Vec2 cp1 = current.add(currentDir.mul(segLen * 0.3));
Vec2 cp2 = end.sub(endDir.mul(segLen * 0.3));
path.addSegment(new CubicBezier(current, cp1, cp2, end));
current = end;
currentDir = endDir;
}
return path;
}
// ==================== 主程序:模拟运行 ====================
public static void main(String[] args) {
System.out.println("=== 跳舞的线引擎启动 ===");
// 1. 生成关卡路径
BezierPath path = generateLevelPath(42);
System.out.printf("路径总长度: %.2f%n", path.totalLength);
// 2. 模拟节拍数据(实际应从音频文件读取)
BeatDetector beats = new BeatDetector();
// 伪造一些节拍点:每0.8秒一个
for (double t = 0.8; t < 30; t += 0.8) {
beats.beatTimes.add(t);
}
// 3. 运行游戏循环
LineEntity line = new LineEntity();
double gameTime = 0;
double deltaTime = 1.0 / 60.0; // 60FPS
System.out.println("\n--- 前10帧位置采样 ---");
for (int frame = 0; frame < 600; frame++) {
line.update(deltaTime, path, beats, gameTime);
gameTime += deltaTime;
if (frame % 60 == 0) {
Vec2 pos = path.pointAtDistanceRatio(line.pathProgress);
double angle = path.tangentAngleAtRatio(line.pathProgress);
System.out.printf("t=%.2fs 进度=%.3f 位置=(%.1f,%.1f) 角度=%.1f°%n",
gameTime, line.pathProgress, pos.x, pos.y, Math.toDegrees(angle));
}
if (!line.alive) {
System.out.println("\n到达终点!");
break;
}
}
System.out.printf("\n尾迹点数: %d%n", line.trail.size());
System.out.println("运行结束。");
}
}
复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 贝塞尔曲线求值 | O(1) | O(1) | 三次多项式计算 |
| 弧长近似 | O(N) | O(1) | N为采样分段数 |
| 路径比例采样 | O(log S) | O(S) | S为段数,二分查找定位 |
| 节拍检测 | O(F) | O(F) | F为音频总帧数 |
| 游戏循环每帧 | O(1) | O(T) | T为尾迹最大长度 |
贝塞尔曲线求值和导数计算均为常数时间,路径跟随通过弧长缓存将每帧定位降至对数级。整个引擎可在60FPS下流畅运行。
扩展方向
- 频域节拍检测:将能量差分升级为频谱通量(Spectral Flux),区分鼓点、贝斯等不同乐器;
- 多线同步:支持多条线并行跟随不同声部(如旋律线+节奏线);
- 碰撞检测:将道路两侧建模为平行偏移曲线,实时检测线体是否偏离路面;
- 3D扩展:将贝塞尔曲线升为三维空间曲线,实现过山车式的立体关卡。
跳舞的线将数学之美(贝塞尔曲线)与音乐之美(节拍同步)完美融合。通过本文的Java实现,读者可以深入理解参数化曲线与音频信号处理的核心原理,并构建自己的节奏游戏原型。