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
This commit is contained in:
2026-07-01 20:24:16 -04:00
parent f6d69545c9
commit 46ff83325f
5 changed files with 803 additions and 1 deletions
+23 -1
View File
@@ -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="."]
+234
View File
@@ -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 ""
+123
View File
@@ -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"
+242
View File
@@ -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)
+181
View File
@@ -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