godot-genre-rhythm
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGenre: Rhythm
类型:节奏游戏
Expert blueprint for rhythm games emphasizing audio-visual synchronization and flow state.
专注于音画同步和心流体验的节奏游戏专业开发蓝图。
NEVER Do
绝对禁止事项
- NEVER skip latency compensation — Use to sync visuals with audio. Missing this causes desync.
AudioServer.get_time_since_last_mix() - NEVER use for input — Use
_processfor precise timing. Frame-dependent input causes missed notes._input() - 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-foundationsgodot-input-handlingsound-manageranimationui-frameworkgodot-project-foundationsgodot-input-handlingsound-manageranimationui-frameworkAudio 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_beatgdscript
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_beatNote 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 notesgdscript
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 notesNote 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.MISSfunc 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.MISSfunc 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 multgdscript
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 multInput 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
常见问题
| Pitfall | Solution |
|---|---|
| Audio desync | Use |
| Unfair judgment | Generous windows at low difficulty, offset calibration |
| Notes bunched visually | Adjust scroll speed or spawn timing |
| Hold notes janky | Separate hold body and tail rendering |
| Frame drops cause misses | Decouple input from framerate |
| 问题 | 解决方案 |
|---|---|
| 音画不同步 | 使用 |
| 判定不合理 | 低难度下放宽判定窗口,提供偏移校准 |
| 音符视觉上挤在一起 | 调整滚动速度或生成时机 |
| 长音符显示卡顿 | 将长音符的身体和尾部分开渲染 |
| 帧率下降导致漏判 | 输入逻辑与帧率解绑 |
Godot-Specific Tips
Godot专属技巧
- Audio latency: Calibrate with and custom offset
AudioServer - Input polling: Use not
_inputfor precise timing_process - Shaders: UV scrolling for note highways
- Particles: Use for hit effects
GPUParticles2D
- 音频延迟:结合和自定义偏移进行校准
AudioServer - 输入轮询:使用而非
_input获得精准计时_process - 着色器:音符轨道可使用UV滚动效果
- 粒子效果:击打效果使用实现
GPUParticles2D
Reference
参考
- Master Skill: godot-master
- 主技能:godot-master