## SpawnManager — Spawn point selection for team-based spawning. ## ## Scans the current scene for Marker3D nodes in team-specific groups ## ("spawn_points_ct", "spawn_points_t") and selects valid spawns. ## ## Spawn validity rules: ## - No enemies within a configurable minimum distance (proximity_check_radius) ## - Fallback: first available spawn if all are contested ## - Preference: farthest from known enemy positions ## ## Usage: ## var sm = SpawnManager.new() ## add_child(sm) ## sm.team_manager = get_node("/root/TeamManager") ## var spawn_pos: Vector3 = sm.select_spawn(player_id, Team.COUNTER_TERRORIST) ## ## Spawn points are found at runtime by scanning scene groups. ## Map authors place Marker3D nodes and add them to the appropriate group. extends Node class_name SpawnManager # Preload for headless compat const _td_ref = preload("res://scripts/teams/team_data.gd") # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- ## Minimum distance (in Godot units) from any enemy player for a spawn to be valid. @export var proximity_check_radius: float = 15.0 ## If true, fall back to first available spawn when all are contested. @export var allow_fallback: bool = true ## Group name for Counter-Terrorist spawn markers. @export var ct_spawn_group: String = "spawn_points_ct" ## Group name for Terrorist spawn markers. @export var t_spawn_group: String = "spawn_points_t" # --------------------------------------------------------------------------- # Dependencies # --------------------------------------------------------------------------- ## Reference to the TeamManager singleton or node. ## If not set, spawn safety checks are skipped (all spawns considered valid). var team_manager: TeamManager = null # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Cached spawn points for the current map. ## Keyed by group name → Array of Marker3D nodes. var _spawn_points: Dictionary = {} ## Tracks which player last occupied which spawn (for spread). ## player_id (int) → Marker3D global position var _last_spawn_positions: Dictionary = {} # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## (Re)scan the scene tree for spawn point markers. ## Call this when the map changes. func refresh_spawn_points() -> void: _spawn_points.clear() var groups_to_scan: Array[String] = [ct_spawn_group, t_spawn_group] for group_name in groups_to_scan: var markers: Array[Marker3D] = [] var nodes: Array[Node] = get_tree().get_nodes_in_group(group_name) for node in nodes: if node is Marker3D: markers.append(node as Marker3D) _spawn_points[group_name] = markers print("[SpawnManager] Refreshed spawn points — CT: %d, T: %d" % [ _spawn_points.get(ct_spawn_group, []).size(), _spawn_points.get(t_spawn_group, []).size(), ]) ## Get all spawn point markers for a given team. func get_spawn_points_for_team(team: int) -> Array[Marker3D]: var group_name: String = _group_for_team(team) return _spawn_points.get(group_name, []).duplicate() ## Select a valid spawn position for a player on a given team. ## ## Strategy: ## 1. Filter to valid spawns (no enemies within proximity_check_radius). ## 2. Among valid spawns, pick the one farthest from known enemies. ## 3. If no spawn is valid, use fallback behaviour. ## 4. If no spawns exist at all, return Vector3.ZERO. ## ## Returns the global position of the chosen spawn point. func select_spawn(player_id: int, team: int) -> Vector3: var markers: Array[Marker3D] = get_spawn_points_for_team(team) if markers.is_empty(): push_warning("[SpawnManager] No spawn points found for team %s" % _td_ref.get_team_name(team)) return Vector3.ZERO # Separate into valid and contested spawns var valid_spawns: Array[Marker3D] = [] var contested_spawns: Array[Marker3D] = [] if team_manager != null: for marker in markers: if _is_spawn_safe(marker.global_position, team): valid_spawns.append(marker) else: contested_spawns.append(marker) else: # No team manager — all spawns are valid valid_spawns = markers.duplicate() # Choose the best spawn var chosen: Marker3D = null if not valid_spawns.is_empty(): # Pick the farthest from known enemy last-positions chosen = _pick_farthest_spawn(valid_spawns, team) elif allow_fallback: # Fallback: pick the first contested spawn if not contested_spawns.is_empty(): chosen = contested_spawns[0] print("[SpawnManager] Fallback: all spawns contested, using first available") else: chosen = markers[0] print("[SpawnManager] Fallback: no spawns available, using first marker") else: chosen = markers[0] print("[SpawnManager] No valid spawns and fallback disabled, using first marker") # Record this spawn as the player's last position var pos: Vector3 = chosen.global_position _last_spawn_positions[player_id] = pos return pos ## Return the number of spawn points for a team. func get_spawn_count(team: int) -> int: var group_name: String = _group_for_team(team) return _spawn_points.get(group_name, []).size() # --------------------------------------------------------------------------- # Internal: Spawn selection helpers # --------------------------------------------------------------------------- ## Check if a position is safe from enemy proximity. func _is_spawn_safe(pos: Vector3, friendly_team: int) -> bool: if team_manager == null: return true # Get the enemy team(s) var enemy_teams: Array[int] = _get_enemy_teams(friendly_team) for enemy_team in enemy_teams: var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team) for enemy_id in enemy_ids: # Check last known position var enemy_pos: Vector3 = _last_spawn_positions.get(enemy_id, Vector3.ZERO) if enemy_pos != Vector3.ZERO: var dist: float = pos.distance_to(enemy_pos) if dist < proximity_check_radius: return false return true ## Pick the spawn farthest from all known enemy positions. func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: int) -> Marker3D: if candidates.is_empty(): return null if candidates.size() == 1: return candidates[0] # Collect enemy positions var enemy_positions: Array[Vector3] = [] if team_manager != null: var enemy_teams: Array[int] = _get_enemy_teams(friendly_team) for enemy_team in enemy_teams: var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team) for enemy_id in enemy_ids: var enemy_pos: Vector3 = _last_spawn_positions.get(enemy_id, Vector3.ZERO) if enemy_pos != Vector3.ZERO: enemy_positions.append(enemy_pos) if enemy_positions.is_empty(): # No known enemy positions — pick randomly among candidates return candidates[randi() % candidates.size()] # Pick candidate with max minimum-distance to any enemy var best: Marker3D = candidates[0] var best_min_dist: float = 0.0 for marker in candidates: var pos: Vector3 = marker.global_position var min_dist: float = INF for epos in enemy_positions: var d: float = pos.distance_to(epos) if d < min_dist: min_dist = d if min_dist > best_min_dist: best_min_dist = min_dist best = marker return best ## Get the teams that are enemies of the given team. ## For now, CT and T are enemies of each other; SPECTATOR has no enemies. func _get_enemy_teams(team: int) -> Array[int]: match team: _td_ref.Team.COUNTER_TERRORIST: return [_td_ref.Team.TERRORIST] _td_ref.Team.TERRORIST: return [_td_ref.Team.COUNTER_TERRORIST] _: return [] ## Translate a Team enum to the spawn group name. func _group_for_team(team: int) -> String: match team: _td_ref.Team.COUNTER_TERRORIST: return ct_spawn_group _td_ref.Team.TERRORIST: return t_spawn_group _: return ""