## Server Main — Headless dedicated server entry point. ## ## Runs as `server_main.tscn` which is the project's main scene. ## In headless mode (--headless), this is the only running scene. ## ## On ready: ## 1. Load config (env overrides) ## 2. Load the first map from the config's map rotation ## 3. Start ENet server on the configured port ## 4. Create GameServer and start simulation ## 5. Spawn players on the map using spawn markers ## 6. Broadcast spawn via RPC so clients create visual player nodes ## 7. Log player join/leave and replicate position ## 8. Hitscan weapon with lag compensation runs in GameServer extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- signal player_spawned(peer_id: int) signal player_despawned(peer_id: int) # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- @export var player_scene: PackedScene = preload("res://scenes/server/server_player.tscn") # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var players: Dictionary = {} # peer_id → Node (player instance) var spawn_points_a: Array[Vector3] = [] # Team A spawns var spawn_points_b: Array[Vector3] = [] # Team B spawns # Game server (drives simulation) var _game_server: Node = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Wait for ServerConfig to finish (it loads via call_deferred). # Check if already loaded to avoid racing the signal. if ServerConfig and not _server_config_loaded(): if ServerConfig.has_signal(&"config_loaded"): await ServerConfig.config_loaded # Config driven — ServerConfig singleton loaded at autoload time. # Allow port override via env var for backwards compatibility # (container/VPS deployments that set SERVER_PORT). var effective_port: int = ServerConfig.port if OS.has_environment("SERVER_PORT"): effective_port = int(OS.get_environment("SERVER_PORT")) print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port) # Create GameServer (drives the 128Hz simulation + hit detection) # Use load() instead of preload() so GameServer's dev dependencies # (weapons, economy, objectives, teams) don't block compilation. var GameServerClass = load("res://server/scripts/game_server.gd") if GameServerClass != null and GameServerClass is GDScript: _game_server = GameServerClass.new() if _game_server != null: add_child(_game_server) # GameServer registers SimulationServer as a singleton on _ready _game_server.start_simulation() else: push_warning("[ServerMain] GameServer instantiation failed") else: push_warning("[ServerMain] GameServer class not available — running without simulation server") # Check if ServerConfig has finished loading. # Instance the map from the config's map rotation _load_map() # Start the ENet server var err: Error = NetworkManager.start_server(effective_port) if err != OK: push_error("[ServerMain] Failed to start server: %s" % error_string(err)) get_tree().quit(1) return # Connect signals NetworkManager.player_connected.connect(_on_player_connected) NetworkManager.player_disconnected.connect(_on_player_disconnected) print("[ServerMain] Tactical Shooter server ready") print("[ServerMain] Port: %d" % effective_port) print("[ServerMain] Name: \"%s\"" % ServerConfig.server_name) print("[ServerMain] Tick rate: %d Hz" % ServerConfig.tick_rate) print("[ServerMain] Maps: %s" % str(ServerConfig.map_list)) print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless")) print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()]) ## Check if ServerConfig has finished loading. func _server_config_loaded() -> bool: return ServerConfig and ServerConfig.has_method(&"get_config_path") and not ServerConfig.get_config_path().is_empty() func _exit_tree() -> void: NetworkManager.stop() # --------------------------------------------------------------------------- # Map loading # --------------------------------------------------------------------------- func _load_map() -> void: # Load the first map from the config's rotation list var maps_list: Array[String] = ServerConfig.map_list if maps_list.is_empty(): push_warning("[ServerMain] Map rotation is empty — running without map geometry") return var first_map_path: String = maps_list[0] var full_path: String = "res://scenes/map/%s.tscn" % first_map_path if not ResourceLoader.exists(full_path): push_error("[ServerMain] Map scene not found: %s" % full_path) return var map_scene_res: PackedScene = load(full_path) var map_instance: Node = map_scene_res.instantiate() map_instance.name = "Map_%s" % first_map_path add_child(map_instance, true) print("[ServerMain] Loaded map: %s" % first_map_path) # Collect spawn markers from the map scene _collect_spawn_points(map_instance) ## Walk the map scene looking for Marker3D nodes named "SpawnA*" and "SpawnB*". func _collect_spawn_points(map_root: Node) -> void: _collect_spawn_points_recursive(map_root) # Fallback: only after full recursion, if still empty if spawn_points_a.is_empty() and spawn_points_b.is_empty(): print("[ServerMain] No spawn markers found — using default positions") spawn_points_a.append(Vector3(-16, 0, -4)) spawn_points_a.append(Vector3(-16, 0, 4)) spawn_points_b.append(Vector3(16, 0, -4)) spawn_points_b.append(Vector3(16, 0, 4)) func _collect_spawn_points_recursive(node: Node) -> void: if node is Marker3D: var name_lower: String = node.name.to_lower() if name_lower.begins_with("spawna") or name_lower.begins_with("spawn_a"): spawn_points_a.append(node.position) elif name_lower.begins_with("spawnb") or name_lower.begins_with("spawn_b"): spawn_points_b.append(node.position) for child in node.get_children(): _collect_spawn_points_recursive(child) # --------------------------------------------------------------------------- # Player management # --------------------------------------------------------------------------- func _on_player_connected(peer_id: int) -> void: print("[ServerMain] Player joined: %d. Total: %d" % [peer_id, multiplayer.get_peers().size() + 1]) _spawn_player(peer_id) func _on_player_disconnected(peer_id: int) -> void: print("[ServerMain] Player left: %d" % peer_id) _despawn_player(peer_id) func _spawn_player(peer_id: int) -> void: # Create player node on the server var player: Node if player_scene: player = player_scene.instantiate() else: player = Node3D.new() player.set_script(preload("res://scripts/network/player.gd")) player.name = "Player_%d" % peer_id player.set_multiplayer_authority(peer_id) # Assign spawn position — alternate between Team A and Team B spawns var is_team_a: bool = (peer_id % 2) == 0 var pool: Array[Vector3] = spawn_points_a if is_team_a else spawn_points_b var spawn_pos: Vector3 = pool[peer_id % pool.size()] if pool.size() > 0 else Vector3.ZERO spawn_pos.y = 0.0 player.position = spawn_pos add_child(player, true) players[peer_id] = player player_spawned.emit(peer_id) # Register with GameServer to get a simulation entity if _game_server != null and _game_server.has_method(&"spawn_player_entity"): var entity_id: int = _game_server.spawn_player_entity(peer_id, spawn_pos) if entity_id >= 0: # Store entity_id on player node for reference player.set_meta(&"entity_id", entity_id) # Register with lag compensation and damage processor # (player is a Node3D, which is a valid argument for register_player_node) if _game_server.has_method(&"register_player_node"): _game_server.register_player_node(entity_id, player) print("[ServerMain] Player %d assigned entity %d" % [peer_id, entity_id]) # Broadcast spawn to all clients so they create a visual player node NetworkManager.broadcast_spawn_player.rpc(peer_id, spawn_pos, is_team_a) print("[ServerMain] Spawned player %d at (%.1f, %.1f) team=%s" % [peer_id, spawn_pos.x, spawn_pos.z, "A" if is_team_a else "B"]) func _despawn_player(peer_id: int) -> void: if peer_id in players: var player: Node = players[peer_id] # Despawn entity from GameServer if _game_server != null and player.has_meta(&"entity_id"): var entity_id: int = player.get_meta(&"entity_id") # Unregister from lag compensation and damage processor first if _game_server.has_method(&"unregister_player_node"): _game_server.unregister_player_node(entity_id) _game_server.despawn_player_entity(entity_id) player.queue_free() players.erase(peer_id) player_despawned.emit(peer_id) # Broadcast despawn to all clients NetworkManager.broadcast_despawn_player.rpc(peer_id) print("[ServerMain] Despawned player %d" % peer_id) else: # Still broadcast even if server doesn't have the player NetworkManager.broadcast_despawn_player.rpc(peer_id) # --------------------------------------------------------------------------- # Server tick (128 Hz) # --------------------------------------------------------------------------- func _physics_process(delta: float) -> void: if not NetworkManager.is_server: return # Future: authoritative physics tick, state broadcast, etc. pass # --------------------------------------------------------------------------- # Client position replication (receives from FPS character clients) # --------------------------------------------------------------------------- @rpc("unreliable", "any_peer") func _send_position(pos: Vector3) -> void: # Called by remote clients to update their position on the server. # Broadcast to all other peers so they see the player move. var peer_id: int = multiplayer.get_remote_sender_id() if peer_id in players: players[peer_id].position = pos # Re-broadcast to all other peers _replicate_position.rpc(pos, peer_id) @rpc("unreliable", "authority") func _replicate_position(pos: Vector3, exclude_peer: int) -> void: # All clients receive this and update the specific player's position. # We need to find which remote player this belongs to by excluding our own. # For now, the non-authority clients will need to filter — handled in client_main.gd pass