制谱器开发文档
谱面 JSON 结构规范 + 编辑器插件系统:让游戏模组的效果在编辑器内可预览、可编辑、可保存
本页面向两类读者:
- 手写谱面的开发者:想直接用文本编辑器产出/加工 Theseus 谱面,需要知道 JSON 的准确结构与字段语义(Part A)。
- 第三方插件作者:已经或打算开发游戏模组,希望自己的自定义音符类型、扩展段数据、渲染效果在制谱器里同样可预览、可编辑、可保存(Part B)。
操作层面的用法见制谱器使用指南;游戏端模组 API 签名见 API 参考。
Part A:谱面 JSON 结构规范
主谱面 JSON
一个谱面对应 data/charts/ 下的一个 JSON 文件。制谱器认读以下顶层字段,未列出的字段会被原样保留:
| 字段 | 类型 | 说明 |
|---|---|---|
metadata.title / metadata.artist | string | 标题 / 艺术家;缺失时显示 Untitled / Unknown |
lanes | number | 轨道数,默认 4 |
audio.file | string | 音频文件引用 |
audio.offset | number | 音频偏移(毫秒),用于对齐 |
timingPoints | array | { time, bpm } 数组(毫秒 + BPM);缺失时以 {time:0, bpm:120} 兜底。多段实现变速 |
scrollSpeed | number | 预览下落速度(px/s 量级),默认 600 |
notes | array | 音符数组,见下文 |
disorderMarkers | array | 无序模式标记 { time, type },type 取 charge_start / charge_end / mode_start / mode_end,按 time 升序持久化 |
extensions | object | 插件自定义数据段,原样读写,见"extensions 段" |
最小可用示例:
{
"metadata": { "title": "My Song", "artist": "Alice" },
"lanes": 4,
"audio": { "file": "my_song.ogg", "offset": 0 },
"timingPoints": [{ "time": 0, "bpm": 150 }],
"scrollSpeed": 600,
"notes": []
}notes 字段
notes 数组中的每个元素是一个音符。内置类型共 5 种,type 值为小写字符串:normal、hold、barrier、exploit、payload。
| 字段 | 适用类型 | 说明 |
|---|---|---|
time | 全部 | 判定时间(毫秒)。保存时按 time 升序排序 |
lane | 全部 | 轨道索引,从 0 起 |
type | 全部 | 小写内置类型字符串;自定义类型为任意字符串(见下) |
endTime | hold | 长条结束时间;其他类型持久化为 null |
laneMask | barrier 等 | 位掩码(bit i = 第 i 条轨受影响),缺省按 1 << lane 补全 |
hitCount | barrier 等 | 命中次数,缺省 5 |
exploitId / parentExploitId | exploit / payload | 持久化的关联编号,无关联时 -1 |
EXPLOIT 与 PAYLOAD 的约束:PAYLOAD 需要同 lane 在其之前存在一个 EXPLOIT 作为宿主;制谱器删除 EXPLOIT 时会连带删除关联 PAYLOAD。手写谱面时请保持这一不变量,虽然 EXPLOIT 与 PAYLOAD 间的距离没有限制,不必强制相邻,可以将PAYLOAD音符作为其他音游常见的Drag等仅需保持快捷键触发的音符使用,但请注意保证EXPLOIT不会处于高难度段落,导致意外 miss 而扣除巨量血量。
自定义音符类型:游戏模组可以通过 NoteTypeRegistry 注册新 type(serializationKey 与此处的字符串一致)。规则是:
- 制谱器加载时不认识的自定义 type 会原样保留,不会降级成 normal,使用图片作为皮肤时在预览中降级为 normal 样式,但实际数据不变;
- 导出时除编辑器内部字段(id / parentExploitTime)外,其余字段全部原样写回;
- 若玩家安装了对应的编辑器插件,则该类型的显示名、颜色、符号与可编辑字段由插件提供。
_extra.json(事件文件)
主谱面同名目录下的 xxx_extra.json 承载事件。顶层结构:
{
"items": [],
"groupEndTimes": {}
}每个 item 代表一条事件:
{
"groupId": "g-abc",
"trigger": { "type": "time", "timeMs": 30000 },
"content": { "type": "...", "...config": {} },
"position": { "anchor": "track_right", "offsetX": 0, "offsetY": 0, "autoAdjust": true },
"animation": { "fadeIn": {}, "fadeOut": {}, "move": {} }
}seq 已废弃:ExtraContentItem 没有 seq 字段(旧谱面里的 seq 是历史残留,运行时从不读取)。同一 timeMs 下的执行顺序 = JSON 数组顺序(稳定排序只按 timeMs);编辑器导出时请按期望执行序排列 items。
trigger(3 种)
| type | 触发字段 | 含义 |
|---|---|---|
time | timeMs | 到达指定时间触发 |
combo | comboThreshold | 连击数达到阈值触发 |
noMiss | durationMs | 持续无 miss 达到时长触发 |
content(14 种)
旧谱面必须迁移(2026-10-17 起):真轨本地
(0,0,0)= 世界原点,轨道不再按车道序号自动排开。未写lane_pose的旧谱面会把全部轨道堆叠在世界原点(判定线在世界 y = 0、轨道体向上出画)。请给每轨补lane_pose(例如x = 60 + 100 * i、y = 判定线 y)或运行迁移脚本;整区偏移用track_pose。
| type | 配置键 | 关键参数 | ||
|---|---|---|---|---|
text | text | 纯文字(正交屏幕文本,白字,透明度由 animation/组计时驱动);position 支持 lane / entity 3D 投影锚点(详下) | ||
lane_alpha | laneAlphaConfig | lane, alpha(0-1;null = 不改变), affectKeyIndicator | ||
lane_pose | lanePoseConfig | lane, x/y(世界坐标,真轨本地 (0,0,0) = 世界原点)/z(深度 px), pitchDeg/yawDeg/rollDeg(度), scaleX/scaleY(钳 ≥0.01), animated, start*;分量一律可空,null = 不改变该分量。未写 lane_pose 时所有轨堆叠在世界原点,制谱者须自行平移摆位 | ||
track_pose | trackPoseConfig | x, y(轨道区整体偏移), animated, startX/startY | ||
ui_alpha | uiAlphaConfig | alpha(0-1;null = 不改变) | ||
entity_create | entityCreateConfig | id, kind("track" 假轨道 / "note" 假音符), mount(track:"lane:N" / "canvas" / "entity:ID";note:"entity:ID"), x/y/z, pitchDeg/yawDeg/rollDeg(度), alpha, noteType, lengthMs | ||
entity_pose | entityPoseConfig | id, kind(缺省 "track"), x/y/z, pitchDeg/yawDeg/rollDeg(rotationDeg = roll,度), alpha, scaleX/scaleY, animated, start*;可空分量同 lane_pose | ||
entity_deform | entityDeformConfig | id, skew, skewAnchorY, arcBendDir, arcBendAmp, arcCenterY, height, animated, start* | ||
entity_destroy | entityDestroyConfig | id, kind("track" 级联销毁全部子孙与挂载音符 / "note" 只删该音符) | ||
lane_note_alpha | laneNoteAlphaConfig | lane, targetAlpha, startAlpha?, animated(仅影响该轨道音符,不影响背景) | ||
lane_scroll | laneScrollConfig | lane, targetSpeed(倍率,可负=反向下落), startSpeed?, animated | ||
lane_deform | laneDeformConfig | lane, skew(钳 \ | skew\ | ≤1), skewAnchorY, arcBendDir(-1 左弯 / +1 右弯), arcBendAmp(钳 ≤300), arcCenterY, height, auto(自动高度,仅真轨;true 时忽略 height), animated, start* |
track_tilt | trackTiltConfig | targetTiltDeg(度,正值顶部向后倒,钳 ±89°), startTiltDeg?, animated | ||
camera_pose | cameraPoseConfig | x/y/z(相机位置,内容坐标 px,+z 向屏幕内;null = 不改变), yawDeg/pitchDeg(null = 不改变), fovDeg(缺省=AUTO), start*, animated |
注意:
easing只认animation.move.easing;各 Config 上的同名旧字段已删除(不再是有效字段)。已删除(三期 3D-native):
textbox(name 与实际能力不符,且全库零使用);lane_move/lane_scale/lane_rotate/lane_3d(合并为lane_pose);track_move(并入track_pose);lane_skew/lane_arc/lane_height(合并为lane_deform);lane_z(画家算法层级整体移除);fake_track_*/fake_note_*(合并为entity_create/entity_pose/entity_deform)。旧谱面按API 参考与doc/mod-docs/changelog的迁移表改写。
text 的 3D 投影锚点(新增):position.anchor 可取 "lane" 或 "entity",配 attachId(真轨道索引 / 实体 id)与可选 attachDepth(投影深度平面 px,缺省 0)。文本仍以正交屏幕字体绘制(不随相机旋转),但位置由当前世界相机把目标世界坐标投影成屏幕坐标后,再叠加 offsetX/offsetY;目标不存在或投影退化时回退 (offsetX, offsetY)。缺省锚点(custom 等)与旧谱面逐字节一致。
实体父子继承(2026-10-17 起):entity_create(kind="track") 的 mount 可写 "entity:ID",把假轨挂到另一个假轨下(父必须已创建、不得成环、深度 ≤16)。子实体继承父链的变换(位置/朝向/缩放/深度,世界(子)=世界(父)·本地(子))与 alpha 相乘;颜色/样式不继承;entity_destroy(kind="track") 级联销毁子孙及挂载音符。mount="lane:N" 的语义是「以世界 (0,0,0) 为二维原点」——真轨本地 (0,0,0) 即世界原点,只继承该轨 lane_pose 平移(不继承该轨的旋转/缩放/3D 朝向/深度)。未写 lane_pose 的谱面所有轨堆叠在世界原点,须由制谱者自行平移摆位。
带 animated 且配置了 animation.move 的移动类事件拥有持续时间,时间轴上表现为有宽度的块。
animation / position
"position": {
"anchor": "track_right", // track_right | track_left | top | bottom | custom | lane | entity
// offsetX 方向:track_right / top / bottom / custom 为 +x(向右);
// track_left 为朝轨道外侧为正(实现为 baseX = leftEdge - charW*6 - offsetX)
"offsetX": 0, "offsetY": 0,
"autoAdjust": true,
"attachId": null, // anchor=lane/entity 时:真轨道索引 / 实体 id
"attachDepth": null // 可选:投影深度平面 px(缺省 0)
},
"animation": {
"fadeIn": { "duration": 0.5, "easing": "easeInOut" },
"fadeOut": { "duration": 0.5, "delay": 2.0, "easing": "easeIn" },
"move": { "startX": 0, "startY": 0, "endX": 200, "endY": 0,
"duration": 1.0, "easing": "easeInOutQuad", "curve": "bezier" }
}easing 支持 linear / easeIn / easeOut / easeInOut 及 Quad/Cubic/Quart/Quint/Sine/Expo/Circ/Back/Elastic/Bounce 全系组合;move.curve 仅在 bezier 与 bounce 时参与路径形态(linear 等价省略)。
_mods.json(插件事件文件)
编辑器插件注册的自定义事件类型不写入 _extra.json,而是保存在谱面同目录的 xxx_mods.json(xxx 与主谱面同名),结构与其完全同构:
{
"items": [
{
"seq": 1,
"trigger": { "type": "time", "timeMs": 15000 },
"content": { "type": "image-layer:image", "src": "assets/bg.png", "x": 640, "y": 360 }
}
]
}约定只有三条:
- 原生事件与插件事件分离:
_extra.json只含游戏本体的 13 种 content 类型,游戏 mod 原样消费;插件事件走_mods.json,由对应的游戏端模组自行读取并表演。两类文件互不污染。 - 事件 item 结构一致:trigger / seq / groupId 语义与
_extra.json相同;content.type为插件注册的事件类型 key(必含:,如image-layer:image),其余字段由插件的 fields schema 决定。 - 编辑器内合并编辑:制谱器加载时把两个文件的 items 合并成一个统一时间轴(按
(timeMs, seq)排序并重编 seq),保存时再按类型自动拆回两个文件。无需手工同步。
extensions 段
extensions 是主谱面顶层的自由对象,是官方指定的模组自定义数据入口:
{
"extensions": {
"mymod:stages": [{ "time": 15000, "stage": "act2" }],
"yourmod:config": { "theme": "neon" }
}
}约定只有三条:
- key 必须是
<pluginId>:<name>形式,且与游戏端ChartExtensionParser.key()完全一致——同一个 key 决定数据在游戏侧和编辑器侧归哪个模组消费。 - value 是对象(单段)或对象数组(多段);数组段的每个元素若含数值型时间字段,可被投射到制谱器竖向时间轴(由插件的 timeField 声明)。
- 原样读写:制谱器不解析、不校验、不重组未注册 key 的内容,保存时逐字节保留。安装对应编辑器插件后才会出现表单与校验。
Part B:编辑器插件系统
为什么需要它
游戏模组是 Java jar,运行在游戏进程里;制谱器是独立的 Electron 应用,无法执行 jar。如果一个模组注册了新的音符类型或往 extensions 写入了演出数据,没有桥接的话制谱器要么丢失这些内容、要么只能盲盒保留。编辑器插件系统用一个纯 JS 的镜像层解决这件事:游戏模组声明了什么能力,制谱器侧放一份同 key 的轻量描述文件,即可获得该内容的查看、表单编辑、预览渲染与无损保存,反之亦然,编辑器插件为谱面写入的数据若游戏没有对应模组则无法识别这些内容,导致演出缺失。

