godot-genre-rhythm

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Genre: Rhythm

类型:节奏游戏

Expert blueprint for rhythm games emphasizing audio-visual synchronization and flow state.
专注于音画同步和心流体验的节奏游戏专业开发蓝图。

NEVER Do

绝对禁止事项

  • NEVER skip latency compensation — Use
    AudioServer.get_time_since_last_mix()
    to sync visuals with audio. Missing this causes desync.
  • NEVER use
    _process
    for input
    — Use
    _input()
    for precise timing. Frame-dependent input causes missed notes.
  • NEVER forget offset calibration — Audio hardware latency varies (10-200ms). Provide player-adjustable offset setting.
  • NEVER tight timing windows on low difficulty — Perfect: 25ms, Great: 50ms is for experts. Beginners need 100-150ms windows.
  • NEVER decouple input from audio — Input timing must reference MusicConductor.song_position, not frame time. Framerate drops shouldn't cause misses.

  • 绝对不能跳过延迟补偿 — 使用
    AudioServer.get_time_since_last_mix()
    同步视觉内容与音频,缺失这一步会导致音画不同步。
  • 绝对不要用
    _process
    处理输入
    — 使用
    _input()
    实现精准计时,依赖帧的输入处理会导致音符漏判。
  • 绝对不要忘记偏移校准 — 不同音频硬件的延迟存在差异(10-200ms),需要提供玩家可自定义的偏移设置。
  • 绝对不要在低难度下设置过窄的判定窗口 — Perfect: 25ms、Great: 50ms是面向高手的设置,新手需要100-150ms的判定窗口。
  • 绝对不要将输入与音频解绑 — 输入计时必须参考MusicConductor.song_position,而非帧时间,帧率下降不应该导致音符漏判。

Available Scripts

可用脚本

MANDATORY: Read the appropriate script before implementing the corresponding pattern.
强制要求:在实现对应模式前,请先阅读相关脚本。

conductor_sync.gd

conductor_sync.gd

BPM conductor with AudioServer latency compensation. Emits beat_hit/measure_hit signals for audio-synced game logic.
自带AudioServer延迟补偿的BPM控制器,会触发beat_hit/measure_hit信号,用于实现与音频同步的游戏逻辑。

rhythm_chart_parser.gd

rhythm_chart_parser.gd

JSON chart loader with time-sorted notes. Provides optimized get_notes_in_range() for efficient note querying in highways.

JSON谱面加载器,会返回按时间排序的音符列表,提供优化后的get_notes_in_range()方法,可高效查询轨道内的音符。

Core Loop

核心循环

Music Plays → Notes Appear → Player Inputs → Timing Judged → Score/Feedback → Combo Builds
音乐播放 → 音符出现 → 玩家输入 → 时间判定 → 得分/反馈 → 累积连击

Skill Chain

技能依赖链

godot-project-foundations
,
godot-input-handling
,
sound-manager
,
animation
,
ui-framework

godot-project-foundations
,
godot-input-handling
,
sound-manager
,
animation
,
ui-framework

Audio Synchronization

音频同步

THE most critical aspect - notes MUST align perfectly with audio.
最核心的功能点 - 音符必须和音频完全对齐。

Music Time System

音乐时间系统

gdscript
class_name MusicConductor
extends Node

signal beat(beat_number: int)
signal measure(measure_number: int)

@export var bpm := 120.0
@export var music: AudioStream

var seconds_per_beat: float
var song_position: float = 0.0  # In seconds
var song_position_in_beats: float = 0.0
var last_reported_beat: int = 0

@onready var audio_player: AudioStreamPlayer

func _ready() -> void:
    seconds_per_beat = 60.0 / bpm
    audio_player.stream = music

func _process(_delta: float) -> void:
    # Get precise audio position with latency compensation
    song_position = audio_player.get_playback_position() + AudioServer.get_time_since_last_mix()
    
    # Convert to beats
    song_position_in_beats = song_position / seconds_per_beat
    
    # Emit beat signals
    var current_beat := int(song_position_in_beats)
    if current_beat > last_reported_beat:
        beat.emit(current_beat)
        if current_beat % 4 == 0:
            measure.emit(current_beat / 4)
        last_reported_beat = current_beat

func start_song() -> void:
    audio_player.play()
    song_position = 0.0
    last_reported_beat = 0

func beats_to_seconds(beats: float) -> float:
    return beats * seconds_per_beat

func seconds_to_beats(secs: float) -> float:
    return secs / seconds_per_beat

