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 ## 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 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 _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: print("[PlayerSpawner] spawn_host_avatar=false — 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): # 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: print("[PlayerSpawner] Skipping spawn for peer 1 (spawn_host_avatar=false)") 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: # Dedicated server handles match start via headless_server.gd if not _dedicated_server: 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() # On the client side, initialize weapon loadout locally so the player # has weapons immediately. The server will reconcile via rollback state sync. if not multiplayer.is_server(): var weapon_mgr = avatar.find_child("WeaponManager") if weapon_mgr and weapon_mgr.has_method("set_default_loadout"): # Default to T team — server will correct via state sync weapon_mgr.set_default_loadout(1) # TeamManager.Team.T = 1 print("[Spawner] Client-side weapon loadout initialized for peer %s" % id) 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)