diff --git a/client/assets/maps/test_range/test_range.tscn b/client/assets/maps/test_range/test_range.tscn index b1b62ad..68fd263 100644 --- a/client/assets/maps/test_range/test_range.tscn +++ b/client/assets/maps/test_range/test_range.tscn @@ -74,13 +74,35 @@ directional_shadow_split_3 = 0.6 directional_shadow_blend_splits = true -; === Spawn Point === +; === Spawn Points === [node name="SpawnPoint" type="Marker3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, 0) groups = ["spawn_points"] +; === CT Spawn Points (Counter-Terrorist) === + +[node name="CTSpawn_01" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, -10) +groups = ["spawn_points_ct"] + +[node name="CTSpawn_02" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -25, 0.5, -15) +groups = ["spawn_points_ct"] + + +; === T Spawn Points (Terrorist) === + +[node name="TSpawn_01" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 25, 0.5, 25) +groups = ["spawn_points_t"] + +[node name="TSpawn_02" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 30, 0.5, 20) +groups = ["spawn_points_t"] + + ; === Open Area Floor (80×80) === [node name="Floor" type="CSGBox3D" parent="."] diff --git a/scripts/teams/spawn_manager.gd b/scripts/teams/spawn_manager.gd new file mode 100644 index 0000000..151b58c --- /dev/null +++ b/scripts/teams/spawn_manager.gd @@ -0,0 +1,234 @@ +## 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 "" diff --git a/scripts/teams/team_data.gd b/scripts/teams/team_data.gd new file mode 100644 index 0000000..332b449 --- /dev/null +++ b/scripts/teams/team_data.gd @@ -0,0 +1,123 @@ +## TeamData — Team definitions and resource for a tactical FPS. +## +## Defines the Team enum, named constants for team metadata, +## and a TeamData resource for per-team configuration. +## +## Usage: +## var team: Team = Team.COUNTER_TERRORIST +## var name: String = TeamData.get_team_name(team) +## var color: Color = TeamData.get_team_color(team) +## +## Persists across rounds (round-independent). +## +## Enum values follow Godot-friendly ordering: +## 0 = SPECTATOR (no team) +## 1 = COUNTER_TERRORIST +## 2 = TERRORIST + +extends Resource +class_name TeamData + +# --------------------------------------------------------------------------- +# Team enum +# --------------------------------------------------------------------------- +enum Team { + SPECTATOR = 0, + COUNTER_TERRORIST = 1, + TERRORIST = 2, +} + +# --------------------------------------------------------------------------- +# Named constants +# --------------------------------------------------------------------------- +const TEAM_NAMES: Dictionary = { + Team.SPECTATOR: "Spectator", + Team.COUNTER_TERRORIST: "Counter-Terrorist", + Team.TERRORIST: "Terrorist", +} + +const TEAM_COLORS: Dictionary = { + Team.SPECTATOR: Color(0.6, 0.6, 0.6, 1.0), + Team.COUNTER_TERRORIST: Color(0.2, 0.5, 0.9, 1.0), # Blue + Team.TERRORIST: Color(0.9, 0.3, 0.2, 1.0), # Red +} + +## Default starting money per player on each team. +const STARTING_MONEY: Dictionary = { + Team.COUNTER_TERRORIST: 800, + Team.TERRORIST: 800, + Team.SPECTATOR: 0, +} + +## Default spawn group suffixes used by SpawnManager when looking for markers. +const TEAM_SPAWN_GROUPS: Dictionary = { + Team.COUNTER_TERRORIST: "spawn_points_ct", + Team.TERRORIST: "spawn_points_t", +} + +# --------------------------------------------------------------------------- +# Resource properties +# --------------------------------------------------------------------------- + +## The team this resource describes. +@export var team_id: Team = Team.SPECTATOR + +## Human-readable display name for this team. +@export var display_name: String = "" + +## Team colour (used for UI, minimap, etc.). +@export var color: Color = Color.WHITE + +## Default spawn group name to scan when placing this team on a map. +@export var default_spawn_group: String = "" + + +# --------------------------------------------------------------------------- +# Static helpers +# --------------------------------------------------------------------------- + +## Return the human-readable name for a Team enum value. +static func get_team_name(team: Team) -> String: + return TEAM_NAMES.get(team, "Unknown") + + +## Return the colour for a Team enum value. +static func get_team_color(team: Team) -> Color: + return TEAM_COLORS.get(team, Color.WHITE) + + +## Return the starting money for a Team enum value. +static func get_starting_money(team: Team) -> int: + return STARTING_MONEY.get(team, 0) + + +## Return the spawn group name for a Team enum value. +static func get_spawn_group(team: Team) -> String: + return TEAM_SPAWN_GROUPS.get(team, "") + + +## Return true if the team is a playable team (not spectator). +static func is_playable_team(team: Team) -> bool: + return team == Team.COUNTER_TERRORIST or team == Team.TERRORIST + + +## Convert a string to a Team enum value. Returns SPECTATOR if unknown. +static func from_string(name: String) -> Team: + match name.to_lower(): + "counter_terrorist", "counter-terrorist", "ct", "counterterrorist": + return Team.COUNTER_TERRORIST + "terrorist", "t": + return Team.TERRORIST + _: + return Team.SPECTATOR + + +## Convert a Team enum value to its short string identifier. +static func to_short_string(team: Team) -> String: + match team: + Team.COUNTER_TERRORIST: + return "CT" + Team.TERRORIST: + return "T" + _: + return "SPEC" diff --git a/scripts/teams/team_manager.gd b/scripts/teams/team_manager.gd new file mode 100644 index 0000000..5f0d4cc --- /dev/null +++ b/scripts/teams/team_manager.gd @@ -0,0 +1,242 @@ +## TeamManager — Team assignment, tracking, and auto-balance. +## +## Manages which player belongs to which team, tracks team scores, +## and provides auto-balancing logic to keep team sizes fair. +## +## Teams are round-independent — player assignments persist across rounds. +## +## Usage: +## var tm = TeamManager.new() +## add_child(tm) +## tm.assign_player(player_id, Team.COUNTER_TERRORIST) +## var team: Team = tm.get_player_team(player_id) +## var ct_players: Array[int] = tm.get_team_players(Team.COUNTER_TERRORIST) +## +## Signals: +## player_joined_team(player_id, team) +## player_left_team(player_id, team) +## team_scores_updated(team_scores) + +extends Node +class_name TeamManager + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when a player is assigned to a team (including on team switch). +signal player_joined_team(player_id: int, team: TeamData.Team) + +## Emitted when a player leaves a team (disconnected or switched). +signal player_left_team(player_id: int, team: TeamData.Team) + +## Emitted when scores for any team change. +signal team_scores_updated(team_scores: Dictionary) + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Mapping: player_id (int) → Team enum value. +var _player_teams: Dictionary = {} + +## Mapping: Team enum → Array of player IDs. +var _team_players: Dictionary = {} + +## Score per team: Team enum → int (rounds won, kills, etc.). +var _team_scores: Dictionary = {} + +## Maximum players allowed on a single team (0 = unlimited). +## Set from ServerConfig on _ready if available. +var max_players_per_team: int = 0 + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + if ServerConfig and ServerConfig.has_method(&\"get_config_path\"): + max_players_per_team = ServerConfig.players_per_team + else: + max_players_per_team = 8 + + +# --------------------------------------------------------------------------- +# Public API — Assignment +# --------------------------------------------------------------------------- + +## Assign a player to a team. Returns true if successful, false if the team is full. +## Emits player_left_team for the old team (if any) and player_joined_team for the new team. +func assign_player(player_id: int, team: TeamData.Team) -> bool: + # Validate team + if team < 0 or team > TeamData.Team.size() - 1: + push_warning("[TeamManager] Invalid team value: %d" % team) + return false + + # Check if team is full (skip for spectator) + if team != TeamData.Team.SPECTATOR and max_players_per_team > 0: + var current_count: int = _team_players.get(team, []).size() + if current_count >= max_players_per_team: + # Allow if the player is already on this team + if _player_teams.get(player_id, TeamData.Team.SPECTATOR) != team: + push_warning("[TeamManager] Team %s is full (%d/%d)" % [TeamData.get_team_name(team), current_count, max_players_per_team]) + return false + + # Remove from old team if assigned + var old_team: TeamData.Team = _player_teams.get(player_id, TeamData.Team.SPECTATOR) + if old_team != team: + _remove_from_team(player_id, old_team) + + # Add to new team + _player_teams[player_id] = team + if not _team_players.has(team): + _team_players[team] = [] + if player_id not in _team_players[team]: + _team_players[team].append(player_id) + + player_joined_team.emit(player_id, team) + return true + + +## Remove a player from any team (equivalent to assigning to SPECTATOR). +func remove_player(player_id: int) -> void: + var current_team: TeamData.Team = _player_teams.get(player_id, TeamData.Team.SPECTATOR) + _remove_from_team(player_id, current_team) + + +## Unregister a player entirely (on disconnect). +func unregister_player(player_id: int) -> void: + remove_player(player_id) + _player_teams.erase(player_id) + + +# --------------------------------------------------------------------------- +# Public API — Querying +# --------------------------------------------------------------------------- + +## Get the team a player belongs to. Returns SPECTATOR if unassigned. +func get_player_team(player_id: int) -> TeamData.Team: + return _player_teams.get(player_id, TeamData.Team.SPECTATOR) + + +## Get an array of all player IDs on a given team. +func get_team_players(team: TeamData.Team) -> Array[int]: + return _team_players.get(team, []).duplicate() + + +## Get the total number of players on a given team. +func get_team_player_count(team: TeamData.Team) -> int: + return _team_players.get(team, []).size() + + +## Check if a player is on a specific team. +func is_player_on_team(player_id: int, team: TeamData.Team) -> bool: + return _player_teams.get(player_id, TeamData.Team.SPECTATOR) == team + + +## Get a dictionary of all player-to-team mappings. +func get_all_assignments() -> Dictionary: + return _player_teams.duplicate() + + +# --------------------------------------------------------------------------- +# Public API — Auto-balance +# --------------------------------------------------------------------------- + +## Assign a player to the team with the fewest members (ties: random). +## Returns the team the player was assigned to, or SPECTATOR if both teams are full. +func assign_to_team_with_fewest(player_id: int) -> TeamData.Team: + var ct_count: int = _team_players.get(TeamData.Team.COUNTER_TERRORIST, []).size() + var t_count: int = _team_players.get(TeamData.Team.TERRORIST, []).size() + + # If one team is at capacity, force to the other + if max_players_per_team > 0: + if ct_count >= max_players_per_team and t_count < max_players_per_team: + assign_player(player_id, TeamData.Team.TERRORIST) + return TeamData.Team.TERRORIST + if t_count >= max_players_per_team and ct_count < max_players_per_team: + assign_player(player_id, TeamData.Team.COUNTER_TERRORIST) + return TeamData.Team.COUNTER_TERRORIST + if ct_count >= max_players_per_team and t_count >= max_players_per_team: + push_warning("[TeamManager] Both teams are full") + return TeamData.Team.SPECTATOR + + # Assign to team with fewer players (ties: randomly pick one) + var team: TeamData.Team + if ct_count < t_count: + team = TeamData.Team.COUNTER_TERRORIST + elif t_count < ct_count: + team = TeamData.Team.TERRORIST + else: + # Tied — pick randomly + team = TeamData.Team.COUNTER_TERRORIST if randi() % 2 == 0 else TeamData.Team.TERRORIST + + assign_player(player_id, team) + return team + + +# --------------------------------------------------------------------------- +# Public API — Scoring +# --------------------------------------------------------------------------- + +## Set the score for a given team. +func set_team_score(team: TeamData.Team, score: int) -> void: + _team_scores[team] = score + team_scores_updated.emit(_team_scores.duplicate()) + + +## Add to a team's current score. +func add_team_score(team: TeamData.Team, amount: int = 1) -> void: + var current: int = _team_scores.get(team, 0) + _team_scores[team] = current + amount + team_scores_updated.emit(_team_scores.duplicate()) + + +## Get the score for a given team. +func get_team_score(team: TeamData.Team) -> int: + return _team_scores.get(team, 0) + + +## Get all team scores as a dictionary: Team enum → int. +func get_all_scores() -> Dictionary: + return _team_scores.duplicate() + + +## Reset all scores to zero. +func reset_scores() -> void: + for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]: + _team_scores[team] = 0 + team_scores_updated.emit(_team_scores.duplicate()) + + +# --------------------------------------------------------------------------- +# Public API — Utility +# --------------------------------------------------------------------------- + +## Reset all teams (clears all assignments and scores). +func reset_all() -> void: + # Emit leave signals for all assigned players + for player_id in _player_teams.keys(): + var team: TeamData.Team = _player_teams[player_id] + player_left_team.emit(player_id, team) + + _player_teams.clear() + _team_players.clear() + reset_scores() + + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + +func _remove_from_team(player_id: int, team: TeamData.Team) -> void: + if _player_teams.has(player_id): + _player_teams.erase(player_id) + + if _team_players.has(team): + var arr: Array = _team_players[team] + var idx: int = arr.find(player_id) + if idx >= 0: + arr.remove_at(idx) + player_left_team.emit(player_id, team) diff --git a/server/scripts/zones/buy_zone.gd b/server/scripts/zones/buy_zone.gd new file mode 100644 index 0000000..04435c7 --- /dev/null +++ b/server/scripts/zones/buy_zone.gd @@ -0,0 +1,181 @@ +## BuyZone — Area3D that defines where players can buy weapons/equipment. +## +## A trigger zone that tracks which players are inside it and emits +## enter/exit signals. Buy zones can be filtered by team, so only +## the appropriate team can purchase within a given zone. +## +## In the map scene, place BuyZone nodes (Area3D) near each team's +## spawn area. Set zone_team to restrict which team can use it. +## +## Usage (map tscn): +## [node name="BuyZone_CT" type="Area3D" parent="."] +## script = ExtResource("...buy_zone.gd") +## zone_team = 1 # Team.COUNTER_TERRORIST +## zone_radius = 10.0 +## +## (Add a CollisionShape3D child with a SphereShape3D for the trigger volume.) +## +## Signals: +## player_entered_buyzone(player_id) — emitted when a player enters the zone +## player_exited_buyzone(player_id) — emitted when a player leaves the zone +## +## Usage (server runtime): +## var bz: BuyZone = $BuyZone_CT +## if bz.is_in_buyzone(player_id): +## show_buy_menu(player_id) + +extends Area3D +class_name BuyZone + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when a player enters the buy zone. +signal player_entered_buyzone(player_id: int) + +## Emitted when a player exits the buy zone. +signal player_exited_buyzone(player_id: int) + + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + +## Which team(s) can use this buy zone. +## Team.COUNTER_TERRORIST = 1, Team.TERRORIST = 2, Team.SPECTATOR = 0. +## Use the Team enum: TeamData.Team.COUNTER_TERRORIST, etc. +@export var zone_team: TeamData.Team = TeamData.Team.COUNTER_TERRORIST + +## Radius of the buy zone in Godot units (visual hint only; actual collision +## shape determines the trigger volume). +@export var zone_radius: float = 10.0 + +## If true, any team can use this zone (overrides zone_team). +@export var allow_all_teams: bool = false + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Set of player IDs currently inside this buy zone. +var _players_in_zone: Dictionary = {} # player_id (int) → true + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Connect area signals + body_entered.connect(_on_body_entered) + body_exited.connect(_on_body_exited) + area_entered.connect(_on_area_entered) + area_exited.connect(_on_area_exited) + + # Set collision layer/mask for buy zones (layer 4 by convention) + collision_layer = 0 # Buy zones don't collide with physics + collision_mask = 0 # They detect bodies via signals only + + # Ensure we have at least a basic collision shape hint + _setup_collision_shape() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Check if a specific player is inside this buy zone. +func is_in_buyzone(player_id: int) -> bool: + return _players_in_zone.has(player_id) + + +## Get an array of player IDs currently inside this buy zone. +func get_players_in_buyzone() -> Array[int]: + return _players_in_zone.keys() + + +## Check if the given team is allowed to use this buy zone. +func is_team_allowed(team: TeamData.Team) -> bool: + if allow_all_teams: + return true + return team == zone_team + + +## Get the number of players currently in the zone. +func get_player_count() -> int: + return _players_in_zone.size() + + +# --------------------------------------------------------------------------- +# Internal: Signal handlers +# --------------------------------------------------------------------------- + +func _on_body_entered(body: Node) -> void: + var player_id: int = _extract_player_id(body) + if player_id >= 0: + _players_in_zone[player_id] = true + player_entered_buyzone.emit(player_id) + + +func _on_body_exited(body: Node) -> void: + var player_id: int = _extract_player_id(body) + if player_id >= 0 and _players_in_zone.erase(player_id): + player_exited_buyzone.emit(player_id) + + +func _on_area_entered(_area: Area3D) -> void: + # Areas can also trigger if the player is an Area3D (e.g. a trigger zone + # on the player). For now, body detection is the primary path. + pass + + +func _on_area_exited(_area: Area3D) -> void: + pass + + +# --------------------------------------------------------------------------- +# Internal: helpers +# --------------------------------------------------------------------------- + +## Try to extract a multiplayer peer ID from a body node. +## The body is expected to be a Player node or have a "get_peer_id" method, +## or the body's owner is a Player node with multiplayer authority info. +func _extract_player_id(body: Node) -> int: + # If the body has a peer_id property or method + if body.has_method(&"get_peer_id"): + return body.get_peer_id() + + # If the body is a Player node with multiplayer authority + if body.has_method(&"get_multiplayer_authority"): + return body.get_multiplayer_authority() + + # Check for a direct peer_id variable + if "peer_id" in body and body.get("peer_id") is int: + return body.get("peer_id") + + # Try the body's owner + var owner_node: Node = body.owner if body.owner else body.get_parent() + if owner_node and owner_node != body: + return _extract_player_id(owner_node) + + return -1 + + +## Ensure there's a collision shape for the trigger volume. +## If no CollisionShape3D child exists, create a SphereShape3D one. +func _setup_collision_shape() -> void: + # Check if we already have a CollisionShape3D + for child in get_children(): + if child is CollisionShape3D: + return # Already have one + + # Create a default sphere shape + var shape := SphereShape3D.new() + shape.radius = zone_radius + + var col_shape := CollisionShape3D.new() + col_shape.shape = shape + add_child(col_shape) + col_shape.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else owner