gdscript
class_name MusicConductor
extends Node

signal beat(beat_number: int)
signal measure(measure_number: int)

@export var bpm := 120.0
@export var music: AudioStream

var seconds_per_beat: float
var song_position: float = 0.0  # In seconds
var song_position_in_beats: float = 0.0
var last_reported_beat: int = 0

@onready var audio_player: AudioStreamPlayer

func _ready() -> void:
    seconds_per_beat = 60.0 / bpm
    audio_player.stream = music

func _process(_delta: float) -> void:
    # Get precise audio position with latency compensation
    song_position = audio_player.get_playback_position() + AudioServer.get_time_since_last_mix()
    
    # Convert to beats
    song_position_in_beats = song_position / seconds_per_beat
    
    # Emit beat signals
    var current_beat := int(song_position_in_beats)
    if current_beat > last_reported_beat:
        beat.emit(current_beat)
        if current_beat % 4 == 0:
            measure.emit(current_beat / 4)
        last_reported_beat = current_beat

func start_song() -> void:
    audio_player.play()
    song_position = 0.0
    last_reported_beat = 0

func beats_to_seconds(beats: float) -> float:
    return beats * seconds_per_beat

func seconds_to_beats(secs: float) -> float:
    return secs / seconds_per_beat

Note System

音符系统

Note Data Structure

音符数据结构

gdscript
class_name NoteData
extends Resource

@export var beat_time: float  # When to hit (in beats)
@export var lane: int  # Which input lane (0-3 for 4-key, etc.)
@export var note_type: NoteType
@export var hold_duration: float = 0.0  # For hold notes (in beats)

enum NoteType { TAP, HOLD, SLIDE, FLICK }
gdscript
class_name NoteData
extends Resource

@export var beat_time: float  # When to hit (in beats)
@export var lane: int  # Which input lane (0-3 for 4-key, etc.)
@export var note_type: NoteType
@export var hold_duration: float = 0.0  # For hold notes (in beats)

enum NoteType { TAP, HOLD, SLIDE, FLICK }

Chart/Beatmap Loading

谱面/Beatmap加载

gdscript
class_name ChartLoader
extends Node

func load_chart(chart_path: String) -> Array[NoteData]:
    var notes: Array[NoteData] = []
    var file := FileAccess.open(chart_path, FileAccess.READ)
    
    while not file.eof_reached():
        var line := file.get_line()
        if line.is_empty() or line.begins_with("#"):
            continue
        
        var parts := line.split(",")
        var note := NoteData.new()
        note.beat_time = float(parts[0])
        note.lane = int(parts[1])
        note.note_type = NoteType.get(parts[2]) if parts.size() > 2 else NoteType.TAP
        note.hold_duration = float(parts[3]) if parts.size() > 3 else 0.0
        notes.append(note)
    
    notes.sort_custom(func(a, b): return a.beat_time < b.beat_time)
    return notes

gdscript
class_name ChartLoader
extends Node

func load_chart(chart_path: String) -> Array[NoteData]:
    var notes: Array[NoteData] = []
    var file := FileAccess.open(chart_path, FileAccess.READ)
    
    while not file.eof_reached():
        var line := file.get_line()
        if line.is_empty() or line.begins_with("#"):
            continue
        
        var parts := line.split(",")
        var note := NoteData.new()
        note.beat_time = float(parts[0])
        note.lane = int(parts[1])
        note.note_type = NoteType.get(parts[2]) if parts.size() > 2 else NoteType.TAP
        note.hold_duration = float(parts[3]) if parts.size() > 3 else 0.0
        notes.append(note)
    
    notes.sort_custom(func(a, b): return a.beat_time < b.beat_time)
    return notes

Note Highway / Receptor

音符轨道/接收区

gdscript
class_name NoteHighway
extends Control

@export var scroll_speed := 500.0  # Pixels per second
@export var hit_position_y := 100.0  # From bottom
@export var note_scene: PackedScene
@export var look_ahead_beats := 4.0

var active_notes: Array[NoteVisual] = []
var chart: Array[NoteData]
var next_note_index: int = 0

func _process(_delta: float) -> void:
    spawn_upcoming_notes()
    update_note_positions()

