Files
tactical-shooter/examples/multiplayer-fps/scripts/player-spawner.gd
T
shawn 70105d8b68 Fix property-pool rollback errors in headless server
The dedicated server was spawning a host avatar (peer 1) via
player-spawner._handle_host() and _handle_new_peer(1) on startup,
which registered RollbackSynchronizer subjects with the netfox
rollback system. Since no client input exists for peer 1, the
rollback system tried to record state for freed/recycled objects,
flooding the log with property-pool.gd errors.

Fix:
- Add spawn_host_avatar @export flag to player-spawner.gd
- Set it false in headless_server.gd before starting the ENet server
- PlayerSpawner._handle_host() skips avatar spawn when flag is false
- PlayerSpawner._handle_new_peer(1) skips avatar spawn when flag is false

Server startup is now clean: no property-pool errors, no RPC path errors.
2026-07-02 22:34:32 -04:00

172 lines
5.0 KiB
GDScript

extends Node
@export var player_scene: PackedScene
## Whether to spawn an avatar for the host (peer 1) when on_server_start fires.
## Set to false for dedicated server operation.
@export var spawn_host_avatar: bool = true
var t_spawn_points: Array[Marker3D] = []
var ct_spawn_points: Array[Marker3D] = []
var team_manager: TeamManager
var round_manager: RoundManager
var economy_manager: EconomyManager
var bomb: Bomb
var avatars: Dictionary = {}
func _ready():
team_manager = $"../TeamManager"
round_manager = $"../RoundManager"
economy_manager = $"../EconomyManager"
bomb = get_tree().current_scene.get_node_or_null("Network/Bomb")
# Discover spawn points from scene containers
var scene_root := get_tree().current_scene
var t_container := scene_root.get_node_or_null("T Spawns")
if t_container:
for child in t_container.get_children():
if child is Marker3D:
t_spawn_points.append(child)
var ct_container := scene_root.get_node_or_null("CT Spawns")
if ct_container:
for child in ct_container.get_children():
if child is Marker3D:
ct_spawn_points.append(child)
NetworkEvents.on_client_start.connect(_handle_connected)
NetworkEvents.on_server_start.connect(_handle_host)
NetworkEvents.on_peer_join.connect(_handle_new_peer)
NetworkEvents.on_peer_leave.connect(_handle_leave)
NetworkEvents.on_client_stop.connect(_handle_stop)
NetworkEvents.on_server_stop.connect(_handle_stop)
if round_manager:
round_manager.round_started.connect(_handle_round_start)
func _handle_connected(id: int):
_spawn(id)
func _handle_host():
if not spawn_host_avatar:
print("[PlayerSpawner] Dedicated server mode — skipping host avatar")
if round_manager:
round_manager.start_match()
return
_spawn(1)
# Auto-start match
if round_manager:
round_manager.start_match()
func _handle_new_peer(id: int):
if id == 1 and not spawn_host_avatar:
print("[PlayerSpawner] Skipping spawn for peer 1 (dedicated server)")
return
_spawn(id)
if multiplayer.is_server():
team_manager.sync_to_peer(id)
if economy_manager:
economy_manager.sync_to_peer(id)
if bomb:
bomb.sync_to_peer(id)
if avatars.size() <= 2:
# First opponent joined — restart round so both spawn alive
round_manager.start_round()
else:
# 3+ players: sync state, spectate until next round
round_manager.sync_to_peer(id)
var avatar = avatars.get(id)
if avatar and avatar.has_method("set_spectator"):
avatar.call_deferred("set_spectator")
func _handle_leave(id: int):
if not avatars.has(id):
return
var avatar = avatars[id] as Node
avatar.queue_free()
avatars.erase(id)
if economy_manager:
economy_manager.remove_player(id)
func _handle_stop():
for avatar in avatars.values():
avatar.queue_free()
avatars.clear()
func _handle_round_start(_round_number: int):
if not multiplayer.is_server():
return
# Respawn all players at their team spawn points
for peer_id in avatars:
var avatar = avatars[peer_id] as CharacterBody3D
if avatar == null:
continue
# Fresh spawns (_has_played_round=false) always get default loadout
var survived: bool = not avatar.is_dead and avatar._has_played_round
var spawn_pos := get_team_spawn_point(peer_id)
avatar.round_respawn(spawn_pos, survived)
# Assign bomb to a random T player
if bomb:
var t_members := team_manager.get_team_members(TeamManager.Team.T)
if not t_members.is_empty():
var idx := hash(_round_number * 31) % t_members.size()
bomb.assign_to_player(t_members[idx])
func _spawn(id: int):
# Auto-assign team on server
if multiplayer.is_server():
team_manager.auto_assign(id)
var avatar = player_scene.instantiate() as Node
avatars[id] = avatar
avatar.name += " #%d" % id
add_child(avatar)
avatar.global_position = get_team_spawn_point(id)
# Avatar is always owned by server
avatar.set_multiplayer_authority(1)
# Pass references
if avatar.has_method("setup_references"):
avatar.setup_references(team_manager, round_manager)
# Init economy
if multiplayer.is_server() and economy_manager:
economy_manager.init_player(id)
print("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()])
# Avatar's input object is owned by player
var input = avatar.find_child("Input")
if input != null:
input.set_multiplayer_authority(id)
print("Set input(%s) ownership to %s" % [input.name, id])
# Notify RollbackSynchronizer about ownership changes
var rollback_sync = avatar.find_child("RollbackSynchronizer")
if rollback_sync and rollback_sync.has_method("process_settings"):
rollback_sync.process_settings()
func get_team_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
var team := team_manager.get_team(peer_id)
var points: Array[Marker3D]
match team:
TeamManager.Team.T:
points = t_spawn_points
TeamManager.Team.CT:
points = ct_spawn_points
_:
# Fallback: use T spawns
points = t_spawn_points
if points.is_empty():
return Vector3.ZERO
var idx := hash(peer_id * 37 + spawn_idx * 19) % points.size()
return points[idx].global_position
func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
return get_team_spawn_point(peer_id, spawn_idx)