有人问过我一个挺有意思的问题:拿C语言能不能从头搓一个舞蹈节奏类游戏出来,不靠现成大引擎,也不改别人客户端那套东西,就自己写主循环、自己解音频、自己做判定。标题里那个“2.1.4”,其实就是我自己这个项目的迭代号,从最早期一个只能跟着节拍闪方块的Demo,一路改到能跑完整首歌、带连击和评级、手感还算能看的版本。这篇就把整条路摊开讲一遍:C语言做节奏游戏真正难的地方在哪,音画同步为什么是所有问题的根,判定窗口怎么定才不别扭,以及我在输入延迟、缓冲、漂移这些坑里是怎么爬出来的。
先把话说清楚:这里做的是从零自研的客户端,玩的是自己写的谱面、自己打包的音频。改别人客户端、做自动打击、做挂机脚本这类东西我不碰,也不建议碰,一方面是法律和协议的边,另一方面是那玩意儿根本学不到东西——你把判定交给一段模拟按键的代码,自己永远不会知道一个节奏游戏的手感是怎么被一毫秒一毫秒抠出来的。这篇文章适合两类人:一类是会点C、想搞明白游戏主循环和实时音频到底怎么配合的人;另一类是做着玩、想给自己写个小工具或者小游戏的人。基础不用很深,指针、结构体、函数指针能看懂就够了,剩下的我会把“为什么这么干”讲透。
1. 整体架构与方案选型:为什么敢用C硬写
1.1 先把“手感”拆成可测量的四个量
很多人一上手就想画界面,我觉得这是最容易走偏的起点。节奏游戏的手感,本质上不是美术给的,是四个可测量的量叠出来的:时间精度(你判定用的时间戳到底准到几毫秒)、输入延迟(手指按下到程序收到,链路有多长)、视觉反馈延迟(程序决定命中到屏幕真的亮起来,中间隔了几帧)、判定宽容度(窗口开多大,玩家容错多少)。这四个量里任何一个失控,玩家就会说“这游戏打得我很难受”,但他自己说不清难受在哪。
我最初那版就是典型反例:主循环按帧跑,判定时间直接用帧号乘以帧时长。结果显示器一换、垂直同步一开,同一份谱面在一台机器上全Perfect,在另一台机器上满屏Good。后来我才想明白,帧号是个非常不可靠的时间源——它会掉帧、会被窗口拖动卡住、会在切后台的时候一次性补一堆。真正稳的时间源只有一个:音频本身播到哪儿了。这个认知转变,是整个项目从“玩具”变成“能玩”的分水岭。
所以架构的第一条原则定下来了:音频时钟是唯一权威,其他一切时间都从它派生。逻辑更新、音符位置、判定、特效计时,全部读同一个song_time。这条原则一旦松动,后面所有调参都是白费劲,因为你根本不知道误差是从哪一层进来的。
1.2 技术栈为什么不选大引擎
有人会问,用个成熟引擎半天就能搭出界面,何必自找麻烦。我的理由是三条,而且都是实操中真金白银换来的。
第一是可控性。节奏游戏对时间的敏感度远超一般游戏,引擎帮你封装的更新循环、音频回调、事件队列,恰恰是你最需要知道细节的三块。封装越厚,出了问题你越只能靠猜。我自己写主循环,掉一帧我能立刻定位是哪一步慢了,而不是对着引擎日志发呆。
第二是可测性。我需要能在没有窗口、没有声卡的环境里跑判定逻辑的回归测试——喂一串“按键时间+音符时间”的样本进去,看输出对不对。C写的纯逻辑模块天然好测,把渲染和音频一剥离,判定函数就是个纯函数。
第三是体积和启动速度。一个几十兆的运行包和一个几百K的可执行文件,体验是两回事,尤其你想扔到老旧机器上跑的时候。
具体选型我最后落在SDL2 + miniaudio这个组合上。SDL2负责窗口、渲染、输入、事件,跨平台稳、文档多、坑都被前人踩平了;miniaudio是单头文件音频库,直接把miniaudio.h拖进工程就能用,解码mp3/ogg/flac、开设备、拿播放进度全都有,不需要额外装依赖。这套组合的好处是编译极简,一个gcc main.c -lSDL2 -lm -lpthread -ldl基本就能跑起来,新机器上五分钟能复现。
注意:SDL2 里
SDL_GetTicks()精度只有毫秒,而且返回值会回绕。要测短间隔一律用SDL_GetPerformanceCounter()配SDL_GetPerformanceFrequency()换算,别图省事。
1.3 模块划分与数据流向
我最终把工程切成六块,边界尽量清晰,每块只依赖它下面那层:
| 模块 | 职责 | 关键约束 |
|---|---|---|
audio | 解码、播放、提供权威时钟 | 不依赖渲染,任何时刻可查询 |
chart | 谱面解析、音符列表、轨道索引 | 纯数据,无副作用 |
input | 采样按键、打时间戳、进队列 | 不打业务逻辑,只记录 |
judge | 消费输入+音符,产出判定结果 | 纯函数,可单测 |
render | 画音符、判定线、特效 | 只读状态,不改状态 |
game | 状态机、计分、连击、存档 | 粘合剂,尽量薄 |
数据流是单向的:audio给出song_time→game驱动更新 →input队列里的原始事件被judge逐一消化 → 判定结果写回game状态 →render读状态出画面。单向数据流最大的好处是排错时你能二分:画面不对,是状态错了还是渲染错了?状态错了,是判定错了还是输入时间戳错了?一层层往下砍,比在一坨互相调用的代码里瞎猜高效太多。
2. 核心难点拆解:时间轴、判定窗口与“完美”的定义
2.1 为什么音频时钟不能用系统时间代替
我见过不少自制节奏游戏在这一步翻车:用系统单调时钟记录“游戏开始到现在过了多久”,然后拿这个时间当音乐进度。短时间看没问题,跑两分钟就开始飘,因为声卡的采样时钟和你主板的时钟压根不是同一个晶振,两者存在微小的频率差。这个差值通常在几十ppm量级,听起来很小,但换算下来一分钟可能就差几毫秒,一首三分钟的歌累计下来十几毫秒,刚好卡在Perfect和Great的边界上,玩家会觉得“前面好好的,越到后面越对不上”。
正确做法是问音频库“你已经播到第几帧了”。miniaudio 里我用的是自己累计的方式:在音频回调里,把这次要写出的帧数加到written_frames上;查询时用written_frames / sample_rate得到“已经交给声卡的时长”,再减去设备缓冲的延迟,才约等于“玩家耳朵里听到的位置”。伪代码如下:
/* 只在音频回调线程里写, 主线程只读, 用 atomic 保护 */ static _Atomic uint64_t g_written_frames = 0; static uint32_t g_sample_rate = 44100; static void data_cb(ma_device *dev, void *out, const void *in, ma_uint32 frames) { (void)dev; (void)in; /* 这里填 PCM 数据到 out, 我省略了具体混音 */ atomic_fetch_add(&g_written_frames, frames); } /* 主线程调用: 返回"当前听到"的音乐时间(秒) */ double audio_now(void) { uint64_t f = atomic_load(&g_written_frames); double t = (double)f / (double)g_sample_rate; return t - g_output_latency; /* 输出缓冲延迟, 需实测校准 */ }这里那个g_output_latency是必须实测的。设备缓冲、系统混音层、蓝牙耳机,每一层都会吞掉时间,蓝牙能吞一百多毫秒,这就是为什么戴蓝牙耳机打音游永远对不准。我的做法是给玩家一个“延迟校准”界面:让他跟着节拍器连点十六下,取所有偏差的中位数,反过来写进这个参数。中位数比平均值抗误触,实测稳定得多。
2.2 判定窗口怎么定才不别扭
窗口数值没有标准答案,但有一个经验区间。太严,玩家挫败;太松,打起来没反馈感,“完美”两个字就贬值了。我最后用的是单边误差,以音符时间为零点,输入时间落在哪个区间就算哪档:
| 判定 | 单边误差 | 分数系数 | 连击 | 说明 |
|---|---|---|---|---|
| Perfect | ≤ 45 ms | 1.0 | 累计 | 手感目标区,正常人稳定命中率 85% 左右 |
| Great | ≤ 90 ms | 0.7 | 累计 | 明显偏了但没崩 |
| Good | ≤ 140 ms | 0.4 | 累计 | 快糊了,听觉上已经开始错拍 |
| Miss | > 180 ms | 0 | 清零 | 超过这个数基本是没按或按早了 |
| 空打 | 无对应音符 | 0 | 清零 | 乱按要罚,不然玩家会无脑连点 |
几个细节值得说。第一,Good到Miss之间那40毫秒是“真空区”,我故意留白,让超过180毫秒的输入直接判Miss而不是算Good,否则会出现“乱按也有分”的漏洞。第二,空打必须罚,这是节奏游戏的底线,没有惩罚就等于鼓励连点,整套判定失去意义。第三,连击清零只针对Miss和空打,Good不清,因为Good本身扣分系数已经够了,再清连击惩罚过重。
代码上判定就是个纯函数,输入是时间差,输出是枚举:
typedef enum { J_NONE, J_PERFECT, J_GREAT, J_GOOD, J_MISS } Judge; static const double W_PERFECT = 45.0; static const double W_GREAT = 90.0; static const double W_GOOD = 140.0; static const double W_LATE = 180.0; /* 超过即视为没打中 */ Judge judge_by_delta(double delta_ms) { double a = fabs(delta_ms); if (a <= W_PERFECT) return J_PERFECT; if (a <= W_GREAT) return J_GREAT; if (a <= W_GOOD) return J_GOOD; if (a > W_LATE) return J_MISS; return J_GOOD; /* 140~180 之间我按 Good 处理, 你想更严可以改 Miss */ }写这段的时候注意一件事:音符不是无限期等待的。一首歌几百个音符,如果你只用“最近的那个音符”做比较,会出现玩家漏了一个音符后,下一次按键去匹配已经过去很久的旧音符,判出一个离谱的Good。我给每个音符加了一个expired标记,当song_time超过音符时间加180毫秒还没被消费,就自动判Miss并推进游标,这样永远不会错位。
2.3 谱面格式:自己定一套够用就行的
我没用现成的谱面标准,因为需求简单:时间、轨道、类型、时长。自定义了一个纯文本格式,好处是可读、可手改、diff友好,出问题能一眼看出来哪行错了。
# chart v1 title = demo audio = demo.ogg offset = -38 # 全局偏移, 毫秒, 正数表示音符整体后移 bpm = 128 lanes = 4 [notes] # time(s) lane type hold(s) 1.250 3 tap 0 1.719 1 tap 0 2.188 2 hold 0.938 2.891 0 tap 0解析用fgets逐行读,#和空行跳过,[notes]之后切到音符模式,每行用sscanf("%lf %d %7s %lf")取四个字段。这里有个坑我踩过:某些编辑器会在文件开头塞BOM,sscanf会静默失败,音符全空。解决办法是读第一行时判断前三个字节是不是EF BB BF,是就往后跳三字节。
还有一个更隐蔽的坑:浮点数精度。一秒一百多毫秒的判定精度,double完全够,但如果你图省事用float存时间,加到几分钟的时候有效位数就不太够了,误差会攒到几毫秒。所有时间一律用double,别在这上面省内存。
长条(hold)的判定比单点复杂一层:按下时认为开始,中途松开算断,撑到结尾才算完成。我的实现是给 hold 加一个holding状态和一个held_until时间,松手时刻距结尾超过120毫秒就判断,否则算完成。这个数值没有定论,抓的是玩家体感——松得太早给点宽容,太晚就别管了。
2.4 输入延迟:从手指到判定的整条链路
这条链路我拆成五段,每段都能独立优化:键盘扫描率 → 操作系统事件队列 → 你的程序取事件 → 打时间戳 → 送进判定。其中前两段你控制不了,后三段完全可控。
可控的部分里,最容易犯的错是在渲染之后才处理输入。如果你的循环顺序是“更新→渲染→读输入”,那玩家的按键要等到下一帧才被处理,等于白送一帧延迟。正确顺序是“读输入(打时间戳)→更新→判定→渲染”。时间戳一定要在读到事件的那一刻就打,别拖到逻辑里再打,中间的任何排队都会污染数据。
第二个错是开垂直同步还指望低延迟。垂直同步会引入一到两帧的等待,在60Hz下就是16到33毫秒,对判定窗口只有45毫秒的Perfect来说,这一下就吃掉大半。我的做法是默认关垂直同步,帧率不锁,用固定步长的逻辑更新来保证判定精度——渲染快慢不影响判定,因为判定读的是音频时间,不读帧号。
第三个容易被忽视的是事件合并。有些人为了“省事”,同一帧里同一个键按下两次只取最后一次,结果快速连击丢了。我的输入队列是一个环形缓冲,每个事件带一个时间戳,一个不漏地存,判定时按时间顺序消费。缓冲大小给到1024,正常人两秒内按不出这么多。
3. 实操过程:从空目录到能跳完一首歌
3.1 工程骨架与编译
目录我习惯这么放,简单粗暴:
rhythm/ src/ main.c audio.c audio.h chart.c chart.h input.c input.h judge.c judge.h render.c render.h game.c game.h third_party/miniaudio.h assets/demo.ogg demo.chart编译命令分两步走,先确认SDL能找到,再链进去:
gcc -O2 -std=c11 -Wall -Wextra -c src/*.c gcc -O2 *.o -o rhythm -lSDL2 -lm -lpthread -ldlWindows下换成-lmingw32 -lSDL2main -lSDL2,并且main要写成int main(int argc, char **argv),否则SDLmain会报链接错误——这个错我第一次遇到时查了半小时,其实就是入口签名不对。
注意:
-Wall -Wextra一定要开。C里大量隐式转换和未初始化变量的问题,编译器其实都能提示,只是很多人不开。我在这个项目里至少三次靠警告发现了double被悄悄截断成int的 bug。
3.2 主循环:固定步长加插值
主循环是整个程序的骨架,我采用的是“固定逻辑步长 + 累加器 + 渲染插值”的经典结构。逻辑步长设4毫秒,也就是250Hz,够细,又不至于把CPU吃满。
#define TICK_S 0.004 /* 逻辑步长, 秒 */ #define MAX_FRAME 0.100 /* 单帧最多追 100ms, 防止切后台回来炸掉 */ double prev = now_seconds(); double acc = 0.0; while (running) { double cur = now_seconds(); double frame = cur - prev; prev = cur; if (frame > MAX_FRAME) frame = MAX_FRAME; /* 关键: 钳制 */ acc += frame; input_pump(); /* 先取输入, 打时间戳 */ while (acc >= TICK_S) { game_update(TICK_S); /* 逻辑只认固定步长 */ acc -= TICK_S; } double alpha = acc / TICK_S; /* 0~1, 给渲染插值 */ render_frame(alpha); }那个MAX_FRAME钳制是我吃过亏之后加的。早期版本没钳制,玩家按Alt+Tab切出去看个网页再切回来,累加器里攒了十几秒,程序一口气跑几千次更新,音符瞬间全判Miss,画面卡死两秒。钳制之后,切回来最多补100毫秒,视觉上就是轻微跳一下,可接受。
插值的作用是让画面在低帧率下也不抖。音符的屏幕位置由song_time算出,但song_time只在逻辑步里更新,如果直接用它画,60Hz屏幕下会出现音符位置一顿一顿的现象。用alpha在上一帧位置和当前帧位置之间插值,视觉就顺滑了:
double note_y = judge_line_y - (note->time - song_time_interp) * px_per_sec;px_per_sec是下落速度,这个值直接决定难度观感。我的经验是:让音符从屏幕顶端落到判定线大约用1.2到1.6秒,对应px_per_sec = 屏幕高度 / 1.4,这个区间对新手友好,老手可以调到0.7秒。
3.3 音频初始化与时钟校准
miniaudio的初始化很短,但每一步都不能漏。设备配置里采样率给44100,格式用ma_format_f32,声道2。解码用ma_decoder,先读进内存再播,避免边解边播在慢速磁盘上卡顿。
ma_device_config cfg = ma_device_config_init(ma_device_type_playback); cfg.playback.format = ma_format_f32; cfg.playback.channels = 2; cfg.sampleRate = 44100; cfg.dataCallback = data_cb; cfg.pUserData = &g_ctx; if (ma_device_init(NULL, &cfg, &g_device) != MA_SUCCESS) return -1; ma_device_start(&g_device);时钟校准这一步必须做,而且必须让玩家自己做,因为每个人设备不一样。我设计的流程是这样:进入校准页,播放一段60BPM的节拍器(一秒一下),屏幕上有一个圆环在节拍点收缩。玩家在圆环收缩到最小的瞬间按空格,连按十六次。程序记录每次按下的raw_time和最近的节拍点beat_time,算出偏差,取中位数,取反写进offset。
为什么取中位数而不是平均?因为人会走神。十六次里有两三次手滑,平均值会被拉偏,中位数天然抗异常值。实测下来,同一个人同一套设备的两次校准结果差异通常在±3毫秒以内,够用。
3.4 判定、连击与评分的完整实现
判定模块我做成一个“推进器”,每次逻辑更新时调用一次,内部维护每个轨道的游标,避免每次按键都从头遍历音符列表。
typedef struct { double time; int lane; int is_hold; double hold_len; int state; /* 0=待判 1=按住中 2=已完成 3=已Miss */ } Note; void judge_on_press(Game *g, int lane, double t) { Note *best = NULL; double best_d = 1e9; for (int i = g->cursor[lane]; i < g->note_count; i++) { Note *n = &g->notes[i]; if (n->lane != lane) continue; if (n->state != 0) continue; double d = (t - n->time) * 1000.0; if (d < -W_LATE) break; /* 还没到, 后面的更晚, 直接退出 */ if (fabs(d) < fabs(best_d)) { best_d = d; best = n; } } if (!best) { g->combo = 0; g->miss++; return; } /* 空打 */ Judge j = judge_by_delta(best_d); apply_judge(g, j, best); } void judge_tick(Game *g, double t) { for (int i = 0; i < g->note_count; i++) { Note *n = &g->notes[i]; if (n->state != 0) continue; if ((t - n->time) * 1000.0 > W_LATE) { n->state = 3; /* 超时判Miss */ g->combo = 0; g->miss++; if (i == g->cursor[n->lane]) g->cursor[n->lane]++; } } }计分我用了简单的加权:基础分100万,score = Σ(系数 × 1000000 / note_count),最后四舍五入。这样不同长度的曲子满分都是100万,榜单可读性好。连击加成我没做进分数,只作为独立的最大连击统计展示——加进去会让分数膨胀,玩家反而不理解为什么同样的命中率分数差这么多。
3.5 渲染与反馈:让判定“看得见”
节奏游戏的反馈比画面精致重要得多。我的渲染一共就四层:背景、音符轨道、音符本体、判定特效。判定线上那一下闪光,比任何华丽特效都有效——玩家需要的是“我这一下打得准不准”的即时确认。
特效我用一个简单的粒子池,命中Perfect时在判定线对应轨道位置炸出十二个小方块,随机方向飞散,300毫秒内淡出。粒子数量固定,不动态分配,池子满了就覆盖最老的,这样绝不会因为特效太多掉帧。
typedef struct { float x, y, vx, vy, life; uint8_t r, g, b; } Particle; static Particle g_pool[256]; static int g_pool_head = 0; void spawn_hit(float x, float y, int count) { for (int i = 0; i < count; i++) { Particle *p = &g_pool[g_pool_head]; g_pool_head = (g_pool_head + 1) % 256; p->x = x; p->y = y; p->vx = (rand() % 200 - 100) * 0.6f; p->vy = (rand() % 200 - 100) * 0.6f - 60.0f; p->life = 0.30f; } }音符的视觉尺寸也要跟判定窗口挂钩。我一开始把音符画得很小,结果玩家反馈“看不见自己有没有打中”,后来把音符高度设成和Perfect窗口在屏幕上的物理距离等宽——这样玩家看一眼就知道自己的容错范围有多大,非常直观。
4. 联调排查:卡顿、漂移、爆音怎么定位
4.1 常见问题速查表
下面这些是我这个项目2.1.4版本之前踩过的真实问题,按“现象 → 最可能原因 → 处理”整理,遇到类似情况可以直接照着查:
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 前半首准,后半首越打越偏 | 用了系统时钟而非音频时钟 | 改用已播放帧数换算 |
| 全部音符偏早/偏晚固定值 | 全局 offset 没校准或设备延迟 | 跑校准流程,写回 offset |
| 戴蓝牙耳机完全打不中 | 输出延迟被忽略 | 手动加大 offset,或提示用户换有线 |
| 音符下落一顿一顿 | 渲染直接用逻辑时间,无插值 | 加 alpha 插值 |
| 长条总是断 | 松手判定窗口过窄 | 尾端宽容放到 120ms 以上 |
| 切后台回来瞬间满屏 Miss | 累加器未钳制 | 单帧步进上限钳到 100ms |
| 偶发爆音、杂音 | 回调里做了解码或分配内存 | 回调只做拷贝,解码放主线程 |
| 快速连击丢键 | 输入队列过小或事件被合并 | 环形缓冲扩到 1024,逐事件消费 |
4.2 时间漂移的分层排查法
漂移最难查,因为它不是每次都出现。我的排查顺序是固定的三层,从下往上砍:
第一层,音频时钟本身准不准。写一个最小测试:播放一段十秒的正弦波,每秒钟打印一次audio_now()和真实墙钟的差值。如果这个差值在十秒内变化超过3毫秒,问题在音频层,通常是采样率不匹配(设备实际跑48000,你按44100算)或者缓冲延迟算错了。这一层不清干净,上面全白搭。
第二层,判定用的时间戳是不是在正确的时机打的。我在输入处理函数里加过一个临时日志,记录事件从SDL_WaitEvent返回到写进队列的微秒数,正常情况下应该是几微秒,如果偶尔跳到几百微秒,说明主线程被渲染卡住了。这时候就要去看渲染有没有阻塞调用。
第三层,渲染和逻辑有没有互相污染。最常见的表现是渲染函数里顺手改了状态,比如“画完就把粒子计时减一点”,导致粒子寿命和帧率挂钩。这类问题只能靠规矩避免:渲染函数签名里传const Game *,物理上禁止修改,编译器会帮你抓住。
4.3 资源和内存的几条硬规矩
C写游戏,内存这块必须自己立规矩,不然到了一定规模就烂。我给自己定了四条:
- 音频解码出来的PCM数据,一首歌大概几十兆,直接用
malloc一次分配,退出时释放,不做动态扩容,避免碎片; - 音符数组在解析时先数一遍行数再分配,一次到位,不搞
realloc循环; - 回调函数里绝不
malloc、绝不加锁、绝不做IO,只做内存拷贝,这是硬要求; - 所有
SDL_Surface、SDL_Texture、ma_decoder在退出路径上统一走一个cleanup(),用goto串起来,别在每个分支里各写一遍释放逻辑。
提示:调试内存问题的时候,用
-fsanitize=address,undefined编译一遍,很多越界和未定义行为会当场暴露。我在这上面发现过一个很隐蔽的 bug:谱面解析时传了strtod的指针没往后移动,导致所有音符时间都是第一个值,表面上看画面正常,实际所有音符堆在同一个时间点。
5. 谱面工具与后续扩展:让它真的能一直玩下去
5.1 手搓一个极简谱面编辑器
手写谱面文件写十行就烦了。我花了一个下午做了个最简单的编辑器:播放音乐,四个轨道,按键时把当前audio_now()写进一个音符结构,实时显示在轨道上,退出时导出成刚才那个文本格式。总共不到四百行代码,但效率提升是数量级的。
编辑器的核心技巧是吸附。人按键盘永远按不到精确的1/16拍上,所以要按BPM算出一个网格,落点自动吸附到最近的格子上。128BPM下一拍是0.469秒,1/16拍就是29毫秒,这个粒度对听觉来说刚好可分辨。吸附之后谱面整齐,玩起来也稳。
另一个技巧是试听循环。选中某一段,按R键循环播放这一段,边听边改,改完接着听。没有这个功能,每改一个音符都要从头听一遍,做一首三分钟的歌能耗掉一整天。
5.2 后续可以往哪几个方向长
这个项目我没打算停,但也不急着堆功能。想清楚优先级更重要,我列了三个方向的取舍:
方向一,多押与变轨。现在的四轨固定布局比较简单,加上左右手分工和轨道切换会更有舞蹈感。代价是谱面复杂度上来,编辑器要跟着改,工作量不小。我倾向先做多押(同时按两个键),因为它只影响判定,不动渲染结构。
方向二,判定参数可视化调参。现在所有窗口数值都是编译期常量,改一次要重新编译。改成运行时配置文件读取,玩家可以自己调宽容度,甚至做成几个预设档位(休闲/标准/硬核)。这个投入产出比最高,我下一步就做这个。
方向三,录像与回放。记录每次按键的时间戳和判定结果,存成一个小文件,回放时按时间轴重放。这个功能对调参极有帮助——你可以反复看一局里到底是哪几个音符判得勉强,而不是凭感觉改数值。文件格式简单得可怜,就是一行行的time_ms lane judge。
至于更远的东西,比如联网对战、自定义皮肤、体感输入,我暂时不碰。一个项目的死法通常不是功能太少,而是摊子铺太大最后每样都半成品。把一首歌从点击开始到结算展示这条主链路打磨到自己愿意反复玩,比加十个花哨功能有意义。
最后说点个人体会。这个项目走到2.1.4,改得最多的不是渲染,也不是谱面,而是判定和时钟这两块看起来最“没画面”的东西。我一开始也觉得写游戏就是画得好看,后来才明白节奏游戏是个测量仪器——玩家每一次按键都是一次测量,你所有代码的意义就是把这次测量做得足够准、足够快、足够诚实。搞清楚这一层,剩下的事情就是把每一毫秒从各个环节里抠出来还回去。我至今还记得第一次把自己做的谱面打出全Perfect那一下的感觉,比任何特效都爽。