|
|
|
@@ -3,20 +3,17 @@
|
|
|
|
|
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
|
|
|
|
## not a child), so RPC node paths match what clients expect.
|
|
|
|
|
##
|
|
|
|
|
## Usage:
|
|
|
|
|
## godot --headless --scene res://scenes/headless_server.tscn
|
|
|
|
|
## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197
|
|
|
|
|
##
|
|
|
|
|
## Architecture:
|
|
|
|
|
## This script runs as a temporary scene root. It loads multiplayer-fps.tscn
|
|
|
|
|
## and adds it directly under get_tree().root so node paths (e.g.
|
|
|
|
|
## /root/multiplayer-fps/Network/TeamManager) are identical on both
|
|
|
|
|
## server and client.
|
|
|
|
|
##
|
|
|
|
|
## 1. Loads multiplayer-fps.tscn, adds to /root/
|
|
|
|
|
## 2. Removes Environment and UI
|
|
|
|
|
## 3. Starts ENet server
|
|
|
|
|
## 4. RCON admin console on configurable port
|
|
|
|
|
## Critical: NetworkEvents.on_server_start is DISCONNECTED from
|
|
|
|
|
## NetworkTime.start(). The netfox tick loop only begins AFTER the first
|
|
|
|
|
## real client connects (on_peer_join). This ensures the rollback system
|
|
|
|
|
## always has at least one player subject before ticking starts, preventing
|
|
|
|
|
## PropertyPool/PerObjectHistory typed array corruption (Godot 4.7).
|
|
|
|
|
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
@@ -26,6 +23,9 @@ const DEFAULT_PORT: int = 34197
|
|
|
|
|
## Reference to the multiplayer-fps game scene (at root level).
|
|
|
|
|
var _game_scene: Node = null
|
|
|
|
|
|
|
|
|
|
## Whether a client has connected (deferred tick start).
|
|
|
|
|
var _first_client_connected: bool = false
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lifecycle
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@@ -45,24 +45,22 @@ func _ready() -> void:
|
|
|
|
|
|
|
|
|
|
_game_scene = GameScene.instantiate()
|
|
|
|
|
|
|
|
|
|
# CRITICAL: Add game scene directly to /root/ as a sibling, NOT as child.
|
|
|
|
|
# This ensures RPC node paths are the same on both server and client.
|
|
|
|
|
# Must use call_deferred because we're still in _ready().
|
|
|
|
|
# Strip BEFORE adding to tree so queue_free works cleanly.
|
|
|
|
|
_strip_for_headless()
|
|
|
|
|
# Disconnect NetworkEvents.on_server_start from NetworkTime.start.
|
|
|
|
|
# The netfox tick loop must NOT start until the first client connects,
|
|
|
|
|
# so the rollback system has at least one player subject.
|
|
|
|
|
if NetworkEvents and NetworkEvents.on_server_start.is_connected(NetworkTime.start):
|
|
|
|
|
NetworkEvents.on_server_start.disconnect(NetworkTime.start)
|
|
|
|
|
print("[HeadlessServer] Disconnected NetworkTime.start from on_server_start")
|
|
|
|
|
|
|
|
|
|
# Keep full scene tree — no stripping. Environment/UI nodes are harmless
|
|
|
|
|
# in headless mode. Keeping the tree identical to the client ensures
|
|
|
|
|
# NetworkIdentityServer produces matching identity references.
|
|
|
|
|
_verify_scene()
|
|
|
|
|
get_tree().root.call_deferred("add_child", _game_scene)
|
|
|
|
|
|
|
|
|
|
# Start RCON admin console
|
|
|
|
|
_start_rcon()
|
|
|
|
|
|
|
|
|
|
# Set dedicated server flag on the player spawner to prevent
|
|
|
|
|
# spawning a host avatar (peer 1) that would cause rollback errors.
|
|
|
|
|
# Must be set before the ENet server starts (before on_server_start fires).
|
|
|
|
|
var spawner = _game_scene.get_node_or_null("Network/Player Spawner")
|
|
|
|
|
if spawner:
|
|
|
|
|
spawner.spawn_host_avatar = false
|
|
|
|
|
print("[HeadlessServer] Set spawn_host_avatar=false on PlayerSpawner")
|
|
|
|
|
|
|
|
|
|
# Wait for game scene to enter the tree
|
|
|
|
|
await get_tree().process_frame
|
|
|
|
|
await get_tree().process_frame
|
|
|
|
@@ -71,34 +69,27 @@ func _ready() -> void:
|
|
|
|
|
get_tree().current_scene = _game_scene
|
|
|
|
|
print("[HeadlessServer] Game scene added to root, current_scene updated")
|
|
|
|
|
|
|
|
|
|
# Set dedicated server flag BEFORE starting ENet, so _handle_host()
|
|
|
|
|
# runs in the correct mode.
|
|
|
|
|
var spawner = _game_scene.get_node_or_null("Network/Player Spawner")
|
|
|
|
|
if spawner and spawner.has_method("set_dedicated_server"):
|
|
|
|
|
spawner.set_dedicated_server()
|
|
|
|
|
print("[HeadlessServer] PlayerSpawner set to dedicated server mode")
|
|
|
|
|
|
|
|
|
|
# Connect first-client handler BEFORE starting the server, so we
|
|
|
|
|
# don't miss the on_peer_join event for the very first client.
|
|
|
|
|
NetworkEvents.on_peer_join.connect(_on_first_peer_join)
|
|
|
|
|
|
|
|
|
|
# Start the ENet server
|
|
|
|
|
_start_server(port)
|
|
|
|
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
|
|
|
_stop_server()
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Strip scene for headless mode
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
## Remove nodes that are useless in headless mode: rendering, UI, audio.
|
|
|
|
|
func _strip_for_headless() -> void:
|
|
|
|
|
func _verify_scene() -> void:
|
|
|
|
|
if _game_scene == null:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Remove Environment (sky, light, camera, world environment)
|
|
|
|
|
var env = _game_scene.get_node_or_null("Environment")
|
|
|
|
|
if env:
|
|
|
|
|
env.queue_free()
|
|
|
|
|
print("[HeadlessServer] Removed Environment node")
|
|
|
|
|
|
|
|
|
|
# Remove UI (network popup, HUD, crosshair, etc.)
|
|
|
|
|
var ui = _game_scene.get_node_or_null("UI")
|
|
|
|
|
if ui:
|
|
|
|
|
ui.queue_free()
|
|
|
|
|
print("[HeadlessServer] Removed UI node")
|
|
|
|
|
|
|
|
|
|
# Verify critical game logic nodes exist
|
|
|
|
|
var network = _game_scene.get_node_or_null("Network")
|
|
|
|
|
if network == null:
|
|
|
|
|
push_error("[HeadlessServer] Missing Network node in game scene!")
|
|
|
|
@@ -108,7 +99,32 @@ func _strip_for_headless() -> void:
|
|
|
|
|
if network.get_node_or_null(required) == null:
|
|
|
|
|
push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required)
|
|
|
|
|
|
|
|
|
|
print("[HeadlessServer] Scene stripped for headless mode")
|
|
|
|
|
print("[HeadlessServer] Scene tree verified, keeping full tree for identity sync")
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# First peer handling — deferred tick and match start
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
func _on_first_peer_join(_peer_id: int) -> void:
|
|
|
|
|
if _first_client_connected:
|
|
|
|
|
return
|
|
|
|
|
_first_client_connected = true
|
|
|
|
|
|
|
|
|
|
print("[HeadlessServer] First client connected — starting netfox tick loop...")
|
|
|
|
|
|
|
|
|
|
# Start netfox tick loop now that we have at least one player
|
|
|
|
|
if NetworkTime and NetworkTime.has_method("start"):
|
|
|
|
|
var err = await NetworkTime.start()
|
|
|
|
|
if err != OK:
|
|
|
|
|
push_error("[HeadlessServer] NetworkTime.start() returned error: %d" % err)
|
|
|
|
|
|
|
|
|
|
# Start the match via the round manager
|
|
|
|
|
var network = _game_scene.get_node_or_null("Network") if _game_scene else null
|
|
|
|
|
if network:
|
|
|
|
|
var round_mgr = network.get_node_or_null("RoundManager")
|
|
|
|
|
if round_mgr and round_mgr.has_method("start_match"):
|
|
|
|
|
round_mgr.start_match()
|
|
|
|
|
print("[HeadlessServer] RoundManager.start_match() called on first join")
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Server startup
|
|
|
|
@@ -128,15 +144,10 @@ func _start_server(port: int) -> void:
|
|
|
|
|
|
|
|
|
|
print("[HeadlessServer] ENet server started on port %d" % port)
|
|
|
|
|
|
|
|
|
|
# Wait for NetworkEvents to fire on_server_start
|
|
|
|
|
# Let NetworkEvents fire on_server_start (without starting NetworkTime)
|
|
|
|
|
await get_tree().process_frame
|
|
|
|
|
|
|
|
|
|
# NetworkEvents handles on_server_start -> player-spawner -> round_manager
|
|
|
|
|
if NetworkEvents and NetworkEvents.enabled:
|
|
|
|
|
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
|
|
|
|
|
else:
|
|
|
|
|
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
|
|
|
|
|
_manual_startup()
|
|
|
|
|
print("[HeadlessServer] NetworkEvents on_server_start should have fired (NetworkTime not started yet)")
|
|
|
|
|
# No host avatar — no RPC errors, no client-side duplicate
|
|
|
|
|
|
|
|
|
|
func _stop_server() -> void:
|
|
|
|
|
var mp = get_tree().get_multiplayer()
|
|
|
|
@@ -145,44 +156,6 @@ func _stop_server() -> void:
|
|
|
|
|
mp.multiplayer_peer = null
|
|
|
|
|
print("[HeadlessServer] Server stopped")
|
|
|
|
|
|
|
|
|
|
## Fallback startup when NetworkEvents isn't available.
|
|
|
|
|
func _manual_startup() -> void:
|
|
|
|
|
if Engine.has_singleton("NetworkTime"):
|
|
|
|
|
var nt = Engine.get_singleton("NetworkTime")
|
|
|
|
|
if nt and nt.has_method("start"):
|
|
|
|
|
nt.start()
|
|
|
|
|
print("[HeadlessServer] NetworkTime started manually")
|
|
|
|
|
|
|
|
|
|
var network = _game_scene.get_node_or_null("Network") if _game_scene else null
|
|
|
|
|
if network == null:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
var spawner = network.get_node_or_null("Player Spawner")
|
|
|
|
|
if spawner:
|
|
|
|
|
if spawner.has_method("_handle_host"):
|
|
|
|
|
spawner._handle_host()
|
|
|
|
|
print("[HeadlessServer] PlayerSpawner._handle_host() called")
|
|
|
|
|
|
|
|
|
|
if Engine.has_singleton("NetworkEvents"):
|
|
|
|
|
var ne = Engine.get_singleton("NetworkEvents")
|
|
|
|
|
if ne and ne.has_signal("on_server_start"):
|
|
|
|
|
ne.on_server_start.emit()
|
|
|
|
|
print("[HeadlessServer] Emitted NetworkEvents.on_server_start")
|
|
|
|
|
if ne and ne.has_signal("on_peer_join"):
|
|
|
|
|
ne.on_peer_join.emit(1)
|
|
|
|
|
print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)")
|
|
|
|
|
|
|
|
|
|
var round_mgr = network.get_node_or_null("RoundManager")
|
|
|
|
|
if round_mgr:
|
|
|
|
|
if round_mgr.has_method("start_match"):
|
|
|
|
|
round_mgr.call_deferred("start_match")
|
|
|
|
|
print("[HeadlessServer] RoundManager.start_match() called")
|
|
|
|
|
elif round_mgr.has_method("start_round"):
|
|
|
|
|
round_mgr.call_deferred("start_round")
|
|
|
|
|
print("[HeadlessServer] RoundManager.start_round() called")
|
|
|
|
|
|
|
|
|
|
print("[HeadlessServer] Manual startup complete")
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# RCON admin console
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|