func spawn_upcoming_notes() -> void:
    var look_ahead_time := MusicConductor.song_position_in_beats + look_ahead_beats
    
    while next_note_index < chart.size():
        var note_data := chart[next_note_index]
        if note_data.beat_time > look_ahead_time:
            break
        
        var note_visual := note_scene.instantiate() as NoteVisual
        note_visual.setup(note_data)
        note_visual.position.x = get_lane_x(note_data.lane)
        add_child(note_visual)
        active_notes.append(note_visual)
        next_note_index += 1

func update_note_positions() -> void:
    for note in active_notes:
        var beats_until_hit := note.data.beat_time - MusicConductor.song_position_in_beats
        var seconds_until_hit := MusicConductor.beats_to_seconds(beats_until_hit)
        
        # Note scrolls down from top
        note.position.y = (size.y - hit_position_y) - (seconds_until_hit * scroll_speed)
        
        # Remove if too far past
        if note.position.y > size.y + 100:
            if not note.was_hit:
                register_miss(note.data)
            note.queue_free()
            active_notes.erase(note)

gdscript
class_name NoteHighway
extends Control

@export var scroll_speed := 500.0  # Pixels per second
@export var hit_position_y := 100.0  # From bottom
@export var note_scene: PackedScene
@export var look_ahead_beats := 4.0

var active_notes: Array[NoteVisual] = []
var chart: Array[NoteData]
var next_note_index: int = 0

func _process(_delta: float) -> void:
    spawn_upcoming_notes()
    update_note_positions()

func spawn_upcoming_notes() -> void:
    var look_ahead_time := MusicConductor.song_position_in_beats + look_ahead_beats
    
    while next_note_index < chart.size():
        var note_data := chart[next_note_index]
        if note_data.beat_time > look_ahead_time:
            break
        
        var note_visual := note_scene.instantiate() as NoteVisual
        note_visual.setup(note_data)
        note_visual.position.x = get_lane_x(note_data.lane)
        add_child(note_visual)
        active_notes.append(note_visual)
        next_note_index += 1

func update_note_positions() -> void:
    for note in active_notes:
        var beats_until_hit := note.data.beat_time - MusicConductor.song_position_in_beats
        var seconds_until_hit := MusicConductor.beats_to_seconds(beats_until_hit)
        
        # Note scrolls down from top
        note.position.y = (size.y - hit_position_y) - (seconds_until_hit * scroll_speed)
        
        # Remove if too far past
        if note.position.y > size.y + 100:
            if not note.was_hit:
                register_miss(note.data)
            note.queue_free()
            active_notes.erase(note)

Timing Judgment

时间判定

gdscript
class_name JudgmentSystem
extends Node

signal note_judged(judgment: Judgment, note: NoteData)

enum Judgment { PERFECT, GREAT, GOOD, BAD, MISS }
gdscript
class_name JudgmentSystem
extends Node

signal note_judged(judgment: Judgment, note: NoteData)

enum Judgment { PERFECT, GREAT, GOOD, BAD, MISS }

Timing windows in milliseconds (symmetric around hit time)

Timing windows in milliseconds (symmetric around hit time)

const WINDOWS := { Judgment.PERFECT: 25.0, Judgment.GREAT: 50.0, Judgment.GOOD: 100.0, Judgment.BAD: 150.0 }
func judge_input(input_time: float, note_time: float) -> Judgment: var difference := abs(input_time - note_time) * 1000.0 # ms
if difference <= WINDOWS[Judgment.PERFECT]:
    return Judgment.PERFECT
elif difference <= WINDOWS[Judgment.GREAT]:
    return Judgment.GREAT
elif difference <= WINDOWS[Judgment.GOOD]:
    return Judgment.GOOD
elif difference <= WINDOWS[Judgment.BAD]:
    return Judgment.BAD
else:
    return Judgment.MISS
func get_timing_offset(input_time: float, note_time: float) -> float: # Positive = late, Negative = early return (input_time - note_time) * 1000.0

---
const WINDOWS := { Judgment.PERFECT: 25.0, Judgment.GREAT: 50.0, Judgment.GOOD: 100.0, Judgment.BAD: 150.0 }
func judge_input(input_time: float, note_time: float) -> Judgment: var difference := abs(input_time - note_time) * 1000.0 # ms
if difference <= WINDOWS[Judgment.PERFECT]:
    return Judgment.PERFECT
elif difference <= WINDOWS[Judgment.GREAT]:
    return Judgment.GREAT
elif difference <= WINDOWS[Judgment.GOOD]:
    return Judgment.GOOD
elif difference <= WINDOWS[Judgment.BAD]:
    return Judgment.BAD
else:
    return Judgment.MISS
