Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597d6dde2d | |||
| aad186552c | |||
| 5a1695e2ab | |||
| fc2c0236cb |
@@ -142,10 +142,9 @@ signal on_panic(offset: float)
|
|||||||
func start() -> void:
|
func start() -> void:
|
||||||
if _active:
|
if _active:
|
||||||
return
|
return
|
||||||
|
|
||||||
_clock.set_time(0.)
|
|
||||||
|
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
|
_clock.set_time(0.)
|
||||||
_active = true
|
_active = true
|
||||||
_sample_idx = 0
|
_sample_idx = 0
|
||||||
_sample_buffer = _RingBuffer.new(sync_samples)
|
_sample_buffer = _RingBuffer.new(sync_samples)
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ func _init(p_history_size: int):
|
|||||||
_history_size = p_history_size
|
_history_size = p_history_size
|
||||||
|
|
||||||
func subjects() -> Array[Object]:
|
func subjects() -> Array[Object]:
|
||||||
|
# Filter out freed objects — Godot 4.7 stricter typed arrays
|
||||||
|
# reject invalid references and crash the engine.
|
||||||
|
var valid := []
|
||||||
|
for o in _data.keys():
|
||||||
|
if is_instance_valid(o):
|
||||||
|
valid.append(o)
|
||||||
var result := [] as Array[Object]
|
var result := [] as Array[Object]
|
||||||
result.assign(_data.keys())
|
result.assign(valid)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
func is_auth(tick: int, subject: Object) -> bool:
|
func is_auth(tick: int, subject: Object) -> bool:
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ func get_properties_of(subject: Object) -> Array[NodePath]:
|
|||||||
return properties
|
return properties
|
||||||
|
|
||||||
func get_subjects() -> Array[Object]:
|
func get_subjects() -> Array[Object]:
|
||||||
|
# Filter out freed objects — Godot 4.7 stricter typed arrays
|
||||||
|
# reject invalid references and crash the engine.
|
||||||
|
var valid := []
|
||||||
|
for s in _properties_by_subject.keys():
|
||||||
|
if is_instance_valid(s):
|
||||||
|
valid.append(s)
|
||||||
var subjects := [] as Array[Object]
|
var subjects := [] as Array[Object]
|
||||||
subjects.assign(_properties_by_subject.keys())
|
subjects.assign(valid)
|
||||||
return subjects
|
return subjects
|
||||||
|
|
||||||
func is_empty() -> bool:
|
func is_empty() -> bool:
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ extends Node
|
|||||||
## Set to false for dedicated server operation.
|
## Set to false for dedicated server operation.
|
||||||
@export var spawn_host_avatar: bool = true
|
@export var spawn_host_avatar: bool = true
|
||||||
|
|
||||||
|
## When true, this is a dedicated server. Peer 1 (the server itself) never
|
||||||
|
## gets a player avatar. Clients also skip spawning peer 1 locally.
|
||||||
|
var _dedicated_server: bool = false
|
||||||
|
|
||||||
|
## Mark this spawner as running on a dedicated server.
|
||||||
|
## The host avatar (peer 1) is skipped entirely — no avatar is spawned for the
|
||||||
|
## server process itself.
|
||||||
|
func set_dedicated_server() -> void:
|
||||||
|
_dedicated_server = true
|
||||||
|
print("[PlayerSpawner] Dedicated server mode enabled")
|
||||||
|
|
||||||
var t_spawn_points: Array[Marker3D] = []
|
var t_spawn_points: Array[Marker3D] = []
|
||||||
var ct_spawn_points: Array[Marker3D] = []
|
var ct_spawn_points: Array[Marker3D] = []
|
||||||
var team_manager: TeamManager
|
var team_manager: TeamManager
|
||||||
@@ -47,8 +58,12 @@ func _handle_connected(id: int):
|
|||||||
_spawn(id)
|
_spawn(id)
|
||||||
|
|
||||||
func _handle_host():
|
func _handle_host():
|
||||||
|
if _dedicated_server:
|
||||||
|
print("[PlayerSpawner] Dedicated server — skipping host avatar entirely")
|
||||||
|
# Match starts when the first client connects (handled by headless_server)
|
||||||
|
return
|
||||||
if not spawn_host_avatar:
|
if not spawn_host_avatar:
|
||||||
print("[PlayerSpawner] Dedicated server mode — skipping host avatar")
|
print("[PlayerSpawner] spawn_host_avatar=false — skipping host avatar")
|
||||||
if round_manager:
|
if round_manager:
|
||||||
round_manager.start_match()
|
round_manager.start_match()
|
||||||
return
|
return
|
||||||
@@ -58,8 +73,18 @@ func _handle_host():
|
|||||||
round_manager.start_match()
|
round_manager.start_match()
|
||||||
|
|
||||||
func _handle_new_peer(id: int):
|
func _handle_new_peer(id: int):
|
||||||
|
# On a dedicated server, peer 1 (the server process) has no avatar.
|
||||||
|
# When a client connects, it receives on_peer_join for peer 1, but
|
||||||
|
# should skip spawning because the server doesn't have that avatar.
|
||||||
|
# This also applies to listen-server: the server replicates peer 1's
|
||||||
|
# avatar to clients; spawning a local copy would duplicate it.
|
||||||
|
if id == 1 and not multiplayer.is_server():
|
||||||
|
print("[PlayerSpawner] Client skipping spawn for peer 1 (server has no dedicated avatar)")
|
||||||
|
return
|
||||||
|
if _dedicated_server and id == 1:
|
||||||
|
return
|
||||||
if id == 1 and not spawn_host_avatar:
|
if id == 1 and not spawn_host_avatar:
|
||||||
print("[PlayerSpawner] Skipping spawn for peer 1 (dedicated server)")
|
print("[PlayerSpawner] Skipping spawn for peer 1 (spawn_host_avatar=false)")
|
||||||
return
|
return
|
||||||
_spawn(id)
|
_spawn(id)
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ text = "Address:"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 0
|
size_flags_vertical = 0
|
||||||
text = "localhost"
|
text = "192.168.0.127"
|
||||||
|
|
||||||
[node name="Port Label" type="Label" parent="LAN/Address Row"]
|
[node name="Port Label" type="Label" parent="LAN/Address Row"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -44,7 +44,7 @@ text = "Port:"
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 0
|
size_flags_vertical = 0
|
||||||
text = "16384"
|
text = "34197"
|
||||||
|
|
||||||
[node name="Actions Row" type="HBoxContainer" parent="LAN"]
|
[node name="Actions Row" type="HBoxContainer" parent="LAN"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|||||||
+67
-87
@@ -3,20 +3,17 @@
|
|||||||
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
||||||
## not a child), so RPC node paths match what clients expect.
|
## 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:
|
## Architecture:
|
||||||
## This script runs as a temporary scene root. It loads multiplayer-fps.tscn
|
## 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.
|
## and adds it directly under get_tree().root so node paths (e.g.
|
||||||
## /root/multiplayer-fps/Network/TeamManager) are identical on both
|
## /root/multiplayer-fps/Network/TeamManager) are identical on both
|
||||||
## server and client.
|
## server and client.
|
||||||
##
|
##
|
||||||
## 1. Loads multiplayer-fps.tscn, adds to /root/
|
## Critical: NetworkEvents.on_server_start is DISCONNECTED from
|
||||||
## 2. Removes Environment and UI
|
## NetworkTime.start(). The netfox tick loop only begins AFTER the first
|
||||||
## 3. Starts ENet server
|
## real client connects (on_peer_join). This ensures the rollback system
|
||||||
## 4. RCON admin console on configurable port
|
## always has at least one player subject before ticking starts, preventing
|
||||||
|
## PropertyPool/PerObjectHistory typed array corruption (Godot 4.7).
|
||||||
|
|
||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
@@ -26,6 +23,9 @@ const DEFAULT_PORT: int = 34197
|
|||||||
## Reference to the multiplayer-fps game scene (at root level).
|
## Reference to the multiplayer-fps game scene (at root level).
|
||||||
var _game_scene: Node = null
|
var _game_scene: Node = null
|
||||||
|
|
||||||
|
## Whether a client has connected (deferred tick start).
|
||||||
|
var _first_client_connected: bool = false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -45,24 +45,22 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_game_scene = GameScene.instantiate()
|
_game_scene = GameScene.instantiate()
|
||||||
|
|
||||||
# CRITICAL: Add game scene directly to /root/ as a sibling, NOT as child.
|
# Disconnect NetworkEvents.on_server_start from NetworkTime.start.
|
||||||
# This ensures RPC node paths are the same on both server and client.
|
# The netfox tick loop must NOT start until the first client connects,
|
||||||
# Must use call_deferred because we're still in _ready().
|
# so the rollback system has at least one player subject.
|
||||||
# Strip BEFORE adding to tree so queue_free works cleanly.
|
if NetworkEvents and NetworkEvents.on_server_start.is_connected(NetworkTime.start):
|
||||||
_strip_for_headless()
|
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)
|
get_tree().root.call_deferred("add_child", _game_scene)
|
||||||
|
|
||||||
# Start RCON admin console
|
# Start RCON admin console
|
||||||
_start_rcon()
|
_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
|
# Wait for game scene to enter the tree
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
@@ -71,34 +69,27 @@ func _ready() -> void:
|
|||||||
get_tree().current_scene = _game_scene
|
get_tree().current_scene = _game_scene
|
||||||
print("[HeadlessServer] Game scene added to root, current_scene updated")
|
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 the ENet server
|
||||||
_start_server(port)
|
_start_server(port)
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
_stop_server()
|
_stop_server()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
func _verify_scene() -> void:
|
||||||
# Strip scene for headless mode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Remove nodes that are useless in headless mode: rendering, UI, audio.
|
|
||||||
func _strip_for_headless() -> void:
|
|
||||||
if _game_scene == null:
|
if _game_scene == null:
|
||||||
return
|
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")
|
var network = _game_scene.get_node_or_null("Network")
|
||||||
if network == null:
|
if network == null:
|
||||||
push_error("[HeadlessServer] Missing Network node in game scene!")
|
push_error("[HeadlessServer] Missing Network node in game scene!")
|
||||||
@@ -108,7 +99,39 @@ func _strip_for_headless() -> void:
|
|||||||
if network.get_node_or_null(required) == null:
|
if network.get_node_or_null(required) == null:
|
||||||
push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required)
|
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 — letting avatar spawn...")
|
||||||
|
|
||||||
|
# Wait TWO frames so PlayerSpawner._handle_new_peer() creates the avatar
|
||||||
|
# BEFORE NetworkTime starts. The tick loop needs at least one subject.
|
||||||
|
await get_tree().process_frame
|
||||||
|
await get_tree().process_frame
|
||||||
|
|
||||||
|
print("[HeadlessServer] Starting netfox tick loop with player subjects...")
|
||||||
|
|
||||||
|
# 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
|
# Server startup
|
||||||
@@ -128,15 +151,10 @@ func _start_server(port: int) -> void:
|
|||||||
|
|
||||||
print("[HeadlessServer] ENet server started on port %d" % port)
|
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
|
await get_tree().process_frame
|
||||||
|
print("[HeadlessServer] NetworkEvents on_server_start should have fired (NetworkTime not started yet)")
|
||||||
# NetworkEvents handles on_server_start -> player-spawner -> round_manager
|
# No host avatar — no RPC errors, no client-side duplicate
|
||||||
if NetworkEvents and NetworkEvents.enabled:
|
|
||||||
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
|
|
||||||
else:
|
|
||||||
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
|
|
||||||
_manual_startup()
|
|
||||||
|
|
||||||
func _stop_server() -> void:
|
func _stop_server() -> void:
|
||||||
var mp = get_tree().get_multiplayer()
|
var mp = get_tree().get_multiplayer()
|
||||||
@@ -145,44 +163,6 @@ func _stop_server() -> void:
|
|||||||
mp.multiplayer_peer = null
|
mp.multiplayer_peer = null
|
||||||
print("[HeadlessServer] Server stopped")
|
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
|
# RCON admin console
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c3h0oeqxo60l1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c0oo6rmioaxm4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dhrejdsgejls4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cg36adsrfnl1r
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dwfsp7sttekha
|
||||||
Reference in New Issue
Block a user