Theseus 模组文档
制谱器

制谱器开发文档

谱面 JSON 结构规范 + 编辑器插件系统:让游戏模组的效果在编辑器内可预览、可编辑、可保存


本页面向两类读者:

操作层面的用法见制谱器使用指南;游戏端模组 API 签名见 API 参考。

Part A:谱面 JSON 结构规范

主谱面 JSON

一个谱面对应 data/charts/ 下的一个 JSON 文件。制谱器认读以下顶层字段,未列出的字段会被原样保留:

字段类型说明
metadata.title / metadata.artiststring标题 / 艺术家;缺失时显示 Untitled / Unknown
lanesnumber轨道数,默认 4
audio.filestring音频文件引用
audio.offsetnumber音频偏移(毫秒),用于对齐
timingPointsarray{ time, bpm } 数组(毫秒 + BPM);缺失时以 {time:0, bpm:120} 兜底。多段实现变速
scrollSpeednumber预览下落速度(px/s 量级),默认 600
notesarray音符数组,见下文
disorderMarkersarray无序模式标记 { time, type },type 取 charge_start / charge_end / mode_start / mode_end,按 time 升序持久化
extensionsobject插件自定义数据段,原样读写,见"extensions 段"

最小可用示例:

json
{
  "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全部小写内置类型字符串;自定义类型为任意字符串(见下)
endTimehold长条结束时间;其他类型持久化为 null
laneMaskbarrier 等位掩码(bit i = 第 i 条轨受影响),缺省按 1 << lane 补全
hitCountbarrier 等命中次数,缺省 5
exploitId / parentExploitIdexploit / payload持久化的关联编号,无关联时 -1

EXPLOIT 与 PAYLOAD 的约束:PAYLOAD 需要同 lane 在其之前存在一个 EXPLOIT 作为宿主;制谱器删除 EXPLOIT 时会连带删除关联 PAYLOAD。手写谱面时请保持这一不变量,虽然 EXPLOIT 与 PAYLOAD 间的距离没有限制,不必强制相邻,可以将PAYLOAD音符作为其他音游常见的Drag等仅需保持快捷键触发的音符使用,但请注意保证EXPLOIT不会处于高难度段落,导致意外 miss 而扣除巨量血量。

自定义音符类型:游戏模组可以通过 NoteTypeRegistry 注册新 type(serializationKey 与此处的字符串一致)。规则是:

  1. 制谱器加载时不认识的自定义 type 会原样保留,不会降级成 normal,使用图片作为皮肤时在预览中降级为 normal 样式,但实际数据不变;
  2. 导出时除编辑器内部字段(id / parentExploitTime)外,其余字段全部原样写回;
  3. 若玩家安装了对应的编辑器插件,则该类型的显示名、颜色、符号与可编辑字段由插件提供。

_extra.json(事件文件)

主谱面同名目录下的 xxx_extra.json 承载事件。顶层结构:

json
{
  "items": [],
  "groupEndTimes": {}
}

每个 item 代表一条事件:

json
{
  "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触发字段含义
timetimeMs到达指定时间触发
combocomboThreshold连击数达到阈值触发
noMissdurationMs持续无 miss 达到时长触发

content(14 种)

旧谱面必须迁移(2026-10-17 起):真轨本地 (0,0,0) = 世界原点,轨道不再按车道序号自动排开。未写 lane_pose 的旧谱面会把全部轨道堆叠在世界原点(判定线在世界 y = 0、轨道体向上出画)。请给每轨补 lane_pose(例如 x = 60 + 100 * i、y = 判定线 y)或运行迁移脚本;整区偏移用 track_pose。

type配置键关键参数
texttext纯文字(正交屏幕文本,白字,透明度由 animation/组计时驱动);position 支持 lane / entity 3D 投影锚点(详下)
lane_alphalaneAlphaConfiglane, alpha(0-1;null = 不改变), affectKeyIndicator
lane_poselanePoseConfiglane, x/y(世界坐标,真轨本地 (0,0,0) = 世界原点)/z(深度 px), pitchDeg/yawDeg/rollDeg(度), scaleX/scaleY(钳 ≥0.01), animated, start*;分量一律可空,null = 不改变该分量。未写 lane_pose 时所有轨堆叠在世界原点,制谱者须自行平移摆位
track_posetrackPoseConfigx, y(轨道区整体偏移), animated, startX/startY
ui_alphauiAlphaConfigalpha(0-1;null = 不改变)
entity_createentityCreateConfigid, kind("track" 假轨道 / "note" 假音符), mount(track:"lane:N" / "canvas" / "entity:ID";note:"entity:ID"), x/y/z, pitchDeg/yawDeg/rollDeg(度), alpha, noteType, lengthMs
entity_poseentityPoseConfigid, kind(缺省 "track"), x/y/z, pitchDeg/yawDeg/rollDeg(rotationDeg = roll,度), alpha, scaleX/scaleY, animated, start*;可空分量同 lane_pose
entity_deformentityDeformConfigid, skew, skewAnchorY, arcBendDir, arcBendAmp, arcCenterY, height, animated, start*
entity_destroyentityDestroyConfigid, kind("track" 级联销毁全部子孙与挂载音符 / "note" 只删该音符)
lane_note_alphalaneNoteAlphaConfiglane, targetAlpha, startAlpha?, animated(仅影响该轨道音符,不影响背景)
lane_scrolllaneScrollConfiglane, targetSpeed(倍率,可负=反向下落), startSpeed?, animated
lane_deformlaneDeformConfiglane, skew(钳 \skew\≤1), skewAnchorY, arcBendDir(-1 左弯 / +1 右弯), arcBendAmp(钳 ≤300), arcCenterY, height, auto(自动高度,仅真轨;true 时忽略 height), animated, start*
track_tilttrackTiltConfigtargetTiltDeg(度,正值顶部向后倒,钳 ±89°), startTiltDeg?, animated
camera_posecameraPoseConfigx/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

json
"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 与主谱面同名),结构与其完全同构:

json
{
  "items": [
    {
      "seq": 1,
      "trigger": { "type": "time", "timeMs": 15000 },
      "content": { "type": "image-layer:image", "src": "assets/bg.png", "x": 640, "y": 360 }
    }
  ]
}

约定只有三条:

  1. 原生事件与插件事件分离:_extra.json 只含游戏本体的 13 种 content 类型,游戏 mod 原样消费;插件事件走 _mods.json,由对应的游戏端模组自行读取并表演。两类文件互不污染。
  2. 事件 item 结构一致:trigger / seq / groupId 语义与 _extra.json 相同;content.type 为插件注册的事件类型 key(必含 :,如 image-layer:image),其余字段由插件的 fields schema 决定。
  3. 编辑器内合并编辑:制谱器加载时把两个文件的 items 合并成一个统一时间轴(按 (timeMs, seq) 排序并重编 seq),保存时再按类型自动拆回两个文件。无需手工同步。

extensions 段

extensions 是主谱面顶层的自由对象,是官方指定的模组自定义数据入口:

json
{
  "extensions": {
    "mymod:stages": [{ "time": 15000, "stage": "act2" }],
    "yourmod:config": { "theme": "neon" }
  }
}

约定只有三条:

  1. key 必须是 <pluginId>:<name> 形式,且与游戏端 ChartExtensionParser.key() 完全一致——同一个 key 决定数据在游戏侧和编辑器侧归哪个模组消费。
  2. value 是对象(单段)或对象数组(多段);数组段的每个元素若含数值型时间字段,可被投射到制谱器竖向时间轴(由插件的 timeField 声明)。
  3. 原样读写:制谱器不解析、不校验、不重组未注册 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 结构:

字段类型说明
idstring必填,唯一标识,如 demo-mirror
namestring显示名
version / authorstring可选元信息
noteTypesNoteTypeDef[]声明式自定义音符类型
chartExtensionsChartExtensionDef[]声明式 extensions 段 schema
eventTypesPluginEventTypeDef[]声明式自定义事件类型(编辑器端一等公民事件,存 _mods.json,见下文)
entrystringES module 相对路径(./main.js);存在时动态 import 并调用模块导出的 activate(api)

activate(api) 收到的 api 即 PluginHostApi(下文第 5 节)。声明式插件可以完全不写 main.js。

NoteTypeDef:自定义音符类型

json
{
  "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": "直线" } ]
    }
  ]
}

