562 lines
21 KiB
GDScript
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
|