extends Control # ============================================================================= # Server Browser UI # ============================================================================= # In-game server list that fetches from the master server API and lets the # player pick a server to connect to. # # Usage: # var browser = preload("res://client/scripts/server_browser_ui.gd").new() # add_child(browser) # browser.master_url = "http://master.example.com:28961" # browser.refresh() # # Signals: # server_selected(server_info: Dictionary) # Emitted when the player clicks "Connect" on a server. # The server_info dict has ip, port, name, password, etc. # ============================================================================= signal server_selected(server_info: Dictionary) const DEFAULT_COLUMN_WIDTHS := { "name": 0.30, "map": 0.15, "players": 0.10, "game_mode": 0.12, "ping": 0.08, "version": 0.10, "tags": 0.15, } # Configuration — set these before calling refresh() var master_url: String = "http://127.0.0.1:28961" var refresh_interval: float = 30.0 var auto_refresh: bool = false # Internal state var _http: HTTPRequest var _refresh_timer: Timer var _servers: Array = [] var _selected_index: int = -1 var _loading: bool = false # UI nodes (assigned in _ready or injected) var _panel: Panel var _title_label: Label var _server_list: ItemList var _refresh_button: Button var _connect_button: Button var _status_label: Label var _auto_refresh_check: CheckBox var _filter_edit: LineEdit func _ready() -> void: _build_ui() # HTTP request node _http = HTTPRequest.new() _http.name = "BrowserHTTP" _http.timeout = 10 _http.use_threads = true add_child(_http) _http.request_completed.connect(_on_servers_received) # Auto-refresh timer _refresh_timer = Timer.new() _refresh_timer.name = "RefreshTimer" _refresh_timer.one_shot = false _refresh_timer.wait_time = refresh_interval add_child(_refresh_timer) _refresh_timer.timeout.connect(_do_refresh) func _build_ui() -> void: # Main panel _panel = Panel.new() _panel.name = "BrowserPanel" _panel.anchor_right = 1.0 _panel.anchor_bottom = 1.0 add_child(_panel) var margin := 8 var style = StyleBoxFlat.new() style.bg_color = Color(0.12, 0.12, 0.14, 0.95) style.corner_radius_top_left = 4 style.corner_radius_top_right = 4 style.corner_radius_bottom_left = 4 style.corner_radius_bottom_right = 4 style.content_margin_left = margin style.content_margin_top = margin style.content_margin_right = margin style.content_margin_bottom = margin _panel.add_theme_stylebox_override("panel", style) # Title _title_label = Label.new() _title_label.name = "TitleLabel" _title_label.text = "SERVER BROWSER" _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title_label.add_theme_font_size_override("font_size", 18) _title_label.add_theme_color_override("font_color", Color(1.0, 0.84, 0.0)) # gold _title_label.position = Vector2(margin, margin) _panel.add_child(_title_label) # Filter _filter_edit = LineEdit.new() _filter_edit.name = "FilterEdit" _filter_edit.placeholder_text = "Filter by name, map, or tag..." _filter_edit.text_changed.connect(_on_filter_changed) _panel.add_child(_filter_edit) # Server list _server_list = ItemList.new() _server_list.name = "ServerList" _server_list.allow_reselect = false _server_list.select_mode = ItemList.SELECT_SINGLE _server_list.item_selected.connect(_on_server_selected) _server_list.nothing_selected.connect(_clear_selection) _panel.add_child(_server_list) # Buttons row _refresh_button = Button.new() _refresh_button.name = "RefreshButton" _refresh_button.text = "↻ Refresh" _refresh_button.pressed.connect(_do_refresh) _panel.add_child(_refresh_button) _connect_button = Button.new() _connect_button.name = "ConnectButton" _connect_button.text = "Connect" _connect_button.disabled = true _connect_button.pressed.connect(_on_connect_pressed) _panel.add_child(_connect_button) _auto_refresh_check = CheckBox.new() _auto_refresh_check.name = "AutoRefreshCheck" _auto_refresh_check.text = "Auto-refresh" _auto_refresh_check.button_pressed = auto_refresh _auto_refresh_check.toggled.connect(_on_auto_refresh_toggled) _panel.add_child(_auto_refresh_check) # Status bar _status_label = Label.new() _status_label.name = "StatusLabel" _status_label.text = "Ready" _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) _panel.add_child(_status_label) # Close/resize buttons var close_btn = Button.new() close_btn.name = "CloseButton" close_btn.text = "✕" close_btn.pressed.connect(_on_close_pressed) _panel.add_child(close_btn) func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED or what == NOTIFICATION_SORT_CHILDREN: _resize_ui() func _resize_ui() -> void: var w = _panel.size.x var h = _panel.size.y var m := 8 var y := m # Title _title_label.position = Vector2(m, y) _title_label.size = Vector2(w - m * 2, 24) y += 30 # Filter _filter_edit.position = Vector2(m, y) _filter_edit.size = Vector2(w - m * 2, 24) y += 32 # Server list var list_height = h - y - 80 _server_list.position = Vector2(m, y) _server_list.size = Vector2(w - m * 2, max(list_height, 60)) y += _server_list.size.y + 8 # Buttons row var btn_y = y var btn_w = (w - m * 3) / 3 _refresh_button.position = Vector2(m, btn_y) _refresh_button.size = Vector2(btn_w, 28) _connect_button.position = Vector2(m + btn_w + m / 2, btn_y) _connect_button.size = Vector2(btn_w, 28) _auto_refresh_check.position = Vector2(m + btn_w * 2 + m, btn_y) _auto_refresh_check.size = Vector2(btn_w, 28) y += 36 # Status _status_label.position = Vector2(m, y) _status_label.size = Vector2(w - m * 2, 20) # Close button close_btn = _panel.get_node("CloseButton") close_btn.position = Vector2(w - 28, 4) close_btn.size = Vector2(24, 24) func _get_close_button() -> Button: var node = _panel.get_node("CloseButton") return node as Button # ------------------------------------------------------------------------- # Public API # ------------------------------------------------------------------------- func refresh() -> void: """Manually trigger a server list refresh.""" _do_refresh() func set_master_url(url: String) -> void: master_url = url.trim_suffix("/") if not master_url.begins_with("http"): master_url = "http://" + master_url # ------------------------------------------------------------------------- # Internal # ------------------------------------------------------------------------- func _do_refresh() -> void: if _loading: return _loading = true _status_label.text = "Fetching servers..." _refresh_button.disabled = true var endpoint = master_url + "/api/v1/servers" var err = _http.request(endpoint, [], HTTPClient.METHOD_GET) if err != OK: _status_label.text = "Failed to send request (error " + str(err) + ")" _loading = false _refresh_button.disabled = false func _on_servers_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _loading = false _refresh_button.disabled = false if result != HTTPRequest.RESULT_SUCCESS: _status_label.text = "Network error " + str(result) return if response_code != 200: _status_label.text = "Server error (HTTP " + str(response_code) + ")" return var body_text = body.get_string_from_utf8() var json = JSON.new() var parse_err = json.parse(body_text) if parse_err != OK or not (json.data is Dictionary): _status_label.text = "Invalid response from master server" return var data = json.data as Dictionary _servers = data.get("servers", []) as Array _populate_list() _status_label.text = str(_servers.size()) + " server(s) found" # Start auto-refresh timer if supposed to if auto_refresh and not _refresh_timer.is_stopped(): _refresh_timer.start() func _populate_list() -> void: _server_list.clear() var filter_text = _filter_edit.text.strip_edges().to_lower() for srv in _servers: if not (srv is Dictionary): continue # Apply filter if not filter_text.is_empty(): var match_found = false for field in ["name", "map", "tags", "game_mode"]: var val = _get_str(srv, field, "") if val.to_lower().contains(filter_text): match_found = true break if not match_found: continue var name = _get_str(srv, "name", "Unnamed") var map_name = _get_str(srv, "map", "?") var players = _get_int(srv, "players", 0) var max_players = _get_int(srv, "max_players", 16) var mode = _get_str(srv, "game_mode", "?") var version = _get_str(srv, "version", "?") var tags = _get_str(srv, "tags", "") var has_pass = _get_bool(srv, "password", false) # Format: "Server Name dust2 8/16 DM v1.0 eu,ranked" var pass_mark = "🔒" if has_pass else " " var tag_str = "" if tags is Array: tag_str = ",".join(tags) elif tags is String: tag_str = tags var label = pass_mark + " " + name # Pad fields with spaces label += " " + _pad_right(map_name, 14) label += str(players) + "/" + str(max_players) + " " label += _pad_right(mode, 10) label += version + " " label += tag_str _server_list.add_item(label) _connect_button.disabled = true _selected_index = -1 func _on_server_selected(index: int) -> void: _selected_index = index _connect_button.disabled = index < 0 func _clear_selection() -> void: _selected_index = -1 _connect_button.disabled = true func _on_connect_pressed() -> void: if _selected_index < 0 or _selected_index >= _servers.size(): return var srv = _servers[_selected_index] as Dictionary var host = _get_str(srv, "host", "") var port = _get_int(srv, "port", 0) if host.is_empty() or port == 0: _status_label.text = "ERROR: Server has no address info" return emit_signal("server_selected", srv) func _on_filter_changed(_new_text: String) -> void: _populate_list() func _on_auto_refresh_toggled(enabled: bool) -> void: auto_refresh = enabled if enabled: _refresh_timer.wait_time = refresh_interval _refresh_timer.start() else: _refresh_timer.stop() func _on_close_pressed() -> void: _refresh_timer.stop() queue_free() # ------------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------------- static func _get_str(d: Dictionary, key: String, fallback: String) -> String: var val = d.get(key, fallback) if val is String: return val return str(val) static func _get_int(d: Dictionary, key: String, fallback: int) -> int: var val = d.get(key, fallback) if val is int: return val if val is float: return int(val) if val is String: return val.to_int() return fallback static func _get_bool(d: Dictionary, key: String, fallback: bool) -> bool: var val = d.get(key, fallback) if val is bool: return val if val is int: return val != 0 return fallback static func _pad_right(text: String, min_width: int) -> String: var result = text while result.length() < min_width: result += " " return result