Files
tactical-shooter/scripts/teams/spawn_manager.gd
T
shawn 46ff83325f t_p2: Add team, spawn, and buy-zone system
- team_data.gd: Team enum (SPECTATOR/CT/Terrorist), constants for names,
  colors, starting money, and spawn groups; TeamData resource class
- team_manager.gd: Player-to-team assignment, auto-balancing, score
  tracking; round-independent (persists across rounds)
- spawn_manager.gd: Scans scene for Marker3D nodes in spawn_points_ct
  and spawn_points_t groups; selects valid spawns (farthest from enemies
  with proximity check, fallback to first available)
- buy_zone.gd: Area3D-based trigger zone with team filtering, player
  enter/exit tracking and signals
- test_range.tscn: Added 2 CT spawn markers and 2 T spawn markers with
  appropriate groups
2026-07-01 20:24:16 -04:00

235 lines
7.9 KiB
GDScript

## 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
# ---------------------------------------------------------------------------
# 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: TeamData.Team) -> 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: TeamData.Team) -> 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" % TeamData.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: TeamData.Team) -> 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: TeamData.Team) -> bool:
if team_manager == null:
return true
# Get the enemy team(s)
var enemy_teams: Array[TeamData.Team] = _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: TeamData.Team) -> 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[TeamData.Team] = _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: TeamData.Team) -> Array[TeamData.Team]:
match team:
TeamData.Team.COUNTER_TERRORIST:
return [TeamData.Team.TERRORIST]
TeamData.Team.TERRORIST:
return [TeamData.Team.COUNTER_TERRORIST]
_:
return []
## Translate a Team enum to the spawn group name.
func _group_for_team(team: TeamData.Team) -> String:
match team:
TeamData.Team.COUNTER_TERRORIST:
return ct_spawn_group
TeamData.Team.TERRORIST:
return t_spawn_group
_:
return ""