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:
@@ -74,13 +74,35 @@ directional_shadow_split_3 = 0.6
|
|||||||
directional_shadow_blend_splits = true
|
directional_shadow_blend_splits = true
|
||||||
|
|
||||||
|
|
||||||
; === Spawn Point ===
|
; === Spawn Points ===
|
||||||
|
|
||||||
[node name="SpawnPoint" type="Marker3D" parent="."]
|
[node name="SpawnPoint" type="Marker3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -30, 0.5, 0)
|
||||||
groups = ["spawn_points"]
|
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) ===
|
; === Open Area Floor (80×80) ===
|
||||||
|
|
||||||
[node name="Floor" type="CSGBox3D" parent="."]
|
[node name="Floor" type="CSGBox3D" parent="."]
|
||||||
|
|||||||
@@ -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 ""
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user