Files
rthythm_archor_game/scenes/ui/rhythm_track.gd
T
2026-07-06 22:59:47 -07:00

562 lines
21 KiB
GDScript

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