func get_timing_offset(input_time: float, note_time: float) -> float: # Positive = late, Negative = early return (input_time - note_time) * 1000.0

---

Scoring System

计分系统

gdscript
class_name RhythmScoring
extends Node

signal score_changed(new_score: int)
signal combo_changed(new_combo: int)
signal combo_broken

const JUDGMENT_SCORES := {
    Judgment.PERFECT: 100,
    Judgment.GREAT: 75,
    Judgment.GOOD: 50,
    Judgment.BAD: 25,
    Judgment.MISS: 0
}

const COMBO_MULTIPLIER_THRESHOLDS := {
    10: 1.5,
    25: 2.0,
    50: 2.5,
    100: 3.0
}

var score: int = 0
var combo: int = 0
var max_combo: int = 0

func register_judgment(judgment: Judgment) -> void:
    if judgment == Judgment.MISS:
        if combo > 0:
            combo_broken.emit()
        combo = 0
    else:
        combo += 1
        max_combo = max(max_combo, combo)
    
    var base_score := JUDGMENT_SCORES[judgment]
    var multiplier := get_combo_multiplier()
    var earned := int(base_score * multiplier)
    
    score += earned
    score_changed.emit(score)
    combo_changed.emit(combo)

func get_combo_multiplier() -> float:
    var mult := 1.0
    for threshold in COMBO_MULTIPLIER_THRESHOLDS:
        if combo >= threshold:
            mult = COMBO_MULTIPLIER_THRESHOLDS[threshold]
    return mult

gdscript
class_name RhythmScoring
extends Node

signal score_changed(new_score: int)
signal combo_changed(new_combo: int)
signal combo_broken

const JUDGMENT_SCORES := {
    Judgment.PERFECT: 100,
    Judgment.GREAT: 75,
    Judgment.GOOD: 50,
    Judgment.BAD: 25,
    Judgment.MISS: 0
}

const COMBO_MULTIPLIER_THRESHOLDS := {
    10: 1.5,
    25: 2.0,
    50: 2.5,
    100: 3.0
}

var score: int = 0
var combo: int = 0
var max_combo: int = 0

func register_judgment(judgment: Judgment) -> void:
    if judgment == Judgment.MISS:
        if combo > 0:
            combo_broken.emit()
        combo = 0
    else:
        combo += 1
        max_combo = max(max_combo, combo)
    
    var base_score := JUDGMENT_SCORES[judgment]
    var multiplier := get_combo_multiplier()
    var earned := int(base_score * multiplier)
    
    score += earned
    score_changed.emit(score)
    combo_changed.emit(combo)

func get_combo_multiplier() -> float:
    var mult := 1.0
    for threshold in COMBO_MULTIPLIER_THRESHOLDS:
        if combo >= threshold:
            mult = COMBO_MULTIPLIER_THRESHOLDS[threshold]
    return mult

Input Processing

输入处理

gdscript
class_name RhythmInput
extends Node

@export var lane_actions: Array[StringName] = [
    &"lane_0", &"lane_1", &"lane_2", &"lane_3"
]

var held_notes: Dictionary = {}  # lane: NoteData for hold notes

func _input(event: InputEvent) -> void:
    for i in lane_actions.size():
        if event.is_action_pressed(lane_actions[i]):
            process_lane_press(i)
        elif event.is_action_released(lane_actions[i]):
            process_lane_release(i)

func process_lane_press(lane: int) -> void:
    var current_time := MusicConductor.song_position
    var closest_note := find_closest_note_in_lane(lane, current_time)
    
    if closest_note:
        var note_time := MusicConductor.beats_to_seconds(closest_note.beat_time)
        var judgment := JudgmentSystem.judge_input(current_time, note_time)
        
        if judgment != Judgment.MISS:
            hit_note(closest_note, judgment)
            if closest_note.note_type == NoteType.HOLD:
                held_notes[lane] = closest_note

func process_lane_release(lane: int) -> void:
    if held_notes.has(lane):
        var hold_note := held_notes[lane]
        var hold_end_time := hold_note.beat_time + hold_note.hold_duration
        var current_beat := MusicConductor.song_position_in_beats
        
        # Check if released at correct time
        if abs(current_beat - hold_end_time) < 0.25:  # Quarter beat tolerance
            complete_hold_note(hold_note)
        else:
            drop_hold_note(hold_note)
        
        held_notes.erase(lane)

gdscript
class_name RhythmInput
extends Node

@export var lane_actions: Array[StringName] = [
    &"lane_0", &"lane_1", &"lane_2", &"lane_3"
]

