6.1. 构建谱面

本教程演示如何用 GDScript 从头构建一个 SyncDocumentChart, 并保存为 JSON 文件或 .res 资源文件。

6.1.1. 1. 创建文档谱面

一切从 SyncDocumentChart 开始:

var chart := SyncDocumentChart.new()
chart.spec_name = "MyChart"
chart.spec_version = "1.0.0"
chart.bpm = 120.0
chart.offset_ms = 0.0
chart.music_duration_ms = 60000.0  # 1分钟

6.1.2. 2. 设置元数据

var metadata := chart.get_metadata_dict()
metadata["creator"] = "YourName"
metadata["difficulty_label"] = "Easy"
metadata["music"] = "Song Title"
metadata["musician"] = "Artist Name"
chart.set_metadata_dict(metadata)

6.1.3. 3. BPM 轴与拍号

BPM 轴可以有多个关键帧实现变速:

# BPM 轴:初始 120,第 16 拍变为 180
var bpm_axis := SyncBpmAxis.new()
bpm_axis.init_value = 120.0
bpm_axis.add_keyframe(SyncBeat.new(), 120.0)  # 起始关键帧
bpm_axis.add_keyframe(_beat(16, 0, 1), 180.0)
chart.set_bpm_axis(bpm_axis)

# 拍号:4/4
var tsm := SyncTimeSignatureMap.new()
tsm.add_keyframe(SyncBeat.new(), 4, 4)
chart.set_time_signature_map(tsm)

6.1.4. 4. 创建轨道并添加音符

var track := SyncTrack.new()
track.track_id = "track_001"
track.metadata = { "type": "GroundTrack" }

# TAP 音符(第 4 拍)
var tap := SyncNote.new()
tap.local_id = "tap_001"
tap.kind = 0  # TAP
tap.time = _beat(4, 0, 1)
track.add_note(tap)

# HOLD 音符(第 6 拍到第 8 拍)
var hold := SyncNote.new()
hold.local_id = "hold_001"
hold.kind = 1  # HOLD
hold.time = _beat(6, 0, 1)
hold.end_time = _beat(8, 0, 1)
track.add_note(hold)

# 另一个 TAP(第 10 拍)
var tap2 := SyncNote.new()
tap2.local_id = "tap_002"
tap2.kind = 0  # TAP
tap2.time = _beat(10, 0, 1)
track.add_note(tap2)

chart.add_root_track(track)

6.1.5. 5. 动画轴

每条轨道可以挂载多种动画轴:

# Float 轴(例如透明度)
var float_axis := SyncFloatAxis.new()
float_axis.axis_name = "opacity"
float_axis.init_value = 1.0
float_axis.add_keyframe(_beat(4, 0, 1), 0.5, SyncTrans.LINEAR, SyncEase.IN_OUT)
float_axis.add_keyframe(_beat(8, 0, 1), 1.0, SyncTrans.SINE, SyncEase.OUT)
track.add_float_axis(float_axis)

# Vector2 轴(例如 2D 位置)
var v2_axis := SyncVector2Axis.new()
v2_axis.axis_name = "position"
v2_axis.init_value = Vector2.ZERO
v2_axis.add_keyframe(_beat(2, 0, 1), Vector2(1.0, 0.0), SyncTrans.LINEAR, SyncEase.IN_OUT)
track.add_vector2_axis(v2_axis)

# Vector3 轴(例如 RGB 颜色)
var v3_axis := SyncVector3Axis.new()
v3_axis.axis_name = "color"
v3_axis.init_value = Vector3.ZERO
v3_axis.add_keyframe(_beat(4, 0, 1), Vector3(1.0, 0.5, 0.0), SyncTrans.STEP, SyncEase.IN_OUT)
track.add_vector3_axis(v3_axis)

# Vector4 轴(例如 RGBA 颜色)
var v4_axis := SyncVector4Axis.new()
v4_axis.axis_name = "color_rgba"
v4_axis.init_value = Vector4.ZERO
v4_axis.add_keyframe(_beat(4, 0, 1), Vector4(1.0, 0.0, 0.0, 1.0), SyncTrans.SINE, SyncEase.OUT)
track.add_vector4_axis(v4_axis)

# Speed 轴(速度变化)
var speed_axis := SyncSpeedAxis.new()
speed_axis.axis_name = "speed"
speed_axis.init_value = 1.0
speed_axis.add_keyframe(_beat(8, 0, 1), 2.0, SyncTrans.LINEAR)
track.add_speed_axis(speed_axis)

6.1.6. 6. 保存与加载

保存为 JSON:

