6.2. 编辑器组件教程

本教程介绍 Sync 包的 3 个编辑器组件: SyncProgressManagerSyncTimelineSyncBeatGrid。 这些组件只提供**坐标映射和节拍线数据**,渲染和输入命中检测由你的 GDScript 完成。

基于 Sync/project/editor_demo_components.gd 的完整示例。

6.2.1. 步骤 1:SyncProgressManager — 进度管理

SyncProgressManagerSyncDocumentChart 的伴生播放引擎, 用于制谱/预览时的进度管理。时长模型 = 音乐时长 + 前后留白(默认各 5s)。

var pm := SyncProgressManager.new()
add_child(pm)
pm.bind_chart(chart)

# seek 到第 4 拍(120 BPM 下 = 2000ms)
pm.seek_beats(4.0)

print("进度: %.1f ms" % pm.get_progress_ms())
print("时长: %.1f ms" % pm.get_duration_ms())
print("节拍文本: %s" % pm.get_beat_string())

6.2.2. 步骤 2:SyncTimeline — 归一化坐标映射

SyncTimeline 提供时间 ↔ 归一化坐标的映射(底部=0,顶部=1):

var timeline := SyncTimeline.new()
timeline.progress_manager = pm

# 当前进度在归一化坐标中的位置
var pos_now := timeline.time_to_normalized(pm.get_progress_ms())
# 未来 1000ms 的位置
var pos_future := timeline.time_to_normalized(pm.get_progress_ms() + 1000.0)

# 归一化坐标 → 时间
var t_back := timeline.normalized_to_time(1.0)

6.2.3. 步骤 3:输入映射 — 点击位置 → 节拍

# 玩家在归一化位置 0.575 处点击
var click_pos := 0.575
var beat := timeline.normalized_to_beat(click_pos)
print("点击 %d:%d/%d" % [beat.beat, beat.numerator, beat.denominator])

6.2.4. 步骤 4:SyncBeatGrid — 节拍线数据

SyncBeatGrid 生成节拍线数据(位置、类型、标签、颜色),你负责绘制:

var grid := SyncBeatGrid.new()
grid.timeline = timeline

var lines := grid.get_lines()
for line: Dictionary in lines:
    if line.type != SyncBeatGrid.TYPE_SUBDIVISION:
        print("位置=%.3f 类型=%d 拍=%d 标签='%s'" % [
            line.position, line.type, line.beat, line.label
        ])

6.2.5. 步骤 5:自定义颜色

grid.color_whole_beat = Color.GOLD         # 整拍线
grid.color_half_beat = Color.DARK_GOLDENROD
grid.color_quarter_beat = Color.GRAY
grid.color_subdivision = Color.DIM_GRAY    # 细分线
grid.color_judge_line = Color.RED          # 判定线

6.2.6. 步骤 6:BeatlineView — 完整的绘制 + 输入控件

以下是一个完整的 Control 子类,将 SyncBeatGrid 的数据绘制为 节拍线,并将点击位置映射回节拍:

class BeatlineView:
    extends Control

    var grid: SyncBeatGrid
    var timeline: SyncTimeline

    func _ready() -> void:
        mouse_filter = MOUSE_FILTER_STOP

    func _draw() -> void:
        if grid == null or timeline == null:
            return
        var h := size.y
        for line: Dictionary in grid.get_lines():
            var pos: float = line.position
            if pos < 0.0 or pos > 1.0:
                continue  # 裁剪视野外的线
            # 归一化(底部=0, 顶部=1)→ 像素(顶部=0)
            var y := (1.0 - pos) * h
            var col: Color = line.color
            draw_line(Vector2(0, y), Vector2(size.x, y), col, line.thickness)
            if line.label != "":
                var font := get_theme_default_font()
                draw_string(font, Vector2(2, y - 2), line.label,
                        HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col)

    func _gui_input(event: InputEvent) -> void:
        if timeline == null:
            return
        var mb := event as InputEventMouseButton
        if mb != null and mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT:
            # 像素 → 归一化
            var pos := 1.0 - mb.position.y / size.y
            var beat := timeline.normalized_to_beat(pos)
            var snapped_ms := timeline.snap_time_ms(
                timeline.normalized_to_time(pos))
            print("点击: pos=%.3f → beat %d:%d/%d, snapped %.1f ms" % [
                pos, beat.beat, beat.numerator, beat.denominator, snapped_ms
            ])

6.2.7. 完整代码

以下是可以直接运行的完整示例:

extends Node

# 引用 editor_demo_components.gd
const ComponentsDemo := preload("res://editor_demo_components.gd")

func _ready() -> void:
    var chart := _build_chart()
    ComponentsDemo.run(chart, self)
    get_tree().quit()

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

    var track := SyncTrack.new()
    track.track_id = "demo_track"
    for beat in 4:
        var n := SyncNote.new()
        n.local_id = "note_%d" % beat
        n.kind = 0
        n.time = _beat(beat * 4, 0, 1)
        track.add_note(n)
    chart.add_root_track(track)
    return chart

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.2.8. 关键设计理念

  • SyncTimeline / SyncBeatGrid 只做数据输出,不做渲染 — 你可以在任何 UI 框架中自由绘制(Godot Control、自定义 shader 等)。

  • 统一约定:时间 = 毫秒、坐标 = 归一化 0~1、 progress_ms=-1 哨兵 = 取绑定的 progress_manager 当前进度。