一个跑在终端里的 6 键节奏游戏。Rust 写的,ratatui 渲染界面,cpal 处理音频,支持导入 osu!mania 谱面。在 16ms 帧率的终端里做音游,听起来离谱,但跑起来真的能玩。
为什么要在终端里做音游 #
音游通常是图形密集型应用——下落的音符、炫酷的特效、实时的反馈。终端环境看起来完全不适合。
但终端有自己的优势:零依赖、SSH 可玩、资源占用极低。如果能在终端里做出流畅的音游体验,说明对系统底层的控制已经到位了。
说实话,一开始只是想练 Rust。选音游是因为技术挑战足够多:实时音频、毫秒级输入判定、60fps 渲染、文件格式解析。每个模块都能深入纯好玩。
架构 #
项目分 8 个模块,各管各的:
src/
├── app/ 主应用流程:启动闪屏、歌曲选择、游戏循环
├── audio/ cpal 音频播放 + 时钟同步
├── chart/ 谱面解析、调度、投影
├── config/ 配置加载、按键映射、品牌定制
├── content/ 曲目目录(内置 + osu 导入)
├── gameplay/ 判定逻辑、计分、状态机
├── osu/ osu!mania 谱面解析与导入
├── runtime/ 游戏时钟、输入处理、回放
└── ui/ ratatui 组件 + kitty 图片协议核心依赖:
ratatui = "0.29" # TUI 框架
crossterm = "0.28" # 终端控制
cpal = "0.15" # 跨平台音频
symphonia = "0.5" # 音频解码(MP3/OGG/WAV)
image = "0.25" # 封面图处理crossterm 只管终端控制(光标、颜色、输入事件),不管音频。音频需要单独的库。cpal 是 Rust 生态里最成熟的跨平台音频库,symphonia 负责解码各种音频格式。
游戏循环:16ms 一帧 #
音游对帧率敏感。Seythm 用固定 16ms 帧时间(约 60fps)驱动游戏循环:
const FRAME_TIME: Duration = Duration::from_millis(16);每帧做三件事:
- 轮询输入——crossterm 的非阻塞事件,不会卡渲染
- 推进状态——音符下落、判定触发、计分更新
- 渲染 UI——ratatui 画到终端
loop {
let frame_start = Instant::now();
// 1. 输入
poll_input(&mut app);
// 2. 状态
app.advance(current_time);
// 3. 渲染
terminal.draw(|f| render_demo(f, &app))?;
// 等到下一帧
let elapsed = frame_start.elapsed();
if elapsed < FRAME_TIME {
std::thread::sleep(FRAME_TIME - elapsed);
}
}游戏循环不需要 async。每一帧都是同步的:读输入 → 更新状态 → 画界面。异步反而会增加复杂度,没有实际收益。

判定系统 #
音游的核心是判定——玩家按下的时间和音符应该到达的时间差多少。
Seythm 实现了标准四档判定:
| 判定 | 分值 | 含义 |
|---|---|---|
| Perfect | 1000 | 几乎完全对齐 |
| Great | 800 | 稍有偏差 |
| Good | 500 | 勉强算命中 |
| Miss | 0 | 没按到或差太远 |
判定逻辑很直接——算偏移量,看落在哪个窗口:
pub fn classify_offset(self, offset_ms: i64) -> Judgment {
let distance = offset_ms.abs();
if distance <= self.perfect_ms {
Judgment::Perfect
} else if distance <= self.great_ms {
Judgment::Great
} else if distance <= self.good_ms {
Judgment::Good
} else {
Judgment::Miss
}
}
判定窗口不能太大也不能太小。太大会让游戏无聊(怎么按都 Perfect),太小会让人崩溃(明明按对了还是 Miss)。参考 osu!mania 的默认值,再根据终端输入的延迟微调。
音频时钟同步 #
音频播放和游戏时钟必须同步。如果音频有延迟但游戏时钟没跟着调,判定就会偏——你觉得按对了,但系统说你偏了。
Seythm 用 cpal 做底层音频输出,配合独立的游戏时钟模块(runtime/clock.rs)。音频回调和游戏逻辑共享同一个时间基准。
// 音频回调里更新时钟
fn audio_callback(data: &mut [f32], info: &OutputCallbackInfo) {
// cpal 提供精确的音频时间戳
let audio_time = info.timestamp();
// 用音频时间校准游戏时钟
clock.sync(audio_time);
}osu!mania 谱面导入 #
Seythm 支持导入 osu!mania 的 beatmap 文件。这意味着有成千上万的现成谱面可以用。
.osu 文件是一个文本格式,包含元数据、timing point、hit object 等信息:
[Metadata]
Title:某首歌
Artist:某个人
[TimingPoints]
1000,300,4,1,0,100,1,0
[HitObjects]
64,192,1000,1,0,0:0:0:0:解析器在 osu/parser.rs 里,把文本格式转换成内部的 Chart 结构。导入流程在 osu/import.rs——读文件、解析、验证、保存到曲目目录。
osu! 有多个模式(standard、taiko、catch、mania)。Seythm 是 6 键音游,只有 mania 模式的 6K 谱面能直接用。其他模式暂时没做。
终端图片协议 #
Seythm 支持在终端里显示专辑封面。实现方式是 kitty 图片协议——一种终端内嵌图片的标准。
// 检测终端是否支持 kitty 图片协议
fn terminal_supports_graphics() -> bool {
// 发送查询指令,看终端回复什么
// kitty/ghostty 会回复支持信息
// 其他终端会忽略
}
如果终端不支持(比如 GNOME Terminal),自动降级为纯文本模式。游戏本身不受影响,只是没有封面图。
交互设计 #
终端的 UI 空间有限。Seythm 用 ratatui 的 widget 系统把界面拆成独立组件:
- startup — 启动闪屏 + ASCII logo
- song_select — 歌曲浏览、搜索、排序
- gameplay — 6 车道下落音符 + 判定反馈
- results — 结算画面(分数、准确率、最大连击)
- settings — 设置面板(音量、延迟校准、主题)
- calibration — 延迟校准工具
品牌信息(名称、标语、logo)通过 assets/brand.toml 配置,不硬编码在代码里。这样换一套品牌就是另一个产品。
测试 #
项目有 13 个测试文件:
tests/
├── judgment_windows.rs 判定窗口边界
├── scoring.rs 计分正确性
├── chart_parser.rs 谱面解析
├── osu_import.rs osu 导入
├── osu_parser.rs .osu 文件解析
├── replay_flow.rs 回放系统
├── clock_sync.rs 时钟同步
├── config_loading.rs 配置加载
├── content_catalog.rs 曲目目录
├── chart_scheduler.rs 谱面调度
├── demo_state.rs 演示模式
├── imported_view.rs 导入视图
└── judgment_windows.rs 判定窗口游戏逻辑和 UI 完全分离,核心算法全部可测。这也是为什么模块拆得这么细——gameplay 不依赖 ui,chart 不依赖 audio,每个模块都能独立测试。
状态 #
Alpha 阶段。核心循环可玩,但产品还在快速迭代。
- Linux:主要开发平台,最稳定
- macOS:理论上可行,需要真机测试
- Windows:代码能编译,但终端图片预览和设备行为需要验证
# 构建并运行
cargo run
# 自动演示模式(看 AI 怎么打)
cargo run -- autoplay
# 导入 osu!mania 谱面
cargo run -- --import-osu /path/to/beatmap-folder