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)
214 lines
7.2 KiB
GDScript
214 lines
7.2 KiB
GDScript
extends Node
|
|
|
|
# =============================================================================
|
|
# Server Browser — Heartbeat Sender
|
|
# =============================================================================
|
|
# Periodically POSTs server state to the configured master server so the
|
|
# server appears in the public server browser.
|
|
#
|
|
# Relies on cvars defined by the config system:
|
|
# sv_master_server — Master server URL (e.g. "http://master.example.com:28961")
|
|
# sv_server_id — Unique server identifier (auto-generated if empty)
|
|
# sv_heartbeat_interval — Seconds between heartbeats (default 60, range [10,300])
|
|
# sv_server_tags — Comma-separated tags for filtering
|
|
# sv_server_password — Whether the server requires a password
|
|
#
|
|
# Signals:
|
|
# heartbeat_sent(success: bool, server_id: String, http_code: int)
|
|
# master_connection_failed(error_message: String)
|
|
# =============================================================================
|
|
|
|
signal heartbeat_sent(success: bool, server_id: String, http_code: int)
|
|
signal master_connection_failed(error_message: String)
|
|
|
|
const DEFAULT_HEARTBEAT_INTERVAL: float = 60.0
|
|
const MIN_INTERVAL: float = 10.0
|
|
const MAX_INTERVAL: float = 300.0
|
|
const DEFAULT_MASTER_SERVER: String = "http://127.0.0.1:28961"
|
|
|
|
# HTTP request node for async requests
|
|
var _http: HTTPRequest
|
|
var _timer: Timer
|
|
var _server_id: String = ""
|
|
var _enabled: bool = false
|
|
|
|
|
|
func _ready() -> void:
|
|
# Create HTTP request node
|
|
_http = HTTPRequest.new()
|
|
_http.name = "MasterServerHTTP"
|
|
_http.timeout = 10
|
|
_http.use_threads = true
|
|
add_child(_http)
|
|
_http.request_completed.connect(_on_heartbeat_completed)
|
|
|
|
# Create timer for periodic heartbeats
|
|
_timer = Timer.new()
|
|
_timer.name = "HeartbeatTimer"
|
|
_timer.one_shot = false
|
|
add_child(_timer)
|
|
_timer.timeout.connect(_send_heartbeat)
|
|
|
|
|
|
func start() -> void:
|
|
"""Begin the heartbeat loop. Call after cvars are loaded and server is ready."""
|
|
var master_url = _get_cvar_string("sv_master_server", "")
|
|
if master_url.is_empty():
|
|
push_warning("[ServerBrowser] sv_master_server is empty — heartbeat disabled")
|
|
return
|
|
|
|
# Strip trailing slash
|
|
master_url = master_url.trim_suffix("/")
|
|
if not master_url.begins_with("http"):
|
|
master_url = "http://" + master_url
|
|
|
|
if not master_url.begins_with("http://") and not master_url.begins_with("https://"):
|
|
push_warning("[ServerBrowser] Invalid master server URL: ", master_url)
|
|
return
|
|
|
|
_server_id = _get_cvar_string("sv_server_id", "")
|
|
_enabled = true
|
|
|
|
var interval = _get_cvar_float("sv_heartbeat_interval", DEFAULT_HEARTBEAT_INTERVAL)
|
|
interval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL)
|
|
|
|
_timer.wait_time = interval
|
|
_timer.start()
|
|
|
|
print("[ServerBrowser] Heartbeat enabled — master: ", master_url, " interval: ", interval, "s")
|
|
|
|
# Send initial heartbeat immediately
|
|
_send_heartbeat()
|
|
|
|
|
|
func stop() -> void:
|
|
"""Stop sending heartbeats."""
|
|
_enabled = false
|
|
_timer.stop()
|
|
print("[ServerBrowser] Heartbeat disabled")
|
|
|
|
|
|
func set_server_id(new_id: String) -> void:
|
|
"""Update the server id (e.g., after first heartbeat response)."""
|
|
_server_id = new_id
|
|
|
|
|
|
func _get_master_url() -> String:
|
|
var raw = _get_cvar_string("sv_master_server", DEFAULT_MASTER_SERVER).trim_suffix("/")
|
|
if raw.is_empty():
|
|
return ""
|
|
if not raw.begins_with("http"):
|
|
raw = "http://" + raw
|
|
return raw
|
|
|
|
|
|
func _send_heartbeat() -> void:
|
|
if not _enabled:
|
|
return
|
|
|
|
var master_url = _get_master_url()
|
|
if master_url.is_empty():
|
|
return
|
|
|
|
# Build heartbeat payload
|
|
var payload = {
|
|
"server_id": _server_id,
|
|
"name": _get_cvar_string("sv_server_name", "Tactical Shooter Server"),
|
|
"map": _get_cvar_string("sv_map", "unknown"),
|
|
"players": _get_player_count(),
|
|
"max_players": _get_cvar_int("sv_max_players", 16),
|
|
"game_mode": _get_cvar_string("sv_game_mode", "deathmatch"),
|
|
"version": _get_cvar_string("sv_version", "1.0.0"),
|
|
"tags": _get_cvar_string("sv_server_tags", ""),
|
|
"password": _get_cvar_int("sv_server_password", 0) != 0,
|
|
"host": _get_external_ip(),
|
|
"port": _get_cvar_int("sv_port", 0),
|
|
"steam_id": _get_cvar_string("sv_steam_id", ""),
|
|
}
|
|
|
|
var json_string = JSON.stringify(payload)
|
|
var headers = ["Content-Type: application/json"]
|
|
|
|
var endpoint = master_url + "/api/v1/heartbeat"
|
|
var err = _http.request(endpoint, headers, HTTPClient.METHOD_POST, json_string)
|
|
if err != OK:
|
|
push_error("[ServerBrowser] Failed to send heartbeat: HTTP request error ", err)
|
|
master_connection_failed.emit("HTTP request failed (code " + str(err) + ")")
|
|
|
|
|
|
func _on_heartbeat_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if result != HTTPRequest.RESULT_SUCCESS:
|
|
var msg = "Heartbeat failed — network error " + str(result)
|
|
push_warning("[ServerBrowser] ", msg)
|
|
master_connection_failed.emit(msg)
|
|
heartbeat_sent.emit(false, _server_id, response_code)
|
|
return
|
|
|
|
if response_code != 200:
|
|
var msg = "Heartbeat failed — HTTP " + str(response_code)
|
|
push_warning("[ServerBrowser] ", msg)
|
|
master_connection_failed.emit(msg)
|
|
heartbeat_sent.emit(false, _server_id, response_code)
|
|
return
|
|
|
|
# Parse response for server_id (first heartbeat may get one assigned)
|
|
var body_text = body.get_string_from_utf8()
|
|
var json = JSON.new()
|
|
var parse_err = json.parse(body_text)
|
|
if parse_err == OK and json.data is Dictionary:
|
|
if json.data.has("server_id") and not json.data["server_id"].is_empty():
|
|
_server_id = json.data["server_id"]
|
|
|
|
heartbeat_sent.emit(true, _server_id, response_code)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Helper: read cvars from the config system (if available)
|
|
# -------------------------------------------------------------------------
|
|
|
|
func _get_cvar_string(name: String, default_value: String) -> String:
|
|
"""Try the CvarRegistry autoload first, fall back to ProjectSettings."""
|
|
if Engine.has_singleton("CvarRegistry"):
|
|
var reg = Engine.get_singleton("CvarRegistry")
|
|
if reg.has_method("get_string"):
|
|
return reg.get_string(name, default_value)
|
|
# Fallback to ProjectSettings if no cvar registry exists
|
|
if ProjectSettings.has_setting(name):
|
|
return ProjectSettings.get_setting(name)
|
|
return default_value
|
|
|
|
|
|
func _get_cvar_int(name: String, default_value: int) -> int:
|
|
if Engine.has_singleton("CvarRegistry"):
|
|
var reg = Engine.get_singleton("CvarRegistry")
|
|
if reg.has_method("get_int"):
|
|
return reg.get_int(name, default_value)
|
|
if ProjectSettings.has_setting(name):
|
|
return ProjectSettings.get_setting(name)
|
|
return default_value
|
|
|
|
|
|
func _get_cvar_float(name: String, default_value: float) -> float:
|
|
if Engine.has_singleton("CvarRegistry"):
|
|
var reg = Engine.get_singleton("CvarRegistry")
|
|
if reg.has_method("get_float"):
|
|
return reg.get_float(name, default_value)
|
|
if ProjectSettings.has_setting(name):
|
|
return ProjectSettings.get_setting(name)
|
|
return default_value
|
|
|
|
|
|
func _get_player_count() -> int:
|
|
"""Get current connected player count."""
|
|
# Try to read from game's player manager singleton
|
|
if Engine.has_singleton("PlayerManager"):
|
|
var pm = Engine.get_singleton("PlayerManager")
|
|
if pm.has_method("get_player_count"):
|
|
return pm.get_player_count()
|
|
return 0
|
|
|
|
|
|
func _get_external_ip() -> String:
|
|
"""Return our public/external IP if known, or empty for auto-detect."""
|
|
return _get_cvar_string("sv_external_ip", "")
|