ChartExtensionDef:extensions 段 schema

json
{
  "key": "demo:split",
  "title": "Lane Split Color Band",
  "timeField": "time",
  "fields": [
    { "key": "time", "label": "Start time (ms)", "type": "number", "min": 0, "defaultValue": 0 }
  ]
}

PluginEventTypeDef:自定义事件类型

ChartExtensionDef 适合"少量配置段",但当模组需要大量按时间轴排列的演出事件时,塞进 extensions 段意味着在长列表里滚动查找、没有独立的轨道与事件块。PluginEventTypeDef 解决这个问题:注册的类型成为编辑器的一等公民事件,与原生事件同样可创建、选中、拖动、撤销。

json
{
  "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 }
  ]
}

注册后的编辑器行为:

  1. 独立轨道行:横向时间轴出现该类型的专属轨道(与原生事件轨道并列),点击轨道空白处创建事件,双击轨道头展开逐行排列;竖向事件编辑模式出现对应的轨道组列。
  2. 完整编辑链路:事件块可选中 / 框选 / 拖动改时间 / 复制 / 删除,撤销重做与原生事件共用。
  3. 独立存储:保存时写入 _mods.json(见 Part A),不混入原生 _extra.json;游戏端模组按约定读写该文件做表演。
  4. 运行时注册:除 manifest 声明外,也可在 activate(api) 内调用 api.registerEventType(def)(与声明式二选一或叠加)。

插件侧读取自己类型的事件用于渲染:

js
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+)

apply 回调内的 frame 对齐游戏 FilterFrame:

字段含义
width / height画布尺寸(像素)
musicTimeMs当前音乐时间
progress播放进度 0-1(无音频时 0)
canvasModCanvas 绘制原语
layer / zOrder本滤镜所在层与序(宿主注入)
context.lanesLaneViewCompat[]:{ 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),迁移概念零成本。仅两处差异需要注意:

  1. 颜色一律字符串:'#RRGGBB'、'#RRGGBBAA'、'#RGB' 或 CSS 颜色名;Java Color 对象请自行序列化。
  2. rotate 弧度并带轴点:rotate(angle, pivotX, pivotY) 直接指明旋转中心,不必手动 translate。

另外 drawImage 接收浏览器 CanvasImageSource(img/canvas 元素),不是游戏的资源句柄。

运行模型与安全边界

完整示例:demo-mirror

这是一个最小而完整的 插件:当音乐处于某个时间段内时,给目标真轨道的左右两半各叠一层半透明色带。它同时演示了 chartExtensions 表单、timeField 时间轴投射与 registerRenderFilter 三件套。

mods/demo-mirror/plugin.json:声明了 5 个表单字段(起始时间 / 时长 / 目标轨 / 左右颜色),timeField: "time" 让每一段在竖向时间轴上出现标记。

mods/demo-mirror/main.js:

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 即可直接消费这些数据。

后果警示