var err := chart.save_json("user://my_chart.json")
if err == OK:
    print("JSON 保存成功")

保存为 Godot 资源文件:

var err := chart.save_resource("user://my_chart.tres")
if err == OK:
    print(".res 保存成功")

加载回来:

# 从 JSON 加载
var json_chart := SyncDocumentChart.new()
var err := json_chart.load_json("user://my_chart.json")

# 从 .res 加载
var res_chart: SyncDocumentChart = ResourceLoader.load("user://my_chart.tres",
    "", ResourceLoader.CACHE_MODE_IGNORE)

6.1.7. 7. 验证

for track: SyncTrack in chart.root_tracks:
    print("轨道 '%s': %d 个音符" % [track.track_id, track.get_note_count()])
    for note: SyncNote in track.notes:
        print("  %s kind=%d time=%d:%d/%d" % [
            note.local_id, note.kind,
            note.time.beat, note.time.numerator, note.time.denominator
        ])
print("谱面时长: %.1f ms" % chart.get_duration_ms())

6.1.8. 完整代码

以下是可以直接运行的完整示例(取自 Sync/project/editor_demo.gd):

extends Node

func _ready() -> void:
    var chart := _build_sample_chart()
    print("构建完成: %d 个轨道, %d 个音符" % [
        chart.root_tracks.size(), _count_notes(chart)
    ])

    # 保存验证
    chart.save_json("user://demo_chart.json")
    chart.save_resource("user://demo_chart.tres")

    var reloaded := SyncDocumentChart.new()
    reloaded.load_json("user://demo_chart.json")
    print("重新加载: %d 个音符" % _count_notes(reloaded))
    get_tree().quit()

func _build_sample_chart() -> SyncDocumentChart:
    var chart := SyncDocumentChart.new()
    chart.bpm = 120.0
    chart.music_duration_ms = 60000.0
    chart.offset_ms = 0.0

    var metadata := chart.get_metadata_dict()
    metadata["creator"] = "Tutorial"
    metadata["difficulty_label"] = "Demo"
    chart.set_metadata_dict(metadata)

    var bpm_axis := SyncBpmAxis.new()
    bpm_axis.init_value = 120.0
    bpm_axis.add_keyframe(SyncBeat.new(), 120.0)
    bpm_axis.add_keyframe(_beat(16, 0, 1), 180.0)
    chart.set_bpm_axis(bpm_axis)

    var tsm := SyncTimeSignatureMap.new()
    tsm.add_keyframe(SyncBeat.new(), 4, 4)
    chart.set_time_signature_map(tsm)

    var track := SyncTrack.new()
    track.track_id = "main"

    var tap := SyncNote.new()
    tap.local_id = "tap_001"; tap.kind = 0
    tap.time = _beat(4, 0, 1)
    track.add_note(tap)

    var hold := SyncNote.new()
    hold.local_id = "hold_001"; hold.kind = 1
    hold.time = _beat(6, 0, 1)
    hold.end_time = _beat(8, 0, 1)
    track.add_note(hold)

    var tap2 := SyncNote.new()
    tap2.local_id = "tap_002"; tap2.kind = 0
    tap2.time = _beat(10, 0, 1)
    track.add_note(tap2)

    chart.add_root_track(track)
    return chart

func _count_notes(chart: SyncDocumentChart) -> int:
    var total := 0
    for track: SyncTrack in chart.root_tracks:
        total += track.get_note_count()
    return total

func _beat(b: int, n: int, d: int) -> SyncBeat:
    var bt := SyncBeat.new()
    bt.beat = b
    bt.numerator = n
    bt.denominator = d
    return bt

6.1.9. SyncBeat 构造说明

SyncBeat 表示一个音乐节拍位置:

  • beat — 拍号(整数部分)

  • numerator — 分子(拍内偏移的分子)

  • denominator — 分母(拍内偏移的分母)

例如 beat=4, numerator=0, denominator=1 表示第 4 拍整拍位置。 beat=4, numerator=1, denominator=4 表示第 4 拍后的 1/4 拍位置。

6.1.10. 音符种类参考

kind

名称

说明

0

TAP

单点音符,引擎按"点"语义处理(任何非 HOLD 的 kind 都视作 TAP)

1

HOLD

长按音符,需在 end_time 前持续按住

备注

引擎只保留 TAPHOLD 两种时间语义—— CATCH / VOID 等游戏专属种类没有专用 kind, 写在 note.metadata (如 metadata["note_type"] = "catch"),由你的 GDScript 判定按字符串分发。引擎对它们"完全透明"——可见性、位置采样、 序列化行为与 TAP 完全一致。