class_name RhythmTrack extends Control @onready var judgement_label: Label = $JudgementLabel @onready var track_background: TextureRect = $TrackBackground @onready var track_line: TextureRect = $TrackLine @onready var center_base: TextureRect = $CenterBase @onready var center_flash: TextureRect = $CenterFlash @onready var left_mover: TextureRect = $LeftMover @onready var right_mover: TextureRect = $RightMover @onready var chart_marker_container: Control = $ChartMarkerContainer @onready var combo_count_label: Label = $HudReadoutRow/ComboCountLabel @onready var attack_buff_value_label: Label = $HudReadoutRow/AttackBuffValueLabel @onready var buff_pip_row: HBoxContainer = $BuffPipRow @onready var judgement_art: TextureRect = $JudgementArt @export var bpm := 80.0 @export var responsive_layout := true # Full track skin per time phase: map A (past) is the purple/gold set, map B # (future) the red "d" set. Time anchors never add screen elements — the mover # itself swaps to the special-beat texture. A held special beat shows the # OTHER phase's hit emblem (cross variant) while the flash stays own-phase. const TRACK_SKINS := { &"past": { &"background": preload("res://assets/ui/rhythm/b.png"), &"center_idle": preload("res://assets/ui/rhythm/anchor01.png"), &"center_hit_normal": preload("res://assets/ui/rhythm/anchor02.png"), &"center_hit_special": preload("res://assets/ui/rhythm/anchor02d.png"), &"flash_normal": preload("res://assets/ui/rhythm/c01.png"), &"flash_special": preload("res://assets/ui/rhythm/c02.png"), &"mover_normal": preload("res://assets/ui/rhythm/star.png"), &"mover_special": preload("res://assets/ui/rhythm/sanchor.png"), }, &"future": { &"background": preload("res://assets/ui/rhythm/bd.png"), &"center_idle": preload("res://assets/ui/rhythm/anchor01d.png"), &"center_hit_normal": preload("res://assets/ui/rhythm/anchor02d.png"), &"center_hit_special": preload("res://assets/ui/rhythm/anchor02.png"), &"flash_normal": preload("res://assets/ui/rhythm/c01d.png"), &"flash_special": preload("res://assets/ui/rhythm/c02d.png"), &"mover_normal": preload("res://assets/ui/rhythm/stard.png"), &"mover_special": preload("res://assets/ui/rhythm/sanchord.png"), }, } const DESIGN_SIZE := Vector2(1280.0, 720.0) const HUD_MIN_SCALE := 0.72 const HUD_MAX_SCALE := 1.15 const CENTER_HIT_HOLD_SECONDS := 0.62 const BEAT_TICK_FLASH_PEAK := 0.35 const BEAT_FLASH_DECAY_RATE := 8.0 const CENTER_HIT_FLASH_DECAY_RATE := 2.8 const CENTER_HIT_FLASH_SCALE := 1.55 const TIME_ANCHOR_HELD_COLOR := Color(0.45, 1.0, 0.75) const TIME_ANCHOR_BROKEN_COLOR := Color(1.0, 0.2, 0.9) const TIME_ANCHOR_MOVER_SCALE := 1.65 const BUFF_SLOT_COUNT := 7 const BUFF_ON_TEXTURE := preload("res://assets/ui/buff/buff_on.png") const BUFF_OFF_TEXTURE := preload("res://assets/ui/buff/buff_off.png") const JUDGEMENT_ARTS := { &"perfect": preload("res://assets/ui/judgements/perfect.png"), &"good": preload("res://assets/ui/judgements/good.png"), &"bad": preload("res://assets/ui/judgements/bad.png"), &"miss": preload("res://assets/ui/judgements/miss.png"), } ## 2026-07-05 策划:判定字样显示 1 秒后自动消失;新判定顶替时重置计时。 const JUDGEMENT_VISIBLE_SECONDS := 1.0 var chart_markers: Array[Control] = [] var _time_anchor_beats: Dictionary = {} var _last_beat_index := -1 var _last_upcoming_beat := -1 var _time_phase: StringName = &"past" var _streak_count := 0 var _attack_buff_stacks := 0 var _attack_buff_max := BUFF_SLOT_COUNT var _center_hit_timer := 0.0 var _judgement_visible_left := 0.0 var _time_anchor_feedback_latched := false var track_center := Vector2.ZERO var left_mover_start := Vector2.ZERO var right_mover_start := Vector2.ZERO var mover_size := Vector2.ZERO var normal_mover_size := Vector2.ZERO var time_anchor_mover_size := Vector2.ZERO var center_flash_size := Vector2.ZERO var normal_center_flash_size := Vector2.ZERO var hit_center_flash_size := Vector2.ZERO var center_flash_decay_rate := BEAT_FLASH_DECAY_RATE var center_flash_color := Color.WHITE var center_flash_peak := 1.0 var beat_flash := 0.0 var beat_age := 0.0 var feedback_flash := 0.0 var _last_layout_size := Vector2.ZERO var _responsive_scale := 1.0 func _ready() -> void: _apply_responsive_layout() _cache_rhythm_track_layout() center_flash.modulate = Color(1.0, 1.0, 1.0, 0.0) _sync_bpm_with_rhythm_manager() _apply_time_phase_skin(_initial_time_phase()) _refresh_combo_count() _refresh_attack_buff_display() var bus := _event_bus() bus.connect("beat_ticked", _on_beat_ticked) bus.connect("judgement_made", _on_judgement_made) bus.connect("chart_event_upcoming", _on_chart_event_upcoming) bus.connect("chart_event_triggered", _on_chart_event_triggered) if bus.has_signal("time_anchor_scheduled"): bus.connect("time_anchor_scheduled", _on_time_anchor_scheduled) if bus.has_signal("time_anchor_resolved"): bus.connect("time_anchor_resolved", _on_time_anchor_resolved) if bus.has_signal("time_phase_changed"): bus.connect("time_phase_changed", _on_time_phase_changed) if bus.has_signal("chart_reset"): bus.connect("chart_reset", _on_chart_reset_clear_time_anchor_markers) if bus.has_signal("streak_changed"): bus.connect("streak_changed", _on_streak_changed) if bus.has_signal("attack_buff_changed"): bus.connect("attack_buff_changed", _on_attack_buff_changed) func _apply_responsive_layout() -> void: if not responsive_layout: return var viewport_size := size if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: viewport_size = get_viewport_rect().size if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: return _last_layout_size = viewport_size _responsive_scale = _hud_scale(viewport_size) var s := _responsive_scale var center_x := viewport_size.x * 0.5 var track_y := viewport_size.y - 94.0 * s var axis_y := track_y + 18.0 * s var track_max_w := maxf(320.0 * s, viewport_size.x - 96.0 * s) var track_w := minf(832.0 * s, track_max_w) _set_control_rect(track_background, Vector2(center_x, track_y + 14.0 * s), Vector2(track_w, 116.0 * s)) _set_control_rect(track_line, Vector2(center_x, axis_y), Vector2(maxf(120.0 * s, track_w - 100.0 * s), 47.0 * s)) _set_control_rect(center_base, Vector2(center_x, axis_y), Vector2(112.0 * s, 131.0 * s)) _set_control_rect(center_flash, Vector2(center_x, axis_y), Vector2(160.0 * s, 151.0 * s)) normal_center_flash_size = center_flash.size hit_center_flash_size = normal_center_flash_size * CENTER_HIT_FLASH_SCALE _set_center_flash_profile(false) normal_mover_size = Vector2(68.0 * s, 62.0 * s) time_anchor_mover_size = normal_mover_size * TIME_ANCHOR_MOVER_SCALE _set_control_rect(left_mover, Vector2(center_x - track_w * 0.43, axis_y), normal_mover_size) _set_control_rect(right_mover, Vector2(center_x + track_w * 0.43, axis_y), normal_mover_size) _set_control_top_left(chart_marker_container, Vector2.ZERO, viewport_size) _set_control_top_left($HudReadoutRow, Vector2(154.0 * s, 135.0 * s), Vector2(260.0 * s, 76.0 * s)) combo_count_label.custom_minimum_size = Vector2(240.0 * s, 32.0 * s) attack_buff_value_label.custom_minimum_size = Vector2(240.0 * s, 32.0 * s) combo_count_label.add_theme_font_size_override("font_size", int(round(26.0 * s))) attack_buff_value_label.add_theme_font_size_override("font_size", int(round(26.0 * s))) var buff_total_w := (42.0 * 7.0 + 12.0 * 6.0) * s _set_control_top_left(buff_pip_row, Vector2(center_x - buff_total_w * 0.5, 132.0 * s), Vector2(buff_total_w, 58.0 * s)) buff_pip_row.add_theme_constant_override("separation", int(round(12.0 * s))) for child: Node in buff_pip_row.get_children(): var pip := child as TextureRect if pip != null: pip.custom_minimum_size = Vector2(42.0 * s, 55.0 * s) var judgement_center := Vector2(center_x, viewport_size.y * 0.56) _set_control_rect(judgement_art, judgement_center, Vector2(270.0 * s, 72.0 * s)) _set_control_rect(judgement_label, judgement_center, Vector2(500.0 * s, 74.0 * s)) judgement_label.add_theme_font_size_override("font_size", int(round(32.0 * s))) _cache_rhythm_track_layout() func _hud_scale(viewport_size: Vector2) -> float: return clampf(minf(viewport_size.x / DESIGN_SIZE.x, viewport_size.y / DESIGN_SIZE.y), HUD_MIN_SCALE, HUD_MAX_SCALE) func _set_control_rect(control: Control, center: Vector2, target_size: Vector2) -> void: control.offset_left = center.x - target_size.x * 0.5 control.offset_top = center.y - target_size.y * 0.5 control.offset_right = center.x + target_size.x * 0.5 control.offset_bottom = center.y + target_size.y * 0.5 func _set_control_top_left(control: Control, top_left: Vector2, target_size: Vector2) -> void: control.offset_left = top_left.x control.offset_top = top_left.y control.offset_right = top_left.x + target_size.x control.offset_bottom = top_left.y + target_size.y func _sync_bpm_with_rhythm_manager() -> void: var rhythm := get_tree().root.get_node_or_null("RhythmManager") if rhythm != null: var manager_bpm := float(rhythm.get("bpm")) if manager_bpm > 0.0: bpm = manager_bpm func _initial_time_phase() -> StringName: var manager := get_tree().root.get_node_or_null("TimePhaseManager") if manager != null: return StringName(str(manager.get("current_time_phase"))) return &"past" func _process(delta: float) -> void: if responsive_layout and size != _last_layout_size: _apply_responsive_layout() var visual_delta := minf(delta, 1.0 / 30.0) beat_age += delta beat_flash = maxf(0.0, beat_flash - visual_delta * center_flash_decay_rate) if _center_hit_timer > 0.0: _center_hit_timer -= delta if _center_hit_timer <= 0.0: center_base.texture = _skin()[&"center_idle"] var upcoming := _upcoming_beat_index() if upcoming != _last_upcoming_beat: _refresh_mover_textures() _update_movers() if feedback_flash > 0.0: feedback_flash = maxf(0.0, feedback_flash - visual_delta * 4.0) var feedback_scale := Vector2.ONE * (1.0 + feedback_flash * 0.18) judgement_label.scale = feedback_scale judgement_art.scale = feedback_scale # 满 1 秒才隐藏,倒计时期间不动 modulate/text,避免与脉冲/判定色互相干扰。 if _judgement_visible_left > 0.0: _judgement_visible_left -= visual_delta if _judgement_visible_left <= 0.0: _judgement_visible_left = 0.0 judgement_label.visible = false judgement_art.visible = false func _on_beat_ticked(beat_index: int) -> void: _last_beat_index = beat_index _set_center_flash_profile(false) center_flash.texture = _skin()[&"flash_normal"] center_flash_color = Color.WHITE center_flash_peak = BEAT_TICK_FLASH_PEAK beat_flash = 1.0 beat_age = 0.0 _refresh_mover_textures() _update_movers() func _on_judgement_made(quality: StringName, offset_ms: float, _beat_index: int) -> void: if _time_anchor_feedback_latched: # time_anchor_resolved already drove the center emblem, flash and label # for this very judgement; don't overwrite the anchor feedback with the # plain grade readout. _time_anchor_feedback_latched = false return var color := _judgement_color(quality) judgement_label.text = "%s %s" % [ str(quality).to_upper(), _format_signed_ms(offset_ms / 1000.0), ] judgement_label.modulate = color _show_judgement_art(quality) feedback_flash = 1.0 if quality != &"miss": _show_center_hit(false) func _on_chart_event_upcoming(_event: Resource, _time_to_event: float) -> void: return func _on_chart_event_triggered(event: Resource) -> void: _set_center_flash_profile(false) center_flash.texture = _skin()[&"flash_normal"] if StringName(str(event.get("event_type"))) == &"camera_pulse": center_flash_color = Color(1.0, 0.84, 0.26, 1.0) else: center_flash_color = _chart_marker_color(event) center_flash_peak = 1.0 beat_flash = 1.0 _update_movers() func _on_time_anchor_scheduled(anchor: Dictionary) -> void: _time_anchor_beats[int(anchor.get("beat", 0))] = true _refresh_mover_textures() func _on_time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: _time_anchor_beats.erase(int(anchor.get("beat", 0))) _refresh_mover_textures() # Held anchors resolve inside the judgement_made dispatch (judgement dict is # non-empty there); latch so the follow-up grade readout keeps this feedback. _time_anchor_feedback_latched = not judgement.is_empty() if held: _show_center_hit(true) judgement_label.text = "ANCHOR HELD" judgement_label.modulate = TIME_ANCHOR_HELD_COLOR _show_judgement_art(_judgement_from_payload(judgement)) else: _set_center_flash_profile(false) center_flash.texture = _skin()[&"flash_special"] center_flash_color = TIME_ANCHOR_BROKEN_COLOR center_flash_peak = 1.0 beat_flash = 1.0 if judgement.is_empty(): judgement_label.text = "" judgement_label.visible = false judgement_art.visible = false feedback_flash = 0.0 _judgement_visible_left = 0.0 else: judgement_label.text = "TIME SHIFT!" judgement_label.modulate = TIME_ANCHOR_BROKEN_COLOR _show_judgement_art(_judgement_from_payload(judgement, &"miss")) feedback_flash = 1.0 if held: feedback_flash = 1.0 _update_movers() func _show_center_hit(special: bool) -> void: # Normal hit: own-phase anchor02 variant + c01 variant flash. Special hit: # the cross-phase anchor02 variant + own-phase c02 variant flash. var skin := _skin() center_base.texture = skin[&"center_hit_special"] if special else skin[&"center_hit_normal"] _set_center_flash_profile(true) center_flash.texture = skin[&"flash_special"] if special else skin[&"flash_normal"] center_flash_color = Color.WHITE center_flash_peak = 1.0 beat_flash = 1.0 _center_hit_timer = CENTER_HIT_HOLD_SECONDS _update_movers() func _refresh_mover_textures() -> void: # The beat the movers are converging toward is the next integer beat; if it # is a time anchor the rhythm point itself upgrades to the special texture. var skin := _skin() var upcoming := _upcoming_beat_index() _last_upcoming_beat = upcoming var special := _time_anchor_beats.has(upcoming) var texture: Texture2D = skin[&"mover_special"] if special else skin[&"mover_normal"] mover_size = time_anchor_mover_size if special else normal_mover_size if left_mover != null: left_mover.texture = texture if right_mover != null: right_mover.texture = texture _update_movers() func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: _set_center_flash_profile(false) _apply_time_phase_skin(current) center_flash_color = Color(0.45, 0.85, 1.0) if current == &"future" else Color(1.0, 0.84, 0.4) center_flash_peak = 1.0 beat_flash = 1.0 _update_movers() func _apply_time_phase_skin(time_phase: StringName) -> void: _time_phase = time_phase if TRACK_SKINS.has(time_phase) else &"past" var skin := _skin() track_background.texture = skin[&"background"] track_line.visible = false track_line.texture = null center_base.texture = skin[&"center_idle"] center_flash.texture = skin[&"flash_normal"] _set_center_flash_profile(false) _center_hit_timer = 0.0 _refresh_mover_textures() func _skin() -> Dictionary: return TRACK_SKINS[_time_phase] func _on_chart_reset_clear_time_anchor_markers(_chart_id: StringName) -> void: _time_anchor_beats.clear() _refresh_mover_textures() func _on_streak_changed(count: int) -> void: _streak_count = maxi(0, count) _refresh_combo_count() func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void: _attack_buff_max = maxi(1, max_stacks) _attack_buff_stacks = clampi(stacks, 0, BUFF_SLOT_COUNT) _refresh_attack_buff_display() func _refresh_combo_count() -> void: combo_count_label.text = "combo %d" % _streak_count func _refresh_attack_buff_display() -> void: attack_buff_value_label.text = "ATK x %d%%" % (_attack_buff_stacks * 100) for index: int in range(buff_pip_row.get_child_count()): var pip := buff_pip_row.get_child(index) as TextureRect if pip != null: pip.texture = BUFF_ON_TEXTURE if index < _attack_buff_stacks else BUFF_OFF_TEXTURE func _show_judgement_art(quality: StringName) -> void: var normalized := StringName(str(quality).to_lower()) judgement_art.texture = JUDGEMENT_ARTS.get(normalized, JUDGEMENT_ARTS[&"miss"]) judgement_art.visible = true _judgement_visible_left = JUDGEMENT_VISIBLE_SECONDS var feedback_scale := Vector2(1.18, 1.18) judgement_label.scale = feedback_scale judgement_art.scale = feedback_scale func _judgement_from_payload(judgement: Dictionary, fallback := &"miss") -> StringName: if judgement.has("label"): return StringName(str(judgement["label"]).to_lower()) if judgement.has(&"label"): return StringName(str(judgement[&"label"]).to_lower()) if judgement.has("quality"): return StringName(str(judgement["quality"]).to_lower()) if judgement.has(&"quality"): return StringName(str(judgement[&"quality"]).to_lower()) return fallback func _update_movers() -> void: var progress := _current_beat_progress() _set_control_center(left_mover, left_mover_start.lerp(track_center, progress), mover_size) _set_control_center(right_mover, right_mover_start.lerp(track_center, progress), mover_size) _set_control_center(center_flash, track_center, center_flash_size) center_flash.modulate = Color(center_flash_color.r, center_flash_color.g, center_flash_color.b, beat_flash * center_flash_peak) ## Movers follow the musical clock itself so their glide is continuous — the ## old frame-accumulated beat_age plus the beat_flash snap made every beat ## teleport the points to the center and back out. The arrival at the center ## coincides exactly with the beat (progress wraps 1 → 0 on the tick). func _current_beat_progress() -> float: var rhythm := _rhythm_manager_or_null() if rhythm != null and rhythm.has_method("get_current_beat_progress") and bool(rhythm.get("running")): return clampf(float(rhythm.call("get_current_beat_progress")), 0.0, 1.0) var seconds_per_beat := 60.0 / maxf(1.0, bpm) return clampf(beat_age / seconds_per_beat, 0.0, 1.0) ## 节奏点正收束的目标拍:直接由音乐时钟推导,而不是等物理帧上报的 ## beat_ticked(会晚约一个物理帧),否则拍界处锚点纹理会闪一下普通纹理。 func _upcoming_beat_index() -> int: var rhythm := _rhythm_manager_or_null() if rhythm != null and bool(rhythm.get("running")) and rhythm.has_method("song_position"): var beat_seconds := float(rhythm.get("beat_time")) var adjusted := float(rhythm.call("song_position")) + float(rhythm.get("beat_offset")) if beat_seconds > 0.0 and adjusted >= 0.0: return int(floor(adjusted / beat_seconds)) + 1 return _last_beat_index + 1 func _rhythm_manager_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("RhythmManager") func _set_center_flash_profile(hit: bool) -> void: center_flash_size = hit_center_flash_size if hit else normal_center_flash_size center_flash_decay_rate = CENTER_HIT_FLASH_DECAY_RATE if hit else BEAT_FLASH_DECAY_RATE func _cache_rhythm_track_layout() -> void: track_center = _control_center(center_base) left_mover_start = _control_center(left_mover) right_mover_start = _control_center(right_mover) normal_mover_size = left_mover.size if normal_mover_size == Vector2.ZERO else normal_mover_size time_anchor_mover_size = normal_mover_size * TIME_ANCHOR_MOVER_SCALE mover_size = normal_mover_size normal_center_flash_size = center_flash.size if normal_center_flash_size == Vector2.ZERO else normal_center_flash_size hit_center_flash_size = normal_center_flash_size * CENTER_HIT_FLASH_SCALE center_flash_size = normal_center_flash_size func _control_center(control: Control) -> Vector2: return Vector2( (control.offset_left + control.offset_right) * 0.5, (control.offset_top + control.offset_bottom) * 0.5 ) func _set_control_center(control: Control, center: Vector2, size: Vector2) -> void: control.offset_left = center.x - size.x * 0.5 control.offset_top = center.y - size.y * 0.5 control.offset_right = center.x + size.x * 0.5 control.offset_bottom = center.y + size.y * 0.5 func _chart_marker_text(event: Resource) -> String: match StringName(str(event.get("event_type"))): &"show_accent_marker": return "ACC" &"enemy_action": return _enemy_action_marker_text(event) &"camera_pulse": return "CAM" return str(event.get("event_type")).to_upper() func _chart_marker_color(event: Resource) -> Color: match StringName(str(event.get("event_type"))): &"show_accent_marker": return Color("ffd84a") &"enemy_action": return Color("ff3355") &"camera_pulse": return Color("ffffff") return Color("00f2ff") func _enemy_action_marker_text(event: Resource) -> String: var action_id := str(event.get("action_id")) if action_id.is_empty(): return "ACT" if action_id.begins_with("enemy_"): action_id = action_id.substr(6) return action_id.replace("_", " ").to_upper() func _judgement_color(quality: StringName) -> Color: match quality: &"perfect": return Color("00f2ff") &"good": return Color("ffffff") &"bad": return Color("ffaa00") _: return Color("ff0055") func _chart_marker_position(time_to_event: float) -> Vector2: var seconds_per_beat := 60.0 / maxf(1.0, bpm) var beat_distance := clampf(time_to_event / seconds_per_beat, 0.0, 4.0) var x := track_center.x + beat_distance * 92.0 * _responsive_scale return Vector2(x - 33.0 * _responsive_scale, track_center.y + 44.0 * _responsive_scale) func _format_signed_ms(seconds: float) -> String: if is_inf(seconds): return "-- ms" return "%+.0f ms" % (seconds * 1000.0) func _event_bus() -> Node: var root := get_tree().root var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus