Phase 7: netfox + godot-jolt stack upgrade

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)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+411
View File
@@ -0,0 +1,411 @@
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