做音游谱面调校时,最容易出现的问题不是“谱面写不出来”,而是“写出来之后看不到”。谱面在编辑器里是一串串数字,在数据文件里是 JSON 对象或二进制字段,只有把它渲染成一个随时间推进的画面,才能判断键位布局、密度变化、长条衔接和段落爆发是否合理。铺面预览要解决的,正是这个“把静态数据还原成动态过程”的问题。
这篇文章围绕《In Falsus》曲包中的 New Vision FBD 11 与 Sin Utopia ULT 10 两个铺面展开。它们是同一批谱面里需要反复预览和验收的对象。文中会用一个最小可运行的 Python 预览工具,演示如何把谱面 JSON 解析成统一的内存对象,再按帧渲染成视频,最后通过命令行同时导出两个谱面的预览结果。读者看完后,可以把这个思路移植到自制谱面工具、谱面评审系统或音游编辑器插件中。
1. 先理解铺面预览到底在还原什么
1.1 铺面预览不是画静态图,而是按时间轴重放
在不少音游谱面作者的习惯里,chart 会写成“铺面”,输入标题里也沿用这个叫法。实际含义就是谱面。铺面预览要还原的内容,不是一张铺满音符的截图,而是一段时间轴上的状态变化。
以常见的下落式音游为例:每一帧画面中,note 都有一个由“当前时间”决定的位置。某个 note 的判定时间越靠后,它离判定线越远;当前时间越接近判定时间,它就越靠近判定线。到了判定瞬间,note 会落在判定线上并消失。长条 note 还要额外处理头部命中、身体拖尾和尾部判定三个阶段。
因此,预览器的核心时钟非常重要。它要从谱面第一个可视时间开始,按固定帧率推进,并在每一帧根据当前时间计算所有可视化物件的位置。预览器写的对不对,不取决于某一帧画得是否精美,而取决于所有帧连起来之后,note 是否按照谱面数据中记录的节奏准确出现。
对于 New Vision FBD 11 这种中高密度的铺面,预览主要用来检查键位有没有重叠、双押是否可读、爆发段是否让玩家来不及反应。对于 Sin Utopia ULT 10 这种高难度铺面,预览要紧盯长条和密集单点的衔接,以及变速段是否会导致 note 离判定线太近才出现。没有时间轴还原能力,这些判断都只能靠猜。
1.2 谱面 JSON 中必须能被预览器读懂的信息
预览器不关心谱面背景故事,也不关心难度名称怎么念,它只关心与时间轴和判定区域有关的数据。为了让两个谱面跑在同一套预览工具里,最好先把谱面源数据统一成一份“中间格式”。
下面这个 JSON 片段示意了这种中间格式的基本字段:
{ "schemaVersion": 1, "id": "sin_utopia_ult_10", "pack": "In Falsus", "difficultyTag": "ULT 10", "lanes": 4, "bpm": 185, "offsetMs": -12, "notes": [ { "timeMs": 960, "lane": 0, "type": "tap" }, { "timeMs": 1120, "lane": 3, "type": "tap" }, { "timeMs": 1248, "lane": 1, "type": "hold", "holdMs": 640 }, { "timeMs": 1920, "lane": 2, "type": "tap" } ] }这里有几个字段是预览器必须建立统一理解的:
| 字段 | 含义 | 解析阶段需要做的处理 |
|---|---|---|
lanes | 轨道数量 | 决定横向区间划分,解析时校验必须在合理范围内 |
bpm | 拍速 | 用于源数据从 beat 到 ms 的换算,换算完成后渲染器不再依赖 BPM |
offsetMs | 音频对齐偏移 | 用于换算绝对时间,避免散落在渲染逻辑里手改 |
timeMs | note 的判定时间 | 按绝对毫秒保存,并在内存对象中按时间排序 |
lane | 轨道编号 | 统一从 0 开始,解析时校验是否超出lanes |
type | 物件类型 | 至少区分tap和hold,后续可扩展drag、flick |
holdMs | 长条持续时间 | 普通tap记为 0,长条用来计算尾部时间 |
需要特别强调的是,不要把bpm和offsetMs放到渲染阶段去做时间换算。预览器逐帧运行,如果每一帧都重新解析源数据,逻辑会变得混乱且难以排查。正确做法是:先通过解析器把原始谱面转换成只包含绝对毫秒时间的中等对象,后续所有渲染只依赖这些对象。
2. 铺面预览器的最小实现链路
2.1 技术选型与目录结构
实现铺面预览通常有两种路线:一种是在游戏引擎里直接跑谱面验证,另一种是做一个纯预览脚本。前者环境重、启动慢,不适合谱面作者快速看区段;后者更轻,适合批量出图、出视频,也容易接入自动校验。
这里选择 Python 加 OpenCV 的方案。Python 负责解析 JSON 和写 CLI,OpenCV 负责逐帧绘制和视频编码。整个项目不依赖音游引擎,逻辑非常清楚。
chart-preview/ ├── requirements.txt ├── charts/ │ ├── new_vision_fbd_11.json │ └── sin_utopia_ult_10.json ├── preview/ └── src/ ├── parser.py ├── renderer.py ├── preview_cli.py └── config.yaml目录设计有几个目的:
charts目录只放谱面中间格式,可能是手工整理,也可能是从编辑器导入工具导出的。preview目录放输出视频和单帧图片,避免和源码混在一起。src目录按职责拆成解析器、渲染器和命令行入口。
requirements.txt内容如下:
numpy>=1.24 opencv-python>=4.8 PyYAML>=6.0OpenCV 自带的VideoWriter可以导出 MP4,numpy 用来创建帧缓存。PyYAML 用于批量配置,如果只想跑单个 JSON,也可以去掉这一项。
2.2 用解析器把谱面 JSON 转成渲染对象
解析器的作用是把 JSON 字段转成一个干净的 Python 数据结构。解析阶段只做转换和校验,不涉及绘图。下面代码定义了两个数据类,Note表示单个物件,Chart表示整张谱面:
from dataclasses import dataclass import json @dataclass class Note: time_ms: float lane: int kind: str = "tap" hold_ms: float = 0.0 @property def end_time_ms(self) -> float: return self.time_ms + self.hold_ms @dataclass class Chart: id: str pack: str difficulty_tag: str lanes: int bpm: float offset_ms: float notes: list[Note] @property def end_time_ms(self) -> float