Server requests move out of Settings into a new MusicServerClient autoload with one function per endpoint. It sends requests through a swappable HttpTransport so tests can fake the server, and it frees its HTTPRequest nodes after use. Players now have a UUID that never changes. Their position in Settings.player_array is only used for the Alt+<number> keybinds. Previously the position doubled as the id, so removing a player made points and wins land on the wrong player, and could delete another player's point from a song. Also fixes: - format_time showed 59.6 seconds as "00:60" - searches containing regex characters such as "Q*bert" broke search - SongObject and PlayerObject were Nodes that were never freed; they are now RefCounted - search matching is split out of the UI code into find_matches()
193 lines
6.1 KiB
GDScript
193 lines
6.1 KiB
GDScript
extends Control
|
|
|
|
@onready
|
|
var search_list: VBoxContainer = $ScrollContainer/VBoxContainer
|
|
|
|
@onready
|
|
var search_panel: Panel = %SearchPanel
|
|
|
|
@onready
|
|
var clear_button: Button = $ClearButton
|
|
|
|
@onready
|
|
var search_bar: TextEdit = $Searchbar
|
|
|
|
var soundtracks: Array = []
|
|
|
|
# Precomputed soundtrack data: array of dicts with original, lowercase, and cleaned fields
|
|
var precomputed_soundtracks: Array = []
|
|
|
|
# Regex cache: maps cleaned search terms to compiled RegEx objects
|
|
var regex_cache: Dictionary = {}
|
|
|
|
var label_pool: Array[Label] = []
|
|
|
|
# Called when the node enters the scene tree for the first time.
|
|
func _ready() -> void:
|
|
clear_button.pressed.connect(clear)
|
|
search_bar.grab_focus()
|
|
search_bar.text_changed.connect(search)
|
|
visibility_changed.connect(focus)
|
|
|
|
func focus() -> void:
|
|
if self.visible == true:
|
|
search_bar.grab_focus()
|
|
clear()
|
|
|
|
func close() -> void:
|
|
clear()
|
|
self.visible = false
|
|
|
|
func search() -> void:
|
|
print(search_bar.text)
|
|
var search_text: String = search_bar.text
|
|
|
|
if search_text == "":
|
|
show_all_soundtracks()
|
|
return
|
|
|
|
perform_search(search_text)
|
|
|
|
func show_all_soundtracks() -> void:
|
|
# Hide all visible labels and add to pool
|
|
for child: Label in search_list.get_children():
|
|
if child.visible:
|
|
child.visible = false
|
|
label_pool.append(child)
|
|
|
|
# Show all soundtracks in order
|
|
for soundtrack: String in soundtracks:
|
|
add_soundtrack_reuse(soundtrack)
|
|
|
|
func perform_search(search_text: String) -> void:
|
|
# Hide all visible labels and add to pool
|
|
for child: Label in search_list.get_children():
|
|
if child.visible:
|
|
child.visible = false
|
|
label_pool.append(child)
|
|
|
|
for soundtrack: String in find_matches(search_text):
|
|
add_soundtrack_reuse(soundtrack)
|
|
|
|
## Returns the soundtracks matching [param search_text], best matches first: plain
|
|
## case-insensitive matches, then matches ignoring spaces and special characters,
|
|
## then matches with the letters in order but not next to each other.
|
|
func find_matches(search_text: String) -> Array[String]:
|
|
var seen: Dictionary = {}
|
|
var search_text_lower: String = search_text.to_lower()
|
|
var clean_search_text: String = clean_term(search_text)
|
|
|
|
var matches: Array[String] = []
|
|
|
|
# Pass 1: Exact case-insensitive contains match on lowercase versions
|
|
for i in range(precomputed_soundtracks.size()):
|
|
var st_data = precomputed_soundtracks[i]
|
|
if st_data["lowercase"].contains(search_text_lower):
|
|
if not st_data["original"] in seen:
|
|
seen[st_data["original"]] = true
|
|
matches.append(st_data["original"])
|
|
|
|
# Pass 2: Contains match on cleaned terms
|
|
for i in range(precomputed_soundtracks.size()):
|
|
var st_data = precomputed_soundtracks[i]
|
|
if st_data["cleaned"].contains(clean_search_text):
|
|
if not st_data["original"] in seen:
|
|
seen[st_data["original"]] = true
|
|
matches.append(st_data["original"])
|
|
|
|
# Pass 3: Regex match on cleaned terms
|
|
var regex = compile_regex_cached(clean_search_text)
|
|
for i in range(precomputed_soundtracks.size()):
|
|
var st_data = precomputed_soundtracks[i]
|
|
if regex.search(st_data["cleaned"]):
|
|
if not st_data["original"] in seen:
|
|
seen[st_data["original"]] = true
|
|
matches.append(st_data["original"])
|
|
|
|
return matches
|
|
|
|
# Cleans a term by removing spaces and replacing special characters.
|
|
# Matches MusicServer's clean_term() function.
|
|
func clean_term(term: String) -> String:
|
|
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and").replace("'n", "and").to_lower()
|
|
|
|
# Compiles a regex pattern with caching to avoid recompiling the same pattern multiple times.
|
|
func compile_regex_cached(search_term: String) -> RegEx:
|
|
if not search_term in regex_cache:
|
|
var regex = RegEx.new()
|
|
var regText: String = ".*"
|
|
for letter: String in search_term:
|
|
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
|
|
if "\\^$.|?*+()[]{}".contains(letter):
|
|
regText += "\\"
|
|
regText += letter + ".*"
|
|
regex.compile(regText)
|
|
regex_cache[search_term] = regex
|
|
return regex_cache[search_term]
|
|
|
|
func add_soundtrack(soundtrack: String) -> void:
|
|
# Backward compatibility - delegates to reuse version
|
|
add_soundtrack_reuse(soundtrack)
|
|
|
|
# Optimized version that reuses label nodes from pool instead of creating new ones
|
|
func add_soundtrack_reuse(soundtrack: String) -> void:
|
|
var label: Label
|
|
if label_pool.size() > 0:
|
|
label = label_pool.pop_front()
|
|
label.text = soundtrack
|
|
label.visible = true
|
|
# Move to end to maintain display order
|
|
search_list.move_child(label, search_list.get_child_count() - 1)
|
|
else:
|
|
label = Label.new()
|
|
label.text = soundtrack
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
|
search_list.add_child(label)
|
|
label.visible = true
|
|
|
|
func check_if_soundtrack_exists(soundtrack: String) -> bool:
|
|
var soundtrack_exists: bool = false
|
|
for child: Label in search_list.get_children():
|
|
if child.text == soundtrack and child.visible:
|
|
soundtrack_exists = true
|
|
return soundtrack_exists
|
|
|
|
func get_list_of_soundtracks() -> void:
|
|
print("get_list_of_soundtracks")
|
|
var handle_soundtracks: Callable = func handle_soundtracks(array) -> void:
|
|
if typeof(array) == TYPE_ARRAY:
|
|
# Reuse existing labels: hide all and add to pool
|
|
for child: Label in search_list.get_children():
|
|
child.visible = false
|
|
label_pool.append(child)
|
|
set_soundtracks(array)
|
|
for soundtrack: String in soundtracks:
|
|
add_soundtrack_reuse(soundtrack)
|
|
else:
|
|
print("Unexpected data")
|
|
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
|
|
|
## Replaces the searchable soundtracks and precomputes their search forms.
|
|
func set_soundtracks(list: Array) -> void:
|
|
soundtracks = []
|
|
precomputed_soundtracks = []
|
|
soundtracks.append_array(list)
|
|
for soundtrack: String in soundtracks:
|
|
precomputed_soundtracks.append({
|
|
"original": soundtrack,
|
|
"lowercase": soundtrack.to_lower(),
|
|
"cleaned": clean_term(soundtrack)
|
|
})
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if self.visible == true:
|
|
if event is InputEventMouseButton && event.is_pressed():
|
|
var evLocal: InputEvent = make_input_local(event)
|
|
if !Rect2(Vector2(0, 0), search_panel.size).has_point(evLocal.position):
|
|
self.visible = false
|
|
|
|
func clear() -> void:
|
|
search_bar.text = ""
|
|
show_all_soundtracks()
|
|
search_bar.grab_focus()
|