var held_notes: Dictionary = {}  # lane: NoteData for hold notes

func _input(event: InputEvent) -> void:
    for i in lane_actions.size():
        if event.is_action_pressed(lane_actions[i]):
            process_lane_press(i)
        elif event.is_action_released(lane_actions[i]):
            process_lane_release(i)

func process_lane_press(lane: int) -> void:
    var current_time := MusicConductor.song_position
    var closest_note := find_closest_note_in_lane(lane, current_time)
    
    if closest_note:
        var note_time := MusicConductor.beats_to_seconds(closest_note.beat_time)
        var judgment := JudgmentSystem.judge_input(current_time, note_time)
        
        if judgment != Judgment.MISS:
            hit_note(closest_note, judgment)
            if closest_note.note_type == NoteType.HOLD:
                held_notes[lane] = closest_note

func process_lane_release(lane: int) -> void:
    if held_notes.has(lane):
        var hold_note := held_notes[lane]
        var hold_end_time := hold_note.beat_time + hold_note.hold_duration
        var current_beat := MusicConductor.song_position_in_beats
        
        # Check if released at correct time
        if abs(current_beat - hold_end_time) < 0.25:  # Quarter beat tolerance
            complete_hold_note(hold_note)
        else:
            drop_hold_note(hold_note)
        
        held_notes.erase(lane)

Visual Feedback

视觉反馈

gdscript
func show_judgment_splash(judgment: Judgment, position: Vector2) -> void:
    var splash := judgment_sprites[judgment].instantiate()
    splash.position = position
    add_child(splash)
    
    var tween := create_tween()
    tween.tween_property(splash, "scale", Vector2(1.2, 1.2), 0.1)
    tween.tween_property(splash, "scale", Vector2(1.0, 1.0), 0.1)
    tween.tween_property(splash, "modulate:a", 0.0, 0.3)
    tween.tween_callback(splash.queue_free)

func pulse_receptor(lane: int, judgment: Judgment) -> void:
    var receptor := lane_receptors[lane]
    receptor.modulate = judgment_colors[judgment]
    
    var tween := create_tween()
    tween.tween_property(receptor, "modulate", Color.WHITE, 0.15)

gdscript
func show_judgment_splash(judgment: Judgment, position: Vector2) -> void:
    var splash := judgment_sprites[judgment].instantiate()
    splash.position = position
    add_child(splash)
    
    var tween := create_tween()
    tween.tween_property(splash, "scale", Vector2(1.2, 1.2), 0.1)
    tween.tween_property(splash, "scale", Vector2(1.0, 1.0), 0.1)
    tween.tween_property(splash, "modulate:a", 0.0, 0.3)
    tween.tween_callback(splash.queue_free)

func pulse_receptor(lane: int, judgment: Judgment) -> void:
    var receptor := lane_receptors[lane]
    receptor.modulate = judgment_colors[judgment]
    
    var tween := create_tween()
    tween.tween_property(receptor, "modulate", Color.WHITE, 0.15)

Common Pitfalls

常见问题

PitfallSolution
Audio desyncUse
AudioServer.get_time_since_last_mix()
latency compensation
Unfair judgmentGenerous windows at low difficulty, offset calibration
Notes bunched visuallyAdjust scroll speed or spawn timing
Hold notes jankySeparate hold body and tail rendering
Frame drops cause missesDecouple input from framerate

问题解决方案
音画不同步使用
AudioServer.get_time_since_last_mix()
延迟补偿
判定不合理低难度下放宽判定窗口,提供偏移校准
音符视觉上挤在一起调整滚动速度或生成时机
长音符显示卡顿将长音符的身体和尾部分开渲染
帧率下降导致漏判输入逻辑与帧率解绑

Godot-Specific Tips

Godot专属技巧

  1. Audio latency: Calibrate with
    AudioServer
    and custom offset
  2. Input polling: Use
    _input
    not
    _process
    for precise timing
  3. Shaders: UV scrolling for note highways
  4. Particles: Use
    GPUParticles2D
    for hit effects
  1. 音频延迟:结合
    AudioServer
    和自定义偏移进行校准
  2. 输入轮询:使用
    _input
    而非
    _process
    获得精准计时
  3. 着色器:音符轨道可使用UV滚动效果
  4. 粒子效果:击打效果使用
    GPUParticles2D
    实现

Reference

参考

  • Master Skill: godot-master
  • 主技能:godot-master