包格式与安装
两种形态并存:
| 形态 | 位置 | 组成 | 能力 |
|---|---|---|---|
| 声明式(旧式) | 编辑器 mods/*.json | 单个 manifest JSON | 只注册 noteTypes / chartExtensions,无代码 |
| 目录式 | 编辑器 mods/<plugin-id>/ | plugin.json + 可选 main.js | 声明 + JS 入口 registerRenderFilter 等运行时 API |
启动时制谱器扫描 mods 目录:先注册内置类型,再逐个解析 manifest。单个插件失败只记日志并跳过,不影响其它插件。manifest 结构:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 必填,唯一标识,如 demo-mirror |
name | string | 显示名 |
version / author | string | 可选元信息 |
noteTypes | NoteTypeDef[] | 声明式自定义音符类型 |
chartExtensions | ChartExtensionDef[] | 声明式 extensions 段 schema |
eventTypes | PluginEventTypeDef[] | 声明式自定义事件类型(编辑器端一等公民事件,存 _mods.json,见下文) |
entry | string | ES module 相对路径(./main.js);存在时动态 import 并调用模块导出的 activate(api) |
activate(api) 收到的 api 即 PluginHostApi(下文第 5 节)。声明式插件可以完全不写 main.js。
NoteTypeDef:自定义音符类型
{
"key": "slide",
"displayName": "滑键",
"color": "#FF6644",
"symbol": "S",
"asciiSymbol": "<->",
"isHold": true,
"isBarrier": false,
"isExploit": false,
"isPayload": false,
"hotkey": "S",
"fields": [
{ "key": "endTime", "label": "结束时间(ms)", "type": "number", "min": 0 },
{ "key": "style", "label": "样式", "type": "select", "options": [
{ "value": "arc", "label": "弧线" }, { "value": "line", "label": "直线" } ]
}
]
}key即谱面 JSONnotes[].type的值,必须与游戏端 serializationKey 一致;key 与内置类型相同则注册无效(不可覆盖)。color/symbol用于时间线绘制;轨道宽度不足时降级为等宽的asciiSymbol。- 行为旗标决定编辑器语义:isHold 显示 endTime 手柄、isBarrier 按 laneMask 渲染横条、isExploit/isPayload 参与关联删除逻辑。
hotkey(可选):音符编辑模式下的放置快捷键,单个字母或数字(如"S"/"7"),无修饰键;按下后直接在光标处放置该类型音符。内置类型(Q/W/E/B/I/P)与用户自定义快捷键优先——若冲突,该 hotkey 不生效。放置类型下拉中会显示各自键位。fields驱动右侧属性面板表单、新建默认值与导出保留;field.type 支持 number / boolean / select / text / image。
ChartExtensionDef:extensions 段 schema
{
"key": "demo:split",
"title": "Lane Split Color Band",
"timeField": "time",
"fields": [
{ "key": "time", "label": "Start time (ms)", "type": "number", "min": 0, "defaultValue": 0 }
]
}- 声明后右面板出现该 key 的表单卡片,就地校验(范围 / 必填 / 类型),错误红字提示且不阻塞保存。
- value 为对象数组且声明了 timeField 时,每个元素按其时间字段的值投射到竖向事件编辑模式,成为一枚只读标记:点击定位光标,悬停展示原始 JSON。对象形态(非数组)只出现在表单,不上时间轴。
- 未声明的 key 继续走"原样保留 + 只读折叠卡",永不丢数据。
image字段:表单渲染「导入图片」控件,选择本地图片后由宿主复制到谱面同目录assets/下(重名自动加后缀),字段值保存为谱面相对路径(如assets/bg.png),谱面可移植;渲染时用getAssetDataUrl(relPath)换取 dataURL 供 drawImage。谱面需以文件形式打开(保存过一次)才能导入。
PluginEventTypeDef:自定义事件类型
ChartExtensionDef 适合"少量配置段",但当模组需要大量按时间轴排列的演出事件时,塞进 extensions 段意味着在长列表里滚动查找、没有独立的轨道与事件块。PluginEventTypeDef 解决这个问题:注册的类型成为编辑器的一等公民事件,与原生事件同样可创建、选中、拖动、撤销。
{
"key": "image-layer:image",
"displayName": "图片图层",
"color": "#4aa3ff",
"symbol": "IMG",
"fields": [
{ "key": "src", "label": "图片", "type": "image" },
{ "key": "x", "label": "目标 X", "type": "number", "step": 1, "defaultValue": 640 },
{ "key": "alpha", "label": "目标透明度", "type": "number", "min": 0, "max": 1, "step": 0.01, "defaultValue": 1 }
]
}key必须含:(建议<pluginId>:<name>),是content.type的值;重复注册时先注册者生效。displayName/color/symbol分别用作时间轴轨道行标签、事件块颜色与窄块短标签。fields与 NoteTypeDef 的字段同构:创建事件时按defaultValue生成初始值,右侧属性面板按 schema 渲染表单(支持 number / boolean / select / text / image)。
注册后的编辑器行为:
- 独立轨道行:横向时间轴出现该类型的专属轨道(与原生事件轨道并列),点击轨道空白处创建事件,双击轨道头展开逐行排列;竖向事件编辑模式出现对应的轨道组列。
- 完整编辑链路:事件块可选中 / 框选 / 拖动改时间 / 复制 / 删除,撤销重做与原生事件共用。
- 独立存储:保存时写入
_mods.json(见 Part A),不混入原生_extra.json;游戏端模组按约定读写该文件做表演。 - 运行时注册:除 manifest 声明外,也可在
activate(api)内调用api.registerEventType(def)(与声明式二选一或叠加)。
插件侧读取自己类型的事件用于渲染:
export function activate(api) {
api.registerRenderFilter({
id: 'imagelayer:render',
zOrder: 1500,
layer: 'fullscreen',
apply(canvas, frame) {
// 返回本插件该类型全部事件,按 (timeMs, seq) 升序
var events = api.getPluginEvents('image-layer:image');
for (var ev of events) {
var t = ev.trigger.timeMs; // 事件时间
var c = ev.content; // fields 声明的字段值
// ... 按 frame.musicTimeMs 与 c 中的字段绘制
}
},
});
}PluginHostApi:运行时接口参考
main.js 中 export function activate(api) 后可用。所有回调都在预览渲染循环所在的上下文中同步调用。
| 方法 | 说明 |
|---|---|
registerRenderFilter(filter) | 注册渲染回调。{ id, zOrder?, layer?, apply(canvas, frame) }。层级由统一数字 zOrder 决定(见下节);layer 仅为缺省值来源(background→500、fullscreen→2000),显式 zOrder 优先 |
registerBackgroundRenderer(renderer) | 等价于 registerRenderFilter({ layer: 'background', ... }) 的快捷方式,签名 { id, zOrder?, render(canvas, frame) } |
registerNoteType(def) | 运行时注册 NoteTypeDef(与 manifest.noteTypes 二选一或叠加,同样不可覆盖内置;hotkey 字段可声明放置快捷键) |
registerEventType(def) | 运行时注册 PluginEventTypeDef(与 manifest.eventTypes 二选一或叠加)。key 必须含 :,重复注册跳过 |
getPluginEvents(typeKey) | 读取本插件注册事件类型的全部事件项,按 (timeMs, seq) 升序;item.content 中即 fields 声明的字段值。同步快照,渲染帧内每帧重调即可;仅读使用,修改返回对象会直接篡改谱面数据 |
getActiveChartJson() | 当前活动谱面的原始 JSON(未加载返回 null)。返回的是内部对象引用,修改它会直接篡改谱面数据,属于最危险的操作之一,请只读使用 |
getChartExtension(key) | 读 extensions[key],未设置返回 undefined |
setChartExtension(key, value) | 写 extensions[key]。仅允许写入本插件 manifest.chartExtensions 里声明的 key,越权写入会被拒绝并在日志告警 |
getLaneGeometry() | 当前帧的轨道几何快照数组(同 frame.context.lanes),无需等待一帧即可取最近值 |
getAssetDataUrl(relPath) | 读取谱面资产为 dataURL(供 drawImage 使用)。relPath 为谱面相对路径(如 'assets/bg.png')。同步返回:缓存命中立即返回;首次调用触发后台加载并返回 null,加载完成后下一帧生效——渲染循环内每帧重调即可 |
on(evt, cb) | 订阅宿主事件,返回退订函数。可选事件:chartLoaded(谱面打开)、beforeSave(保存前,适合最终落盘前修正数据)、selectionChanged(选中集变化)、previewTick(每帧,携带 musicTimeMs) |
notify(level, msg) | 向用户发提示,level 为 info / warn / error |
渲染层级:统一数字 zOrder
所有插件渲染回调与宿主轨道区渲染共用一个数字排序空间:
| zOrder 范围 | 绘制位置 |
|---|---|
< 1000 | 清背景之后、轨道区之前(衬底内容,如背景图) |
= 1000 | 宿主轨道区锚点(背景/分隔线/判定线/音符/假轨/快捷键框整体) |
> 1000 | 轨道区之上(覆盖内容,如图片图层 1500、全屏滤镜 2000+) |
- 同 zOrder 按注册先后;数值建议留出间隔(1500、1600……)便于多个插件共存。
layer字段仍可声明:background→500、fullscreen→2000 只是缺省值,显式 zOrder 永远优先。- 错误隔离按单个回调生效:连续 30 帧抛错的回调被自动停用,不影响其它回调。
apply 回调内的 frame 对齐游戏 FilterFrame:
| 字段 | 含义 |
|---|---|
width / height | 画布尺寸(像素) |
musicTimeMs | 当前音乐时间 |
progress | 播放进度 0-1(无音频时 0) |
canvas | ModCanvas 绘制原语 |
layer / zOrder | 本滤镜所在层与序(宿主注入) |
context.lanes | LaneViewCompat[]:{ id, x, y, width, height, angle, isFake, keyLabel }。id 规则与游戏一致:真轨 lane_N、假轨 fake_{id}。口径与游戏 LaneView 对齐:x / y 为画布坐标——轨道区被整体平移(游戏侧 trackOffset / setTrackOffset,含模组的 track_move)时计入平移量,按画布坐标而非局部坐标使用;矩形始终为未旋转布局矩形(旋转只以 angle 单列,lane 偏移与形变不烘焙进矩形);width 为每轨宽(相邻轨中心间距,真轨/假轨同口径) |
ModCanvas 与游戏的差异点
方法集与游戏 RenderCanvas 一一对应(save/restore/translate/scale/rotate/save/clip/fillRect/drawRect/drawLine/fillTriangle/drawText/measureText/drawImage/setColor/setAlpha),迁移概念零成本。仅两处差异需要注意:
- 颜色一律字符串:
'#RRGGBB'、'#RRGGBBAA'、'#RGB'或 CSS 颜色名;Java Color 对象请自行序列化。 - rotate 弧度并带轴点:
rotate(angle, pivotX, pivotY)直接指明旋转中心,不必手动 translate。
另外 drawImage 接收浏览器 CanvasImageSource(img/canvas 元素),不是游戏的资源句柄。
运行模型与安全边界
- 权限等同本机权限:入口源码经 Blob URL 动态 import,与渲染上下文同环境执行,没有沙箱。安装来历不明的插件等于把电脑交给它的作者,请切记只装可信来源插件,若有需要,却找不到可信插件,也可将开发文档发送给AI要求生成编辑器插件,来快速得到一份完全可信的插件。
- 错误隔离:apply 抛错会被逐帧捕获,不会弄崩预览;同一滤镜连续 30 帧失败即自动停用并向用户提示一次。
- 热隔离粒度:单个插件激活失败只跳过它本身;已成功注册的类型/滤镜在其他插件崩溃时不受牵连。
完整示例:demo-mirror
这是一个最小而完整的 插件:当音乐处于某个时间段内时,给目标真轨道的左右两半各叠一层半透明色带。它同时演示了 chartExtensions 表单、timeField 时间轴投射与 registerRenderFilter 三件套。
mods/demo-mirror/plugin.json:声明了 5 个表单字段(起始时间 / 时长 / 目标轨 / 左右颜色),timeField: "time" 让每一段在竖向时间轴上出现标记。
mods/demo-mirror/main.js:
export function activate(api) {
api.registerRenderFilter({
id: 'demo-mirror:lane-split',
zOrder: 2010, // > 1000: drawn above the track area
layer: 'fullscreen',
apply(canvas, frame) {
var segs = api.getChartExtension('demo:split');
if (!Array.isArray(segs)) return;
var t = frame.musicTimeMs;
for (var seg of segs) {
var start = seg.time ?? 0;
var dur = seg.durationMs ?? 8000;
if (t < start || t > start + dur) continue;
var ln = frame.context.lanes.find(
l => !l.isFake && l.id === 'lane_' + (seg.lane ?? 0));
if (!ln) continue;
// 口径提示:ln.x / ln.y 为画布坐标(轨道区被平移 / trackOffset 时已计入),
// 可直接用于画布空间的 clip / fillRect;需按轨道中心定位时推荐 ln.x + ln.width / 2。
var halfW = ln.width / 2;
for (var [x, color] of [[ln.x, seg.colorLeft], [ln.x + halfW, seg.colorRight]]) {
canvas.save();
canvas.clip(x, ln.y, halfW, ln.height);
canvas.setColor(color ?? '#2030C8');
canvas.setAlpha(0.25);
canvas.fillRect(x, ln.y, halfW, ln.height);
canvas.restore();
}
}
},
});
}使用流程:放入 mods 目录 → 启动制谱器 → 打开任意谱面 → 右面板 EXTENSIONS 出现"Lane Split Color Band"卡片 → 点加号添加若干段并填好起止与颜色 → 竖向时间轴出现灰色只读标记 → 预览面板内色带随音乐进出淡入淡出 → Ctrl+S 后 extensions 原样写入主谱面 JSON,游戏侧只要存在同 key 的 ChartExtensionParser 即可直接消费这些数据。
后果警示
- 修改
getActiveChartJson()返回对象等同于直接改内存中的谱面本体,且会在下一次 Ctrl+S 时落入磁盘。除非明确知道自己要做什么,否则不要动这个对象。 - extensions key 冲突(两个插件声明同一个 key)时先注册者生效——后来者的表单不会出现,但数据仍然无损保留。
- 自定义音符类型的 key 如果与游戏端 serializationKey 不一致,会出现"编辑器里看起来正常、游戏里变 normal"的静默错位。发布前务必与游戏模组联调一次。