e7299b17e9
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
412 lines
11 KiB
GDScript
412 lines
11 KiB
GDScript
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
|