6.3. 游戏运行教程

本教程演示 Sync 的运行时流程:构建谱面 → 编译 → 自写判定 → 命中收集。Sync 不带判定引擎——本教程演示的 就是"如何用最少的 GDScript 在引擎外面写一个最小的判定"。

基于 Sync/project/play_demo.gd 的真实示例。

6.3.1. 步骤 1:构建谱面

创建 1 条 TAP 轨(含一个自定义种类的音符作为示例)和 1 条 HOLD 轨:

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

    # Track 1:TAPs,每 4 拍 1 个;第三个带自定义种类 metadata
    var taps := SyncTrack.new()
    taps.track_id = "Track-Taps"
    for beat in 8:
        var n := SyncNote.new()
        n.local_id = "tap_%d" % beat
        n.kind = 0  # TAP
        n.time = _beat(beat * 4, 0, 1)
        if beat == 2:
            n.metadata = { "note_type": "catch" }  # 游戏专属种类
        taps.add_note(n)
    chart.add_root_track(taps)

    # Track 2:单个 HOLD(8~16 拍)
    var holds := SyncTrack.new()
    holds.track_id = "Track-Holds"
    var h := SyncNote.new()
    h.local_id = "hold_0"
    h.kind = 1  # HOLD
    h.time = _beat(8, 0, 1)
    h.end_time = _beat(16, 0, 1)
    holds.add_note(h)
    chart.add_root_track(holds)

    return chart

6.3.2. 步骤 2:创建播放器并编译

var player := SyncChartPlayer.new()
add_child(player)
player.set_document_chart(chart)
player.play()

set_document_chart 内部完成编译;之后可以从 player.get_play_chart() 拿到编译后的 SyncChart 做枚举。

6.3.3. 步骤 3:判初——枚举所有音符并缓存静态数据

var _notes: Array = []  # 缓存 get_note_info() 结果

func _ready() -> void:
    _chart = _build_chart()
    _player = SyncChartPlayer.new()
    add_child(_player)
    _player.set_document_chart(_chart)
    _play_chart = _player.get_play_chart()

    # 一次性枚举所有 track × 所有 note,缓存 info
    for track_id in _play_chart.get_track_ids():
        for note_id in _play_chart.get_note_ids_for_track(track_id):
            var info: Dictionary = _play_chart.get_note_info(note_id)
            info["head_done"] = false
            info["tail_done"] = false
            _notes.append(info)

    _player.play()

6.3.4. 步骤 4:每帧推进 + 自写判定

判定只读 info.hit_time_ms / info.end_time_ms 与当前 player.get_chart_progress_ms() 的差值——这就是最简判定:

func _auto_judge_tick() -> void:
    var now := _player.get_chart_progress_ms()
    var visible_now := _play_chart.get_visible_note_ids(now).size()
    for info in _notes:
        if not info.head_done and now >= info.hit_time_ms:
            info.head_done = true
            _hits += 1
            _log("hit  %s kind=%d note_type=%s t=%.0f (visible=%d)" % [
                info.logic_id, info.kind,
                info.metadata.get("note_type", "-"),
                info.hit_time_ms, visible_now,
            ])
        if info.is_hold and not info.tail_done and now >= info.end_time_ms:
            info.tail_done = true
            _hits += 1
            _log("tail %s t=%.0f" % [info.logic_id, info.end_time_ms])

func _process(_delta: float) -> void:
    _t_ms += TICK_MS
    if _t_ms > RUN_MS:
        _finalize()
        return
    _player.seek_ms(_t_ms)
    _auto_judge_tick()

6.3.4.1. 判定层的关键数据(不修改引擎数据)

SyncChart.get_note_info(note_id) 返回的字典里,判定需要的所有字段:

  • kind (int) — 0=TAP / 1=HOLD(游戏种类在 metadata

  • hit_time_ms (float) — 头部命中时间

  • end_time_ms (float) — HOLD 结束时间;TAP 为 0

  • is_hold (bool) — kind == 1 的便捷判断

  • logic_id (String) — 唯一 ID

  • track_id (String)

  • metadata (Dictionary) — 游戏专属数据(如 note_type

小技巧

渲染层每帧用 player.get_visible_note_ids_for_track(track_id) 拿"当前可见音符";判定层用 SyncChart.get_visible_note_ids(time_ms) 拿任意时间的可见集合(不受 seek_ms 缓存影响)。两个 API 在大谱面下都是 O(1)(C++ 按轨分桶)。

6.3.5. 步骤 5:自定义种类的判定分发

metadata["note_type"] 是约定的游戏种类字符串(也可以自己定)。 判定里 match/if 字符串即可:

if not info.head_done and now >= info.hit_time_ms:
    info.head_done = true
    match info.metadata.get("note_type", "tap"):
        "tap":
            _log("TAP hit %s" % info.logic_id)
        "catch":
            # 到线时手指在判定区即中;判定在更上层做
            _log("CATCH target %s" % info.logic_id)
        _:
            _log("unknown kind hit %s" % info.logic_id)

引擎对自定义种类**完全透明**:任何非 kind=1 的音符都被当作 TAP 处理,可见性、位置采样、序列化全部按 TAP 工作。你的判定只需要识别 metadata 字符串就够了。

6.3.6. 步骤 6:输出总结

func _finalize() -> void:
    _finalized = true
    print("=== final ===")
    print("  judged events = %d (notes = %d)" % [_hits, _notes.size()])
    _player.stop()
    get_tree().quit()

6.3.7. 完整代码

完整可运行示例见 OpenSource/Sync/project/play_demo.gd: 约 130 行 GDScript(其中手写判定约 20 行),全部基于上面演示的 API。

6.3.8. 判定层与渲染层的边界

  • 判定层:使用 SyncChart.get_note_info() / get_track_ids() / get_note_ids_for_track() + player.get_chart_progress_ms()。 与玩家输入(你自己的 InputAgent)耦合;判定状态、分数、连击 都在 GDScript 里维护。

  • 渲染层:使用 player.get_visible_note_ids_for_track() / get_play_chart().get_note_info() + player.sample_track_*_axis() / track.get_note_position_fast(),按可见音符集合更新 你的渲染节点。

两者**不共享任何状态**——引擎是纯无状态的"数据 + 时间"核心。

6.3.9. 下一步